diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2fbe37e918..3113545559 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -40,6 +40,7 @@ This is the section I read most carefully. I need to see that *you* tested this, ### Checklist +- [ ] Plugin changes follow the [SDK import boundaries](../docs/plugins.md#sdk-import-boundaries) (if applicable) - [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..833820ff2d 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -34,6 +34,7 @@ hub: - "packages/server/src/server/hub/**" server: + - "plugins/**" - "packages/server/**" - "packages/app/e2e/support/fixtures/recording.*" @@ -50,11 +51,26 @@ app: - "packages/expo-two-way-audio/**" sdk: + - "packages/plugin/**" + - "plugin-examples/**" + - "public-docs/plugins/**" - "packages/client/**" - "packages/highlight/**" - "packages/protocol/**" browser: + - "packages/server/src/server/agent/provider-snapshot-manager.ts" + - "packages/server/src/server/session/provider/provider-catalog-session.ts" + - "packages/client/src/compat/normalize-provider-models.ts" + - "packages/protocol/src/client-capabilities.ts" + - "packages/server/src/server/agent/provider-registry.ts" + - "packages/server/src/server/agent/agent-sdk-types.ts" + - "packages/server/src/server/agent/providers/codex-app-server-agent.ts" + - "packages/server/src/server/agent/providers/claude/agent.ts" + - "packages/server/src/server/agent/plugin-provider.ts" + - "packages/server/src/server/plugins/{index,plugin-process,plugin-process-protocol,runtime}.ts" + - "packages/server/src/executable-resolution/**" + - "packages/plugin/src/server/provider.ts" - "packages/app/src/!(desktop)/**" - "packages/app/e2e/browser/**" - "packages/app/e2e/support/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69689e9c13..0e3f65f336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,15 +52,13 @@ jobs: filters: .github/ci-paths.yml - name: Validate CI contracts - run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs + run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs format: name: format needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -80,8 +78,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -107,8 +103,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -123,9 +117,21 @@ jobs: - name: Build server stack run: npm run build:server + - name: Verify built-in plugins from built output + run: node --test scripts/builtin-plugins-dist.test.mjs + - name: Typecheck all packages run: npm run typecheck + - name: Run website tests + run: npx vitest run packages/website/src --bail=1 + + - name: Install website test browsers + run: npx playwright install --with-deps chromium webkit + + - name: Verify website mockups + run: npm run test:e2e --workspace=@getpaseo/website + - name: Verify public package contents run: | npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol @@ -137,8 +143,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: &server_test_steps - uses: actions/checkout@v4 with: @@ -171,6 +175,10 @@ jobs: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + - name: Run built-in plugin tests + if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} + run: npm run test --workspace=@getpaseo/builtin-plugins + - name: Run Hub CLI/daemon contract if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }} run: npm run test:hub-cli-contract --workspace=@getpaseo/server @@ -180,15 +188,44 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }} runs-on: windows-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: *server_test_steps + # native-recursive.ts serves darwin and win32, but windows-latest above is the only + # runner that exercises it. macOS — the platform the reported watcher bug was on — + # otherwise has no server-test coverage. Scoped to the observer/watcher suites to + # bound runtime. + server-tests-macos: + name: server-tests (macos-14, file observation) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Build server dependencies + run: npm run build:server-deps + + - name: Run file observation tests + run: | + npm run test:unit --workspace=@getpaseo/server -- \ + src/server/file-observer \ + src/server/workspace-git-service.observation.test.ts \ + src/server/workspace-git-service.observation.integration.test.ts \ + --bail=1 + desktop-tests-ubuntu: name: desktop-tests (ubuntu-latest) needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read @@ -211,12 +248,35 @@ jobs: - name: Install virtual display if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth xdotool - name: Install Playwright browser if: ${{ runner.os == 'Linux' }} run: npx playwright install chromium + - name: Verify Windows daemon process control + if: ${{ runner.os == 'Windows' }} + run: npx vitest run packages/cli/src/commands/daemon/lifecycle.e2e.test.ts --bail=1 --maxWorkers=1 -t "Windows unbound|managed two-home" + + - name: Run desktop lifecycle E2E (Linux) + if: ${{ runner.os == 'Linux' }} + run: xvfb-run -a --server-args="-screen 0 1280x800x24" npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + env: + PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-lifecycle + + - name: Upload desktop lifecycle screenshots + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() && runner.os == 'Linux' }} + with: + name: desktop-lifecycle + path: ${{ runner.temp }}/desktop-lifecycle + if-no-files-found: ignore + retention-days: 7 + + - name: Run desktop lifecycle E2E (Windows) + if: ${{ runner.os == 'Windows' }} + run: npm run test:e2e:lifecycle --workspace=@getpaseo/desktop + - name: Run desktop renderer E2E if: ${{ runner.os == 'Linux' }} run: npm run test:e2e:renderer --workspace=@getpaseo/desktop @@ -236,23 +296,6 @@ jobs: if-no-files-found: ignore retention-days: 7 - - name: Build and smoke unpacked desktop app - if: ${{ runner.os == 'Linux' }} - run: npm run build:desktop -- --publish never --linux --x64 --dir - env: - EP_GH_IGNORE_TIME: true - PASEO_DESKTOP_SMOKE: "1" - PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - - - name: Upload packaged smoke diagnostics - uses: actions/upload-artifact@v4 - if: ${{ failure() && runner.os == 'Linux' }} - with: - name: desktop-packaged-smoke-linux-x64 - path: ${{ runner.temp }}/desktop-smoke - if-no-files-found: ignore - retention-days: 7 - desktop-tests-windows: name: desktop-tests (windows-latest) needs: changes @@ -268,8 +311,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -296,8 +337,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -310,7 +349,13 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Build client dependencies - run: npm run build:client + run: npm run build:relay && npm run build:client + + - name: Build plugin SDK + run: npm run build:plugin + + - name: Run plugin SDK tests + run: npm run test --workspace=@getpaseo/plugin - name: Run protocol tests run: npm run test --workspace=@getpaseo/protocol @@ -330,7 +375,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "1/4" PLAYWRIGHT_ARTIFACT: "1" steps: &playwright_test_steps @@ -361,6 +405,9 @@ jobs: run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # Weight file groups by observed duration; raw test counts overload + # early agent/Changes specs. Required check names remain unchanged. + PWTEST_SHARD_WEIGHTS: "111:172:137:160" - name: Upload test artifacts uses: actions/upload-artifact@v4 @@ -378,7 +425,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "2/4" PLAYWRIGHT_ARTIFACT: "2" steps: *playwright_test_steps @@ -389,7 +435,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "3/4" PLAYWRIGHT_ARTIFACT: "3" steps: *playwright_test_steps @@ -400,7 +445,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PLAYWRIGHT_SHARD: "4/4" PLAYWRIGHT_ARTIFACT: "4" steps: *playwright_test_steps @@ -410,8 +454,6 @@ jobs: needs: changes if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest - env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -435,7 +477,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -452,14 +493,18 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci - - name: Build server stack - run: npm run build:server - - name: Install agent CLIs for provider tests run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - - name: Run CLI tests - run: npm run test --workspace=@getpaseo/cli + # The local runner builds its dependencies and shards its own E2E files. + - name: Run CLI E2E tests + run: npm run test:local --workspace=@getpaseo/cli + + # Source tests do not read PASEO_CLI_TEST_SHARD. Run them once, on the + # shortest E2E shard, after its runner has built the server stack. + - name: Run CLI source tests + if: ${{ env.PASEO_CLI_TEST_SHARD == '3' }} + run: npm run test:unit --workspace=@getpaseo/cli cli-tests-2: name: cli-tests (shard 2/3) @@ -467,7 +512,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" @@ -481,7 +525,6 @@ jobs: if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest env: - ELECTRON_SKIP_BINARY_DOWNLOAD: "1" PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" PASEO_DICTATION_ENABLED: "0" PASEO_VOICE_MODE_ENABLED: "0" diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index cd20556182..f99dabaea9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -7,6 +7,7 @@ on: - "CHANGELOG.md" - "public-docs/**" - "packages/website/**" + - "packages/protocol/**" - "package.json" - "package-lock.json" - "patches/**" @@ -29,7 +30,10 @@ jobs: cache: "npm" - name: Install dependencies - run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --include-workspace-root + run: node scripts/npm-retry.mjs ci --workspace=@getpaseo/website --workspace=@getpaseo/protocol --include-workspace-root + + - name: Build protocol + run: npm run build:protocol - name: Typecheck run: npm run typecheck --workspace=@getpaseo/website diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml new file mode 100644 index 0000000000..c30eea7da4 --- /dev/null +++ b/.github/workflows/desktop-packages.yml @@ -0,0 +1,91 @@ +name: Desktop Packages + +on: + push: + branches: [main] + # Packaging leaves ordinary PR CI, but a PR that changes what gets packaged + # or how it is smoked still proves the real artifacts before merge. + pull_request: + branches: [main] + paths: + - "packages/desktop/**" + - "plugins/**" + - "nix/**" + - ".github/workflows/desktop-packages.yml" + +concurrency: + group: desktop-packages-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + ONNXRUNTIME_NODE_INSTALL: skip + +jobs: + linux: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Install package smoke dependencies + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm + + - name: Build Linux desktop artifacts + # Smoke package contents and installation without spending minutes on xz compression. + run: | + printf '{"extends":"%s/packages/desktop/electron-builder.yml","deb":{"compression":"gz"},"rpm":{"fpm":["--rpm-compression=gzip"]}}' "$GITHUB_WORKSPACE" > "$RUNNER_TEMP/desktop-smoke-builder.json" + npm run build:desktop -- --publish never --linux --x64 --config "$RUNNER_TEMP/desktop-smoke-builder.json" + env: + EP_GH_IGNORE_TIME: true + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + + - name: Smoke installed artifacts with restricted user namespaces + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + if unshare --user --map-root-user --net true; then + echo "Expected the launching user's namespace probe to be denied" + exit 1 + fi + sudo apt-get install -y ./packages/desktop/release/*.deb + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/restricted + + - name: Smoke sandbox-preserving portable launches + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + unshare --user --map-root-user --net true + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release enabled + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/userns + + - name: Smoke installed RPM with restricted user namespaces + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 + sudo dpkg --remove paseo + # Ubuntu supplies the runtime libraries under Debian package names. + # RPM still performs the real payload installation and postinstall. + sudo rpm --install --nodeps packages/desktop/release/*.rpm + node packages/desktop/e2e/linux-artifact-smoke.js packages/desktop/release disabled --installed-only + env: + PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke/rpm + + - name: Upload packaged smoke diagnostics + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: ${{ !cancelled() }} + with: + name: desktop-packaged-smoke-linux-x64 + path: ${{ runner.temp }}/desktop-smoke + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 89f2f83ae6..f6b07c8b37 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -102,8 +102,10 @@ jobs: include: - runner: macos-14 electron_arch: arm64 + node_heap_mb: 4096 - runner: macos-15-intel electron_arch: x64 + node_heap_mb: 8192 permissions: contents: write packages: read @@ -161,6 +163,10 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} PASEO_DESKTOP_SMOKE: "1" PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke + # Metro's jest workers OOM on the generated ws-outbound.aot.js validator, + # the last module in the web bundle. Node's default heap is sized from + # runner RAM, which is not enough on the Intel runner. + NODE_OPTIONS: --max-old-space-size=${{ matrix.node_heap_mb }} run: | set -euo pipefail build_args=(-- --publish never --mac --${{ matrix.electron_arch }}) @@ -185,16 +191,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" - gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -261,7 +259,7 @@ jobs: NODE - name: Install Linux smoke display - run: sudo apt-get update && sudo apt-get install -y xvfb xauth + run: sudo apt-get update && sudo apt-get install -y xvfb xauth rpm - name: Build desktop release shell: bash @@ -278,7 +276,7 @@ jobs: npm run build:desktop "${build_args[@]}" - name: Upload packaged smoke diagnostics - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: desktop-smoke-linux-x64 @@ -294,16 +292,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" - gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -400,16 +390,8 @@ jobs: run: | set -euo pipefail release_dir="${DESKTOP_PACKAGE_PATH}/release" - files=() - while IFS= read -r -d '' f; do - files+=("$f") - done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z) - if (( ${#files[@]} == 0 )); then - echo "::error::No release artifacts found in $release_dir" - exit 1 - fi release_lookup="$(node scripts/github-release.mjs --repo "${{ github.repository }}" --tag "$RELEASE_TAG")" - gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" + node scripts/upload-release-assets.mjs "$release_lookup" "$release_dir" "${{ github.repository }}" - name: Upload desktop artifacts to workflow if: env.SHOULD_PUBLISH != 'true' @@ -547,28 +529,10 @@ jobs: fi timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")" node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}" - # shellcheck disable=SC2016 - ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e ' - const yaml = require("js-yaml"); - const fs = require("fs"); - const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED); - const expectedDate = process.env.RELEASE_DATE_EXPECTED; - if (!Number.isFinite(expectedHours) || expectedHours < 0) { - throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`); - } - for (const f of process.argv.slice(1)) { - const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {}; - if (m.rolloutHours !== expectedHours) { - throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`); - } - if (m.releaseDate !== expectedDate) { - throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`); - } - if (typeof m.version !== "string" || m.version.length === 0) { - throw new Error(`${f}: missing or invalid version`); - } - } - ' "${files[@]}" + node ../scripts/validate-desktop-manifests.mjs \ + --release-date "$timestamp" \ + --rollout-hours "$ROLLOUT_HOURS" \ + "${files[@]}" gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}" failed_builds=() diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index dc5221fa6e..aa53f3ec81 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,27 +1,6 @@ name: Docker on: - pull_request: - branches: [main] - paths: - - "docker/**" - - ".dockerignore" - - ".github/workflows/docker.yml" - - "package.json" - - "package-lock.json" - - "patches/**" - - "scripts/**" - - "tsconfig.json" - - "tsconfig.base.json" - - "packages/app/**" - - "packages/cli/**" - - "packages/client/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/plugin/**" - - "packages/protocol/**" - - "packages/relay/**" - - "packages/server/**" push: branches: [main] tags: @@ -51,7 +30,7 @@ on: concurrency: group: docker-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false env: REGISTRY: ghcr.io diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index bf198d1c94..f37a8b0cdf 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -1,27 +1,8 @@ name: Nix on: - pull_request: + push: branches: [main] - paths: - - "nix/**" - - "flake.nix" - - "flake.lock" - - "package.json" - - "package-lock.json" - - "packages/app/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/plugin/**" - - "packages/protocol/**" - - "packages/client/**" - - "packages/server/**" - - "packages/relay/**" - - "packages/cli/**" - - "scripts/build-daemon-web-ui.mjs" - - "scripts/update-nix.sh" - - "scripts/fix-lockfile.mjs" - - ".github/workflows/nix.yml" permissions: contents: read @@ -31,8 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - uses: actions/setup-node@v4 with: @@ -57,7 +36,7 @@ jobs: PASEO_HOME="$(mktemp -d)" export PASEO_LISTEN=127.0.0.1:6767 - WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + WRAPPER_LOG="$PASEO_HOME/paseo-cli.log" cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -68,7 +47,7 @@ jobs: } trap cleanup EXIT - PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 & + PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 & DAEMON_PID=$! deadline=$((SECONDS + 30)) @@ -78,7 +57,22 @@ jobs: echo "$STATUS_JSON" curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html" [[ -s "$PASEO_HOME/web-ui.html" ]] - exit 0 + if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then + break + fi + TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")" + ./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter + deadline=$((SECONDS + 15)) + while (( SECONDS < deadline )); do + if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \ + | jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then + ./result/bin/paseo terminal kill "$TERMINAL_ID" + exit 0 + fi + sleep 1 + done + echo "Nix terminal did not execute the command." + break fi if ! kill -0 "$DAEMON_PID" 2>/dev/null; then @@ -93,7 +87,7 @@ jobs: cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "" echo "::endgroup::" - echo "::group::paseo-server stdout/stderr" + echo "::group::paseo daemon run stdout/stderr" cat "$WRAPPER_LOG" 2>/dev/null || echo "" echo "::endgroup::" @@ -110,8 +104,6 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - uses: cachix/install-nix-action@v31 with: diff --git a/.gitignore b/.gitignore index 5298013fdc..6d6bb279b7 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,9 @@ packages/protocol/src/generated/validation/*.aot.ts packages/desktop/.cache/ packages/desktop/src-tauri/resources/managed-runtime/ app.json + +# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging. +*.heapsnapshot + +# Project-local Serena configuration and memories. +.serena/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 269097aab4..4582602a97 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -84,6 +84,45 @@ "max-nested-callbacks": ["error", { "max": 3 }] }, "overrides": [ + { + "files": [ + "plugins/*-provider/server/**/*.ts", + "packages/plugin/src/server/acp-internal/**/*.ts" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "node:child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + }, + { + "name": "child_process", + "importNames": [ + "spawn", + "exec", + "execFile", + "spawnSync", + "execSync", + "execFileSync" + ], + "message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { @@ -213,7 +252,6 @@ "packages/app/src/components/download-toast.tsx", "packages/app/src/components/draggable-list.native.tsx", "packages/app/src/components/headers/back-header.tsx", - "packages/app/src/components/headers/menu-header.tsx", "packages/app/src/components/headers/screen-header.tsx", "packages/app/src/components/host-status-dot.tsx", "packages/app/src/components/hosts/host-picker.tsx", diff --git a/CHANGELOG.md b/CHANGELOG.md index f3349960bb..0e2b24ac5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,503 @@ # Changelog +## 0.11.2 - 2026-10-09 + +### Fixed + +- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445)) + +## 0.11.1 - 2026-10-07 + +### Added + +- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332)) + +### Fixed + +- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308)) +- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316)) +- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301)) + +## 0.11.0 - 2026-10-07 + +Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry). + +**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory. + +### Added + +- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95)) +- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795)) +- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216)) +- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844)) +- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156)) +- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m)) +- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi)) +- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet)) +- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680)) +- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1)) +- Added `agents.providers..options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780)) +- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone)) + +### Plugins + +- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224)) +- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-items) to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685)) +- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876)) +- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976)) +- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood)) +- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707)) +- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263)) +- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226)) + +### Changed + +- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465)) +- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185)) +- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot)) +- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942)) +- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090)) +- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805)) +- Changed the sidebar workspace row to show last activity by default on new installs ([#6087](https://github.com/getpaseo/paseo/pull/6087)) +- Changed compact sidebar footer buttons to 44×44 tap targets ([#6044](https://github.com/getpaseo/paseo/pull/6044)) +- Changed new workspaces branched from `origin/main` or another remote branch to fetch that branch first ([#5788](https://github.com/getpaseo/paseo/pull/5788) by [@odoo-mvds](https://github.com/odoo-mvds)) +- Changed Paseo's internal Claude agents for branch names, commit messages, and PR text to run with Claude hooks disabled ([#5750](https://github.com/getpaseo/paseo/pull/5750)) +- Changed OMP approval mode changes to apply on the next idle relaunch, and to be refused during a turn ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Changed relative timestamps in agent, schedule, and Import session rows to keep advancing while on screen ([#5340](https://github.com/getpaseo/paseo/pull/5340)) + +### Fixed + +- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer)) +- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson)) +- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272)) +- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248)) +- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268)) +- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022)) +- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089)) +- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074)) +- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov)) +- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151)) +- Fixed ACP agents running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273)) +- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228)) +- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045)) +- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang)) +- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765)) +- Fixed OMP agents staying bound to a dead `omp` process after it crashed ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@viet-initx](https://github.com/viet-initx)) +- Fixed OMP multi-select questions showing radio buttons and asking once per choice ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed `/compact` on OMP reporting a failure after 60s while OMP kept compacting ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@Kh05ifr4nD](https://github.com/Kh05ifr4nD)) +- Fixed `paseo import --provider omp` starting the agent on the default model instead of the session's ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@alloevil](https://github.com/alloevil)) +- Fixed OMP-injected rows showing a `[custom_message]` prefix after a reload ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@jegork](https://github.com/jegork)) +- Fixed OMP tool rows showing raw objects or generic labels for failed tools, Task, Wait, web search, and fetch ([#5550](https://github.com/getpaseo/paseo/pull/5550)) +- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi)) +- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff)) +- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan)) +- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112)) +- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115)) +- Fixed reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215)) +- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061)) +- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ)) +- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755)) +- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826)) +- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93)) +- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul)) +- Fixed OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188)) +- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum)) +- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988)) +- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a denied Codex command or file edit keeping a running spinner after the turn ended ([#5717](https://github.com/getpaseo/paseo/pull/5717) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed rewinding a legacy Codex chat failing with `unknown variant thread/rollback` on Codex 0.156 and newer ([#5711](https://github.com/getpaseo/paseo/pull/5711) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex custom prompt arguments containing `$` sequences reaching Codex altered ([#5669](https://github.com/getpaseo/paseo/pull/5669) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed Codex structured output failing when the schema has a field named `properties` ([#5684](https://github.com/getpaseo/paseo/pull/5684)) +- Fixed a Codex agent created without a thinking option losing its default effort after a daemon restart ([#5686](https://github.com/getpaseo/paseo/pull/5686)) +- Fixed only one permission showing when Codex asks to approve two commands from one item ([#5758](https://github.com/getpaseo/paseo/pull/5758)) +- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774)) +- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804)) +- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796)) +- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970), [#6172](https://github.com/getpaseo/paseo/pull/6172)) +- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970)) +- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138)) +- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158)) +- Fixed copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195)) +- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968)) +- Fixed text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207)) +- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219)) +- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd)) +- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq)) +- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn)) +- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen)) +- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu)) +- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio)) +- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone)) +- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart)) +- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898)) +- Fixed dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922)) +- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279)) +- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117)) +- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097)) +- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095)) +- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694)) +- Fixed a failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255)) +- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh)) +- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart)) +- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722)) +- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585)) +- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058)) +- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang)) +- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716)) +- Fixed the context ring filling from three o'clock instead of twelve on web and desktop ([#5598](https://github.com/getpaseo/paseo/pull/5598)) +- Fixed browser annotation screenshots capturing the wrong region when the app is zoomed ([#5703](https://github.com/getpaseo/paseo/pull/5703)) +- Fixed the desktop app launching a `daemon status` process every second when no local daemon runs ([#5636](https://github.com/getpaseo/paseo/pull/5636)) +- Fixed the `/` menu listing `/clear` and `/exit` in a draft before a project or model is chosen ([#5594](https://github.com/getpaseo/paseo/pull/5594) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed `paseo agent delete --all` and `--cwd` leaving archived agents behind ([#6027](https://github.com/getpaseo/paseo/pull/6027) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed `paseo agent` commands reporting a generic failure instead of `AGENT_NOT_FOUND` for an unknown agent ID ([#6053](https://github.com/getpaseo/paseo/pull/6053)) +- Fixed `paseo agent open` opening Desktop and exiting 0 for an agent that does not exist ([#5727](https://github.com/getpaseo/paseo/pull/5727)) +- Fixed `paseo restart` exiting with `RESTART_NOT_CONFIRMED` when provider checks take more than a second ([#6009](https://github.com/getpaseo/paseo/pull/6009)) +- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet)) +- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan)) +- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579)) +- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239)) +- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620)) +- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838)) +- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy)) +- Fixed bundling `@getpaseo/client` from npm failing with `Could not resolve "@getpaseo/relay/e2ee"` ([#5815](https://github.com/getpaseo/paseo/pull/5815)) + +## 0.10.3 - 2026-10-02 + +### Added + +- Added a confirmation before a pairing link connects to a new host or to a saved host whose key or relay changed ([#5753](https://github.com/getpaseo/paseo/pull/5753)) + +## 0.10.2 - 2026-09-30 + +### Fixed + +- Fixed OpenCode v2 turns longer than five minutes failing with `UND_ERR_HEADERS_TIMEOUT` ([#5674](https://github.com/getpaseo/paseo/pull/5674)) +- Fixed the context meter staying empty during OpenCode v2 turns and disappearing after them ([#5710](https://github.com/getpaseo/paseo/pull/5710) by [@mcowger](https://github.com/mcowger)) +- Fixed OpenCode v2 question cards showing only the header and offering no typed answer ([#5679](https://github.com/getpaseo/paseo/pull/5679)) +- Fixed OpenCode v2 patch edits from GPT models showing as a raw Patch card instead of an edit diff ([#5696](https://github.com/getpaseo/paseo/pull/5696)) +- Fixed completed OpenCode v2 edits showing no diff ([#5609](https://github.com/getpaseo/paseo/pull/5609)) + +## 0.10.1 - 2026-09-29 + +### Added + +- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi)) + +### Fixed + +- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587)) +- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg)) +- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577)) +- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451)) +- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572)) +- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450)) +- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386)) +- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987)) +- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379)) + +## 0.10.0 - 2026-09-28 + +### Added + +- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep)) +- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309)) +- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull)) + +### Changed + +- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459)) + +### Fixed + +- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393)) +- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338)) +- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434)) +- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445)) +- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid)) +- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394)) +- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437)) +- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446)) +- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372)) +- Fixed `paseo --host run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392)) +- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383)) +- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432)) +- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343)) +- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415)) +- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411)) +- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407)) +- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347)) +- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506)) +- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374)) +- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341)) +- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358)) + +## 0.9.2 - 2026-09-24 + +### Added + +- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206)) + +### Improved + +- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170)) +- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190)) + +### Fixed + +- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172)) +- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346)) +- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB)) +- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai)) +- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227)) +- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238)) +- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229)) +- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249)) +- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221)) +- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322)) +- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317)) +- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer)) +- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245)) +- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281)) +- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton)) +- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285)) +- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289)) +- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286)) +- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258)) +- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243)) +- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235)) +- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion)) +- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS)) +- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen)) +- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9)) +- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248)) +- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253)) +- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231)) +- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298)) +- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302)) +- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301)) +- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306)) +- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277)) +- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315)) +- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332)) +- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337)) +- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310)) +- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335)) +- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305)) +- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290)) +- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258)) +- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224)) +- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272)) +- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287)) + +## 0.9.1 - 2026-09-22 + +### Added + +- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri)) + +### Fixed + +- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu)) +- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae)) +- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200)) + +## 0.9.0 - 2026-09-22 + +### Added + +- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files ([#4589](https://github.com/getpaseo/paseo/pull/4589)) +- Added Cmd/Ctrl+F Find to terminal scrollback ([#4650](https://github.com/getpaseo/paseo/pull/4650)) +- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window ([#4765](https://github.com/getpaseo/paseo/pull/4765)) +- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- Added automatic Pull request tab opening once per workspace when a PR is detected ([#4956](https://github.com/getpaseo/paseo/pull/4956)) +- Added expandable plan cards, with rejected plans collapsed by default ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- Added an attachment placeholder with a spinner while a selected file uploads ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- Added match highlighting to the workspace, agent, project, and branch fields in History search ([#4945](https://github.com/getpaseo/paseo/pull/4945)) + +### Plugins + +- Added plugin installation from npm, including scoped packages, versions, tags, and ranges ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added `paseo plugin update` with `--all`, `--check`, and `--yes`, showing the current and proposed revision before approval ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added each plugin's description, source, and installed revision to Settings → Plugins ([#4975](https://github.com/getpaseo/paseo/pull/4975)) +- Added an optional `serverId` to `navigation.openAgent()` and `navigation.openWorkspace()` ([#4942](https://github.com/getpaseo/paseo/pull/4942)) +- Added host discovery through `useHosts()` and host-targeted SDK clients through `getPaseoClient(serverId)` ([#4971](https://github.com/getpaseo/paseo/pull/4971)) +- Added `openExternalUrl()` and `` for opening a URL outside Paseo ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- Added `navigation.openBrowser()` for opening a URL in a workspace browser on desktop ([#4972](https://github.com/getpaseo/paseo/pull/4972)) +- Added a server settings handle returned by `registerSettings()` with `read()` and `subscribe()` ([#4674](https://github.com/getpaseo/paseo/pull/4674) by [@mcowger](https://github.com/mcowger)) +- Changed `assistant_message` transformers to receive the whole accumulated message on each update ([#4675](https://github.com/getpaseo/paseo/pull/4675) by [@mcowger](https://github.com/mcowger), [@jegork](https://github.com/jegork)) +- Changed `tool_call` transformers to receive every original call before Overview groups them ([#4675](https://github.com/getpaseo/paseo/pull/4675)) +- Fixed a plugin session losing its host API permanently after its heartbeat lease expired ([#4912](https://github.com/getpaseo/paseo/pull/4912) by [@gpambrozio](https://github.com/gpambrozio)) +- Fixed plugin build commands failing with `spawn npm ENOENT` on Windows ([#4776](https://github.com/getpaseo/paseo/pull/4776) by [@ABorakati](https://github.com/ABorakati)) +- Fixed ACP text chunks without a `messageId` splitting one reply into a message per chunk ([#4701](https://github.com/getpaseo/paseo/pull/4701) by [@L4XB](https://github.com/L4XB)) +- Fixed nested provider subagents appearing as direct children of the root agent ([#4970](https://github.com/getpaseo/paseo/pull/4970)) + +### Changed + +- Changed History search to keep results chronological in date buckets instead of reordering by relevance ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- Changed workspace Restore to keep the archived agent selected, with its own Unarchive action ([#4736](https://github.com/getpaseo/paseo/pull/4736)) +- Changed the workspace error screen's Retry to Reload, which reopens at the project picker ([#4598](https://github.com/getpaseo/paseo/pull/4598)) +- Changed closing the last content tab to leave the New launcher instead of an unusable pane ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- Changed `paseo daemon start` to read persistent configuration; removed configuration flags fail with migration instructions ([#4575](https://github.com/getpaseo/paseo/pull/4575)) + +### Improved + +- Bold, italics, strikethrough, inline code, and link labels stay formatted while a reply streams ([#4742](https://github.com/getpaseo/paseo/pull/4742)) +- Reduced time to first voice audio from 4.80s to 0.95s on a three-sentence reply ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- Reduced cold diff generation from 11.45s to 2.65s on a 213-file workspace ([#4676](https://github.com/getpaseo/paseo/pull/4676)) +- Reduced desktop memory use, from 290.5 MiB to 152.1 MiB RSS in the Electron main process after loading daemon management ([#5007](https://github.com/getpaseo/paseo/pull/5007)) +- Kept open chats subscribed across view eviction, app backgrounding, and reconnect ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- Added reconnection and Updating messages status to the chat toast ([#4863](https://github.com/getpaseo/paseo/pull/4863)) +- Kept the app responsive during large uploads by yielding between 128 KiB chunks ([#4958](https://github.com/getpaseo/paseo/pull/4958)) +- Let resident browser pages idle between screenshots while the desktop window is hidden ([#4646](https://github.com/getpaseo/paseo/pull/4646)) + +### Fixed + +- Fixed the daemon exhausting its heap in long conversations with cumulative tool output ([#4838](https://github.com/getpaseo/paseo/pull/4838)) +- Fixed repeated submissions creating several workspaces or agents for one intent ([#4442](https://github.com/getpaseo/paseo/pull/4442)) +- Fixed a crash loop after closing the last content tab in a workspace ([#4844](https://github.com/getpaseo/paseo/pull/4844)) +- Fixed New Agent crashing when an enabled provider published duplicate model IDs ([#4839](https://github.com/getpaseo/paseo/pull/4839)) +- Fixed long drafts growing behind the chat header on Android and iOS ([#4824](https://github.com/getpaseo/paseo/pull/4824)) +- Fixed the Android composer growing taller when the keyboard closed ([#4902](https://github.com/getpaseo/paseo/pull/4902)) +- Fixed the Android composer keeping its height after hold-to-delete emptied a draft ([#4946](https://github.com/getpaseo/paseo/pull/4946)) +- Fixed New workspace setup content not moving up as the composer grows ([#4973](https://github.com/getpaseo/paseo/pull/4973)) +- Fixed the Android timeline scrolling away when tapping or selecting text in a chat ([#5013](https://github.com/getpaseo/paseo/pull/5013)) +- Fixed Paste image failing before an attachment reached the composer on Android ([#4758](https://github.com/getpaseo/paseo/pull/4758)) +- Fixed provider and model pickers not responding on Android tablets ([#4845](https://github.com/getpaseo/paseo/pull/4845) by [@cjcrjc](https://github.com/cjcrjc), [@mkuhl](https://github.com/mkuhl)) +- Fixed Linux desktop packages launching without the Chromium sandbox ([#4447](https://github.com/getpaseo/paseo/pull/4447)) +- Fixed `paseo daemon stop` shutting down a daemon other than the selected one ([#4575](https://github.com/getpaseo/paseo/pull/4575)) +- Fixed restored archived workspaces showing empty Changes and Commits ([#4926](https://github.com/getpaseo/paseo/pull/4926)) +- Fixed a rejected plan appearing below the follow-up message that rejected it ([#4756](https://github.com/getpaseo/paseo/pull/4756)) +- Fixed Cmd/Ctrl+F not opening chat Find while the composer had focus ([#4991](https://github.com/getpaseo/paseo/pull/4991)) +- Fixed the linked pull request going undetected when a branch remote is a repository URL ([#4862](https://github.com/getpaseo/paseo/pull/4862)) +- Fixed a plugin's filtered agent list replacing the app's own directory subscription ([#4596](https://github.com/getpaseo/paseo/pull/4596)) +- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737)) +- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895)) +- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix)) +- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme)) +- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413)) +- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927)) +- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945)) +- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698)) +- Fixed the Explorer showing `+0 -0` and "No changes" while being dragged open ([#4861](https://github.com/getpaseo/paseo/pull/4861)) +- Fixed the reconnect toast restarting its entrance animation when opening a saved chat ([#4925](https://github.com/getpaseo/paseo/pull/4925)) +- Fixed raised shadows around Android file rows in the changed-files sheet ([#4898](https://github.com/getpaseo/paseo/pull/4898)) + +## 0.8.0 - 2026-09-10 + +Paseo 0.8 adds plugin header buttons, custom providers, and richer chat components, alongside fixes for desktop updates and mobile keyboards. + +**Before upgrading:** the macOS desktop app now requires macOS 13 or newer; Linux and Windows support is unchanged. Plugins written for 0.7 need the [0.8 migration guide](https://paseo.sh/docs/plugins/v0.8/migration), including separate client/server entries and the revised composer pill API. + +### Added + +- Added answer forms for Codex questions asked while the agent continues working ([#4587](https://github.com/getpaseo/paseo/pull/4587)) +- Added Mark as unread to finished workspaces in the sidebar menu ([#3603](https://github.com/getpaseo/paseo/pull/3603) by [@edihasaj](https://github.com/edihasaj)) +- Added an in-app What's new sheet in the help menu, Settings → About, and desktop update callout ([#4576](https://github.com/getpaseo/paseo/pull/4576)) +- Added Import session to the sidebar footer, History, and Command Center ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added search, pagination, and workspace filtering to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Added sidebar item reordering and visibility controls in Appearance settings ([#4203](https://github.com/getpaseo/paseo/pull/4203)) +- Added provider notifications from Pi extensions and OpenCode to the timeline ([#3411](https://github.com/getpaseo/paseo/pull/3411) by [@trim21](https://github.com/trim21)) +- Added a fullscreen Mermaid viewer on web and desktop ([#4107](https://github.com/getpaseo/paseo/pull/4107) by [@gengjiawen](https://github.com/gengjiawen)) +- Added Copy, Copy line, and Select all to the web diff context menu ([#4229](https://github.com/getpaseo/paseo/pull/4229)) +- Added Gajae Code to the ACP provider catalog ([#3471](https://github.com/getpaseo/paseo/pull/3471) by [@Yeachan-Heo](https://github.com/Yeachan-Heo)) +- Added per-provider controls for disabling all or selected Paseo tools ([#4277](https://github.com/getpaseo/paseo/pull/4277) by [@mcowger](https://github.com/mcowger)) +- Added explicit approval before running setup, automatic terminals, or scripts in fork pull request workspaces ([#4215](https://github.com/getpaseo/paseo/pull/4215)) +- Added Hub follow-ups to existing agents, including archived workspace recovery ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Added provider catalog discovery to Hub executions ([#4242](https://github.com/getpaseo/paseo/pull/4242)) + +### Plugins + +- Added custom providers with their own icons, settings, permissions, and timeline rendering ([#4314](https://github.com/getpaseo/paseo/pull/4314)) +- Added provider session recovery after plugin reload ([#4629](https://github.com/getpaseo/paseo/pull/4629) by [@mcowger](https://github.com/mcowger)) +- Added custom timeline components and persisted timeline rows ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added client slash commands that run without a provider turn ([e34aea2](https://github.com/getpaseo/paseo/commit/e34aea206e6ec7ac2d5fa9766db28fb221ae990c)) +- Added plugin settings screens under Settings → Plugins ([#4357](https://github.com/getpaseo/paseo/pull/4357)) +- Added lifecycle hooks for agent creation, turns, permissions, and archive events ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added transforms for new agents' provider, model, environment, MCP servers, and workspace isolation ([#4435](https://github.com/getpaseo/paseo/pull/4435)) +- Added header buttons and composer pills with actions, menus, custom popovers, and live updates ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Added custom modal bodies with scrolling, keyboard-aware inputs, and clipboard actions ([#4392](https://github.com/getpaseo/paseo/pull/4392)) +- Added terminal creation, input, capture, and management to the SDK ([#4358](https://github.com/getpaseo/paseo/pull/4358)) +- Added live project subscriptions through `paseo.projects.subscribe()` ([#3983](https://github.com/getpaseo/paseo/pull/3983) by [@omercnet](https://github.com/omercnet)) +- Added permission responses through `agent.respondToPermission()` ([#3985](https://github.com/getpaseo/paseo/pull/3985) by [@omercnet](https://github.com/omercnet)) +- Added provider usage queries through `providers.listUsage()` ([#4062](https://github.com/getpaseo/paseo/pull/4062) by [@ivanbrykov](https://github.com/ivanbrykov)) +- Added supported Paseo version requirements to plugin manifests ([#4430](https://github.com/getpaseo/paseo/pull/4430)) +- Changed plugins to separate client and server entries with runtime-specific SDK imports ([#4347](https://github.com/getpaseo/paseo/pull/4347) by [@liujin0506](https://github.com/liujin0506), [@panrafal](https://github.com/panrafal)) +- Changed composer pill registration to use `button`, `registration.update()`, and `registration.remove()` ([#4577](https://github.com/getpaseo/paseo/pull/4577)) +- Changed `paseo plugin ls` to show runtime state, installed commit, and load errors without contacting a remote ([#4265](https://github.com/getpaseo/paseo/pull/4265)) +- Fixed workspace plugin panels missing from the Explorer menu ([#4446](https://github.com/getpaseo/paseo/pull/4446)) + +### Changed + +- Changed the desktop app to require macOS 13 or newer; macOS 12 stops receiving desktop updates ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Changed `--host` to a global CLI option: `paseo --host ` ([#4238](https://github.com/getpaseo/paseo/pull/4238)) +- Changed the sidebar footer Home button to Import session ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Changed `paseo hub init` and default `paseo hub deploy` to use organization triggers in `.paseo/triggers/` ([#4517](https://github.com/getpaseo/paseo/pull/4517)) +- Changed Codex Fast availability to the supported model list, including GPT-6 Astra ([#4640](https://github.com/getpaseo/paseo/pull/4640)) +- Changed CLI installs to resolve patched React 19.1.x releases ([#4100](https://github.com/getpaseo/paseo/pull/4100) by [@liujin0506](https://github.com/liujin0506)) + +### Improved + +- Reconnected every host immediately when returning to the app ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Smoothed mobile keyboard transitions by moving the timeline and composer together ([#4275](https://github.com/getpaseo/paseo/pull/4275)) +- Reduced stalls when opening large diffs ([#4574](https://github.com/getpaseo/paseo/pull/4574)) +- Reloaded only providers whose configuration changed ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Removed unsolicited timeline and catalog traffic from idle SDK connections ([#4470](https://github.com/getpaseo/paseo/pull/4470)) +- Added macOS updater diagnostics to App Diagnostics ([#4322](https://github.com/getpaseo/paseo/pull/4322)) + +### Fixed + +- Fixed macOS desktop updates closing the app without installing the new version ([#4322](https://github.com/getpaseo/paseo/pull/4322)) +- Fixed the desktop updater reporting up to date before release manifests were available ([#4201](https://github.com/getpaseo/paseo/pull/4201)) +- Fixed `paseo restart` failing from the desktop-installed CLI on Linux `.deb`, RPM, and tarball installs ([#4207](https://github.com/getpaseo/paseo/pull/4207)) +- Fixed the packaged macOS Dock icon rendering oversized ([#4389](https://github.com/getpaseo/paseo/pull/4389)) +- Fixed the mobile terminal keyboard closing and reopening when using toolbar keys or Enter ([#4469](https://github.com/getpaseo/paseo/pull/4469)) +- Fixed cached conversations and workspace lists failing to appear when live updates arrived during loading ([#4436](https://github.com/getpaseo/paseo/pull/4436)) +- Fixed queued messages not sending after a turn completed while the app was in the background ([#4160](https://github.com/getpaseo/paseo/pull/4160)) +- Fixed Codex reloads and follow-ups after workspace archive failing with an active-writer error ([#4353](https://github.com/getpaseo/paseo/pull/4353)) +- Fixed repeated Claude compaction updates leaving extra Compacting rows spinning ([#4391](https://github.com/getpaseo/paseo/pull/4391) by [@tomgrin10](https://github.com/tomgrin10)) +- Fixed Pi agents stuck running after an extension-triggered turn ([#3849](https://github.com/getpaseo/paseo/pull/3849) by [@mjakl](https://github.com/mjakl)) +- Fixed delayed workspace creation navigating away after you switched elsewhere ([#2986](https://github.com/getpaseo/paseo/pull/2986) by [@cleiter](https://github.com/cleiter)) +- Fixed New workspace losing the selected model after creating a workspace ([#4401](https://github.com/getpaseo/paseo/pull/4401)) +- Fixed a crash when the selected provider disappeared during workspace creation ([#4332](https://github.com/getpaseo/paseo/pull/4332)) +- Fixed Codex model rows staying on Loading in the model picker ([#4283](https://github.com/getpaseo/paseo/pull/4283) by [@colonelpanic8](https://github.com/colonelpanic8)) +- Fixed nested subagents appearing under the root agent instead of their actual parent ([#4321](https://github.com/getpaseo/paseo/pull/4321)) +- Fixed repeated tool-call IDs across autonomous turns producing duplicate timeline rows ([140b0bb](https://github.com/getpaseo/paseo/commit/140b0bb716205cf0e00c0ff5b6b3c20af6c79413)) +- Fixed Import session loading forever when one provider hung ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed Import session ignoring Claude Code `/rename` titles ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed importing a session creating a duplicate workspace for an already-open directory ([#4216](https://github.com/getpaseo/paseo/pull/4216)) +- Fixed empty ACP sessions appearing in Import session ([#4335](https://github.com/getpaseo/paseo/pull/4335)) +- Fixed restored terminal titles briefly showing Terminal before loading ([#4553](https://github.com/getpaseo/paseo/pull/4553)) +- Fixed diff file headers lagging behind while scrolling on web and desktop ([#4240](https://github.com/getpaseo/paseo/pull/4240)) +- Fixed Changes and its diff going out of sync when switching between Committed and Uncommitted ([#4199](https://github.com/getpaseo/paseo/pull/4199)) +- Fixed Mermaid diagrams shrinking during streaming or becoming corrupted on reload ([#4210](https://github.com/getpaseo/paseo/pull/4210)) +- Fixed Settings navigation retaining closed views in memory on desktop ([#4228](https://github.com/getpaseo/paseo/pull/4228)) +- Fixed the file watcher missing nested directory changes on Windows ([#4354](https://github.com/getpaseo/paseo/pull/4354)) +- Fixed daemon Git commands allowing a repository's `core.fsmonitor` to execute its own command ([#4208](https://github.com/getpaseo/paseo/pull/4208)) +- Fixed terminal activity reporting for OpenCode 2 sessions ([#4300](https://github.com/getpaseo/paseo/pull/4300) by [@mr-karan](https://github.com/mr-karan)) +- Fixed the website offering downloads for the wrong platform before loading ([#4222](https://github.com/getpaseo/paseo/pull/4222)) + ## 0.7.2 - 2026-09-02 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index b62a53d409..e1ac653338 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine. -**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi. +**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, Pi, Antigravity, and Muse Code. ## Repository map @@ -106,6 +106,13 @@ Repo dev commands use checkout-local state by default. In this checkout, `PASEO_ See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging. +## Release branches + +When the user says "this goes to next", create or +retarget the PR to `next` and preserve that destination through delivery. Follow +[release branch discipline](docs/release.md#release-branch-discipline) for creating +and updating `next`, integrating it after a release, and releasing a hotfix from a tag. + ## Critical rules - **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process. @@ -118,6 +125,7 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run --bail=1 > /tmp/test-output.txt 2>&1` then read the file. - Never re-run a test suite that another agent already ran and reported green — trust the result. - For full suite verification, push to CI and check GitHub Actions instead. +- Add tests to existing suites and reuse their npm scripts and CI jobs instead of creating feature-specific ones. - **Always run typecheck and lint after every change.** - **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current: - `npm run build:client` — rebuild protocol and client declarations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8eaaf5c0c7..b25810262f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,140 +1,104 @@ # Contributing to Paseo -Thank you for taking the time to contribute to Paseo. +This guide is here to save us both time and help you find a useful way to contribute. + +Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect. ## Philosophy -Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md). +**Paseo has a lean, opinionated core built to be extended.** -Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. +The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control. -I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it. +The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer. -## Report bugs in GitHub issues or Discord +Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops. -Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH). +## Build a plugin -> [!IMPORTANT] -> Feature requests opened as issues will get closed automatically +Most specialized workflows and integrations are better served by plugins. -If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis. +Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk). -You may submit a PR to fix a bug, please read the PR guidelines below. +If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core. -## Product discussions go in GitHub Discussions or Discord +## Report bugs -There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow: +Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template. + +Include: + +- What you did, what you expected, and what happened. +- Reproduction steps, Paseo version, and platform. +- Relevant logs, screenshots, or a recording. + +If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification. + +Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below. + +## Discuss workflows before proposing features + +Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed. + +Explain: - What are you trying to do? - How do you do it today? - Where does Paseo get in the way? -- What would the flow look like if it worked for you? +- What would a better workflow look like? -"Can you build X" is not useful because it doesn't tell me about your problem. +This helps other people describe their needs and gives me useful context when deciding what to build. -A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build. +There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying. -Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me. +## If you still want to submit a pull request -## Pull requests +After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome. -Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. +**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation. -Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at. +Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product. -Here is the criteria I use to decide: +I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do. -✅ Likely to be accepted +If you choose to submit a PR, these are the basics to cover. -- Fits the product vision -- One focused change -- Links the bug it fixes, or the discussion behind it if there is one -- Addresses bot reviews -- Explains the problem you're solving -- QA evidence -- Automated tests -- Screenshots or video for UI changes, on every affected platform -- Says which platforms you tested and which you didn't -- Maintainer edits enabled +### What I look for in a core feature -⛔️ Will be rejected +- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin. +- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task. +- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent. +- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer. -- Bundles unrelated changes -- Fails checks -- Ignores bot reviews -- Takes a feature or the design in a direction I don't want -- No QA evidence -- No tests -- Clearly fully AI-generated PR +### Keep the scope small -### What to expect +Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own. -- PRs that were explicitly approved in a discussion are preferred. -- Unsolicited PR can be closed without a detailed review. -- Your PR can be narrowed, refactored, or redesigned. -- Your PR might be accepted but not merged immediately. -- You will be attributed for your work in the changelog, even if I redesign your PR +### QA evidence -## QA evidence +Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature. -QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar. +Explain the problem and link the relevant bug report or discussion. Include: -Pull requests without evidence will be closed. +- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version. +- The commands you ran and their output. +- A recording for interactive UI changes, or before-and-after screenshots for static changes. +- The platforms you tested and any affected platforms you could not test. -What's accepted: +The [QA guide](docs/qa.md) explains the expected evidence and available tooling. -- The shell commands you ran, pasted with their output -- The tests you added and their results -- Before and after screenshots -- A video of the whole interaction -- Logs, requests, responses +Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar. -Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static. +**Satisfying all of the above does not mean your pull request will be merged.** -The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it. +### What happens to your contribution -## I want to become a maintainer +If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.** -There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement. +A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume. -## FAQ +## Help the community -### I'm blocked, where do I go? +Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop. -Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there. - -### Do you prefer Github or Discord? - -I am more active on Discord for general questions. - -I prefer Github for bugs because the template helps collect information more efficiently. - -### Why was my feature request closed? - -Issues are only for bugs. Open it as a product discussion instead, framed as a workflow. - -### Why didn't my PR get a detailed review? - -Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume. - -### How long until someone looks at my pull request? - -It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask. - -### Why do you want tests and a video for a small change? - -Because it's faster and safer than me checking out and running every PR myself to test it manually. - -QA evidence help me take your PR seriously for a deeper review and maintainer QA. - -### I only have one platform, can I still contribute? - -Yes. Test what you have, and say what you didn't test. - -### Can I use an agent to write my contribution? - -Yes, as long as you understand what you're submitting and you tested it yourself. - -### Is there a roadmap I can read? - -[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next. +There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility. diff --git a/README.ja.md b/README.ja.md index 6629ccc397..c2d1ad88a0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -151,6 +151,12 @@ npm run build:server npm run typecheck ``` +## スポンサー + +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 + + + ## 関連プロジェクト - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー diff --git a/README.ko.md b/README.ko.md index 66f74f45fa..60581767cd 100644 --- a/README.ko.md +++ b/README.ko.md @@ -161,6 +161,12 @@ npm run build:server npm run typecheck ``` +## 스폰서 + +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. + + + ## 관련 프로젝트 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir로 작성한 공식 분산형 릴레이 diff --git a/README.md b/README.md index 3478e15e36..2fc0fc8f3d 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

-

One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.

+

One interface for Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code agents.

Paseo app screenshot @@ -39,14 +39,34 @@ Paseo mobile app

-Run agents in parallel on your own machines. Ship from your phone or your desk. +Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android. +- **Parallel agents:** Run many agents at once, each in its own worktree. +- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers. +- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want. - **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills. -- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job. +- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job. - **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it. - **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal. - **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins. +[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. + +## Plugins + +Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, +iOS, and Android. Write a plugin once and it is on your phone. + +- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes. +- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends. +- **Providers:** add a coding agent as a provider. + +Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory. + +**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)** + +Plugins run with access to your daemon machine and inside connected clients; install only code you trust. + ## Getting Started Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it. @@ -60,6 +80,8 @@ You need at least one agent CLI installed and configured with your credentials: - [GitHub Copilot](https://github.com/features/copilot/cli/) - [OpenCode](https://github.com/anomalyco/opencode) - [Pi](https://pi.dev) +- [Antigravity](https://paseo.sh/docs/supported-providers#antigravity) +- [Muse Code](https://paseo.sh/docs/muse-code) ### Desktop app (recommended) @@ -67,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r To connect from your phone, open **Settings → your host → Pair Device**. -### CLI / headless +### Server -Install the CLI and start Paseo: +For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon: ```bash npm install -g @getpaseo/cli paseo ``` -Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines. +Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host. For full setup and configuration, see: @@ -185,6 +207,12 @@ npm run build:server npm run typecheck ``` +## Sponsors + +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. + + + ## Related projects - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir diff --git a/README.zh-CN.md b/README.zh-CN.md index c9c643b48f..dfdf673b2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -151,6 +151,12 @@ npm run build:server npm run typecheck ``` +## 赞助 + +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 + + + ## 相关项目 - [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay,使用 Elixir 编写 diff --git a/SECURITY.md b/SECURITY.md index 1c95ca9a78..ea23984fa0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly. +When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available. + ## Local daemon trust boundary -By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address. +By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools. -The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer ` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks. +The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens. + +The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission. Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. diff --git a/docs/ad-hoc-daemon-testing.md b/docs/ad-hoc-daemon-testing.md index 87176d24b4..c12f003df9 100644 --- a/docs/ad-hoc-daemon-testing.md +++ b/docs/ad-hoc-daemon-testing.md @@ -51,7 +51,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", // see gotcha #1 }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... do your testing ... @@ -81,7 +81,7 @@ const client = new DaemonClient({ appVersion: "0.1.70", }); await client.connect(); -await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); +await client.fetchAgents({ subscribe: {} }); // ... test ... diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d9f9710599..ee763aa1f9 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,6 +21,10 @@ the agent runs through `ensureAgentLoaded()`, which resumes the durable provider same Paseo agent ID. Provider history is not appended again when the canonical timeline is already primed. +Reload releases the old runtime before resuming its durable session: an idle provider process can +still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the +replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable. + Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle action such as archive, replacement, reload, workspace teardown, or daemon shutdown. @@ -30,8 +34,8 @@ workflows all live in the CLI process, and the completion notification that woul agent never arrives. A runtime that dies mid-turn is reported by whatever is draining its stream, but between turns nothing is watching, so the agent sits at `idle` looking healthy while its background work is gone. Report that exit as a turn failure so the agent lands in `error` with a timeline entry. -Only the Claude provider does this today; the others still report a death only when a turn happens to -be in flight. +Claude and OMP report exits between turns. OMP relaunches from its session file on the next prompt; +the unfinished turn is lost. ### Cancellation @@ -65,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. -Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. +Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. -`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. +`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status. A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown. @@ -80,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime. The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle. +## Background workspaces + +`workspace.background` controls discovery only. Background workspaces contain ordinary durable +agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery +keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot +change it. New workspaces default to false, inherit the caller workspace's value when an agent +creates one without an override, and accept explicit true or false. + +The daemon derives agent discovery from workspace ownership. Default workspace and agent lists, +including History requests, exclude background work; `filter.includeBackground` includes it. +Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only +its sidebar with **Show background**, so direct navigation and restored tabs remain available. + +Sequenced directories keep complete canonical collections. A filtered read must never replace +those collections with its subset. Inclusive and default observations have distinct cursor +scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache. +Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`. + +### Private daemon helpers + +Branch-name and commit-message generators still use private disposable internal runtimes. +`AgentManager` does not persist them or their provider sessions, excludes them from public +discovery and plugin hooks, and drops their timelines on archive. An archived helper retains +its exact-ID result briefly to cover completion before a waiter attaches. This internal +lifecycle is separate from public workspace visibility and is not a public creation option. + ## Archive Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. @@ -105,16 +135,28 @@ archived workspace. History navigation must not infer workspace lifecycle from ` or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state; only the route's explicit Unarchive or Restore action changes the archived workspace. -History navigation preserves the selected agent as an explicit recovery target. If both that agent -and its workspace are archived, the workspace recovery action restores the workspace and unarchives -the selected agent as one user action. Other archived agents in the restored workspace remain -recoverable from History. Opening one pins its tab and renders the archived-agent callout. Authoritative -timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must -leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains -the only transition back to an interactive runtime: it runs the provider's native unarchive hook -(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A -provider session can be archived outside Paseo while its Paseo agent remains active. Interactive -resume repairs that drift through the provider's native unarchive hook; history resume does not. +History navigation opens the selected agent without changing either archive state. Workspace +**Restore** recovers only the workspace; the selected archived agent stays open with its callout. +The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and +history hydration. Other archived agents stay archived. + +Opening an agent is a navigation choice, independent of whether its details are cached. The +layout retains that choice across reload while the panel fetches the agent from the daemon. +Once the daemon reports the agent active, its tab follows normal archive propagation again. +An empty active list cannot cancel an explicit History selection. Agent-detail loading does +not own selection or release the explicit open. + +Persisted resume, native restore, and both live and stored-only archive enter the same per-agent +lifecycle queue. Resume chooses its history or interactive purpose from the durable record after +entering that queue. Shutdown must finish before the manager releases runtime ownership; a failed +close retains the runtime for cleanup and blocks replacement through that close operation. + +Authoritative timeline catch-up can use a runtime-only `history` resume purpose. For Codex, that +purpose initializes a temporary app-server, reads the persisted thread and child histories, and +releases the process before returning. It never loads, resumes, or unarchives a native thread, +including legacy records whose native archive failed. The retained history session contains only +the read results. Interactive resume remains responsible for repairing a provider session archived +outside Paseo while its Paseo agent is active. Provider session connection owns every process it spawns until the session is registered with `AgentManager`. If initialization, persisted-session resume, or initial history hydration fails, @@ -144,6 +186,17 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. +A finished workspace can be marked unread after it has been reviewed. The daemon restores +`finished` attention on its newest eligible workspace-root agent without sending a new completion +notification. Opening the workspace clears that attention through the normal focus flow. + +Attention is set by the agent finishing or failing and cleared by the client's +`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is +neither, so resuming carries the stored attention and the stored last-activity time through +untouched. Forging either makes a background resume look like the user read a workspace and like the +agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted +`updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. @@ -156,9 +209,9 @@ The rows combine two kinds of children: parentAgentId === thisAgent.id AND !archivedAt ``` -- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. +- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. @@ -170,11 +223,13 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta - **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent. - **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. -- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. +- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`. - **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline. - **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. - **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. -- **On replay, `/subagents/` holds every descendant, not just this session's children.** `agent-.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22. +- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one. +- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent. +- **On replay, `/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work. - **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index 0d2fd30fa6..b31a3fd9ee 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -12,7 +12,8 @@ provider deltas (every provider streams incrementally) → recordTimeline: one canonical row per flushed item → agent_stream ws message → reducer queue (app, one commit per frame) → session store - → paced reveal (app, per assistant/reasoning item) → markdown blocks → paint + → source-item plugin transforms → native Markdown blocks / tool grouping + → paced reveal (app, per displayed item) → paint ``` Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`. @@ -28,6 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`. - **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not. - **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal. +- **Markdown blocks belong to presentation, and every assistant message is a block group.** + `agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same + way whether it arrived streaming or as fetched history. Splitting in the reducer instead would + discard paragraph separators and expose fragments to plugin callbacks, which need the whole source + text. Live-head work stays cheap by parsing only the growing last block during append, retaining + completed blocks by object identity, and caching the split per source item so a tail change does + not re-split history. History was once left whole to keep cross-block Markdown context; that gave + one message two shapes depending on how it arrived, and message-addressed features only worked on + one of them. +- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with + `blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two + being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its + message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything + addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts + the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for + React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image + occurrence keys. - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. diff --git a/docs/android.md b/docs/android.md index b25e775cf0..f04ce945bb 100644 --- a/docs/android.md +++ b/docs/android.md @@ -120,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. +## Inverted timeline selection + +Android focus and selection visibility requests must not reposition inverted timelines. The +`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for +child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll +commands still work; non-inverted scroll views keep Android's default behavior. + +Register this package before React Native's core package through its Expo config plugin. Normal +Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does +not change the shipped scroll view. Keep this behavior in the app's compiled native module. + ## F-Droid / source-only Android builds F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild: diff --git a/docs/architecture.md b/docs/architecture.md index 0ed8c46ade..4ed25ba1c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,15 @@ The heart of Paseo. A Node.js process that: All paths are under `packages/server/src/`. +Desktop and CLI import server capabilities through explicit package subpaths such as +`@getpaseo/server/daemon-control`, `/configuration`, and `/process`. The root export +loads daemon bootstrap eagerly, even when a caller only uses a path or process helper. +Keep process-management and configuration dependencies independent of bootstrap and +WebSocket message schemas. Shared configuration schemas belong in protocol leaf modules; +`messages` imports and re-exports them. This prevents every supervising process from +retaining the daemon's runtime and wire-schema allocations. The server export tests guard +these dependency trees with tree-shaking disabled to match unbundled production imports. + Project identity is daemon-global rather than session-owned. After registry bootstrap, the daemon's project Git observer keeps one non-recursive watch on each lexically equivalent active project root and listens only for the root `.git` entry, with a slow rescan as a missed-event fallback. It runs @@ -94,9 +103,10 @@ facade. App and CLI may import the low-level driver from `@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped code imports from `@getpaseo/client`. -`PaseoApi` is the capability-only boundary over workspaces, agents, providers, and config. +`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config. `PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected -host's client; plugin subprocesses use the same facade over a host-owned IPC transport. +host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow +[the client contract](protocol-compatibility.md#client-capability-ownership). ### `packages/app` — Mobile + web client (Expo) @@ -144,7 +154,7 @@ traffic. Workspace assignments stay on the workspace directory sequence. Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`). - `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode` -- `paseo daemon start/stop/restart/status/pair/set-password` +- `paseo daemon start/run/stop/restart/status/reload/config/pair/set-password` - `paseo terminal ls/create/capture/send-keys/kill` - `paseo script ls/start/stop` - `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete` @@ -238,11 +248,22 @@ Server → Client: status message with payload { status: "server_info", serverId, hostname, version, capabilities?, features } ``` -There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +There is no dedicated welcome message; the server emits one initial `status.server_info` after +accepting hello. Modern clients negotiate `owned_subscriptions` against +`features.ownedSubscriptions`. Connecting or attaching a local listener creates no application-data +demand. A list is a snapshot; adding `subscribe: {}` creates an independent server ID. The client +correlates the initial reply by request ID, then routes updates by subscription ID. + +A logical Session can contain several physical sockets. Each socket owns its capabilities, +subscriptions and ongoing operations; the final JSON/binary send boundary requires that source's +request or subscription provenance. Domain modules own filtering, bootstrap and resource teardown. +The shared ownership boundary only tracks source and lifetime. Releasing a subscription acknowledges +its teardown; reconnect creates new server IDs for surviving client handles. See +[protocol compatibility](protocol-compatibility.md) for the legacy boundary. **Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages). -Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead. +Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC or RFC6455 control ping. Current clients ping every 10 seconds, beginning one interval after connecting. The first ping claims an application-ownership lease for that physical socket, all later inbound activity renews it, and the daemon forcibly terminates the socket if the lease expires. A legacy or raw socket that never sends an application ping never enters this lease and is not closed for omitting one. Session RPC timeouts are operation failures, not proof that the socket is dead. A subscription bootstrap timeout closes its source to clear any unknown server-owned registration; the failed handle is released before other surviving handles reconnect. Every physical send path enforces an 8 MiB outbound high-water mark, including JSON broadcasts, binary terminal frames, and the encrypted relay adapter's asynchronous queue. This sits above the terminal stream's 4 MiB soft backpressure threshold, leaving room for snapshot catch-up before the hard cutoff. JSON is serialized once per broadcast after sockets already at the limit are removed, then its exact byte length is checked for every remaining socket. A frame that would cross the limit is not sent; that physical socket is forcibly terminated without disturbing other sockets attached to the same logical session. Multiple tabs and simultaneous direct and relay paths may legitimately share a client id. @@ -296,7 +317,7 @@ messages or transfers. - WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required. - New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`. -- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`. +- Capabilities belong to the physical source socket. `session.supports(...)` reads the requesting or observing source at the domain projection boundary; one socket must not borrow a sibling's capabilities. Example: adding a new enum value @@ -307,6 +328,40 @@ Example: adding a new enum value // 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value" ``` +### Creation ownership + +Creation is owned by the daemon across socket lifetimes. A workspace request can include +its initial agent and prompt. Workspace readiness is published before provider startup; +the app keeps its existing disk-ready navigation and observes the remaining creation. +Callbacks do not advance the workflow. Resource reservations in acknowledgement are +identities, not ready workspace records. + +The creation journal (`server/creation/`) owns identity and execution for both legacy and +modern creation RPCs. Requesting progress never selects a different journal. Existing +agent receipts are imported at this boundary; message delivery receipts remain separate. +It keeps cumulative milestones and permanent IDs under an operation kind and idempotency +key. Reconnect subscribes to that key; retries +join active work or reuse committed stages. A persisted resource alone cannot prove +that a provider accepted its initial prompt. Interrupted side effects with no conclusive +receipt return an unknown outcome instead of being repeated. + +Agent registration commits creation. Rejecting the first prompt is a turn error on that +agent; reporting creation failure leaves an orphaned agent and invites another Create. +Receipt replay must not resend the prompt. + +`packages/client/src/creation/` owns capability selection and legacy orchestration. +Callers always pass the initial prompt to agent creation. On an older host, the client +adapts keyed creation to the legacy create/send sequence; it cannot continue that +sequence after the client process disappears. Explicitly requested IDs or receipts +that an old host cannot honor produce an unsupported error. Keep these adapters inside +the client package, as an exception to the default no-fallback feature policy. + +Creation executes through the existing Session capabilities. Connection-owned delivery +controls observation only: detaching a socket or cleaning up its Session does not cancel +accepted creation. Updates require an explicit subscription and go only to that socket; +reconnect uses the shared subscription owner. Legacy consumers, including Hub, keep their +existing response contract. + ## Agent lifecycle The lifecycle states are defined in `shared/agent-lifecycle.ts`: @@ -417,12 +472,16 @@ $PASEO_HOME/ ├── config.json # Daemon config (mutable) ├── daemon-keypair.json # Daemon identity for relay/E2EE ├── push-tokens.json # Mobile push tokens -├── paseo.sock / paseo.pid # Local IPC socket and pidfile +├── paseo.pid # Supervisor identity and published bound endpoint └── daemon.log # Daemon trace logs (rotated) ``` +The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clears it before respawn, and fails if any worker exits before first readiness. CLI home selection trusts only that live record; config expresses desired state, never an endpoint fallback. POSIX home stop signals the captured supervisor without TCP. Windows graceful stop and ordinary RPCs trust the published endpoint. This metadata is not cryptographic listener ownership: edited endpoints, copied identities, PID reuse, and address takeover races remain outside that guarantee. A stale heartbeat never permits reclaiming a live lock. + +Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management). + ## Deployment models 1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767` -2. **Managed desktop**: Electron app spawns daemon as subprocess, and stops it again on quit so that "restart the app" is a complete reset. Settings > Host > "Keep daemon running after quit" opts out. Only a daemon the desktop started is stopped — a daemon you started yourself with `paseo daemon start` is left alone (`paseo.pid` records `desktopManaged`). +2. **Managed desktop**: Electron uses the server package’s local-instance lifecycle capability for sanitized launch, published readiness, and captured-PID stop. Only a matching `{pid, startedAt}` from a spawn in the current Desktop session authorizes automatic stop or binary replacement. Preexisting instances, including legacy `desktopManaged` records, are attach-only. Keep-running survivors attach in the next session. Explicit attached Stop requires a confirmation naming the captured home/PID. Ordinary Restart uses the worker RPC on every app platform. 3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md index 734dda3c45..f760fafa67 100644 --- a/docs/browser-capture-harness.md +++ b/docs/browser-capture-harness.md @@ -12,8 +12,7 @@ It validates the compositor behavior that unit tests cannot see: by retrying until the frame appears; - both viewport `capturePage` and full-page CDP screenshots return real pixels from the permanent production parking state; -- guest background throttling can be disabled once at attach without per-capture - renderer coordination; +- parked guests remain capturable with Chromium background throttling enabled; - the real-Electron host-composer sentinel proves guest Enter cannot submit a focused host composer; - the automation group loads the compiled production keyboard boundary and guest @@ -44,6 +43,18 @@ Run the shared browser profile fixture with: PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop ``` +On macOS, test native titlebar dragging after scrolling with: + +```bash +PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop +``` + +This group needs Xcode Command Line Tools and Accessibility permission for the +terminal running it. It sends real mouse input to its test window and checks +window movement, including button clicks and a portal over the titlebar. Leave +the mouse idle during this group. It reads the app's production CSS and writes +screenshots and window bounds to the output directory. + The browser profile group runs two Electron processes in sequence. It verifies that each renderer-side `did-attach` identity maps to the correct main-process guest, that two live tabs share cookies and local storage through one persistent session, and that the data is @@ -73,11 +84,17 @@ The harness writes PNG evidence and `results.json` to: packages/desktop/capture-harness/out/ ``` -A passing run prints `PASS` lines for the production P1 attach-off parking state, +A passing run prints `PASS` lines for the production P1 default-throttling parking state, including fresh, settled, 75-second soak, multi-tab, viewport, and full-page checks. The PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport is usually saved as 2560x1600. +The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey +verifies that a hidden window stops guest animation, captures fresh viewport pixels, +and resumes animation after restoring the window. Its artifacts include the screenshot +and animation measurements. Full-page content correctness remains separately tracked in +[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196). + ## Mechanism Electron captures copy from the guest web contents' compositor surface. A resident @@ -96,8 +113,11 @@ plane stays below the overlay plane regardless of body insertion order; menus ke layering inside `overlay-root`. Activating a presented browser also focuses its registered guest `WebContents` in main so macOS assigns keyboard first-responder ownership to the page. -There is no renderer prep/restore handshake. Main disables guest background throttling -once when the webview attaches, then screenshot capture uses the shared serialized queue, -invalidates before each attempt, and retries known first-frame failures within the -5-second capture budget. Viewport screenshots use `capturePage({ stayHidden:false })`; -full-page screenshots use the existing CDP path with layout metrics and screenshot clip. +There is no renderer prep/restore handshake or lifetime background-throttling override. +Screenshot capture temporarily enables frame production inside the shared serialized queue, +restores the previous throttling policy on success or failure, and retains a 5-second capture budget. +The browser tool takes one viewport frame through Electron's frame subscription and releases the +subscription on completion or cancellation. A resized resident guest can produce fresh pixels +while `capturePage()` leaves its surface-copy request pending; waiting for animation frames or +retrying the copy does not repair that state. Full-page screenshots retain the CDP path with layout +metrics and screenshot clip. diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 9d932b32dc..27f9585236 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -92,7 +92,7 @@ For testing rules, see [testing.md](testing.md). - Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects. - Collection rows do not independently subscribe to a high-frequency global store. The collection owner selects structurally shared indexes once, derives a keyed row model with `useMemo`, and passes entries to rows. This keeps retained hidden collections current without running one selector per row on every store update. - Equality functions prevent React renders; they do not prevent selector callbacks from running. A selector attached to a hot store must be O(1) when its relevant source references have not changed. -- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Do not use `Suspense` or render freezing for this on native: those techniques change native tree ownership instead of merely stopping work. +- Retained native panels use `RetainedPanel`. If an existing gesture/layout wrapper must own visibility, wrap its contents in `RetainedPanelActivity` instead. Keep keyed panel roots in a stable sibling order, include the newly active panel in the same render, centralize subscriptions, and gate genuine effects through `useRetainedPanelActive`. Follow the [native ownership constraints and chat suspension boundary](mobile-panels.md) when stopping hidden rendering. - Infinite animations are subscriptions. Start them only while their retained panel is active, and cancel a shared clock when its final active consumer leaves. Synchronized animations use one clock per animation family and feed active instances through local shared values; retained hidden instances stay mounted but unsubscribed. Match state updates to the actual visual cadence; do not run every style worklet at 60 fps when the rendered value changes only a few times per second. - Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary. - Use stable ids for `key`, never array index for reorderable/filterable lists. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 90c4dc07c3..efc5fdf24f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -31,6 +31,35 @@ agent catalog during startup, raise the limit in milliseconds: The limit applies independently to every provider refresh and covers availability plus the entire catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent. +## Provider options + +Set provider defaults in `agents.providers..options`. At agent creation, use +`providerOptions` on the wire or `config.options` in the SDK. Both are opaque +`Record` values: the provider owns validation and application. +Claude, Codex, and OpenCode reject unknown keys with a key-specific creation error. +Providers without option handling ignore the record. + +The daemon deep-merges the agent's options over the provider defaults at each +session launch or resume. Plain objects merge recursively; arrays, scalars, and +`null` replace the base value. Only the agent's own options are persisted, so edits +to provider defaults apply on the next launch or resume. A profile inherits its +base provider's options unless it sets its own options record. + +```json +{ + "agents": { + "providers": { + "codex": { + "options": { "sandbox_mode": "read-only", "approval_policy": "never" } + } + } + } +} +``` + +See the [SDK provider options guide](../public-docs/sdk/provider-options.md) for +per-agent examples and provider-specific keys. + --- ## Table of Contents @@ -391,7 +420,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` "XDG_CONFIG_HOME": "~/.config/omp-work", "XDG_STATE_HOME": "~/.local/state/omp-work" }, - "params": { + "options": { "sessionDir": "~/.local/state/omp-work/omp/agent/sessions", "rpcTimeoutMs": 60000, "smolModel": "openai/gpt-5-mini", @@ -404,7 +433,7 @@ Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` } ``` -`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `params.rpcTimeoutMs` overrides both deadlines. +`options.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `options.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command. OMP waits 20 seconds for its initial `ready` frame and 60 seconds for later control-plane RPCs by default. `options.rpcTimeoutMs` overrides both deadlines. For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory: @@ -416,7 +445,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere "extends": "pi", "label": "My Pi Fork", "command": ["my-pi-fork"], - "params": { + "options": { "sessionDir": "~/.my-pi-fork/sessions", "rpcTimeoutMs": 60000 } @@ -426,7 +455,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere } ``` -This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `params.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. +This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session `. `options.rpcTimeoutMs` overrides the 60-second Pi control-plane RPC deadline. --- @@ -484,7 +513,7 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) -Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `params.supportsMcpServers: false`: +Paseo tools such as subagent creation come from the shared internal tool catalog. ACP providers receive those tools through the MCP fallback by default because ACP exposes `mcpServers`, not Paseo's native tool catalog. Some ACP adapters cannot create sessions when `mcpServers` is non-empty. Disable injected MCP for those providers with `options.supportsMcpServers: false`: ```json { @@ -494,7 +523,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog "extends": "acp", "label": "My Agent", "command": ["my-agent", "acp"], - "params": { + "options": { "supportsMcpServers": false } } @@ -505,7 +534,7 @@ Paseo tools such as subagent creation come from the shared internal tool catalog ACP agents execute filesystem operations in their own environment by default, while terminal operations run through Paseo on the host. To customize which -operations Paseo handles, configure client capabilities in provider params: +operations Paseo handles, configure client capabilities in provider options: ```json { @@ -515,7 +544,7 @@ operations Paseo handles, configure client capabilities in provider params: "extends": "acp", "label": "Container Agent", "command": ["container-agent", "acp"], - "params": { + "options": { "clientCapabilities": { "fs": { "readTextFile": false, @@ -684,7 +713,7 @@ Every entry under `agents.providers` accepts these fields: | `description` | `string` | No | Short description shown in the UI | | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | -| `params` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | +| `options` | `Record` | No | Provider-specific options such as `supportsMcpServers: false` | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | | `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | @@ -714,7 +743,7 @@ Each entry in the `models` array: ### Claude settings.json model discovery -The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. +The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. diff --git a/docs/data-model.md b/docs/data-model.md index 291c58f736..25a214b11c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -48,6 +48,7 @@ $PASEO_HOME/ ├── server-id # Stable daemon identifier (plain text, "srv_") ├── daemon-keypair.json # E2EE keypair for relay (mode 0600) ├── paseo.pid # Daemon PID lock file +├── local-credential # Per-run local client credential (mode 0600) ├── daemon.log # Default log file (path configurable) ├── agents/ │ └── {sanitized-cwd}/ @@ -64,8 +65,8 @@ $PASEO_HOME/ │ └── managed-processes/ │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap ├── plugins/ -│ ├── sources.json # Git origin, ref, commit, and managed checkout ownership -│ └── {pluginId}/{version}/checkout/ # Source checkout for one installed Git commit +│ ├── sources.json # Managed kind and Git acquisition remote +│ └── {pluginId}/{uuid}/ # Git checkout or npm package/lockfile/dependency tree └── push-tokens.json # Expo push notification tokens ``` @@ -181,8 +182,10 @@ Terminal activity contributes to the workspace status bucket **per `workspaceId` Single file, validated with `PersistedConfigSchema`. `agents.skills.selection` is the daemon host's orchestration-skill preference. Missing means -`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from its three managed -skill directories and keeps config plus filesystem convergence behind one serialized owner. +`{ mode: "all" }`. Installed state is not persisted; the daemon derives it from the shared +`~/.agents/skills` and Claude skill directories and keeps config plus filesystem convergence behind +one serialized owner. Codex discovers the shared directory. Updates retire unchanged files from +Paseo-managed copies in `~/.codex/skills`, preserving user edits and untracked files. `paseo reload` reads and validates this file once inside the daemon. That snapshot drives resolution, classification, application, and reload bookkeeping. `DaemonConfigStore` owns applying runtime-safe @@ -254,11 +257,13 @@ snapshot so a mixed edit can apply its live subset and still name the paths that All fields are optional with sensible defaults. -Git-managed plugins still appear as directory sources in `config.json`. This keeps the plugin -runtime and protocol config compatible with directory-only clients. `plugins/sources.json` owns the -Git-specific origin, tracking ref, installed commit, repository subdirectory, and checkout root. -Paseo writes it atomically. An update creates and validates a new version directory before changing -the configured directory path; successful activation removes the old version. +Managed plugins appear as directory sources in `config.json`; it owns the active path and enabled +state. `plugins/sources.json` is written atomically and stores only managed kind and the Git acquisition +remote. Installed revision, package/subdirectory identity and ownership root come from retained +artifacts and the fixed managed layout; [managed source ownership](plugins.md#managed-source-ownership) +explains their authority. An update prepares a new directory before replacing the active path and +removing the previous version. Old record fields are accepted at the store boundary and ignored; +there is no startup migration or persistent update policy. ### Profile lists @@ -275,6 +280,45 @@ rather than storing something it cannot describe. That is why the client gates t UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an older daemon. +### Agent provider Paseo tools + +`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are +`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal +configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`. + +Each entry may include a Paseo-tool policy: + +```json +{ + "agents": { + "providers": { + "my-claude": { + "extends": "claude", + "label": "My Claude", + "paseoTools": { + "enabled": true, + "disabledTools": ["browser_evaluate"] + } + } + } + } +} +``` + +Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are +allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool +IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool. +Browser tools also require `daemon.browserTools.enabled` and a connected browser host. +This policy controls the catalog presented to an agent. It is not an authorization boundary for +agents that can access the host through a shell. + +`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives +Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a +session is created, resumed, imported, or reloaded, so configuration changes affect the next +session rather than an already-running one. + +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. + ### Git process limits Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per @@ -449,7 +493,7 @@ Array of workspace records. A workspace is a specific working directory within a | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | | `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | | `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | +| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | | `createdAt` | `string` (ISO 8601) | | @@ -516,6 +560,7 @@ These small files are not validated as full Zod schemas but are persisted under | `server-id` | Plain text, e.g. `srv_` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. | | `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. | | `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. | +| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. | | `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. | --- @@ -535,8 +580,8 @@ Right-sidebar client state splits on whether it is determined by the directory o The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and -`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id -and have at most one row per host. +`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint +uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested diff --git a/docs/design.md b/docs/design.md index b98e06cb72..e21270c85d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`. -A pressable styled to look like a button is wrong; the button is ` ); @@ -100,9 +92,9 @@ export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { {error} - {isCompact ? null : retry} + {isCompact ? null : reload} - {isCompact ? {retry} : null} + {isCompact ? {reload} : null} ); } diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 6b9290632a..230a41e2e9 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -24,7 +24,7 @@ import { HostStatusDotSlot } from "@/components/hosts/host-picker"; import { createControlGeometry, type FieldControlSize } from "@/components/ui/control-geometry"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { Switch } from "@/components/ui/switch"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { CadenceEditor } from "@/components/schedules/cadence-editor"; import { SelectField, @@ -661,8 +661,8 @@ function ScheduleTargetFields({ [], ); const modelTriggerLeading = useMemo( - () => , - [state.selectedProvider], + () => , + [state.selectedProvider, state.selectedServerId], ); const renderModelTrigger = useCallback( ({ @@ -1019,11 +1019,17 @@ function ThinkingOptionItem({ ); } -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string | null; +}): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider); return ; } diff --git a/packages/app/src/components/schedules/schedule-row.tsx b/packages/app/src/components/schedules/schedule-row.tsx index f2799fcf54..2af4b33428 100644 --- a/packages/app/src/components/schedules/schedule-row.tsx +++ b/packages/app/src/components/schedules/schedule-row.tsx @@ -10,7 +10,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { StatusBadge } from "@/components/ui/status-badge"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { settingsStyles } from "@/styles/settings"; @@ -22,7 +22,7 @@ import { resolveScheduleTitle, scheduleProductName, } from "@/utils/schedule-format"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types"; // Themed lucide wrappers — module-scope so only the icon re-renders on theme @@ -59,6 +59,7 @@ export interface ScheduleRowActions { } interface ScheduleRowProps extends ScheduleRowActions { + serverId: string; schedule: ScheduleSummary; /** Client-derived target line (agent title / project / shortened path). */ targetLabel: string; @@ -95,16 +96,19 @@ function stateBadge(state: ScheduleDerivedState): { // Meta reads left-to-right as identity → history → future: how often, when it // was created, when it last ran, and (only while it can still run) when it runs // next. Status lives on the badge, never repeated here. -function buildMeta( - schedule: ScheduleSummary, - state: ScheduleDerivedState, - serverName: string | undefined, - singleHost: boolean, -): string { +function buildMeta(input: { + schedule: ScheduleSummary; + state: ScheduleDerivedState; + createdAgo: string; + lastRunAgo: string; + serverName: string | undefined; + singleHost: boolean; +}): string { + const { schedule, state, serverName, singleHost } = input; const parts = [ formatCadence(schedule.cadence), - `Created ${formatTimeAgo(new Date(schedule.createdAt))}`, - schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run", + `Created ${input.createdAgo}`, + schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run", ]; if (state === "active") { const next = formatNextRun(schedule.nextRunAt); @@ -118,13 +122,40 @@ function buildMeta( return parts.join(" · "); } +function ScheduleMeta({ + schedule, + state, + serverName, + singleHost, +}: { + schedule: ScheduleSummary; + state: ScheduleDerivedState; + serverName: string | undefined; + singleHost: boolean; +}) { + const createdAgo = useTimeAgo(new Date(schedule.createdAt)); + const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null); + const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost }); + return ( + + {meta} + + ); +} + /** Small provider glyph. Reads the icon color off a StyleSheet object so the - * dynamic component (getProviderIcon) stays compliant without useUnistyles. */ -function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null { + * dynamic component (useProviderIcon) stays compliant without useUnistyles. */ +function ProviderGlyph({ + provider, + serverId, +}: { + provider: string | null; + serverId: string; +}): ReactElement | null { + const Icon = useProviderIcon(provider ?? "", serverId); if (!provider) { return null; } - const Icon = getProviderIcon(provider); return ; } @@ -138,6 +169,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement * highlights without reflow. */ export function ScheduleRow({ + serverId, schedule, targetLabel, provider, @@ -160,7 +192,6 @@ export function ScheduleRow({ const title = resolveScheduleTitle(schedule); const productName = scheduleProductName(schedule); const badge = stateBadge(state); - const meta = buildMeta(schedule, state, serverName, singleHost ?? false); const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused"); const rowStyle = useCallback( @@ -189,7 +220,7 @@ export function ScheduleRow({ > - + @@ -198,9 +229,12 @@ export function ScheduleRow({ {targetLabel} - - {meta} - + diff --git a/packages/app/src/components/schedules/schedules-table.tsx b/packages/app/src/components/schedules/schedules-table.tsx index c591b03d42..d91f95cb72 100644 --- a/packages/app/src/components/schedules/schedules-table.tsx +++ b/packages/app/src/components/schedules/schedules-table.tsx @@ -129,6 +129,7 @@ function SchedulesTableRow({ return ( ({ color: theme.colors.foreground }); + +interface SettingsCollapsibleRowProps { + label: string; + defaultExpanded?: boolean; + testID?: string; + /** The rows revealed underneath, separated like the rows of a `SettingsCard`. */ + children: ReactNode; +} + +/** + * A settings row that folds a group of rows under it. Collapsed, it is one row: the label and a + * trailing chevron that points right, and turns down once expanded. Sits inside a `SettingsCard` like any + * other row. + */ +export function SettingsCollapsibleRow({ + label, + defaultExpanded = false, + testID, + children, +}: SettingsCollapsibleRowProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const toggle = useCallback(() => setExpanded((value) => !value), []); + const accessibilityState = useMemo(() => ({ expanded }), [expanded]); + // React Native Web does not map `accessibilityState.expanded` to `aria-expanded`. + const webExpandedProps = useMemo( + () => (isWeb ? ({ "aria-expanded": expanded } as const) : null), + [expanded], + ); + const headerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ + settingsStyles.row, + hovered && styles.headerHovered, + pressed && styles.headerPressed, + ], + [], + ); + const chevronStyle = useMemo( + () => [styles.chevron, expanded && styles.chevronExpanded], + [expanded], + ); + + return ( + + + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + <> + + {label} + + + + + + )} + + {expanded + ? Children.toArray(children).map((child, index) => ( + + {child} + + )) + : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerHovered: { + backgroundColor: theme.colors.surface2, + }, + headerPressed: { + backgroundColor: theme.colors.surface3, + }, + // Optical: the chevron's ink sits about 4px inside its box whether it points right or down; this + // puts the ink on the card's trailing rail, where the switch and the reports' refresh buttons end. + chevron: { marginRight: -theme.spacing[1] }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, +})); diff --git a/packages/app/src/screens/settings/settings-group.tsx b/packages/app/src/components/settings/headings/settings-group.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-group.tsx rename to packages/app/src/components/settings/headings/settings-group.tsx index 25e4087292..e13bc6017a 100644 --- a/packages/app/src/screens/settings/settings-group.tsx +++ b/packages/app/src/components/settings/headings/settings-group.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; interface SettingsGroupProps { title: string; diff --git a/packages/app/src/screens/settings/settings-info-tip.tsx b/packages/app/src/components/settings/headings/settings-info-tip.tsx similarity index 100% rename from packages/app/src/screens/settings/settings-info-tip.tsx rename to packages/app/src/components/settings/headings/settings-info-tip.tsx diff --git a/packages/app/src/screens/settings/settings-section.tsx b/packages/app/src/components/settings/headings/settings-section.tsx similarity index 96% rename from packages/app/src/screens/settings/settings-section.tsx rename to packages/app/src/components/settings/headings/settings-section.tsx index d3a4a4b01c..28b19f3589 100644 --- a/packages/app/src/screens/settings/settings-section.tsx +++ b/packages/app/src/components/settings/headings/settings-section.tsx @@ -1,7 +1,7 @@ import { useMemo, type ReactNode } from "react"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { SettingsInfoTip } from "@/screens/settings/settings-info-tip"; +import { SettingsInfoTip } from "./settings-info-tip"; import { settingsStyles } from "@/styles/settings"; interface SettingsSectionProps { diff --git a/packages/app/src/components/settings/index.tsx b/packages/app/src/components/settings/index.tsx new file mode 100644 index 0000000000..f31a0e7b38 --- /dev/null +++ b/packages/app/src/components/settings/index.tsx @@ -0,0 +1,207 @@ +import { + Children, + isValidElement, + useCallback, + useImperativeHandle, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { + SettingsRowProps, + SettingsSwitchProps, + SettingsSelectProps, + SettingsInputProps, + SettingsActionProps, +} from "@getpaseo/plugin/client/ui"; +import type { EditingTextInputHandle } from "@/components/ui/text-input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { FormTextInput } from "@/components/ui/form-field"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { settingsStyles } from "@/styles/settings"; +export { SettingsGroup } from "./headings/settings-group"; +export { SettingsSection } from "./headings/settings-section"; +export { SettingsCollapsibleRow } from "./collapsible-row"; + +interface AppSettingsRowProps extends Omit { + hint?: ReactNode; + labelAccessory?: ReactNode; +} + +export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) { + return ( + + {Children.toArray(children).map((child, index) => ( + + {child} + + ))} + + ); +} + +export function SettingsRow({ + label, + labelAccessory, + hint, + error, + children, + testID, +}: AppSettingsRowProps) { + const compact = useIsCompactFormFactor(); + const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]); + return ( + + + {labelAccessory ? ( + + {label} + {labelAccessory} + + ) : ( + {label} + )} + {typeof hint === "string" ? {hint} : hint} + {error ? ( + + {error} + + ) : null} + + {children ? {children} : null} + + ); +} + +export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) { + return ( + + + + ); +} + +function SettingsOption({ + option, + selected, + onValueChange, +}: { + option: { label: string; value: Value }; + selected: boolean; + onValueChange(value: Value): void; +}) { + const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]); + return ( + + {option.label} + + ); +} + +export function SettingsSelect({ + value, + options, + onValueChange, + disabled, + ...row +}: SettingsSelectProps) { + const selectedLabel = options.find((option) => option.value === value)?.label ?? value; + return ( + + + + {selectedLabel} + + + {options.map((option) => ( + + ))} + + + + ); +} + +export function SettingsInput({ + initialValue, + onChangeText, + placeholder, + disabled, + secureTextEntry, + ref, + ...row +}: SettingsInputProps) { + const compact = useIsCompactFormFactor(); + const input = useRef(null); + useImperativeHandle( + ref, + () => ({ + focus: () => input.current?.focus(), + blur: () => input.current?.blur(), + getText: () => input.current?.getText() ?? "", + replaceText: (text) => input.current?.replaceText(text), + }), + [], + ); + return ( + + + + ); +} + +export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) { + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactRow: { flexWrap: "wrap", gap: theme.spacing[3] }, + label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] }, + labelRow: { + flexDirection: "row", + flexWrap: "wrap", + alignItems: "center", + gap: theme.spacing[2], + }, + accessoryLabel: { flexShrink: 1, minWidth: 0 }, + control: { flexShrink: 1, maxWidth: "100%" }, + input: { minWidth: 180 }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 94e6f36466..73c008dd22 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedRuntimeWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 347315bd19..bbda666436 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -30,6 +30,7 @@ import { type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; @@ -63,11 +64,7 @@ import { type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import { - hasActiveSidebarLabelFilter, - useSidebarViewStore, - type SidebarGroupMode, -} from "@/stores/sidebar-view-store"; +import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenu, @@ -118,13 +115,14 @@ import { SidebarWorkspaceTrailingContent, useSidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; +import { DensityIcon } from "@/components/ui/density-icon"; import { PressHighlight } from "@/components/ui/press-highlight"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, @@ -284,6 +282,7 @@ interface WorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -612,6 +611,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -631,6 +631,7 @@ function WorkspaceRowRightGroup({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -642,7 +643,7 @@ function WorkspaceRowRightGroup({ const trailing = useSidebarWorkspaceTrailing(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -664,7 +665,7 @@ function WorkspaceRowRightGroup({ ) : null} {renderSlot ? ( - + ( <> - @@ -1286,15 +1295,21 @@ function WorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -1332,6 +1347,7 @@ function WorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} @@ -1902,9 +1918,6 @@ export function SidebarWorkspaceList({ const onToggleWorkspacePin = useSidebarWorkspacePinController(); const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder); const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder); - const hasActiveLabelFilter = useSidebarViewStore((state) => - hasActiveSidebarLabelFilter(state.labelFilter), - ); const handlePinnedWorkspaceReorder = useCallback( (reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); @@ -1936,11 +1949,10 @@ export function SidebarWorkspaceList({ // this whole subtree, which unmounted the header — and the header is where the display menu's // trigger lives, so filtering the last row away closed the menu you were filtering from. // - // Only the label filter can get here. The project filter resolves against the projects it can - // see and falls back to "all projects" when nothing matches, so it either keeps at least one - // project or is not applied at all — it can narrow this list but never empty it. - const sidebarFilterEmpty = - hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0; + // Background visibility can also hide the last project. Keep the header mounted + // whenever a local filter empties an otherwise populated directory, so users can + // reach the display menu to reveal their work again. + const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0; // Project mode is the one that keeps its project headers; every other grouping mode is a flat // list of grouped rows, so a new mode lands in the grouped branch rather than silently in this @@ -2510,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({ // the step in reads as belonging to that project. Padding rather than margin, so the hover and // pressed fills stay the same box as every other row in the sidebar. newWorkspaceGhostRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[4], @@ -2530,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it. newWorkspaceGhostIconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, color: theme.colors.foreground, }, projectRow: { position: "relative", - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -2591,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -2672,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({ right: theme.spacing[2], }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 018c8cc37e..d62b3f9ff1 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { sheetTitle={t("sidebar.display.heading")} testID="sidebar-display-preferences-content" > + + {t("sidebar.display.showBackground")} + ; export interface SidebarDisplayPreferences { + showBackground: boolean; + toggleBackground: () => void; grouping: SidebarGroupMode; setGrouping: (mode: SidebarGroupMode) => void; titleSource: WorkspaceTitleSource; @@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences { * this for a value and set it; where it lands is this module's problem. */ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { + const showBackground = useSidebarViewStore((state) => state.showBackground); + const toggleBackground = useSidebarViewStore((state) => state.toggleBackground); const grouping = useSidebarViewStore((state) => state.groupMode); const setGrouping = useSidebarViewStore((state) => state.setGroupMode); const hostFilters = useSidebarViewStore((state) => state.hostFilters); @@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { return useMemo( () => ({ + showBackground, + toggleBackground, grouping, setGrouping, titleSource: workspaceTitleSource, @@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences { clearLabelFilter, }), [ + showBackground, + toggleBackground, grouping, setGrouping, workspaceTitleSource, diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx index 4aaf93e0cc..3328ed2c5c 100644 --- a/packages/app/src/components/sidebar/empty-states.tsx +++ b/packages/app/src/components/sidebar/empty-states.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { type ReactNode, useCallback } from "react"; import { Import, Plus } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; @@ -6,6 +6,28 @@ import { StyleSheet } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; +function SidebarEmptyStateCard({ + testID, + title, + description, + children, +}: { + testID: string; + title: string; + description: string; + children: ReactNode; +}) { + return ( + + + {title} + {description} + + {children} + + ); +} + /** * The two things the sidebar's workspace list says when it has no rows. * @@ -27,13 +49,15 @@ export function SidebarFilterEmptyState() { }, [clearLabelFilter, clearProjectFilters]); return ( - - {t("sidebar.filterEmpty.title")} - {t("sidebar.filterEmpty.description")} - - + ); } @@ -47,40 +71,44 @@ export function SidebarProjectEmptyState({ const { t } = useTranslation(); return ( - - {t("sidebar.project.empty.title")} - {t("sidebar.project.empty.description")} - - - + ); } const styles = StyleSheet.create((theme) => ({ - container: { - marginHorizontal: theme.spacing[2], - marginTop: theme.spacing[4], - paddingTop: theme.spacing[6], - paddingBottom: theme.spacing[4], - paddingHorizontal: theme.spacing[4], + card: { + marginTop: theme.spacing[3], + padding: theme.spacing[4], + borderWidth: 1, + borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface0, - alignItems: "center", - gap: theme.spacing[3], + }, + copy: { + gap: theme.spacing[1], + }, + actions: { + flexDirection: "row", + gap: theme.spacing[2], + marginTop: theme.spacing[4], }, title: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - textAlign: "center", + fontWeight: theme.fontWeight.normal, }, description: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - textAlign: "center", }, })); diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx index 9dc2c9fdba..2169492a08 100644 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ b/packages/app/src/components/sidebar/pinned-section-header.tsx @@ -2,16 +2,11 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight } from "lucide-react-native"; import { Pressable, Text } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; -import type { Theme } from "@/styles/theme"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); +import { density, densityFontSize } from "@/styles/density"; export function PinnedSectionHeader({ collapsed, @@ -23,7 +18,6 @@ export function PinnedSectionHeader({ const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; return ( {t("sidebar.pinned.title")} {hovered || isNative || isCompact ? ( - + ) : null} )} @@ -47,7 +45,7 @@ export function PinnedSectionHeader({ const styles = StyleSheet.create((theme) => ({ header: { - minHeight: 36, + minHeight: density.rowHeight, flexDirection: "row", alignItems: "center", alignSelf: "flex-start", @@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({ }, title: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: densityFontSize(theme, "sm"), fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index c3950efe2d..3f31181220 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -1,9 +1,11 @@ import { ActivityIndicator, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, @@ -34,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4; // // The filled alert occupies the full badge shell so needs-input remains more prominent than // the passive status dots. -// Matches the workspace title's lineHeight (sidebar-workspace-row-content's -// workspaceBranchText) so the icon centers on the title rather than floating above it. -const LEADING_SLOT_HEIGHT = 20; +const DensityProjectIconView = withUnistyles(ProjectIconView); +const projectIconProps = densityIconProps("md", () => ({})); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); @@ -170,6 +171,7 @@ function ProjectStatusBadge({ statusBucket: SidebarStateBucket; backdrop: SidebarSurfaceBackdrop; }) { + const { t } = useTranslation(); // Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout, // so nesting it inside would clip it against the very thing that was meant to separate it from // the icon. It anchors to the same corner instead, growing around the centre the dot had. @@ -177,7 +179,7 @@ function ProjectStatusBadge({ return ( @@ -188,7 +190,7 @@ function ProjectStatusBadge({ return ( @@ -228,12 +230,12 @@ function ProjectIcon({ projectViewKey: string; }) { return ( - ); } @@ -269,12 +271,12 @@ const styles = StyleSheet.create((theme) => { return { // The slot is as tall as the title's line box, not as tall as the icon, and centers the - // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a - // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — - // which is why the workspace status indicator is also 20 tall. Keep the two in step. + // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot + // only as tall as the icon puts it above the title and the kebab — which is why the + // workspace status indicator shares the same `rowLineHeight`. projectLeadingVisualSlot: { - width: theme.iconSize.md, - height: LEADING_SLOT_HEIGHT, + width: densityIconBox("md"), + height: density.rowLineHeight, flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -282,8 +284,8 @@ const styles = StyleSheet.create((theme) => { // Anchors the corner badge to the icon rather than to the taller slot. projectIconBox: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), }, projectIconFallbackText: { fontSize: 9, diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index f67bd738ba..3d437ae0ca 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -2,19 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, ChevronUp } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; -import type { Theme } from "@/styles/theme"; - -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -const foregroundColorMapping = (theme: Theme) => ({ - color: theme.colors.foreground, -}); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronUp = withUnistyles(ChevronUp); +import { DensityIcon } from "@/components/ui/density-icon"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({ {({ hovered, pressed }) => ( <> - {expanded ? ( - - ) : ( - - )} + {label} @@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({ const styles = StyleSheet.create((theme) => ({ // Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx. row: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], @@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it rather than two points to their left. iconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, text: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 22d48c2c4e..7001bf66a6 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,20 +1,18 @@ -import { useCallback, useMemo } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { LucideIcon } from "lucide-react-native"; +import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; -import { ICON_SIZE } from "@/styles/theme"; -import type { Theme } from "@/styles/theme"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +type SidebarHeaderRowVariant = "header" | "compact" | "inline"; -type SidebarHeaderRowVariant = "header" | "compact"; +export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; interface SidebarHeaderRowProps { - icon: LucideIcon; + icon: SidebarRowIcon | null; label: string; onPress: () => void; isActive?: boolean; @@ -24,15 +22,24 @@ interface SidebarHeaderRowProps { /** * "header" (default): a sidebar-height row with its own bottom separator — * the lone header at the top of a sidebar (settings "Back to workspace"). - * "compact": a workspace-row-height row with no separator, for entries that + * "compact": a row with no separator, for entries that * sit in a header group whose wrapper owns the single divider. + * "inline": a full-width row with no inset, for a row inside a padded container such as the + * sidebar footer. */ variant?: SidebarHeaderRowVariant; + /** Shown in the right slot while the row is hovered, when `trailing` is not set. */ shortcutKeys?: ShortcutKey[][] | null; + /** + * The right slot. A sibling of the row's button, never inside it (web cannot nest buttons). A + * press on the slot presses the row; a button inside it presses on its own. + */ + trailing?: ReactNode; + rowRef?: Ref; } export function SidebarHeaderRow({ - icon: Icon, + icon, label, onPress, isActive = false, @@ -41,72 +48,60 @@ export function SidebarHeaderRow({ accessibilityLabel, variant = "header", shortcutKeys = null, + trailing, + rowRef, }: SidebarHeaderRowProps) { - const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const isHighlighted = isHovered || isActive; + const iconSize = variant === "header" ? "md" : "sm"; - const containerStyle = useMemo( - () => (variant === "compact" ? styles.containerCompact : styles.container), - [variant], - ); - - const buttonStyle = useCallback( - ({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.button, - variant === "compact" && styles.buttonCompact, - (Boolean(hovered) || isActive) && styles.buttonHovered, - ], - [isActive, variant], - ); - - const renderChildren = useCallback( - (state: PressableStateCallbackType & { hovered?: boolean }) => { - const isHighlighted = Boolean(state.hovered) || isActive; - return ( - <> - - - {shortcutKeys && Boolean(state.hovered) ? ( - - ) : null} - - ); - }, - [ThemedIcon, isActive, label, shortcutKeys], - ); + let right = trailing ?? null; + if (right === null && shortcutKeys && isHovered) { + right = ; + } return ( - - + - {renderChildren} - + + {icon ? ( + + ) : ( + + )} + {label} + + {right === null ? null : ( + + {right} + + )} + ); } -function SidebarHeaderRowLabel({ - label, - isHighlighted, -}: { - label: string; - isHighlighted: boolean; -}) { - const labelStyle = useMemo( - () => [styles.label, isHighlighted && styles.labelHighlighted], - [isHighlighted], - ); - return {label}; -} +const SELECTED_STATE = { selected: true } as const; const styles = StyleSheet.create((theme) => ({ container: { @@ -125,38 +120,60 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", userSelect: "none", }, + containerInline: { + width: "100%", + justifyContent: "center", + userSelect: "none", + }, + row: { + flexDirection: "row", + alignItems: "center", + // Same row geometry as the settings sidebar items. Shorter than the header + // strip so the hover highlight clears the strip's bottom separator. + minHeight: density.tightRowHeight, + borderRadius: theme.borderRadius.lg, + }, + rowHighlighted: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, button: { + flex: 1, + minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - // Match the sidebar workspace-row shape (height, padding, radius) so the - // compact header entries sit tight against the workspace list below. - minHeight: 36, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - // Compact header entries (New workspace / History) sit tighter than the - // workspace-row shape the base button mirrors. - buttonCompact: { - minHeight: 32, - paddingVertical: theme.spacing[1.5], + minHeight: density.tightRowHeight, + paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical - // edge with the workspace list below (base button uses a wider spacing[3]). + // edge with the list below. paddingHorizontal: theme.spacing[2], }, - buttonHovered: { - backgroundColor: theme.colors.surfaceSidebarHover, - }, + iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") }, + iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { - fontSize: theme.fontSize.base, + flexShrink: 1, + fontSize: densityFontSize(theme, "base"), fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, labelHighlighted: { color: theme.colors.foreground, }, - shortcut: { - marginLeft: "auto", + trailing: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingRight: theme.spacing[2], }, })); + +function getContainerStyle(variant: SidebarHeaderRowVariant) { + switch (variant) { + case "header": + return styles.container; + case "compact": + return styles.containerCompact; + case "inline": + return styles.containerInline; + } +} diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index eb00df6e12..97ff3d2772 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -14,28 +14,28 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; +import { density } from "@/styles/density"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; +import { openChangelog } from "@/changelog"; import { openExternalUrl } from "@/utils/open-external-url"; const DISCORD_URL = "https://discord.gg/jz8T2uahpH"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; -const CHANGELOG_URL = "https://paseo.sh/changelog"; const ThemedActivity = withUnistyles(Activity); -const ThemedCircleHelp = withUnistyles(CircleHelp); const ThemedGift = withUnistyles(Gift); const ThemedKeyboard = withUnistyles(Keyboard); const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -97,10 +97,6 @@ export function SidebarHelpMenu() { void openExternalUrl(GITHUB_ISSUE_URL); }, []); - const openChangelog = useCallback(() => { - void openExternalUrl(CHANGELOG_URL); - }, []); - return ( @@ -113,9 +109,10 @@ export function SidebarHelpMenu() { accessibilityLabel={t("sidebar.help.trigger")} > {({ hovered }) => ( - )} @@ -186,8 +183,8 @@ export function SidebarHelpMenu() { const styles = StyleSheet.create((theme) => ({ trigger: { - width: 28, - height: 28, + width: density.iconButton, + height: density.iconButton, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index 9399a3bd13..ec834aee19 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; import { useSidebarWorkspacesList, @@ -54,7 +55,9 @@ export function SidebarModelProvider({ active?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const list = useSidebarWorkspacesList({ enabled: active }); + const showBackground = useSidebarViewStore((state) => state.showBackground); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); const projectFilters = useSidebarViewStore((state) => state.projectFilters); @@ -105,9 +108,12 @@ export function SidebarModelProvider({ workspaces: [...workspaceEntriesByKey.values()], projectFilters: resolvedProjectFilters, }); - const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter }); + const filtered = filterWorkspacesByLabels({ + workspaces: byProject.filter((workspace) => showBackground || !workspace.background), + ...labelFilter, + }); return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace])); - }, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); + }, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]); const visibleWorkspaceKeys = useMemo( () => new Set(filteredWorkspaceEntriesByKey.keys()), [filteredWorkspaceEntriesByKey], @@ -122,16 +128,19 @@ export function SidebarModelProvider({ const included = new Set(resolvedProjectFilters); projects = projects.filter((project) => included.has(project.viewKey)); } - if (hasActiveLabelFilter) { + if (hasActiveLabelFilter || !showBackground) { projects = projects.flatMap((project) => { const workspaces = project.workspaces.filter((workspace) => visibleWorkspaceKeys.has(workspace.workspaceKey), ); - return workspaces.length > 0 ? [{ ...project, workspaces }] : []; + return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0) + ? [{ ...project, workspaces }] + : []; }); } return projects; }, [ + showBackground, hasActiveLabelFilter, hasActiveProjectFilter, resolvedProjectFilters, @@ -150,6 +159,7 @@ export function SidebarModelProvider({ pinnedCollapsed, collapsedProjectKeys, collapsedWorkspaceGroupKeys, + t, }), [ collapsedProjectKeys, @@ -161,6 +171,7 @@ export function SidebarModelProvider({ pinnedKeys, pinnedWorkspaceOrder, filteredWorkspaceEntriesByKey, + t, ], ); const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]); diff --git a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx index 258dc19468..425280207e 100644 --- a/packages/app/src/components/sidebar/sidebar-nav-rows.tsx +++ b/packages/app/src/components/sidebar/sidebar-nav-rows.tsx @@ -1,11 +1,11 @@ import { router, usePathname } from "expo-router"; import { CalendarClock, History, Plus, Search } from "lucide-react-native"; -import { memo, useCallback, useMemo, type ComponentType } from "react"; +import { memo, useCallback, useMemo, useRef, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { View, type StyleProp, type ViewStyle } from "react-native"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; -import { PluginSidebarItemRow } from "@/plugins/sidebar-items"; +import { PluginSidebarItem } from "@/plugins/sidebar-items"; import { canCreateWorktreeForProjectKind } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; import { @@ -38,19 +38,22 @@ interface SidebarNavRowsProps extends SidebarNavRowProps { * wrapper — when every item is hidden. */ export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) { - const { items } = useSidebarNavItems(); + const { items } = useSidebarNavItems("header"); const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]); + const groupRef = useRef(null); if (visibleItems.length === 0) return null; return ( - + {visibleItems.map((item) => { if (item.kind === "plugin") { return ( - ); diff --git a/packages/app/src/components/sidebar/sidebar-popover.tsx b/packages/app/src/components/sidebar/sidebar-popover.tsx new file mode 100644 index 0000000000..e57f3cf6ac --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-popover.tsx @@ -0,0 +1,98 @@ +import { useCallback, type ReactNode, type RefObject } from "react"; +import type { View } from "react-native"; +import { MenuRoot, useMenuContext } from "@/components/ui/menu"; +import { PluginPopoverSurface } from "@/plugins/popover"; +import type { SidebarSection } from "@/sidebar-nav/model"; + +/** + * The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on + * compact ones. Plugin items open it this way: + * `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to, + * and `SidebarPopoverSurface` for the body. + */ +export function SidebarPopoverRoot({ + open, + onOpenChange, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function assignRef(ref: RefObject, node: View | null) { + Object.assign(ref, { current: node }); +} + +/** + * Where the popover anchors. `anchorTo` moves it to an element, such as the row just pressed; + * `offerAnchor` sets it only when nothing is anchored; `releaseAnchor` drops an element that is + * going away; `anchorToFallback` covers an item that anchored nothing. + */ +export function useSidebarPopoverAnchor(name: string): { + anchorTo: (node: View | null) => void; + offerAnchor: (node: View | null) => void; + releaseAnchor: (node: View | null) => void; + anchorToFallback: (fallback: RefObject) => void; +} { + const { triggerRef } = useMenuContext(name); + const anchorTo = useCallback((node: View | null) => assignRef(triggerRef, node), [triggerRef]); + const offerAnchor = useCallback( + (node: View | null) => { + if (!triggerRef.current) assignRef(triggerRef, node); + }, + [triggerRef], + ); + const releaseAnchor = useCallback( + (node: View | null) => { + if (triggerRef.current === node) assignRef(triggerRef, null); + }, + [triggerRef], + ); + const anchorToFallback = useCallback( + (fallback: RefObject) => offerAnchor(fallback.current), + [offerAnchor], + ); + return { anchorTo, offerAnchor, releaseAnchor, anchorToFallback }; +} + +const PLACEMENT = { + header: { side: "right", align: "start" }, + footer: { side: "top", align: "start" }, +} as const satisfies Record; + +export function SidebarPopoverSurface({ + section, + title, + sheetTrailing, + testID, + children, +}: { + section: SidebarSection; + /** The bottom sheet's title. */ + title: string; + /** Controls on the right of the bottom sheet's title. */ + sheetTrailing?: ReactNode; + testID: string; + children: ReactNode; +}) { + const placement = PLACEMENT[section]; + return ( + + {children} + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 55882a0c49..c2dc31f2ee 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -85,6 +86,7 @@ function projectionInput(options?: { pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), collapsedWorkspaceGroupKeys: new Set(), + t: i18n.t, }; } diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index c67042b45e..2e84f02aae 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { buildStatusGroups } from "@/hooks/sidebar-status-view-model"; import { splitPinnedSidebarGroups, @@ -45,6 +46,7 @@ export interface SidebarProjectionInput { pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; collapsedWorkspaceGroupKeys: ReadonlySet; + t: TFunction; } export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection { @@ -100,7 +102,7 @@ function buildWorkspaceGroups( return []; case "status": return statusWorkspaceGroups( - buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey), + buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t), ); } } diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx index 864e56361f..30310d84ad 100644 --- a/packages/app/src/components/sidebar/sidebar-separator.tsx +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -6,8 +6,8 @@ import { StyleSheet } from "react-native-unistyles"; * padded container so the line runs the full sidebar width — matching the * separator beneath the workspace sidebar's "New agent" header. */ -export function SidebarSeparator() { - return ; +export function SidebarSeparator({ testID }: { testID?: string } = {}) { + return ; } const styles = StyleSheet.create((theme) => ({ diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index def203c9cd..85c0d524f8 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; +import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -48,11 +48,12 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, resolveTrailingActionVisibility, + type SidebarWorkspaceTrailingPresentation, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, @@ -81,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers -const foregroundMutedColorMapping = (theme: Theme) => ({ +const mutedGroupIconProps = densityIconProps("sm", (theme) => ({ color: theme.colors.foregroundMuted, -}); +})); // One mapping per bucket, resolved through the status-dot producer so a group header and // the rows under it cannot disagree about what "failed" looks like. -const needsInputColorMapping = (theme: Theme) => ({ +const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); -const failedColorMapping = (theme: Theme) => ({ +})); +const failedGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, -}); -const attentionColorMapping = (theme: Theme) => ({ +})); +const attentionGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, -}); -const runningColorMapping = (theme: Theme) => ({ +})); +const runningGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, -}); +})); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -407,6 +408,7 @@ function StatusGroupHeader({ group: SidebarWorkspaceGroup; collapsed: boolean; }) { + const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleWorkspaceGroupCollapsed, @@ -430,7 +432,7 @@ function StatusGroupHeader({ ; } if (collapsed) { - return ; + return ; } - return ; + return ; } function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { switch (bucket) { case "needs_input": - return ; + return ; case "failed": - return ; + return ; case "attention": - return ; + return ; case "running": - return ; + return ; case "done": - return ; + return ; } } @@ -641,15 +643,21 @@ function StatusWorkspaceRowWithMenu({ const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -682,6 +690,7 @@ function StatusWorkspaceRowWithMenu({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} isPinned={isPinned} onTogglePin={onTogglePin} @@ -719,6 +728,7 @@ interface StatusWorkspaceRowInnerProps { onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; @@ -765,6 +775,7 @@ function StatusWorkspaceRowInnerContent({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, isPinned, onTogglePin, @@ -819,7 +830,7 @@ function StatusWorkspaceRowInnerContent({ {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -860,6 +871,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -897,7 +909,7 @@ function StatusWorkspaceRowInnerContent({ workspace={workspace} backdrop={backdrop} trailing={trailing} - showBase={showTrailing} + trailingPresentation={trailingPresentation} showKebab={showKebabInSlot} showScrim={showScrim} reserveSlotWidth={reserveSlotWidth} @@ -907,6 +919,7 @@ function StatusWorkspaceRowInnerContent({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -927,7 +940,7 @@ function StatusWorkspaceActionSlot({ workspace, backdrop, trailing, - showBase, + trailingPresentation, showKebab, showScrim, reserveSlotWidth, @@ -937,6 +950,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -946,7 +960,7 @@ function StatusWorkspaceActionSlot({ workspace: SidebarWorkspaceEntry; backdrop: SidebarSurfaceBackdrop; trailing: SidebarWorkspaceTrailing; - showBase: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; reserveSlotWidth: boolean; @@ -956,6 +970,7 @@ function StatusWorkspaceActionSlot({ onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -965,7 +980,7 @@ function StatusWorkspaceActionSlot({ const kebab = useOpenKebabMenuVisibility(showKebab); return ( - + ({ }, statusWorkspaceListContainer: {}, statusGroupRow: { - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -1068,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupLeadingVisualSlot: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -1083,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -1092,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index 66df32bdbb..e1543fac41 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Archive, + Circle, CircleCheck, Copy, MoreVertical, @@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedCircle = withUnistyles(Circle); const ThemedPencil = withUnistyles(Pencil); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedPin = withUnistyles(Pin); @@ -63,6 +65,7 @@ const renameLeadingIcon = ); +const markAsUnreadLeadingIcon = ; const archiveLeadingIcon = ; const pinLeadingIcon = ; const unpinLeadingIcon = ; @@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps { onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({ onCopyBranchName, onRename, onMarkAsRead, + onMarkAsUnread, onArchive, archiveLabel, archiveStatus, @@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({ Mark as read ) : null} + {onMarkAsUnread ? ( + + Mark as unread + + ) : null} {onTogglePin ? ( ( () => ({ @@ -399,6 +418,7 @@ export function SidebarWorkspaceContextMenu({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 2eeb6b043d..33f14afcb5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,6 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -27,19 +28,16 @@ import { import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { DensityIcon } from "@/components/ui/density-icon"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedMonitor = withUnistyles(Monitor); -const ThemedFolder = withUnistyles(Folder); -const ThemedFolderGit2 = withUnistyles(FolderGit2); export function SidebarWorkspaceRowFrame({ workspace, @@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({ ) : null; } - let KindIcon: typeof ThemedMonitor; - if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; - else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; - else KindIcon = ThemedFolder; + let KindIcon: typeof Monitor; + if (workspaceKind === "local_checkout") KindIcon = Monitor; + else if (workspaceKind === "worktree") KindIcon = FolderGit2; + else KindIcon = Folder; const dotColorStyle = getStatusDotColorStyle(bucket); return ( - + {dotColorStyle ? : null} ); @@ -343,6 +341,8 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { ); } +export type SidebarWorkspaceTrailingPresentation = "visible" | "hidden" | "absent"; + /** * What the trailing slot shows for a row. Derived in one place because three row renderers * share it: the two project-mode rows and the status-mode row. The rule used to be copied @@ -368,7 +368,7 @@ export function resolveTrailingActionVisibility({ isTouchPlatform: boolean; showShortcut: boolean; }): { - showTrailing: boolean; + trailingPresentation: SidebarWorkspaceTrailingPresentation; showKebab: boolean; showScrim: boolean; renderSlot: boolean; @@ -376,9 +376,13 @@ export function resolveTrailingActionVisibility({ } { const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing }); const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut; - const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab); + // Touch permanently replaces the stats with the menu. Only temporary shortcut hints + // conceal content while retaining its width, so desktop rows do not shift. + const hasContent = hasTrailing && !(hasArchiveAction && isTouchPlatform); + let trailingPresentation: SidebarWorkspaceTrailingPresentation = "absent"; + if (hasContent) trailingPresentation = showShortcut ? "hidden" : "visible"; return { - showTrailing, + trailingPresentation, showKebab, // The scrim paints the row's own hover background, so it can only be drawn on a hovered // row — over an unhovered one the gradient fades to the wrong color. That is also why @@ -389,7 +393,7 @@ export function resolveTrailingActionVisibility({ // does; the kebab only does on touch, where there is no hover for it to appear on and so // no scrim to let it overlay the title. Everywhere else the width goes back to the title // and the kebab fades in over its tail. - reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform), + reserveSlotWidth: hasContent || (hasArchiveAction && isTouchPlatform), }; } @@ -414,14 +418,18 @@ export function SidebarWorkspaceTrailingActionSlot({ } export function SidebarWorkspaceTrailingActionBase({ - visible, + presentation, children, }: { - visible: boolean; + presentation: SidebarWorkspaceTrailingPresentation; children: ReactNode; }) { - if (!children) return null; - return {children}; + if (presentation === "absent") return null; + return ( + + {children} + + ); } export function SidebarWorkspaceTrailingActionOverlay({ @@ -472,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceStatusDot: { position: "relative", - width: theme.iconSize.md, - height: 20, + width: densityIconBox("md"), + height: density.rowLineHeight, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -505,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", - lineHeight: 20, + lineHeight: density.rowLineHeight, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index 1233365673..c5f8a278bd 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -14,7 +15,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; +import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -123,15 +124,21 @@ export function SidebarWorkspaceRow({ }, []); const archiveShortcutKeys = useShortcutKeys("archive-workspace"); - const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - }); + const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } = + useWorkspaceReadState({ + serverId: workspace.serverId, + workspaceId: workspace.workspaceId, + }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); + const handleMarkAsUnread = useCallback(() => { + void markUnread().catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread"); + }); + }, [markUnread, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, @@ -166,6 +173,7 @@ export function SidebarWorkspaceRow({ onCopyPath={handleCopyPath} onRename={handleOpenRename} onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined} + onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined} archiveShortcutKeys={selected ? archiveShortcutKeys : null} /> void; onRename?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -221,6 +230,7 @@ function WorkspaceRowBody({ onCopyPath, onRename, onMarkAsRead, + onMarkAsUnread, archiveShortcutKeys, }: WorkspaceRowBodyProps) { const isCompact = useIsCompactFormFactor(); @@ -293,6 +303,7 @@ function WorkspaceRowBody({ onCopyBranchName={onCopyBranchName} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -340,6 +351,7 @@ function WorkspaceRowBody({ onCopyPath={onCopyPath} onRename={onRename} onMarkAsRead={onMarkAsRead} + onMarkAsUnread={onMarkAsUnread} /> @@ -365,6 +377,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys, onArchive, onMarkAsRead, + onMarkAsUnread, onCopyBranchName, onCopyPath, onRename, @@ -383,6 +396,7 @@ function WorkspaceRowTrailingActions({ archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; + onMarkAsUnread?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; @@ -390,7 +404,7 @@ function WorkspaceRowTrailingActions({ const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { - showTrailing, + trailingPresentation, showKebab: showKebabInSlot, showScrim, renderSlot, @@ -412,7 +426,7 @@ function WorkspaceRowTrailingActions({ ) : null} {renderSlot ? ( - + ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts index 2068dbe271..465caf1344 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import { resolveSidebarWorkspaceAccessibilityLabel, @@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" }, workspaceTitleSource: "title", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Investigate search, Build host"); @@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { hostBadgeLabel: "Build host", pullRequestLabel: "Pull request 42", serviceLabel: "Service web running", + t: i18n.t, }); expect(label).toBe( @@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => { workspaceTitleSource: "title", leadingProjectName: "Search project", hostBadgeLabel: "Build host", + t: i18n.t, }); expect(label).toBe("Search project, Investigate search, Build host"); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts index 955c9d2636..da3782f082 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-title.ts +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -1,6 +1,7 @@ +import type { TFunction } from "i18next"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { WorkspaceTitleSource } from "@/hooks/use-settings"; -import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; +import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; export function resolveSidebarWorkspacePrimaryLabel(input: { workspace: Pick; @@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { hostBadgeLabel?: string | null; pullRequestLabel?: string | null; serviceLabel?: string | null; + t: TFunction; }): string { return [ input.leadingProjectName, @@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: { input.serviceLabel, input.workspace.statusBucket === "done" ? null - : STATUS_BUCKET_LABELS[input.workspace.statusBucket], + : getStatusBucketLabel(input.workspace.statusBucket, input.t), ] .filter((label): label is string => Boolean(label)) .join(", "); diff --git a/packages/app/src/components/sidebar/workspace-trailing/index.tsx b/packages/app/src/components/sidebar/workspace-trailing/index.tsx index ed573b2bfb..1f7b91dd51 100644 --- a/packages/app/src/components/sidebar/workspace-trailing/index.tsx +++ b/packages/app/src/components/sidebar/workspace-trailing/index.tsx @@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useAppSettings } from "@/hooks/use-settings"; import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings"; -import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; +import { useCompactTimeAgo } from "@/hooks/use-time-ago"; export type { SidebarWorkspaceTrailing }; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 5f467785d7..9705f3be15 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -397,18 +397,6 @@ export function SplitContainer({ }, [workspaceKey], ); - const handleCreateExplorerTab = useCallback( - () => onCreateNewTab({ paneId: explorerSidebarPaneId ?? undefined }), - [explorerSidebarPaneId, onCreateNewTab], - ); - const handleMoveExplorerTabToMain = useCallback( - (tabId: string) => { - if (layout.focusedPaneId) { - onMoveTabToPane(tabId, layout.focusedPaneId); - } - }, - [layout.focusedPaneId, onMoveTabToPane], - ); const splitRoot = useMemo( () => resolveSplitContainerRoot({ @@ -725,8 +713,19 @@ export function SplitContainer({ closingTabIds={closingTabIds} onSelectTab={onSelectTabInPane} onCloseTab={onCloseTab} - onCreateNewTab={handleCreateExplorerTab} - onMoveTabToMain={handleMoveExplorerTabToMain} + onCreateNewTab={onCreateNewTab} + hoveredCloseTabKey={hoveredCloseTabKey} + setHoveredCloseTabKey={setHoveredCloseTabKey} + onCopyResumeCommand={onCopyResumeCommand} + onCopyAgentId={onCopyAgentId} + onCopyTerminalId={onCopyTerminalId} + onCopyFilePath={onCopyFilePath} + onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} + onCloseTabsToLeft={onCloseTabsToLeft} + onCloseTabsToRight={onCloseTabsToRight} + onCloseOtherTabs={onCloseOtherTabs} + onExitFocusMode={onExitFocusMode} buildPaneContentModel={buildPaneContentModel} onReorderTabsInPane={onReorderTabsInPane} activeDragTabId={activeDragTabId} diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 5f2d3eb3a0..3575e21500 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -1,3 +1,7 @@ +import type { + TerminalFindHandle, + TerminalFindResult, +} from "../terminal/runtime/terminal-emulator-runtime"; import type { Ref } from "react"; import type { ITheme } from "@xterm/xterm"; import type { TerminalState } from "@getpaseo/protocol/messages"; @@ -12,6 +16,7 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys"; import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness"; export interface TerminalEmulatorHandle { + find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; restoreOutput: (data: TerminalOutputData) => void; renderSnapshot: (state: TerminalState | null) => void; @@ -39,6 +44,8 @@ export interface TerminalEmulatorProps { onSwipeLeft?: () => void; onSwipeRight?: () => void; initialSnapshot?: TerminalState | null; + onFindRequest?: () => void; + onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; onFocus?: () => Promise | void; onResize?: (input: { diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx index 77a5fbb94b..dbaa0175d4 100644 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ b/packages/app/src/components/terminal-emulator-webview.native.tsx @@ -19,7 +19,10 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview"; import type { ITheme } from "@xterm/xterm"; import type { TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; -import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime"; +import type { + TerminalOutputData, + TerminalFindResult, +} from "../terminal/runtime/terminal-emulator-runtime"; import type { TerminalLocalFileLinkSource, TerminalLocalFileLinkTarget, @@ -49,6 +52,9 @@ type BridgeInboundMessage = | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "paste"; streamKey: string; text: string } | { type: "clear"; streamKey: string } + | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } + | { type: "clearFind"; streamKey: string } + | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } | { type: "focus"; streamKey: string; forceRefocus?: boolean } | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } @@ -67,6 +73,8 @@ type BridgeOutboundMessage = | { type: "bridgeReady" } | { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "input"; streamKey: string; data: string } + | { type: "findRequest"; streamKey: string } + | { type: "findResult"; streamKey: string; result: TerminalFindResult } | { type: "resize"; streamKey: string; @@ -162,6 +170,8 @@ export default function WebViewTerminalEmulator({ onSwipeLeft, onSwipeRight, initialSnapshot = null, + onFindRequest, + onFindResult, onInput, onFocus, onResize, @@ -209,6 +219,8 @@ export default function WebViewTerminalEmulator({ swipeGesturesEnabled, }; const callbacksRef = useRef({ + onFindRequest, + onFindResult, onInput, onFocus, onResize, @@ -222,6 +234,8 @@ export default function WebViewTerminalEmulator({ onSwipeRight, }); callbacksRef.current = { + onFindRequest, + onFindResult, onInput, onFocus, onResize, @@ -315,6 +329,11 @@ export default function WebViewTerminalEmulator({ useImperativeHandle( ref, (): TerminalEmulatorHandle => ({ + find: { + setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }), + search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }), + clear: () => sendToWebView({ type: "clearFind", streamKey }), + }, writeOutput: (data: TerminalOutputData) => { const output = outputDecoderRef.current.decode(data, { stream: true }); if (output.length === 0) { @@ -563,6 +582,14 @@ export default function WebViewTerminalEmulator({ ) { return; } + if (message.type === "findRequest") { + callbacksRef.current.onFindRequest?.(); + return; + } + if (message.type === "findResult") { + callbacksRef.current.onFindResult?.(message.result); + return; + } handleTerminalMessage(message); }, [handleLifecycleMessage, handleTerminalMessage, streamKey], diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 438d72d85c..be12355bea 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -1,5 +1,10 @@ "use dom"; +import type { + TerminalFindHandle, + TerminalFindResult, +} from "../terminal/runtime/terminal-emulator-runtime"; + import { useCallback, useEffect, @@ -41,6 +46,7 @@ import { import { getDesktopHost } from "@/desktop/host"; export interface TerminalEmulatorHandle { + find?: TerminalFindHandle; writeOutput: (data: TerminalOutputData) => void; restoreOutput: (data: TerminalOutputData) => void; renderSnapshot: (state: TerminalState | null) => void; @@ -111,6 +117,8 @@ interface TerminalEmulatorProps { onSwipeLeft?: () => void; onSwipeRight?: () => void; initialSnapshot?: TerminalState | null; + onFindRequest?: () => void; + onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; onFocus?: () => Promise | void; onResize?: (input: { @@ -172,6 +180,8 @@ export default function TerminalEmulator({ onSwipeLeft, onSwipeRight, initialSnapshot = null, + onFindRequest, + onFindResult, onInput, onFocus, onResize, @@ -201,6 +211,8 @@ export default function TerminalEmulator({ const onRendererReadyChangeRef = useRef(onRendererReadyChange); onRendererReadyChangeRef.current = onRendererReadyChange; const mountCallbacksRef = useRef({ + onFindRequest, + onFindResult, onInput, onResize, onTerminalKey, @@ -210,6 +222,8 @@ export default function TerminalEmulator({ onOpenLocalFileLink, }); mountCallbacksRef.current = { + onFindRequest, + onFindResult, onInput, onResize, onTerminalKey, @@ -283,6 +297,11 @@ export default function TerminalEmulator({ useImperativeHandle( ref, (): TerminalEmulatorHandle => ({ + find: { + setWidgetSize: (size) => runtimeRef.current?.find.setWidgetSize(size), + search: (query, direction) => runtimeRef.current?.find.search(query, direction), + clear: () => runtimeRef.current?.find.clear(), + }, writeOutput: (data: TerminalOutputData) => { runtimeRef.current?.write({ data }); }, @@ -478,6 +497,8 @@ export default function TerminalEmulator({ useEffect(() => { runtimeRef.current?.setCallbacks({ callbacks: { + onFindRequest, + onFindResult, onInput, onResize, onTerminalKey, @@ -489,6 +510,8 @@ export default function TerminalEmulator({ }, }); }, [ + onFindRequest, + onFindResult, onInput, onInputModeChange, onOpenLocalFileLink, diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 3e98799035..31c36b607c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,8 +1,16 @@ +import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find"; +import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Clipboard from "expo-clipboard"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native"; @@ -14,7 +22,7 @@ import { } from "@getpaseo/protocol/terminal-input-mode"; import { useTranslation } from "react-i18next"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardShiftStyle } from "@/keyboard/shift"; import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { useStableEvent } from "@/hooks/use-stable-event"; import { @@ -29,9 +37,10 @@ import { } from "@/terminal/runtime/terminal-key-dispatch"; import { getTerminalVirtualKeyboardControlId, + resolveTerminalVirtualKeyboardRows, + shouldShowTerminalVirtualKeyBar, shouldShowTerminalFloatingCopyAction, shouldShowTerminalPasteAction, - TERMINAL_VIRTUAL_KEYBOARD_ROWS, type TerminalVirtualKeyboardControl, } from "@/terminal/runtime/terminal-virtual-keyboard"; import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste"; @@ -91,6 +100,7 @@ interface TerminalPaneProps { const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320]; const TERMINAL_RESIZE_DEBOUNCE_MS = 100; +const TERMINAL_KEY_BAR_MAX_WIDTH = 880; const MODIFIER_LABELS = { ctrl: "Ctrl", @@ -136,8 +146,16 @@ function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) { () => [styles.keyButtonText, active && styles.keyButtonTextActive], [active], ); + const accessibilityState = useMemo(() => ({ selected: active }), [active]); return ( - + {MODIFIER_LABELS[modifier]} ); @@ -160,7 +178,13 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps [], ); return ( - + {label} ); @@ -220,12 +244,18 @@ export function TerminalPane({ return trimmed.length > 0 ? trimmed : undefined; }, [settings.monoFontFamily]); const isMobile = useIsCompactFormFactor(); + const showVirtualKeyBar = shouldShowTerminalVirtualKeyBar({ isNative, isCompact: isMobile }); + const [keyBarWidth, setKeyBarWidth] = useState(0); + const virtualKeyboardRows = resolveTerminalVirtualKeyboardRows({ + isCompact: isMobile, + availableWidth: keyBarWidth, + }); const mobileView = usePanelStore((state) => state.mobilePanel.target); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const swipeGesturesEnabled = isMobile; const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ mode: "padding", - enabled: isMobile, + enabled: isNative || isMobile, }); const [keyboardInset, setKeyboardInset] = useState(0); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); @@ -267,6 +297,12 @@ export function TerminalPane({ const [resizeRequestToken, setResizeRequestToken] = useState(0); useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection); const emulatorRef = useRef(null); + const findRef = useRef(null); + const handleFindRequest = useCallback(() => findRef.current?.open(), []); + const handleFindResult = useCallback( + (result: TerminalFindResult) => findRef.current?.update(result), + [], + ); const terminalIdRef = useRef(terminalId); const terminalPresentedRef = useRef(isTerminalPresented); terminalPresentedRef.current = isTerminalPresented; @@ -284,7 +320,7 @@ export function TerminalPane({ }, [terminalId]); const refreshClipboardAvailability = useCallback(async () => { - if (!isMobile) { + if (!showVirtualKeyBar) { setHasClipboardText(false); return; } @@ -294,7 +330,7 @@ export function TerminalPane({ } catch { setHasClipboardText(false); } - }, [isMobile]); + }, [showVirtualKeyBar]); useEffect(() => { void refreshClipboardAvailability(); @@ -326,6 +362,7 @@ export function TerminalPane({ }, [isMobile, isWorkspaceFocused, mobileView]); const handleRendererReadyChange = useCallback( (change: TerminalRendererReadyChange) => { + if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset(); setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change)); if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) { return; @@ -427,7 +464,7 @@ export function TerminalPane({ const handleKeyboardChange = useCallback( (nextShift: number) => { - setKeyboardInset(isMobile ? nextShift : 0); + setKeyboardInset(isNative || isMobile ? nextShift : 0); setIsKeyboardVisible(nextShift > 0); pulseKeyboardRefits(); }, @@ -449,31 +486,11 @@ export function TerminalPane({ [handleKeyboardChange], ); - useEffect(() => { - if (!client || !isConnected || !isWorkspaceFocused) { - return; - } - - return client.on("terminal_stream_exit", (message) => { - if (message.type !== "terminal_stream_exit") { - return; - } - - const exitedTerminalId = message.payload.terminalId; - if (!exitedTerminalId) { - return; - } - - workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); - if (terminalIdRef.current === exitedTerminalId) { - emulatorRef.current?.clear(); - } - streamControllerRef.current?.handleTerminalExit({ - terminalId: exitedTerminalId, - }); - setModifiers({ ...EMPTY_MODIFIERS }); - }); - }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); + const handleStreamExit = useStableEvent((exitedTerminalId: string) => { + workspaceTerminalSession.snapshots.clear({ terminalId: exitedTerminalId }); + if (terminalIdRef.current === exitedTerminalId) emulatorRef.current?.clear(); + setModifiers({ ...EMPTY_MODIFIERS }); + }); useEffect(() => { measuredTerminalSizeRef.current = null; @@ -561,6 +578,7 @@ export function TerminalPane({ onOutput: handleStreamOutput, onRestore: handleStreamRestore, onSnapshot: handleStreamSnapshot, + onExit: handleStreamExit, getRestoreOptions: getStreamRestoreOptions, onStatusChange: handleStreamControllerStatus, }); @@ -581,6 +599,7 @@ export function TerminalPane({ handleStreamOutput, handleStreamRestore, handleStreamSnapshot, + handleStreamExit, isConnected, ]); @@ -964,6 +983,11 @@ export function TerminalPane({ [keyboardPaddingStyle], ); + const handleKeyBarLayout = useCallback((event: LayoutChangeEvent) => { + const width = event.nativeEvent.layout.width; + if (width > 0) setKeyBarWidth(width); + }, []); + const handleSwipeRight = useCallback(() => { if (!swipeGesturesEnabled) return; emulatorRef.current?.blur(); @@ -1060,6 +1084,8 @@ export function TerminalPane({ onRendererReadyChange={handleRendererReadyChange} onSwipeRight={handleSwipeRight} onSwipeLeft={handleSwipeLeft} + onFindRequest={handleFindRequest} + onFindResult={handleFindResult} onInput={handleTerminalData} onFocus={handleTerminalFocus} onResize={handleTerminalResize} @@ -1075,6 +1101,16 @@ export function TerminalPane({ /> + + {showLoadingOverlay ? ( @@ -1096,10 +1132,14 @@ export function TerminalPane({ ) : null} - {isMobile ? ( + {showVirtualKeyBar ? ( - - {TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => ( + + {virtualKeyboardRows.map((row) => ( ({ }, keyboardRows: { gap: theme.spacing[1], + width: "100%", + maxWidth: TERMINAL_KEY_BAR_MAX_WIDTH, + alignSelf: "center", }, keyboardRow: { flexDirection: "row", diff --git a/packages/app/src/components/terminal-profile-icon.tsx b/packages/app/src/components/terminal-profile-icon.tsx index db3a216e7b..66cc00f69b 100644 --- a/packages/app/src/components/terminal-profile-icon.tsx +++ b/packages/app/src/components/terminal-profile-icon.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from "react"; import { SquareTerminal } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import type { Theme } from "@/styles/theme"; const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -17,7 +17,7 @@ function ProviderProfileIcon({ size: number; color?: string; }) { - const Icon = getProviderIcon(iconKey); + const Icon = useProviderIcon(iconKey); return ; } diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 9466bc6ba6..6b9009dd6a 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -1,8 +1,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; +import { + Platform, + Pressable, + StyleSheet as NativeStyleSheet, + Text, + ToastAndroid, + View, +} from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; @@ -44,13 +53,22 @@ type ToastViewportPlacement = "app-shell" | "panel"; const DEFAULT_DURATION_MS = 2200; const TOAST_MAX_WIDTH = 480; +const toastEntering = FadeIn.duration(140); +const toastExiting = FadeOut.duration(140); +const ThemedCheckCircle = withUnistyles(CheckCircle2); +const ThemedInfo = withUnistyles(Info); +const ThemedWarning = withUnistyles(AlertTriangle); +const foregroundIcon = (theme: Theme) => ({ color: theme.colors.foreground }); +const infoIcon = (theme: Theme) => ({ color: theme.colors.palette.blue[300] }); +const successIcon = (theme: Theme) => ({ color: theme.colors.primary }); +const warningIcon = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); +const errorIcon = (theme: Theme) => ({ color: theme.colors.destructive }); export function useToastHost(): { api: ToastApi; toast: ToastState | null; dismiss: () => void; } { - const { theme } = useUnistyles(); const { t } = useTranslation(); const [toast, setToast] = useState(null); const idRef = useRef(0); @@ -90,11 +108,11 @@ export function useToastHost(): { copied: (label?: string) => show(label ? t("common.states.copiedLabel", { label }) : t("common.states.copied"), { variant: "success", - icon: , + icon: , }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), - [show, theme.colors.foreground, t], + [show, t], ); const dismiss = useCallback(() => { @@ -113,11 +131,8 @@ export function ToastViewport({ onDismiss: () => void; placement?: ToastViewportPlacement; }) { - const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const isMobile = useIsCompactFormFactor(); - const opacity = useRef(new Animated.Value(0)).current; - const translateY = useRef(new Animated.Value(-8)).current; const timeoutRef = useRef | null>(null); const dismissDeadlineRef = useRef(null); const remainingDurationRef = useRef(0); @@ -129,28 +144,6 @@ export function ToastViewport({ } }, []); - const animateOut = useCallback(() => { - clearTimer(); - Animated.parallel([ - Animated.timing(opacity, { - toValue: 0, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: -8, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(({ finished }) => { - if (finished) { - onDismiss(); - } - }); - }, [clearTimer, onDismiss, opacity, translateY]); - const scheduleDismiss = useCallback( (durationMs: number | null) => { clearTimer(); @@ -163,10 +156,10 @@ export function ToastViewport({ remainingDurationRef.current = nextDurationMs; dismissDeadlineRef.current = Date.now() + nextDurationMs; timeoutRef.current = setTimeout(() => { - animateOut(); + onDismiss(); }, nextDurationMs); }, - [animateOut, clearTimer], + [onDismiss, clearTimer], ); const pauseDismiss = useCallback(() => { @@ -184,107 +177,35 @@ export function ToastViewport({ scheduleDismiss(remainingDurationRef.current || toast.durationMs); }, [scheduleDismiss, toast]); + const toastId = toast?.id; + const durationMs = toast?.durationMs ?? null; useEffect(() => { - if (!toast) { - clearTimer(); - dismissDeadlineRef.current = null; - remainingDurationRef.current = 0; - opacity.setValue(0); - translateY.setValue(-8); - return; - } - - clearTimer(); - opacity.setValue(0); - translateY.setValue(-8); - - Animated.parallel([ - Animated.timing(opacity, { - toValue: 1, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - Animated.timing(translateY, { - toValue: 0, - duration: 140, - easing: Easing.out(Easing.quad), - useNativeDriver: true, - }), - ]).start(); - - scheduleDismiss(toast.durationMs); - - return () => { - clearTimer(); - }; - }, [clearTimer, opacity, scheduleDismiss, toast, translateY]); + scheduleDismiss(durationMs); + return clearTimer; + }, [clearTimer, durationMs, scheduleDismiss, toastId]); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; - const topOffset = - placement === "app-shell" - ? insets.top + headerTopPadding + headerHeight + theme.spacing[2] - : theme.spacing[3]; - - const toastVariant = toast?.variant; - const toastAnimatedStyle = useMemo( - () => [ - styles.toast, - toastVariant === "info" ? styles.toastInfo : null, - toastVariant === "success" ? styles.toastSuccess : null, - toastVariant === "warning" ? styles.toastWarning : null, - toastVariant === "error" ? styles.toastError : null, - { - marginTop: topOffset, - opacity, - transform: [{ translateY }], - }, - ], - [toastVariant, topOffset, opacity, translateY], - ); - const toastMessageStyle = useMemo( - () => [styles.message, toastVariant === "error" ? styles.messageError : null], - [toastVariant], - ); - - if (!toast) { - return null; - } - - let defaultIcon: ReactNode = null; - if (toast.variant === "info") { - defaultIcon = ; - } else if (toast.variant === "success") { - defaultIcon = ; - } else if (toast.variant === "warning") { - defaultIcon = ; - } else if (toast.variant === "error") { - defaultIcon = ; - } - const icon = toast.icon ?? defaultIcon; + const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0; const content = ( - - - - {icon ? {icon} : null} - {typeof toast.content === "string" ? ( - - {toast.content} - - ) : ( - - {toast.content} - - )} - + + + {toast ? ( + + + + ) : null} ); @@ -296,15 +217,66 @@ export function ToastViewport({ return content; } +function ToastCard({ + toast, + onHoverIn, + onHoverOut, +}: { + toast: ToastState; + onHoverIn: () => void; + onHoverOut: () => void; +}) { + let defaultIcon: ReactNode = null; + if (toast.variant === "info") { + defaultIcon = ; + } else if (toast.variant === "success") { + defaultIcon = ; + } else if (toast.variant === "warning") { + defaultIcon = ; + } else if (toast.variant === "error") { + defaultIcon = ; + } + const icon = toast.icon ?? defaultIcon; + + return ( + + {icon ? {icon} : null} + {typeof toast.content === "string" ? ( + + {toast.content} + + ) : ( + + {toast.content} + + )} + + ); +} + +const animatedStyles = NativeStyleSheet.create({ + surface: { alignSelf: "center", maxWidth: "100%" }, +}); + const styles = StyleSheet.create((theme) => ({ - container: { + container: (placement: ToastViewportPlacement, topOffset: number) => ({ position: "absolute", left: theme.spacing[4], right: theme.spacing[4], - top: 0, + top: topOffset + (placement === "app-shell" ? theme.spacing[2] : theme.spacing[3]), zIndex: OVERLAY_Z.toast, alignItems: "center", - }, + }), widthBoundary: { width: "92%", maxWidth: TOAST_MAX_WIDTH, @@ -324,9 +296,6 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], ...theme.shadow.md, }, - toastSuccess: { - borderColor: theme.colors.border, - }, toastInfo: { borderColor: theme.colors.palette.blue[300], }, @@ -350,7 +319,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, }, - messageError: { - color: theme.colors.foreground, - }, })); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 022a9bc7c2..33009ee376 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { @@ -115,13 +115,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) setSheetData(null); }, [handleSheetDismiss]); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); - const contextValue = useMemo( () => ({ openToolCall, closeToolCall }), [openToolCall, closeToolCall], @@ -138,7 +131,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleToolCallSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > {sheetData && } diff --git a/packages/app/src/components/ui/alert.tsx b/packages/app/src/components/ui/alert.tsx index 674f0cb062..fe9fa3ce4b 100644 --- a/packages/app/src/components/ui/alert.tsx +++ b/packages/app/src/components/ui/alert.tsx @@ -2,14 +2,21 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc import { type ReactNode, useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { + type ButtonControlSize, + buttonIconSize, + createControlGeometry, +} from "@/components/ui/control-geometry"; +import { hexColorWithAlpha } from "@/utils/color"; export type AlertVariant = "default" | "info" | "success" | "warning" | "error"; +export type AlertSize = ButtonControlSize; export interface AlertProps { title?: string; description?: ReactNode; variant?: AlertVariant; - icon?: ReactNode; + size?: AlertSize; children?: ReactNode; testID?: string; } @@ -25,50 +32,93 @@ export function Alert({ title, description, variant = "default", - icon, + size = "md", children, testID, }: AlertProps) { const { theme } = useUnistyles(); const accentColor = resolveAccentColor(variant, theme); - const borderColor = variant === "success" ? theme.colors.border : accentColor; + const borderColor = + variant === "success" || !accentColor + ? theme.colors.border + : hexColorWithAlpha(accentColor, 0.5); - const containerStyle = useMemo( - () => [styles.container, borderColor ? { borderColor } : null], - [borderColor], - ); - - const titleStyle = useMemo( - () => [styles.title, accentColor ? { color: accentColor } : null], - [accentColor], - ); + const sized = resolveSizeStyles(size); + const containerStyle = [styles.container, sized.container, { borderColor }]; + const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null]; + const iconSize = buttonIconSize[size]; const resolvedIcon = useMemo(() => { - if (icon !== undefined) return icon; if (variant === "default") return null; const Icon = VARIANT_ICON[variant]; - return ; - }, [icon, variant, theme, accentColor]); + return ; + }, [variant, theme, accentColor, iconSize]); - const hasDescription = description != null && description !== ""; + let descriptionContent: ReactNode = null; + if (typeof description === "string" && description !== "") { + descriptionContent = {description}; + } else if (description != null && description !== "") { + descriptionContent = {description}; + } + const leadContent = title ? {title} : descriptionContent; + const belowLead = title ? descriptionContent : null; + const hasBody = belowLead !== null || Boolean(children); return ( - {resolvedIcon ? {resolvedIcon} : null} - - {title ? {title} : null} - {hasDescription && typeof description === "string" ? ( - {description} + + {resolvedIcon ? ( + {resolvedIcon} ) : null} - {hasDescription && typeof description !== "string" ? ( - {description} - ) : null} - {children ? {children} : null} + {leadContent ? {leadContent} : null} + {hasBody ? ( + + {belowLead} + {children ? {children} : null} + + ) : null} ); } +function resolveSizeStyles(size: AlertSize) { + if (size === "xs") { + return { + container: styles.containerXs, + text: styles.textXs, + lead: styles.leadXs, + iconSlot: styles.iconSlotXs, + indent: styles.indentXs, + }; + } + if (size === "sm") { + return { + container: styles.containerSm, + text: styles.textSm, + lead: styles.leadSm, + iconSlot: styles.iconSlotSm, + indent: styles.indentSm, + }; + } + if (size === "lg") { + return { + container: styles.containerLg, + text: styles.textLg, + lead: styles.leadLg, + iconSlot: styles.iconSlotLg, + indent: styles.indentLg, + }; + } + return { + container: styles.containerMd, + text: styles.textMd, + lead: styles.leadMd, + iconSlot: styles.iconSlotMd, + indent: styles.indentMd, + }; +} + function resolveAccentColor( variant: AlertVariant, theme: ReturnType["theme"], @@ -80,43 +130,63 @@ function resolveAccentColor( return null; } -const styles = StyleSheet.create((theme) => ({ - container: { - flexDirection: "row", - alignItems: "flex-start", - gap: theme.spacing[3], - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: "transparent", - borderRadius: theme.borderRadius.xl, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - }, - iconSlot: { - paddingTop: 2, - }, - body: { - flex: 1, - minWidth: 0, - gap: theme.spacing[1], - }, - title: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - }, - description: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - descriptionSlot: { - flexShrink: 1, - minWidth: 0, - gap: theme.spacing[2], - }, - actions: { - flexDirection: "row", - gap: theme.spacing[2], - marginTop: theme.spacing[2], - }, -})); +const styles = StyleSheet.create((theme) => { + const { alert } = createControlGeometry(theme); + + return { + container: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: "transparent", + }, + containerXs: alert.xs.container, + containerSm: alert.sm.container, + containerMd: alert.md.container, + containerLg: alert.lg.container, + lead: { + flexDirection: "row", + alignItems: "center", + }, + iconSlot: { + alignItems: "center", + }, + leadText: { + flex: 1, + minWidth: 0, + }, + leadXs: alert.xs.lead, + leadSm: alert.sm.lead, + leadMd: alert.md.lead, + leadLg: alert.lg.lead, + iconSlotXs: alert.xs.iconSlot, + iconSlotSm: alert.sm.iconSlot, + iconSlotMd: alert.md.iconSlot, + iconSlotLg: alert.lg.iconSlot, + indentXs: alert.xs.indent, + indentSm: alert.sm.indent, + indentMd: alert.md.indent, + indentLg: alert.lg.indent, + textXs: alert.xs.text, + textSm: alert.sm.text, + textMd: alert.md.text, + textLg: alert.lg.text, + title: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + }, + description: { + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + }, + descriptionSlot: { + flexShrink: 1, + minWidth: 0, + gap: theme.spacing[2], + }, + actions: { + flexDirection: "row", + gap: theme.spacing[2], + marginTop: theme.spacing[2], + }, + }; +}); diff --git a/packages/app/src/components/ui/menu/menu-anchor.test.ts b/packages/app/src/components/ui/anchor.test.ts similarity index 68% rename from packages/app/src/components/ui/menu/menu-anchor.test.ts rename to packages/app/src/components/ui/anchor.test.ts index ecdf438fe7..cb18a86271 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.test.ts +++ b/packages/app/src/components/ui/anchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor"; +import { computePosition, getTransformOrigin, type Rect } from "./anchor"; const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 }; const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 }; @@ -55,6 +55,18 @@ describe("computePosition", () => { expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" }); }); + it("lets hover cards flip horizontally when the opposite side has room", () => { + const result = position({ + triggerRect: { ...TRIGGER, x: 850 }, + placement: "right", + flipHorizontal: true, + }); + expect(result).toEqual({ x: 646, y: 100, actualPlacement: "left" }); + expect( + position({ triggerRect: { ...TRIGGER, x: 850 }, placement: "right" }).actualPlacement, + ).toBe("right"); + }); + it("keeps the surface inside a display area that does not start at the origin", () => { // Regression: the horizontal clamp used to ignore displayArea.x while the vertical one // honoured displayArea.y, so an offset display area pushed the surface off its left edge. @@ -68,10 +80,20 @@ describe("computePosition", () => { describe("getTransformOrigin", () => { it.each([ - ["bottom", "start", "left top"], - ["top", "end", "right bottom"], - ["right", "center", "center center"], - ] as const)("maps %s/%s to %s", (placement, alignment, expected) => { - expect(getTransformOrigin(placement, alignment)).toBe(expected); + ["bottom", "start", [0, 0, 0]], + ["bottom", "center", ["50%", 0, 0]], + ["bottom", "end", ["100%", 0, 0]], + ["top", "start", [0, "100%", 0]], + ["top", "center", ["50%", "100%", 0]], + ["top", "end", ["100%", "100%", 0]], + ["left", "start", [0, "50%", 0]], + ["left", "center", ["50%", "50%", 0]], + ["left", "end", ["100%", "50%", 0]], + ["right", "start", [0, "50%", 0]], + ["right", "center", ["50%", "50%", 0]], + ["right", "end", ["100%", "50%", 0]], + ] as const)("emits a native-ready origin for %s/%s", (placement, alignment, expected) => { + // Theme updates may reach Fabric without React Native's JS string preprocessing. + expect(getTransformOrigin(placement, alignment)).toEqual(expected); }); }); diff --git a/packages/app/src/components/ui/menu/menu-anchor.ts b/packages/app/src/components/ui/anchor.ts similarity index 70% rename from packages/app/src/components/ui/menu/menu-anchor.ts rename to packages/app/src/components/ui/anchor.ts index c7548dde68..51454a9ee5 100644 --- a/packages/app/src/components/ui/menu/menu-anchor.ts +++ b/packages/app/src/components/ui/anchor.ts @@ -1,10 +1,8 @@ import type { View } from "react-native"; /** - * Where a floating menu surface goes relative to the thing that opened it. - * - * This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`. - * Submenus need a third caller, so it lives here now and the menus import it. + * Where a floating surface goes relative to the thing that opened it. Menus and hover cards + * place themselves with it. */ export type Placement = "top" | "bottom" | "left" | "right"; @@ -38,14 +36,15 @@ export function measureElement(element: View): Promise { * there is more room above. Both conditions matter: flipping into a side that is equally * cramped just moves the clipping, so a surface taller than the whole viewport stays put. * - * Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the - * caller choosing its side up front, because flipping it mid-open would move the surface out - * from under the pointer that is travelling toward it. + * Horizontal flipping is opt-in for hover cards. Submenus choose their direction up front so + * they do not move out from under the pointer travelling toward them. */ function flipPlacement(input: { placement: Placement; triggerRect: Rect; contentHeight: number; + contentWidth: number; + flipHorizontal: boolean; displayArea: Rect; }): Placement { const { placement, triggerRect, contentHeight, displayArea } = input; @@ -58,6 +57,14 @@ function flipPlacement(input: { if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { return "bottom"; } + if (input.flipHorizontal) { + const spaceLeft = triggerRect.x - displayArea.x; + const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); + if (placement === "right" && spaceRight < input.contentWidth && spaceLeft > spaceRight) + return "left"; + if (placement === "left" && spaceLeft < input.contentWidth && spaceRight > spaceLeft) + return "right"; + } return placement; } @@ -120,6 +127,7 @@ export function computePosition({ placement, alignment, offset, + flipHorizontal = false, }: { triggerRect: Rect; contentSize: Size; @@ -127,11 +135,15 @@ export function computePosition({ placement: Placement; alignment: Alignment; offset: number; + /** Hover cards can choose either side; submenu direction is owned by the menu. */ + flipHorizontal?: boolean; }): { x: number; y: number; actualPlacement: Placement } { const actualPlacement = flipPlacement({ placement, triggerRect, contentHeight: contentSize.height, + contentWidth: contentSize.width, + flipHorizontal, displayArea, }); const anchored = anchorToPlacement({ @@ -144,18 +156,21 @@ export function computePosition({ return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement }; } -export function getTransformOrigin(placement: Placement, alignment: Alignment): string { - let vertical: string; - if (placement === "bottom") vertical = "top"; - else if (placement === "top") vertical = "bottom"; - else vertical = "center"; +export function getTransformOrigin( + placement: Placement, + alignment: Alignment, +): [number | string, number | string, number] { + let vertical: number | string; + if (placement === "bottom") vertical = 0; + else if (placement === "top") vertical = "100%"; + else vertical = "50%"; - let horizontal: string; - if (alignment === "start") horizontal = "left"; - else if (alignment === "end") horizontal = "right"; - else horizontal = "center"; + let horizontal: number | string; + if (alignment === "start") horizontal = 0; + else if (alignment === "end") horizontal = "100%"; + else horizontal = "50%"; - // React Native parses transform-origin positionally (x then y), unlike CSS - // which accepts keyword pairs in either order. - return `${horizontal} ${vertical}`; + // Native theme updates can bypass RN's JS string preprocessing. Supply the native + // array shape directly; Reanimated's web style builder also accepts this form. + return [horizontal, vertical, 0]; } diff --git a/packages/app/src/components/ui/autocomplete-popover.tsx b/packages/app/src/components/ui/autocomplete-popover.tsx index df9bc52fd1..9b1dcaf23d 100644 --- a/packages/app/src/components/ui/autocomplete-popover.tsx +++ b/packages/app/src/components/ui/autocomplete-popover.tsx @@ -1,7 +1,20 @@ -import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react"; -import { Keyboard, View, useWindowDimensions } from "react-native"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type RefObject, +} from "react"; +import { View, useWindowDimensions } from "react-native"; import { Portal } from "@gorhom/portal"; -import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete"; @@ -9,7 +22,7 @@ import { measureFloatingPanelPortalHost, useFloatingPanelPortalHostName, } from "@/components/ui/floating-panel-portal"; -import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { useKeyboardShift } from "@/keyboard/shift"; import { SPACING } from "@/styles/theme"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -66,57 +79,57 @@ export function AutocompletePopover({ const windowDimensions = useWindowDimensions(); const safeAreaInsets = useSafeAreaInsets(); const portalHostName = useFloatingPanelPortalHostName(); - const { shift } = useKeyboardShift(); + const { shift, isMoving } = useKeyboardShift(); const measuredShift = useSharedValue(0); + const measurementGeneration = useRef(0); + const canMeasure = visible && (options.length === 0 || selectedIndex >= 0); + + const remeasure = useCallback(() => { + if (!canMeasure) return; + const anchorElement = anchorRef.current; + if (!anchorElement) return; + const generation = measurementGeneration.current; + void Promise.all([ + measureElement(anchorElement), + measureFloatingPanelPortalHost(portalHostName), + ]).then(([anchorRect, hostRect]) => { + if (generation !== measurementGeneration.current || !hostRect) return undefined; + setRelativeAnchorRect({ + x: anchorRect.x - hostRect.x, + y: anchorRect.y - hostRect.y, + width: anchorRect.width, + hostHeight: hostRect.height, + }); + measuredShift.value = shift.value; + return undefined; + }); + }, [anchorRef, canMeasure, measuredShift, portalHostName, shift]); useEffect(() => { - if (!visible || (options.length > 0 && selectedIndex < 0)) { + measurementGeneration.current += 1; + if (!canMeasure) { setRelativeAnchorRect(null); return; } - let cancelled = false; - const remeasure = () => { - const anchorElement = anchorRef.current; - if (!anchorElement) return; - void Promise.all([ - measureElement(anchorElement), - measureFloatingPanelPortalHost(portalHostName), - ]).then(([anchorRect, hostRect]) => { - if (cancelled || !hostRect) return undefined; - setRelativeAnchorRect({ - x: anchorRect.x - hostRect.x, - y: anchorRect.y - hostRect.y, - width: anchorRect.width, - hostHeight: hostRect.height, - }); - measuredShift.value = shift.value; - return undefined; - }); - }; - remeasure(); const raf = requestAnimationFrame(remeasure); - const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) => - Keyboard.addListener(event, () => requestAnimationFrame(remeasure)), - ); return () => { - cancelled = true; + measurementGeneration.current += 1; cancelAnimationFrame(raf); - for (const sub of subscriptions) sub.remove(); }; - }, [ - visible, - options.length, - selectedIndex, - anchorRef, - portalHostName, - measuredShift, - shift, - windowDimensions.width, - windowDimensions.height, - ]); + }, [canMeasure, remeasure, windowDimensions.width, windowDimensions.height]); + + useAnimatedReaction( + () => isMoving.value, + (moving, wasMoving) => { + if (wasMoving && !moving) { + scheduleOnRN(remeasure); + } + }, + [isMoving, remeasure], + ); const baseStyle = useMemo(() => { if (!relativeAnchorRect) return null; diff --git a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx b/packages/app/src/components/ui/bottom-sheet-scope.tsx similarity index 79% rename from packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx rename to packages/app/src/components/ui/bottom-sheet-scope.tsx index 1d25bf7598..cbdaedac5f 100644 --- a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx +++ b/packages/app/src/components/ui/bottom-sheet-scope.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react"; const BottomSheetScopeContext = createContext(false); -export function BottomSheetTextInputScope({ children }: { children: ReactNode }) { +export function BottomSheetScope({ children }: { children: ReactNode }) { return {children}; } diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index e160970223..707ce60648 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -174,6 +174,7 @@ export function Button({ ...props }: PropsWithChildren< Omit & { + ref?: React.Ref; variant?: ButtonVariant; size?: ButtonSize; leftIcon?: LeftIcon; diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts index 6129af9463..e3e98633b3 100644 --- a/packages/app/src/components/ui/combobox-options.test.ts +++ b/packages/app/src/components/ui/combobox-options.test.ts @@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => { expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0); }); }); + +describe("search-only option text", () => { + it("finds hidden search text while keeping title matches first", () => { + const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" }; + const titleMatch = { id: "pr:8", label: "2024 release" }; + expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([ + titleMatch, + bodyMatch, + ]); + expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]); + expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]); + }); +}); diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index e23f2315ef..006eb1a21e 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -10,18 +10,22 @@ export interface ComboboxOptionModel { id: string; label: string; description?: string; + /** Additional text to match without displaying it in the option row. */ + searchText?: string; kind?: ComboboxOptionKind; } -const DESCRIPTION_FALLBACK_TIER = 99; +const SECONDARY_TEXT_FALLBACK_TIER = 99; function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { const best = scoreTextFields(search, [opt.label, opt.id]); if (best) return best; - if (!opt.description) return null; - const descriptionScore = scoreTextFields(search, [opt.description]); - if (!descriptionScore) return null; - return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER }; + const secondaryFields = opt.searchText + ? [opt.label, opt.id, opt.description ?? "", opt.searchText] + : [opt.description ?? ""]; + const secondaryScore = scoreTextFields(search, secondaryFields); + if (!secondaryScore) return null; + return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER }; } export interface BuildVisibleComboboxOptionsInput { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 7ee9407dca..caf73d1bbc 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,12 +28,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { - BottomSheetScrollView, - BottomSheetBackdrop, - BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; import { flip, @@ -53,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options"; import { isWeb } from "@/constants/platform"; import { IsolatedBottomSheetModal, + SheetVisibleFrame, useIsolatedBottomSheetVisibility, } from "./isolated-bottom-sheet-modal"; import { @@ -947,25 +945,23 @@ interface MobileBodyProps { } function MobileComboboxBody(props: MobileBodyProps): ReactElement { - const renderBackdrop = useCallback( - (backdropProps: React.ComponentProps) => ( - - ), - [], - ); - const comboboxTitleStyle = useMemo( () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); + const { footer, safeAreaBottom } = props; const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], - [props.safeAreaBottom], + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], ); const body = props.hasChildren ? ( @@ -990,7 +986,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { enableDynamicSizing={false} onChange={props.handleSheetChange} onDismiss={props.handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose backgroundComponent={ComboboxSheetBackground} handleIndicatorStyle={props.handleIndicatorStyle} @@ -998,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - {props.footer ? {props.footer} : null} - + + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + + ); } @@ -1266,7 +1263,8 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { visible={props.isOpen} onRequestClose={props.handleClose} > - {overlay} + {/* Android Modal opens a separate window outside the app's gesture root. */} + {overlay} ); } diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 5f4a653e51..0b0675596f 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buttonControlHeight, + buttonIconSize, createControlGeometry, getControlInteractionPhase, } from "@/components/ui/control-geometry"; @@ -11,6 +12,7 @@ const theme = { md: 6, lg: 8, xl: 12, + "2xl": 16, full: 9999, }, borderWidth: { @@ -30,10 +32,13 @@ const theme = { }, spacing: { 0: 0, + 0.5: 2, + 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, + 8: 32, }, } as unknown as Theme; @@ -157,4 +162,41 @@ describe("control geometry", () => { geometry.buttonMd.paddingHorizontal, ); }); + + it("gives alert title and description one font size and a tight gap per size", () => { + const { alert } = createControlGeometry(theme); + + expect(alert.xs.text).toEqual({ fontSize: 12 }); + expect(alert.sm.text).toEqual({ fontSize: 14 }); + expect(alert.md.text).toEqual(alert.sm.text); + expect(alert.lg.text).toEqual(alert.sm.text); + for (const size of ["xs", "sm", "md", "lg"] as const) { + expect(alert[size].container.gap).toBeLessThanOrEqual(4); + } + }); + + it("starts alert text below the lead row at the lead text's left edge", () => { + const { alert } = createControlGeometry(theme); + + for (const size of ["xs", "sm", "md", "lg"] as const) { + expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]); + expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap); + } + }); + + it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => { + const { alert } = createControlGeometry(theme); + + expect(alert.sm.container).toMatchObject({ + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 16, + }); + expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical); + expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical); + expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical); + for (const size of ["xs", "sm", "md", "lg"] as const) { + expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12); + } + }); }); diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index 0828e37c66..d56200887e 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -87,6 +87,77 @@ function fieldVerticalPadding( return (controlHeight - lineHeight - borderWidth * 2) / 2; } +/** + * Alerts share the button's size names, text size, and icon size (`buttonIconSize`). + * The icon and the first text sit in one centered lead row; everything below is indented + * by the icon slot plus its gap so it starts at the lead text's left edge. Title and + * description use one font size; only the container gap separates them. + */ +function createAlertGeometry(theme: Theme) { + function alertSize(input: { + size: ButtonControlSize; + fontSize: number; + paddingVertical: number; + paddingHorizontal: number; + borderRadius: number; + iconGap: number; + textGap: number; + }) { + const iconSize = buttonIconSize[input.size]; + return { + container: { + paddingVertical: input.paddingVertical, + paddingHorizontal: input.paddingHorizontal, + borderRadius: input.borderRadius, + gap: input.textGap, + }, + lead: { gap: input.iconGap }, + iconSlot: { width: iconSize }, + indent: { marginLeft: iconSize + input.iconGap }, + text: { fontSize: input.fontSize }, + }; + } + + return { + xs: alertSize({ + size: "xs", + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.xl, + iconGap: theme.spacing[2], + textGap: 0, + }), + sm: alertSize({ + size: "sm", + fontSize: theme.fontSize.base, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius["2xl"], + iconGap: theme.spacing[3], + textGap: theme.spacing[0.5], + }), + md: alertSize({ + size: "md", + fontSize: theme.fontSize.base, + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[6], + borderRadius: theme.borderRadius["2xl"], + iconGap: theme.spacing[3], + textGap: theme.spacing[0.5], + }), + lg: alertSize({ + size: "lg", + fontSize: theme.fontSize.base, + paddingVertical: theme.spacing[6], + paddingHorizontal: theme.spacing[8], + borderRadius: theme.borderRadius["2xl"], + iconGap: theme.spacing[4], + textGap: theme.spacing[1], + }), + } satisfies Record; +} + export function getControlInteractionPhase( state: ControlInteractionState, ): ControlInteractionPhase { @@ -147,6 +218,7 @@ export function createControlGeometry(theme: Theme) { fontSize: theme.fontSize.base, lineHeight: fieldTextMdLineHeight, }; + const alert = createAlertGeometry(theme); const switchControl = { minHeight: CONTROL_HEIGHTS.compact, justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT, @@ -156,22 +228,22 @@ export function createControlGeometry(theme: Theme) { buttonXs: { minHeight: buttonControlHeight.xs, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, buttonSm: { minHeight: buttonControlHeight.sm, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, buttonMd: { minHeight: buttonControlHeight.md, paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, + borderRadius: theme.borderRadius["2xl"], }, buttonLg: { minHeight: buttonControlHeight.lg, paddingHorizontal: theme.spacing[6], - borderRadius: theme.borderRadius.xl, + borderRadius: theme.borderRadius["2xl"], }, buttonText: { fontSize: theme.fontSize.base, @@ -179,6 +251,7 @@ export function createControlGeometry(theme: Theme) { buttonTextXs: { fontSize: theme.fontSize.sm, }, + alert, formTextInputSm: { ...fieldControlSm, ...fieldTextSm, @@ -233,17 +306,17 @@ export function createControlGeometry(theme: Theme) { segmentedSegmentXs: { minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2, paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, segmentedSegmentSm: { minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2, paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.xl, }, segmentedSegmentMd: { minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2, paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, + borderRadius: theme.borderRadius["2xl"], }, segmentedLabelXs: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/ui/density-icon.tsx b/packages/app/src/components/ui/density-icon.tsx new file mode 100644 index 0000000000..ee24ee3588 --- /dev/null +++ b/packages/app/src/components/ui/density-icon.tsx @@ -0,0 +1,51 @@ +import { useMemo, type ComponentType } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { densityIconSize, type DensityIconSize } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; + +type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>; + +type IconColor = { + [K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never; +}[keyof Theme["colors"]]; + +function IconGlyph({ + icon: Icon, + size, + color, + strokeWidth, +}: { + icon: IconComponent; + size: number; + color: string; + strokeWidth?: number; +}) { + return ; +} + +const ThemedIconGlyph = withUnistyles(IconGlyph); + +/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */ +export function DensityIcon({ + icon, + size, + sizeAdjustment = 0, + color, + strokeWidth, +}: { + icon: IconComponent; + size: DensityIconSize; + /** Only for a glyph that reads larger than others at the same size. */ + sizeAdjustment?: number; + color: IconColor; + strokeWidth?: number; +}) { + const uniProps = useMemo( + () => (theme: Theme, rt: { breakpoint?: string }) => ({ + size: densityIconSize(rt, size) + sizeAdjustment, + color: theme.colors[color], + }), + [size, sizeAdjustment, color], + ); + return ; +} diff --git a/packages/app/src/components/ui/dropdown-trigger.tsx b/packages/app/src/components/ui/dropdown-trigger.tsx index 8c88ca84a4..2fc1eb26ac 100644 --- a/packages/app/src/components/ui/dropdown-trigger.tsx +++ b/packages/app/src/components/ui/dropdown-trigger.tsx @@ -1,48 +1,104 @@ -import type { ReactElement, ReactNode } from "react"; -import { View } from "react-native"; +import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; +import { Text, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronDown } from "lucide-react-native"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { + buttonIconSize, + createControlGeometry, + type ButtonControlSize, +} from "@/components/ui/control-geometry"; +import type { Theme } from "@/styles/theme"; import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu"; const ThemedChevronDown = withUnistyles(ChevronDown); -interface DropdownTriggerProps extends Omit { - children?: ReactNode; - chevron?: ReactNode | null; -} - const chevronColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +interface DropdownTriggerProps extends Omit { + /** The selected option's label. */ + children: string; + /** Icon or swatch before the label. */ + leading?: ReactNode; + size?: ButtonControlSize; +} + +/** + * A select-style menu trigger: the selected value plus a chevron, drawn with the + * outline Button's chrome and size scale so a dropdown and a button in the same + * row share one geometry. Callers pick a size; they don't style it. + */ export function DropdownTrigger({ children, - chevron, + leading, + size = "sm", + disabled, ...props }: DropdownTriggerProps): ReactElement { + let sizeStyle: ViewStyle; + if (size === "xs") { + sizeStyle = styles.xs; + } else if (size === "md") { + sizeStyle = styles.md; + } else if (size === "lg") { + sizeStyle = styles.lg; + } else { + sizeStyle = styles.sm; + } + const triggerStyle = useCallback( + ({ pressed }: { pressed: boolean }) => [ + styles.base, + sizeStyle, + pressed ? styles.pressed : null, + disabled ? styles.disabled : null, + ], + [disabled, sizeStyle], + ); + const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]); + return ( - - + + {leading} + {children} - {chevron !== null && - (chevron ?? ( - - - - ))} + + + ); } -const styles = StyleSheet.create((theme) => ({ - row: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - chevronContainer: { - transform: [{ translateY: 1 }], - }, -})); +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + return { + base: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderWidth: 1, + borderColor: theme.colors.borderAccent, + backgroundColor: "transparent", + }, + xs: geometry.buttonXs, + sm: geometry.buttonSm, + md: geometry.buttonMd, + lg: geometry.buttonLg, + pressed: { + opacity: 0.85, + }, + disabled: { + opacity: theme.opacity[50], + }, + label: { + flexShrink: 1, + color: theme.colors.foreground, + ...geometry.buttonText, + }, + labelXs: geometry.buttonTextXs, + chevron: { + transform: [{ translateY: 1 }], + }, + }; +}); diff --git a/packages/app/src/components/ui/external-link.tsx b/packages/app/src/components/ui/external-link.tsx index 02e48cc44f..a29018b042 100644 --- a/packages/app/src/components/ui/external-link.tsx +++ b/packages/app/src/components/ui/external-link.tsx @@ -1,13 +1,16 @@ import { useCallback, type ReactNode } from "react"; import { Pressable, Text } from "react-native"; import { ArrowUpRight } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { Theme } from "@/styles/theme"; import { openExternalUrl } from "@/utils/open-external-url"; -interface ExternalLinkProps { +export interface ExternalLinkProps { href: string; - label: string; + label?: string; + children?: ReactNode; + onError?: (error: unknown) => void; tooltip?: ReactNode; testID?: string; accessibilityLabel?: string; @@ -16,19 +19,20 @@ interface ExternalLinkProps { /** * Inline "Docs ↗" affordance — muted text + arrow-top-right icon, opens the * URL via the platform's external opener. Wrap in a Tooltip when there's a - * one-line hint worth surfacing on hover/tap. + * one-line hint worth surfacing on hover. A native tap opens the URL. */ export function ExternalLink({ href, label, + children, + onError, tooltip, testID, accessibilityLabel, }: ExternalLinkProps) { - const { theme } = useUnistyles(); const handlePress = useCallback(() => { - void openExternalUrl(href); - }, [href]); + void openExternalUrl(href).catch(onError ?? console.error); + }, [href, onError]); const trigger = ( - {label} - + {children ?? label} + ); @@ -49,7 +53,7 @@ export function ExternalLink({ } return ( - + {trigger} {tooltip} @@ -58,6 +62,9 @@ export function ExternalLink({ ); } +const iconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const ThemedArrowUpRight = withUnistyles(ArrowUpRight); + const styles = StyleSheet.create((theme) => ({ trigger: { flexDirection: "row", diff --git a/packages/app/src/components/ui/floating-action-button.tsx b/packages/app/src/components/ui/floating-action-button.tsx new file mode 100644 index 0000000000..2b06e097f2 --- /dev/null +++ b/packages/app/src/components/ui/floating-action-button.tsx @@ -0,0 +1,94 @@ +import { forwardRef, useMemo } from "react"; +import { Text, type View } from "react-native"; +import type { LucideIcon } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { PressHighlight } from "@/components/ui/press-highlight"; +import { ICON_SIZE } from "@/styles/theme"; + +import { + FLOATING_ACTION_BUTTON_SIZE, + FLOATING_ACTION_BUTTON_INSET, +} from "./floating-action-layout"; +export { + FLOATING_ACTION_BUTTON_CLEARANCE, + floatingActionsClearance, +} from "./floating-action-layout"; + +export interface FloatingActionButtonProps { + icon: LucideIcon; + /** Names the action for assistive technology. */ + accessibilityLabel: string; + onPress: () => void; + label?: string; + disabled?: boolean; + bottomInset?: number; + testID?: string; +} + +/** + * Pins an action to the bottom-right of its nearest positioned ancestor. + * The surface reserves scroll clearance and uses bottomInset to separate stacked actions. + */ +export const FloatingActionButton = forwardRef( + function FloatingActionButton( + { icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID }, + ref, + ) { + const accessibilityState = useMemo(() => ({ disabled }), [disabled]); + const buttonStyle = useMemo( + () => [ + styles.button, + label && styles.labeled, + disabled && styles.disabled, + { bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset }, + ], + [bottomInset, disabled, label], + ); + return ( + + + {label ? {label} : null} + + ); + }, +); + +const styles = StyleSheet.create((theme) => ({ + button: { + position: "absolute", + right: FLOATING_ACTION_BUTTON_INSET, + bottom: FLOATING_ACTION_BUTTON_INSET, + width: FLOATING_ACTION_BUTTON_SIZE, + height: FLOATING_ACTION_BUTTON_SIZE, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + ...theme.shadow.md, + }, + labeled: { + width: "auto", + minWidth: FLOATING_ACTION_BUTTON_SIZE, + paddingHorizontal: theme.spacing[4], + flexDirection: "row", + gap: theme.spacing[2], + }, + label: { color: theme.colors.foreground, fontSize: theme.fontSize.base }, + disabled: { opacity: theme.opacity[50] }, + pressed: { + backgroundColor: theme.colors.interactionHighlight, + }, + glyph: { + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/ui/floating-action-layout.test.ts b/packages/app/src/components/ui/floating-action-layout.test.ts new file mode 100644 index 0000000000..2256f4ef9b --- /dev/null +++ b/packages/app/src/components/ui/floating-action-layout.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { + floatingActionsClearance, + FLOATING_ACTION_BUTTON_CLEARANCE, +} from "./floating-action-layout"; + +describe("floating action scroll clearance", () => { + it("keeps the last diff line above Jump to file before and after feedback is added", () => { + const jump = FLOATING_ACTION_BUTTON_CLEARANCE; + const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44; + expect(floatingActionsClearance([jump, 0], 34)).toBe(106); + expect(floatingActionsClearance([jump, feedback], 34)).toBe(222); + expect(floatingActionsClearance([0, feedback], 34)).toBe(150); + expect(floatingActionsClearance([jump, feedback], 0)).toBe(188); + expect(floatingActionsClearance([0, 0], 34)).toBe(0); + }); +}); diff --git a/packages/app/src/components/ui/floating-action-layout.ts b/packages/app/src/components/ui/floating-action-layout.ts new file mode 100644 index 0000000000..c54fd62dbe --- /dev/null +++ b/packages/app/src/components/ui/floating-action-layout.ts @@ -0,0 +1,15 @@ +import { SPACING } from "@/styles/theme"; + +export const FLOATING_ACTION_BUTTON_SIZE = 56; +export const FLOATING_ACTION_BUTTON_INSET = SPACING[4]; +export const FLOATING_ACTION_BUTTON_CLEARANCE = + FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET; + +/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */ +export function floatingActionsClearance( + actionClearances: readonly number[], + bottomInset: number, +): number { + const clearance = actionClearances.reduce((total, action) => total + action, 0); + return clearance > 0 ? clearance + bottomInset : 0; +} diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx index d7c5356f03..f55b10bb10 100644 --- a/packages/app/src/components/ui/form-field.tsx +++ b/packages/app/src/components/ui/form-field.tsx @@ -27,12 +27,13 @@ import { interface FieldProps { label: string; children: ReactNode; + trailing?: ReactNode; hint?: string; error?: string | null; testID?: string; } -export function Field({ label, children, hint, error, testID }: FieldProps) { +export function Field({ label, children, trailing, hint, error, testID }: FieldProps) { const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]); const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]); const subtext = useMemo(() => { @@ -55,7 +56,10 @@ export function Field({ label, children, hint, error, testID }: FieldProps) { return ( - {label} + + {label} + {trailing} + {children} {subtext} @@ -226,7 +230,15 @@ const styles = StyleSheet.create((theme) => ({ container: { gap: theme.spacing[2], }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, label: { + flexShrink: 1, + minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/ui/highlighted-text-segments.test.ts b/packages/app/src/components/ui/highlighted-text-segments.test.ts index b38ed4664b..6f42fe823d 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.test.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { toHighlightSegments } from "./highlighted-text-segments"; +import { findHighlightRanges, toHighlightSegments } from "./highlighted-text-segments"; /** What the eye sees, written back out with the marked runs bracketed. */ function marked(text: string, ranges: { start: number; length: number }[]): string { @@ -48,3 +48,40 @@ describe("toHighlightSegments", () => { expect(toHighlightSegments("main", [])).toEqual([{ start: 0, text: "main", marked: false }]); }); }); + +describe("findHighlightRanges", () => { + it("highlights the same case-insensitive query independently in every field", () => { + for (const text of ["Billing fixes", "Fix BILLING", "feat/billing", "billing-project"]) { + expect(marked(text, findHighlightRanges("bill", text))).toContain( + `[${text.slice(text.toLowerCase().indexOf("bill"), text.toLowerCase().indexOf("bill") + 4)}]`, + ); + } + }); + it("highlights the tokens present even when other words match another field", () => { + expect(marked("Stripe billing", findHighlightRanges("stripe main", "Stripe billing"))).toBe( + "[Stripe] billing", + ); + }); + it("uses the search matcher for typo and scattered-character matches", () => { + expect(marked("billing", findHighlightRanges("bulling", "billing"))).toBe("[billing]"); + expect(marked("paseo-babysit", findHighlightRanges("pasbab", "paseo-babysit"))).toBe( + "[pas]eo-[bab]ysit", + ); + }); + it("does not highlight a query assembled across separate words", () => { + expect( + findHighlightRanges( + "terminal", + "Let me diagnose this problem in a diagnose this problem and", + ), + ).toEqual([]); + expect(marked("check configuration", findHighlightRanges("confg", "check configuration"))).toBe( + "check [conf]i[g]uration", + ); + }); + it("merges overlapping tokens and ignores empty or unmatched queries", () => { + expect(marked("billing", findHighlightRanges("bill billing", "billing"))).toBe("[billing]"); + expect(findHighlightRanges("", "billing")).toEqual([]); + expect(findHighlightRanges("xyz", "billing")).toEqual([]); + }); +}); diff --git a/packages/app/src/components/ui/highlighted-text-segments.ts b/packages/app/src/components/ui/highlighted-text-segments.ts index 65fdbe2e7c..0bd4f1e0bd 100644 --- a/packages/app/src/components/ui/highlighted-text-segments.ts +++ b/packages/app/src/components/ui/highlighted-text-segments.ts @@ -1,4 +1,10 @@ -import type { MatchRange } from "@getpaseo/protocol/search/text-match"; +import { + fuzzyPolicyForToken, + matchRanges, + scoreMatch, + tokenizeQuery, + type MatchRange, +} from "@getpaseo/protocol/search/text-match"; export interface HighlightSegment { /** Offset into the source text; unique per segment, so it doubles as a key. */ @@ -33,3 +39,23 @@ export function toHighlightSegments( } return segments; } + +/** Best-effort presentation of each query token in this field, independently of other fields. */ +export function findHighlightRanges(query: string, text: string): MatchRange[] { + const ranges = tokenizeQuery(query) + .flatMap((token) => { + const score = scoreMatch(token, text, { fuzzy: fuzzyPolicyForToken(token) }); + return score ? matchRanges(token, text, score) : []; + }) + .sort((left, right) => left.start - right.start); + const merged: MatchRange[] = []; + for (const range of ranges) { + const last = merged.at(-1); + if (last && range.start <= last.start + last.length) { + last.length = Math.max(last.length, range.start + range.length - last.start); + } else { + merged.push({ ...range }); + } + } + return merged; +} diff --git a/packages/app/src/components/ui/hover-card.tsx b/packages/app/src/components/ui/hover-card.tsx new file mode 100644 index 0000000000..3d438505d3 --- /dev/null +++ b/packages/app/src/components/ui/hover-card.tsx @@ -0,0 +1,418 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; +import { Dimensions, View, type Role, type StyleProp, type ViewStyle } from "react-native"; +import { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet } from "react-native-unistyles"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import { + OverlayLayerProvider, + useOverlayLayer, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; +import { FloatingSurface } from "@/components/ui/floating"; +import { isWeb } from "@/constants/platform"; +import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; +import { + computePosition, + measureElement, + type Alignment, + type Placement, + type Rect, + type Size, +} from "./anchor"; + +const CLOSE_GRACE_MS = 100; + +interface HoverCardContextValue { + open: boolean; + layer: number; + setTriggerRef: (node: View | null) => void; + triggerRef: RefObject; + contentRef: RefObject; + openNow: () => void; + scheduleClose: () => void; +} + +const HoverCardContext = createContext(null); + +/** + * A card that opens while the pointer is on its trigger and stays open while the pointer crosses + * onto it, so its content can be pressed. Hover only exists on web; elsewhere the trigger renders + * alone and the content never mounts. See docs/hover.md. + * + * ```tsx + * + * {trigger} + * {details} + * + * ``` + */ +export function HoverCard({ + disabled = false, + children, +}: { + /** Closes the card and keeps it closed, e.g. while its trigger is dragged. */ + disabled?: boolean; + children: ReactNode; +}): ReactNode { + if (!isWeb) return children; + return {children}; +} + +function WebHoverCard({ + disabled, + children, +}: { + disabled: boolean; + children: ReactNode; +}): ReactElement { + const layer = useOverlayLayer("floating"); + const triggerRef = useRef(null); + const contentRef = useRef(null); + const [open, setOpen] = useState(false); + const graceTimerRef = useRef | null>(null); + // Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible. + const keyboardFocusedRef = useRef(false); + + const clearGraceTimer = useCallback(() => { + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } + }, []); + + const focusInside = useCallback(() => { + if (!isWeb) return false; + const active = document.activeElement; + return [triggerRef.current, contentRef.current].some((view) => + (view as unknown as HTMLElement | null)?.contains(active), + ); + }, []); + + // Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves + // behind on the trigger or a button in the card does not outlast the pointer leaving. + const keyboardFocusInside = useCallback(() => { + return focusInside() && document.activeElement?.matches(":focus-visible") === true; + }, [focusInside]); + + const scheduleClose = useCallback(() => { + if (keyboardFocusInside()) return; + if (graceTimerRef.current) return; + graceTimerRef.current = setTimeout(() => { + graceTimerRef.current = null; + if (keyboardFocusInside()) return; + setOpen(false); + }, CLOSE_GRACE_MS); + }, [keyboardFocusInside]); + + const openNow = useCallback(() => { + clearGraceTimer(); + if (!disabled) { + setOpen(true); + } + }, [clearGraceTimer, disabled]); + + const keyPressed = useCallback( + (event: KeyboardEvent): boolean => { + if (!isWeb) return false; + const trigger = triggerRef.current as unknown as HTMLElement | null; + if (!trigger) return false; + if (event.key === "Escape" && open) { + event.preventDefault(); + if (focusInside()) trigger.focus(); + clearGraceTimer(); + setOpen(false); + return true; + } else if ( + focusInside() && + event.target === trigger && + ["ArrowDown", "Enter", " "].includes(event.key) + ) { + event.preventDefault(); + openNow(); + requestAnimationFrame(() => { + (contentRef.current as unknown as HTMLElement | null) + ?.querySelector( + 'button, [role="button"][tabindex="0"], a[href], input, [tabindex="0"]', + ) + ?.focus(); + }); + return true; + } + return false; + }, + [clearGraceTimer, focusInside, open, openNow], + ); + + const setOverlayScope = useWebOverlayRegistration({ + active: !disabled && open, + layer, + onKeyDown: keyPressed, + manageFocus: false, + }); + const setTriggerRef = useCallback( + (node: View | null) => { + triggerRef.current = node; + setOverlayScope(node); + }, + [setOverlayScope], + ); + + useEffect(() => { + if (!isWeb) return; + const trigger = triggerRef.current as unknown as HTMLElement | null; + if (!trigger) return; + const close = () => { + clearGraceTimer(); + setOpen(false); + }; + const focusEntered = (event: FocusEvent) => { + const target = event.target as Element; + keyboardFocusedRef.current = target.matches(":focus-visible"); + if (trigger.contains(target)) { + openNow(); + } else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) { + clearGraceTimer(); + } + }; + // Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button + // that disables itself on press drops pointer focus while the pointer is still on the card. + const focusLeft = (event: FocusEvent) => { + const next = event.relatedTarget as Node | null; + if ( + keyboardFocusedRef.current && + !trigger.contains(next) && + !(contentRef.current as unknown as HTMLElement | null)?.contains(next) + ) { + scheduleClose(); + } + }; + const activateClosedTrigger = (event: KeyboardEvent) => { + if ( + !open && + !event.defaultPrevented && + !event.isComposing && + ["ArrowDown", "Enter", " "].includes(event.key) + ) + keyPressed(event); + }; + const scrolled = (event: Event) => { + if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node)) + close(); + }; + document.addEventListener("focusin", focusEntered); + document.addEventListener("focusout", focusLeft); + trigger.addEventListener("keydown", activateClosedTrigger); + document.addEventListener("scroll", scrolled, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("focusin", focusEntered); + document.removeEventListener("focusout", focusLeft); + trigger.removeEventListener("keydown", activateClosedTrigger); + document.removeEventListener("scroll", scrolled, true); + window.removeEventListener("resize", close); + }; + }, [clearGraceTimer, keyPressed, open, openNow, scheduleClose]); + + // While open, the safe zone covers trigger + content + the bridge between + // them. Close only fires when the pointer leaves the safe zone; re-entering + // it (including the bridge) cancels the pending close. + useHoverSafeZone({ + enabled: open, + triggerRef, + contentRef, + onEnterSafeZone: clearGraceTimer, + onLeaveSafeZone: scheduleClose, + }); + + useEffect(() => { + if (disabled) { + clearGraceTimer(); + setOpen(false); + } + }, [clearGraceTimer, disabled]); + + useEffect(() => clearGraceTimer, [clearGraceTimer]); + + const value = useMemo( + () => ({ open, layer, setTriggerRef, triggerRef, contentRef, openNow, scheduleClose }), + [open, layer, setTriggerRef, openNow, scheduleClose], + ); + + return ( + + {children} + + ); +} + +export function HoverCardTrigger({ + children, + focusable = false, + accessibilityLabel, +}: { + children: ReactNode; + focusable?: boolean; + accessibilityLabel?: string; +}): ReactNode { + const ctx = useContext(HoverCardContext); + if (!ctx) return children; + return ( + + {children} + + ); +} + +interface HoverCardContentProps { + placement: Placement; + alignment?: Alignment; + offset?: number; + /** Layered over the default popover surface. */ + style?: StyleProp; + role?: Role; + accessibilityLabel?: string; + testID?: string; + /** Mounted only while the card is open, so whatever it fetches runs only then. */ + children: ReactNode; +} + +export function HoverCardContent(props: HoverCardContentProps): ReactElement | null { + const ctx = useContext(HoverCardContext); + if (!ctx?.open) return null; + return ( + + ); +} + +function HoverCardSurface({ + layer, + triggerRef, + contentRef, + placement, + alignment = "center", + offset = 4, + style, + role, + accessibilityLabel, + testID, + children, +}: HoverCardContentProps & { + layer: number; + triggerRef: RefObject; + contentRef: RefObject; +}): ReactElement { + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (!triggerRef.current) return; + let cancelled = false; + void measureElement(triggerRef.current).then((rect) => { + if (!cancelled) setTriggerRect(rect); + return undefined; + }); + return () => { + cancelled = true; + }; + }, [triggerRef]); + + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width, height } = Dimensions.get("window"); + const { x, y } = computePosition({ + triggerRect, + contentSize, + displayArea: { x: 0, y: 0, width, height }, + placement, + flipHorizontal: true, + alignment, + offset, + }); + setPosition({ x, y }); + }, [alignment, contentSize, offset, placement, triggerRect]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [], + ); + + const frameStyle = useMemo( + () => ({ + position: "absolute" as const, + top: position?.y ?? -9999, + left: position?.x ?? -9999, + }), + [position?.x, position?.y], + ); + const surfaceStyle = useMemo(() => [styles.surface, style], [style]); + + return ( + + + + {children} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + surface: { + backgroundColor: theme.colors.popover, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.xl, + ...theme.shadow.md, + zIndex: 1000, + }, +})); diff --git a/packages/app/src/components/ui/icon-button-chrome.ts b/packages/app/src/components/ui/icon-button-chrome.ts index 394a744e64..75735ac986 100644 --- a/packages/app/src/components/ui/icon-button-chrome.ts +++ b/packages/app/src/components/ui/icon-button-chrome.ts @@ -1,3 +1,4 @@ +import { createContext, useCallback, useContext } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; @@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ export type IconButtonChromeSize = "large" | "small"; const SMALL_ICON_BUTTON_SIZE = 20; +/** Width and height of a large icon button on compact layouts. */ +export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32; const COMPACT_SMALL_ICON_BUTTON_SIZE = 32; export interface IconButtonChromeState { @@ -27,20 +30,53 @@ interface IconButtonChromeOptions { state?: IconButtonChromeState; compact?: boolean; disabled?: boolean; + /** A circular hitbox and highlight, for buttons inside a pill-shaped container. */ + round?: boolean; style?: StyleProp; } +const IconButtonChromeContext = createContext<"default" | "header-island">("default"); + +/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */ +export const IconButtonChromeProvider = IconButtonChromeContext.Provider; + +/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */ +export function useIconButtonChromeStyle({ + size, + compact, + disabled, + round, + style, + active, +}: Omit & { active?: boolean }) { + const scope = useContext(IconButtonChromeContext); + return useCallback( + (state: IconButtonChromeState) => + iconButtonChromeStyle({ + size: scope === "header-island" ? "large" : size, + compact, + disabled, + round: scope === "header-island" || round, + style, + state: { ...state, active: active || state.active }, + }), + [scope, size, compact, disabled, round, style, active], + ); +} + /** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */ export function iconButtonChromeStyle({ size, state, compact = false, disabled = false, + round = false, style, }: IconButtonChromeOptions): StyleProp { const highlighted = state?.active || state?.hovered || state?.pressed || state?.open; return [ resolveIconButtonFrame(size, compact), + round ? styles.round : null, style, highlighted ? styles.highlighted : null, disabled ? styles.disabled : null, @@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number { const styles = StyleSheet.create((theme) => ({ large: { width: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, height: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, padding: 0, @@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({ outlineWidth: 0, outlineColor: "transparent", }, + round: { + borderRadius: theme.borderRadius.full, + }, highlighted: { backgroundColor: theme.colors.interactionHighlight, }, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/back-press.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/back-press.ts new file mode 100644 index 0000000000..d8f58356d6 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/back-press.ts @@ -0,0 +1,16 @@ +import { BackHandler, Platform } from "react-native"; +import type { BackPressSource } from "./visibility-tracker"; + +/** + * Android's hardware Back press. + * + * iOS and web have no such press, and `react-native-web`'s `BackHandler` logs an error as soon as + * anything subscribes, so nothing is registered off Android. + */ +export const systemBackPress: BackPressSource = { + subscribe(onBackPress) { + if (Platform.OS !== "android") return () => {}; + const subscription = BackHandler.addEventListener("hardwareBackPress", onBackPress); + return () => subscription.remove(); + }, +}; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index 7567601eca..cf820ac86c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -58,7 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => { }); import { IsolatedBottomSheetModal, type ContextBridge } from "."; -import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope"; +import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; /** Renders whatever the portal is currently holding, from its own place in the tree. */ function PortalHostProbe() { diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 1f70a6babe..8dd55e5678 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -1,15 +1,18 @@ import { BottomSheetModal as GorhomBottomSheetModal, type BottomSheetModalProps, + type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import React from "react"; -import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react"; import type { ElementRef, ReactNode } from "react"; +import { systemBackPress } from "./back-press"; import { type BottomSheetController, createBottomSheetVisibilityTracker, } from "./visibility-tracker"; -import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope"; +import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; +import { SheetBackdrop } from "./sheet-backdrop"; type GorhomBottomSheetModalMethods = ElementRef; @@ -35,7 +38,7 @@ export type ContextBridge = (children: ReactNode) => ReactNode; type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, - "enableDismissOnClose" | "stackBehavior" | "children" + "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge @@ -43,6 +46,8 @@ type IsolatedBottomSheetModalProps = Omit< */ children?: ReactNode; presentation?: "push" | "replace"; + /** Show a dismissible backdrop. Its interaction belongs to the modal stack. */ + backdropOpacity?: number; /** * Required, and `null` is a real answer: a sheet that needs nothing from its call site should * have to say so. The failure it prevents is invisible until someone adds a `useContext` deep @@ -57,17 +62,45 @@ export const IsolatedBottomSheetModal = forwardRef< IsolatedBottomSheetModalRef, IsolatedBottomSheetModalProps >(function IsolatedBottomSheetModal(props, ref) { - const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props; + // Gorhom puts this ref into its provider queue and reads `.current` for stack operations. + // A callback ref accepts the handle but leaves that queue unable to dismiss or restore it. + const modalRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + present: (...args) => modalRef.current?.present(...args), + dismiss: (...args) => modalRef.current?.dismiss(...args), + snapToIndex: (...args) => modalRef.current?.snapToIndex(...args), + snapToPosition: (...args) => modalRef.current?.snapToPosition(...args), + expand: (...args) => modalRef.current?.expand(...args), + collapse: (...args) => modalRef.current?.collapse(...args), + close: (...args) => modalRef.current?.close(...args), + forceClose: (...args) => modalRef.current?.forceClose(...args), + }), + [], + ); + const { + children, + presentation = "push", + contextBridge, + backdropOpacity, + ...bottomSheetProps + } = props; + const renderBackdrop = useCallback( + (backdropProps: BottomSheetBackdropProps) => ( + + ), + [backdropOpacity], + ); const modal = ( - - {contextBridge ? contextBridge(children) : children} - + {contextBridge ? contextBridge(children) : children} ); @@ -87,7 +120,11 @@ export function useIsolatedBottomSheetVisibility({ onCloseRef.current = onClose; const tracker = useMemo( - () => createBottomSheetVisibilityTracker({ onClose: () => onCloseRef.current() }), + () => + createBottomSheetVisibilityTracker({ + onClose: () => onCloseRef.current(), + backPress: systemBackPress, + }), [], ); @@ -115,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({ handleSheetDismiss, }; } + +export { SheetVisibleFrame } from "./visible-frame"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx new file mode 100644 index 0000000000..1232566462 --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -0,0 +1,5 @@ +import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + return ; +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx new file mode 100644 index 0000000000..ba1e397a9e --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -0,0 +1,36 @@ +import { + BottomSheetBackdrop, + useBottomSheetModal, + type BottomSheetBackdropProps, +} from "@gorhom/bottom-sheet"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { Pressable, StyleSheet, View } from "react-native"; + +export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { + const { dismiss } = useBottomSheetModal(); + const { t } = useTranslation(); + const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + + return ( + + + + + ); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts index d70dffdaa1..60b386bd45 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + type BackPressSource, type BottomSheetController, createBottomSheetVisibilityTracker, } from "./visibility-tracker"; @@ -20,21 +21,55 @@ class FakeBottomSheet implements BottomSheetController { } } +/** + * Stands in for Android's `BackHandler`, which offers the press to listeners newest-first and + * stops at the first one that claims it. + */ +class FakeBackPress implements BackPressSource { + private listeners: (() => boolean)[] = []; + + subscribe(onBackPress: () => boolean): () => void { + this.listeners.push(onBackPress); + return () => { + const index = this.listeners.indexOf(onBackPress); + if (index >= 0) this.listeners.splice(index, 1); + }; + } + + /** True when a sheet claimed the press, false when it falls through to the navigator. */ + press(): boolean { + for (let index = this.listeners.length - 1; index >= 0; index--) { + if (this.listeners[index]?.()) return true; + } + return false; + } +} + function setup() { const sheet = new FakeBottomSheet(); + const backPress = new FakeBackPress(); let closeCount = 0; const tracker = createBottomSheetVisibilityTracker({ onClose: () => { closeCount += 1; }, + backPress, }); return { sheet, + backPress, tracker, closeCount: () => closeCount, }; } +/** Opens a sheet and runs it through the presentation the real sheet reports back. */ +function openSheet(context: ReturnType) { + context.tracker.attachController(context.sheet); + context.tracker.syncDesired({ visible: true }); + context.tracker.handleSheetIndexChange(0); +} + describe("bottom sheet visibility tracker", () => { it("presents once when the sheet becomes visible and dismisses when it goes back to hidden", () => { const { sheet, tracker } = setup(); @@ -160,4 +195,62 @@ describe("bottom sheet visibility tracker", () => { expect(sheet.events).toEqual([{ type: "present" }, { type: "present" }]); }); + + it("dismisses the sheet on a Back press instead of letting it reach the navigator", () => { + const context = setup(); + openSheet(context); + + expect(context.backPress.press()).toBe(true); + expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]); + }); + + it("leaves Back to the navigator before the sheet is on screen and after it closes", () => { + const context = setup(); + context.tracker.attachController(context.sheet); + context.tracker.syncDesired({ visible: false }); + + expect(context.backPress.press()).toBe(false); + + openSheet(context); + context.tracker.handleSheetIndexChange(-1); + context.tracker.handleSheetDismiss(); + + expect(context.backPress.press()).toBe(false); + }); + + it("gives the Back press to the sheet presented last", () => { + const backPress = new FakeBackPress(); + const sheets = ["below", "above"].map((name) => { + const sheet = new FakeBottomSheet(); + const tracker = createBottomSheetVisibilityTracker({ onClose: () => {}, backPress }); + tracker.attachController(sheet); + tracker.syncDesired({ visible: true }); + tracker.handleSheetIndexChange(0); + return { name, sheet }; + }); + + backPress.press(); + + expect(sheets.map((entry) => [entry.name, entry.sheet.events.at(-1)?.type])).toEqual([ + ["below", "present"], + ["above", "dismiss"], + ]); + }); + + it("stops claiming Back once the sheet leaves the tree", () => { + const context = setup(); + openSheet(context); + context.tracker.attachController(null); + + expect(context.backPress.press()).toBe(false); + }); +}); + +it("does not cancel the owner's editor when the compact sheet leaves the tree on resize", () => { + const context = setup(); + openSheet(context); + context.tracker.attachController(null); + context.tracker.handleSheetDismiss(); + expect(context.closeCount()).toBe(0); + expect(context.backPress.press()).toBe(false); }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts index 048d85f361..dec2dd535c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visibility-tracker.ts @@ -8,6 +8,22 @@ export interface BottomSheetVisibilityInput { isEnabled?: boolean; } +/** + * The system Back press, as the tracker needs it. + * + * A sheet is presented through `@gorhom/portal`, which renders it into the app's own view tree + * rather than a native modal window, so Android delivers Back to the navigator underneath it + * unless something on top claims the press first. + * + * `subscribe` registers a listener and returns its removal. The listener returns `true` when it + * claims the press. Listeners are offered the press newest-first, so a tracker holds its + * subscription only while its sheet is on screen, and a stacked sheet answers before the one + * below it. + */ +export interface BackPressSource { + subscribe(onBackPress: () => boolean): () => void; +} + export interface BottomSheetVisibilityTracker { attachController(controller: BottomSheetController | null): void; syncDesired(input: BottomSheetVisibilityInput): void; @@ -19,23 +35,46 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing"; export function createBottomSheetVisibilityTracker(opts: { onClose: () => void; + backPress?: BackPressSource; }): BottomSheetVisibilityTracker { let controller: BottomSheetController | null = null; let visible = false; let isEnabled: boolean | undefined; let phase: BottomSheetPhase = "closed"; let hasNotifiedClose = false; + let releaseBackPress: (() => void) | null = null; + + function setPhase(next: BottomSheetPhase): void { + phase = next; + syncBackPress(); + } + + /** Holds the Back press for exactly the window the sheet is on screen, and no longer. */ + function syncBackPress(): void { + const wantsBackPress = phase === "presented" && controller !== null; + if (wantsBackPress === (releaseBackPress !== null)) return; + if (!wantsBackPress) { + releaseBackPress?.(); + releaseBackPress = null; + return; + } + releaseBackPress = + opts.backPress?.subscribe(() => { + dismiss(); + return true; + }) ?? null; + } function present(): void { if (!controller || phase !== "closed") return; - phase = "presenting"; hasNotifiedClose = false; + setPhase("presenting"); controller.present(); } function dismiss(): void { if (!controller || phase === "closed" || phase === "dismissing") return; - phase = "dismissing"; + setPhase("dismissing"); controller.dismiss(); } @@ -48,6 +87,9 @@ export function createBottomSheetVisibilityTracker(opts: { return { attachController(next) { controller = next; + // A sheet that left the tree cannot answer, so it lets go of the press without changing + // phase: the parent has not acknowledged the dismissal yet. + syncBackPress(); if (next && visible && isEnabled !== false) { present(); } @@ -61,7 +103,7 @@ export function createBottomSheetVisibilityTracker(opts: { return; } if (phase === "dismissing") { - phase = "closed"; + setPhase("closed"); hasNotifiedClose = false; return; } @@ -70,21 +112,24 @@ export function createBottomSheetVisibilityTracker(opts: { handleSheetIndexChange(index) { if (index !== -1) { if (phase === "presenting" || phase === "dismissing") { - phase = "presented"; + setPhase("presented"); } return; } if (phase === "presenting" || phase === "presented") { - phase = "dismissing"; + setPhase("dismissing"); } }, handleSheetDismiss() { + // Portal cleanup reports dismissal after the sheet leaves the tree. It is + // presentation teardown, so the owner keeps its desired state. + if (!controller) return; if (visible) { - phase = "dismissing"; + setPhase("dismissing"); notifyClose(); return; } - phase = "closed"; + setPhase("closed"); hasNotifiedClose = false; }, }; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts new file mode 100644 index 0000000000..9e98bd359f --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +describe("getBottomSheetVisibleContentHeight", () => { + it("stops subtracting the retained keyboard height after the keyboard hides", () => { + const layout = { + containerHeight: 874, + contentPosition: 88, + handleHeight: 24, + keyboardHeight: 344, + }; + + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); + }); +}); + +// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge. +describe("sheet footer motion", () => { + it("keeps the frame height fixed while opening and panning below the lowest snap", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + const heights = [1000, 800, 600, 400].map((contentPosition) => + getBottomSheetVisibleContentHeight({ ...layout, contentPosition }), + ); + expect(heights).toEqual([576, 576, 576, 576]); + }); +}); + +it("resizes above the lowest snap and never returns negative or unmeasured heights", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876); + expect( + getBottomSheetVisibleContentHeight({ + ...layout, + contentPosition: 100, + keyboardHeight: 950, + isKeyboardVisible: true, + }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }), + ).toBe(0); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts new file mode 100644 index 0000000000..316f56048a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts @@ -0,0 +1,25 @@ +interface BottomSheetVisibleContentHeightInput { + containerHeight: number; + contentPosition: number; + lowestDetentPosition?: number; + handleHeight: number; + keyboardHeight: number; + isKeyboardVisible: boolean; +} + +export function getBottomSheetVisibleContentHeight({ + containerHeight, + contentPosition, + lowestDetentPosition, + handleHeight, + keyboardHeight, + isKeyboardVisible, +}: BottomSheetVisibleContentHeightInput): number { + "worklet"; + if (containerHeight < 0 || handleHeight < 0) return 0; + const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition); + return Math.max( + 0, + containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), + ); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx new file mode 100644 index 0000000000..15ead99edf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { View, StyleSheet } from "react-native"; +import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */ +export function SheetVisibleFrame({ + children, + footer, +}: { + children: ReactNode; + footer?: ReactNode; +}) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + const keyboard = animatedKeyboardState.get(); + return { + height: getBottomSheetVisibleContentHeight({ + containerHeight, + handleHeight, + contentPosition: animatedPosition.get(), + lowestDetentPosition: animatedDetentsState.get().detents?.[0], + keyboardHeight: keyboard.heightWithinContainer, + isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN, + }), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + return ( + + {children} + {footer} + + ); +} + +const styles = StyleSheet.create({ + frame: { minHeight: 0, overflow: "hidden" }, + body: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/components/ui/menu/index.ts b/packages/app/src/components/ui/menu/index.ts index c2114d1593..5668556899 100644 --- a/packages/app/src/components/ui/menu/index.ts +++ b/packages/app/src/components/ui/menu/index.ts @@ -35,4 +35,4 @@ export { type MenuPresentation, } from "./menu-context"; export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation"; -export type { Alignment, Placement, Rect, Size } from "./menu-anchor"; +export type { Alignment, Placement, Rect, Size } from "../anchor"; diff --git a/packages/app/src/components/ui/menu/menu-context.ts b/packages/app/src/components/ui/menu/menu-context.ts index 8dceb70ec0..9635f09c9f 100644 --- a/packages/app/src/components/ui/menu/menu-context.ts +++ b/packages/app/src/components/ui/menu/menu-context.ts @@ -9,7 +9,7 @@ import { openSubPage, type MenuPath, } from "./menu-navigation"; -import type { Rect } from "./menu-anchor"; +import type { Rect } from "../anchor"; /** * How a menu draws itself once it is open. diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 32aed88abf..52afc71a77 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -38,7 +38,7 @@ import { type Placement, type Rect, type Size, -} from "./menu-anchor"; +} from "../anchor"; const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const; const CONTENT_ENTERING_DURATION_MS = 150; @@ -292,8 +292,16 @@ export function AnchoredSurface({ revision, }); + // The surface is placed once both measurements land. Until then it sits off-screen so it can + // be measured, and it must not animate: Reanimated's web entering path snapshots the element's + // rect when the animation ends and writes that rect back as inline top/left when it cleans the + // keyframe up 750ms later. If the animation ends before placement, the snapshot is the + // off-screen rect and the menu jumps back off-screen. Remounting on placement starts the + // animation from the final position. + const placed = position !== null; + useEffect(() => { - if (!isWeb || !open || !contentSize || typeof document === "undefined") return undefined; + if (!isWeb || !open || !placed || typeof document === "undefined") return undefined; const frame = requestAnimationFrame(() => { document .getElementById(surfaceNativeID) @@ -301,7 +309,7 @@ export function AnchoredSurface({ ?.focus(); }); return () => cancelAnimationFrame(frame); - }, [contentSize, open, surfaceNativeID]); + }, [open, placed, surfaceNativeID]); const frameStyle = useMemo>(() => { const { width: screenWidth } = Dimensions.get("window"); @@ -371,6 +379,7 @@ export function AnchoredSurface({ /> ) : null} { "worklet"; diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 4f5bb22de1..3b76d4cf2b 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -5,14 +5,13 @@ import { useEffect, useMemo, useRef, - type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { @@ -21,12 +20,13 @@ import { type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; import { SPACING, type Theme } from "@/styles/theme"; +import { useRetainedPanelActive } from "@/components/retained-panel"; import { useMenuContext, MenuContextProvider } from "./menu-context"; import { MenuPage } from "./menu-item"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; -import type { Alignment, Placement } from "./menu-anchor"; +import type { Alignment, Placement } from "../anchor"; import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); @@ -104,6 +104,8 @@ export interface MenuSurfaceProps { pages?: readonly MenuPageDefinition[]; /** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */ sheetTitle?: string; + /** Controls on the right of the sheet's root title. */ + sheetTrailing?: ReactNode; side?: Placement; align?: Alignment; offset?: number; @@ -125,6 +127,10 @@ export interface MenuSurfaceProps { */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { const { presentation } = useMenuContext("MenuSurface"); + const active = useRetainedPanelActive(); + // Portals escape the hidden panel. Remove the surface in its inactive commit, + // before freezing can suspend an async menu action's closing update. + if (!active) return null; if (presentation === "sheet") { return ; } @@ -334,9 +340,11 @@ function MenuSheetSurface({ children, pages = [], sheetTitle, + sheetTrailing, testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { + const { height: windowHeight } = useWindowDimensions(); const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); const safeAreaInsets = useSafeAreaInsets(); @@ -362,18 +370,6 @@ function MenuSheetSurface({ onClose: handleClose, }); - const renderBackdrop = useCallback( - (backdropProps: ComponentProps) => ( - - ), - [], - ); - const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; @@ -397,9 +393,10 @@ function MenuSheetSurface({ // Content-sized rather than fixed snap points: a pushed page is rarely the same height // as the page it replaced, and a fixed sheet would either clip it or leave dead space. enableDynamicSizing + maxDynamicContentSize={windowHeight * 0.8} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.45} enablePanDownToClose // `interactive` rather than `extend`, which is what every other sheet in the app uses. // `extend` grows the sheet to its largest snap point, and with `enableDynamicSizing` that @@ -423,7 +420,9 @@ function MenuSheetSurface({ ) : ( <> - {sheetTitle ? : null} + {sheetTitle ? ( + + ) : null} {children} )} @@ -435,9 +434,11 @@ function MenuSheetSurface({ function MenuSheetHeader({ title, onBack, + trailing = null, }: { title: string; onBack: (() => void) | null; + trailing?: ReactNode; }): ReactElement { const { t } = useTranslation(); return ( @@ -457,6 +458,7 @@ function MenuSheetHeader({ {title} + {trailing ? {trailing} : null} ); } @@ -470,6 +472,11 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[1], paddingBottom: theme.spacing[3], }, + sheetTrailing: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + }, sheetBackButton: { width: 24, height: 24, diff --git a/packages/app/src/components/ui/pane-content-toolbar.test.ts b/packages/app/src/components/ui/pane-content-toolbar.test.ts new file mode 100644 index 0000000000..81ff52f2e0 --- /dev/null +++ b/packages/app/src/components/ui/pane-content-toolbar.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; +import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome"; +import { + paneContentToolbarIconSize, + paneContentToolbarTrailingPadding, +} from "@/components/ui/pane-content-toolbar"; + +/** Where the last control's painted edge lands, measured from the row's right edge. */ +function trailingInkRail(isCompact: boolean, trailing: "glyph" | "framed"): number { + const padding = paneContentToolbarTrailingPadding(isCompact, trailing); + if (trailing === "framed") return padding; + const frameOverhang = + (smallIconButtonChromeFrameSize(isCompact) - paneContentToolbarIconSize(isCompact)) / 2; + return padding + frameOverhang; +} + +describe("pane content toolbar trailing rail", () => { + it("lands a bare glyph on the shared rail by letting its hitbox overhang the row", () => { + expect(trailingInkRail(false, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL); + expect(trailingInkRail(true, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL); + expect(paneContentToolbarTrailingPadding(true, "glyph")).toBeLessThan( + WORKSPACE_PANE_TRAILING_GLYPH_RAIL, + ); + }); + + it("lands a framed control on the shared rail, because its frame is the ink", () => { + expect(trailingInkRail(false, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL); + expect(trailingInkRail(true, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL); + expect(paneContentToolbarTrailingPadding(true, "framed")).toBe( + WORKSPACE_PANE_TRAILING_GLYPH_RAIL, + ); + }); + + it("pads a framed control more than a glyph, since the glyph's frame is bigger on compact", () => { + expect(paneContentToolbarTrailingPadding(true, "framed")).toBeGreaterThan( + paneContentToolbarTrailingPadding(true, "glyph"), + ); + expect(paneContentToolbarTrailingPadding(false, "framed")).toBeGreaterThan( + paneContentToolbarTrailingPadding(false, "glyph"), + ); + }); +}); diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx index 8e5be2da95..4ff947afc9 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, smallIconButtonChromeFrameSize, } from "@/components/ui/icon-button-chrome"; import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; @@ -56,8 +57,12 @@ interface ToolbarButtonCommonProps extends Omit< > { children: ReactNode; label: string; + /** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */ + tooltip?: ReactNode; selected?: boolean; compact?: boolean; + /** A circular highlight, for buttons inside a pill-shaped container. */ + round?: boolean; shortcut?: ShortcutKey[][] | null; tooltipSide?: "left" | "right" | "top" | "bottom"; style?: StyleProp; @@ -76,8 +81,10 @@ type ToolbarButtonProps = ToolbarButtonCommonProps & export function ToolbarButton({ children, label, + tooltip: tooltipBody, selected = false, compact = false, + round = false, shortcut, tooltipSide = "bottom", style, @@ -87,17 +94,14 @@ export function ToolbarButton({ testID, ...props }: ToolbarButtonProps) { - const buttonStyle = useMemo( - () => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) => - iconButtonChromeStyle({ - size: "small", - compact, - state: { ...state, active: selected }, - disabled: Boolean(disabled), - style, - }), - [compact, disabled, selected, style], - ); + const buttonStyle = useIconButtonChromeStyle({ + size: "small", + compact, + active: selected, + disabled: Boolean(disabled), + round, + style, + }); const accessibilityState = useMemo( () => ({ disabled: Boolean(disabled), selected }), [disabled, selected], @@ -105,7 +109,7 @@ export function ToolbarButton({ const tooltip = ( - {label} + {tooltipBody ?? {label}} {shortcut ? : null} @@ -155,8 +159,26 @@ export function paneContentToolbarIconSize(isCompact: boolean): number { return iconButtonChromeGlyphSize("small", isCompact); } -/** Keeps a toolbar glyph on the same trailing rail as tree-row glyphs. */ -export function paneContentToolbarTrailingPadding(isCompact: boolean): number { +/** + * What the last control in a toolbar row ends with. A `glyph` control paints an icon + * inside a larger invisible hitbox; a `framed` control (bordered trigger, split button) + * paints its own frame, so the frame is the ink. + */ +export type ToolbarTrailingControl = "glyph" | "framed"; + +/** + * Keeps the last control in a toolbar row on the same trailing rail as tree-row glyphs. + * A bare glyph's hitbox overhangs its ink, so the row pads by less than the rail and lets + * the hitbox run off the edge; a framed control's ink reaches its own edge, so it pads by + * the full rail. + */ +export function paneContentToolbarTrailingPadding( + isCompact: boolean, + trailing: ToolbarTrailingControl, +): number { + if (trailing === "framed") { + return WORKSPACE_PANE_TRAILING_GLYPH_RAIL; + } const buttonSize = smallIconButtonChromeFrameSize(isCompact); return ( WORKSPACE_PANE_TRAILING_GLYPH_RAIL - (buttonSize - paneContentToolbarIconSize(isCompact)) / 2 diff --git a/packages/app/src/components/ui/scroll-view.tsx b/packages/app/src/components/ui/scroll-view.tsx new file mode 100644 index 0000000000..d6abed39d9 --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type Ref } from "react"; +import { + ScrollView as NativeScrollView, + FlatList as NativeFlatList, + type ScrollViewProps, + type FlatListProps, +} from "react-native"; +import { BottomSheetScrollView, BottomSheetFlatList } from "@gorhom/bottom-sheet"; +import { + ScrollView as GestureScrollView, + FlatList as GestureFlatList, + type NativeViewGestureHandlerProps, +} from "react-native-gesture-handler"; +import { isNative } from "@/constants/platform"; +import { useIsInsideBottomSheet } from "./bottom-sheet-scope"; + +// Both libraries forward to the RN FlatList instance. Their published ref types +// disagree about getScrollResponder (Gorhom) and include the component function +// in the instance intersection (RNGH); keep that type discrepancy at this boundary. +const SheetFlatList = BottomSheetFlatList as unknown as typeof NativeFlatList; +const HorizontalFlatList = GestureFlatList as unknown as ( + props: FlatListProps & NativeViewGestureHandlerProps & { ref?: Ref> }, +) => React.ReactElement; + +/** The presentation owns gesture integration; callers keep the React Native API. */ +export const ScrollView = forwardRef( + function ScrollView(props, ref) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ( + + {props.children} + + ); + }, +); + +function SheetAwareFlatList(props: FlatListProps, ref: Ref>) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ; +} + +// forwardRef erases the item generic. All three implementations expose the RN list methods. +export const FlatList = forwardRef(SheetAwareFlatList) as ( + props: FlatListProps & { ref?: Ref> }, +) => React.ReactElement; diff --git a/packages/app/src/components/ui/scroll-view.web.tsx b/packages/app/src/components/ui/scroll-view.web.tsx new file mode 100644 index 0000000000..7372623b5c --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.web.tsx @@ -0,0 +1,3 @@ +// Web dialogs use ordinary scrolling at every breakpoint. Keep native gesture +// dependencies out of this module's import graph. +export { ScrollView, FlatList } from "react-native"; diff --git a/packages/app/src/components/ui/scrollable-code-surface.tsx b/packages/app/src/components/ui/scrollable-code-surface.tsx index e55093b9d8..3b99f8bcaf 100644 --- a/packages/app/src/components/ui/scrollable-code-surface.tsx +++ b/packages/app/src/components/ui/scrollable-code-surface.tsx @@ -1,14 +1,8 @@ import { useMemo, type ReactNode } from "react"; -import { - ScrollView, - Text, - View, - type StyleProp, - type TextStyle, - type ViewStyle, -} from "react-native"; +import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { ScrollView } from "./scroll-view"; import { isWeb } from "@/constants/platform"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -87,7 +81,7 @@ export function ScrollableCodeSurface({ [maxHeight], ); const outerScrollStyle = useMemo( - () => [maxHeightStyle, scrollStyle], + () => [styles.scroll, maxHeightStyle, scrollStyle], [maxHeightStyle, scrollStyle], ); const contentStyle = useMemo( @@ -108,24 +102,21 @@ export function ScrollableCodeSurface({ - - {horizontal ? ( - - {codeContent} - - ) : ( - codeContent - )} + + + {horizontal ? ( + + {codeContent} + + ) : ( + codeContent + )} + ); @@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp { } const styles = StyleSheet.create((theme) => ({ + scroll: { + flexShrink: 1, + minHeight: 0, + }, container: { overflow: "hidden", }, diff --git a/packages/app/src/components/ui/status-badge.browser.test.tsx b/packages/app/src/components/ui/status-badge.browser.test.tsx index a47b5036e5..858ded8173 100644 --- a/packages/app/src/components/ui/status-badge.browser.test.tsx +++ b/packages/app/src/components/ui/status-badge.browser.test.tsx @@ -32,15 +32,32 @@ afterEach(() => { } }); +function parseColor(value: string): { rgb: string; alpha: number } { + const channels = value.match(/[\d.]+/g)?.map(Number) ?? []; + return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 }; +} + describe("StatusBadge", () => { - it.each(["success", "warning", "error", "muted"] as const)( - "uses the semantic badge shell for the %s variant", + it("uses the neutral badge shell for the muted variant", () => { + const style = getComputedStyle(mountBadge("muted")); + + expect(style.backgroundColor).toBe("rgb(228, 228, 231)"); + expect(style.borderColor).toBe("rgb(228, 228, 231)"); + }); + + it.each(["success", "warning", "error"] as const)( + "fills the %s variant with a translucent tint of its own status color", (variant) => { const badge = mountBadge(variant); - const style = getComputedStyle(badge); + const text = badge.lastElementChild; + if (!(text instanceof HTMLElement)) { + throw new Error("StatusBadge did not render its label"); + } + const fill = parseColor(getComputedStyle(badge).backgroundColor); - expect(style.backgroundColor).toBe("rgb(228, 228, 231)"); - expect(style.borderColor).toBe("rgb(228, 228, 231)"); + expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb); + expect(fill.alpha).toBeGreaterThan(0); + expect(fill.alpha).toBeLessThan(0.5); }, ); diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx index 28035d9294..c1071ac882 100644 --- a/packages/app/src/components/ui/status-badge.tsx +++ b/packages/app/src/components/ui/status-badge.tsx @@ -8,9 +8,21 @@ interface StatusBadgeProps { label: string; variant?: StatusBadgeVariant; leading?: ReactNode; + /** `xs` fits beside a line of `sm` text: the same label on tighter padding. */ + size?: "sm" | "xs"; } -export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) { +export function StatusBadge({ label, variant = "muted", leading, size = "sm" }: StatusBadgeProps) { + const pillStyle = useMemo( + () => [ + styles.pill, + size === "xs" && styles.pillXs, + variant === "success" && styles.pillSuccess, + variant === "warning" && styles.pillWarning, + variant === "error" && styles.pillError, + ], + [size, variant], + ); const textStyle = useMemo( () => [ styles.pillText, @@ -22,7 +34,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr ); return ( - + {leading} {label} @@ -41,6 +53,22 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], paddingVertical: 3, }, + pillXs: { + paddingHorizontal: theme.spacing[1.5], + paddingVertical: 1, + }, + pillSuccess: { + backgroundColor: theme.colors.statusSuccessTint, + borderColor: "transparent", + }, + pillWarning: { + backgroundColor: theme.colors.statusWarningTint, + borderColor: "transparent", + }, + pillError: { + backgroundColor: theme.colors.statusDangerTint, + borderColor: "transparent", + }, pillText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, diff --git a/packages/app/src/components/ui/text-input/sheet-aware.tsx b/packages/app/src/components/ui/text-input/sheet-aware.tsx new file mode 100644 index 0000000000..33539b6cbb --- /dev/null +++ b/packages/app/src/components/ui/text-input/sheet-aware.tsx @@ -0,0 +1,24 @@ +import { forwardRef, useCallback } from "react"; +import { TextInput as NativeTextInput, type TextInputProps } from "react-native"; +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; +import { isNative } from "@/constants/platform"; +import { useIsInsideBottomSheet } from "../bottom-sheet-scope"; + +/** Registers focus with the sheet so the keyboard can move the active input into view. */ +export const SheetAwareTextInput = forwardRef( + function SheetAwareTextInput(props, ref) { + const inSheet = useIsInsideBottomSheet(); + const setRef = useCallback( + (input: NativeTextInput | null | undefined) => { + if (typeof ref === "function") ref(input ?? null); + else if (ref) ref.current = input ?? null; + }, + [ref], + ); + return inSheet && isNative ? ( + + ) : ( + + ); + }, +); diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index a04bd89d50..b52d7b9923 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -2,7 +2,7 @@ import React, { act, createRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { BottomSheetTextInputScope } from "./bottom-sheet-scope"; +import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope"; import { EditingTextInput } from "./text-input.native"; import type { EditingTextInputHandle } from "./types"; @@ -56,15 +56,25 @@ afterEach(() => { function noop() {} +function createHandleRecorder() { + const handleRef = createRef(); + const received: (EditingTextInputHandle | null)[] = []; + function assignHandle(handle: EditingTextInputHandle | null) { + received.push(handle); + handleRef.current = handle; + } + return { handleRef, received, assignHandle }; +} + describe("EditingTextInputNative", () => { it("uses the bottom-sheet input only inside a bottom sheet", () => { act(() => { root?.render( <> - + - + , ); }); @@ -76,6 +86,32 @@ describe("EditingTextInputNative", () => { expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" }); }); + it("keeps its editing handle and selection when native editing rerenders the leaf", () => { + const { handleRef, received, assignHandle } = createHandleRecorder(); + act(() => + root?.render(), + ); + const input = container?.querySelector("textarea"); + if (!input) throw new Error("Expected native editing surface"); + const handle = handleRef.current; + act(() => { + handle?.focus(); + const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setValue) throw new Error("Expected input value setter"); + setValue.call(input, "native edit"); + input.setSelectionRange(2, 5); + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" })); + }); + expect(handleRef.current).toBe(handle); + expect(received).toEqual([handle]); + expect(handle?.getText()).toBe("native edit"); + expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]); + expect(document.activeElement).toBe(input); + act(() => root?.unmount()); + root = null; + expect(received).toEqual([handle, null]); + }); + it("clears text via clear() when replaceText receives an empty string", () => { const handleRef = createRef(); @@ -92,7 +128,7 @@ describe("EditingTextInputNative", () => { expect(handleRef.current?.getText()).toBe(""); }); - it("replaces the native input when clearing text", () => { + it("replaces the native input when resetting the editor", () => { const handleRef = createRef(); act(() => { @@ -105,15 +141,17 @@ describe("EditingTextInputNative", () => { ); }); const grownInput = container?.querySelector("input"); + const handle = handleRef.current; act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(container?.querySelector("input")).not.toBe(grownInput); + expect(handleRef.current).toBe(handle); }); - it("restores focus after replacing a cleared native input", () => { + it("restores focus after replacing a reset native input", () => { const handleRef = createRef(); act(() => { @@ -125,13 +163,13 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); }); expect(document.activeElement).toBe(container?.querySelector("input")); }); - it("focuses the replacement input when focus is requested before a cleared input remounts", () => { + it("focuses the replacement input when focus is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -142,7 +180,7 @@ describe("EditingTextInputNative", () => { const originalFocus = vi.spyOn(originalInput, "focus"); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.focus(); }); @@ -152,7 +190,7 @@ describe("EditingTextInputNative", () => { expect(document.activeElement).toBe(replacementInput); }); - it("drops a pending focus restore when blur is requested before a cleared input remounts", () => { + it("drops a pending focus restore when blur is requested before an editor reset remounts", () => { const handleRef = createRef(); act(() => { @@ -164,7 +202,7 @@ describe("EditingTextInputNative", () => { originalInput.focus(); act(() => { - handleRef.current?.replaceText(""); + handleRef.current?.reset(); handleRef.current?.blur(); }); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index c505a9b917..243e813f26 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -1,11 +1,18 @@ -import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useReducer, + useState, +} from "react"; import { TextInput } from "react-native"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import PasteInput, { type PastedFile, type PasteTextInputInstance, } from "@mattermost/react-native-paste-input"; -import { useIsInsideBottomSheet } from "./bottom-sheet-scope"; +import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope"; import type { EditingTextInputHandle, EditingTextInputProps } from "./types"; type NativeInput = (TextInput | PasteTextInputInstance) & { @@ -35,7 +42,8 @@ export const EditingTextInput = forwardRef(null); const initialTextRef = useRef(initialValue); const textRef = useRef(initialTextRef.current); - // Clearing swaps the native input for a fresh instance (see replaceText). + // Resetting the editor swaps the native input to reset intrinsic multiline + // sizing. Text replacement (including an empty buffer) keeps the input mounted. // Until React commits that swap, `inputRef` still points at the doomed // instance: focusing it asks Android for the keyboard and then tears the // focused view down, which cancels the show. Fabric also runs view @@ -45,6 +53,12 @@ export const EditingTextInput = forwardRef revision + 1, 0); const assignInputRef = useCallback((input: NativeInput | null) => { inputRef.current = input; @@ -55,53 +69,62 @@ export const EditingTextInput = forwardRef ({ ...current, autoFocus })); }, []); - useImperativeHandle(ref, () => ({ - focus: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(true); - return; - } - inputRef.current?.focus(); - }, - blur: () => { - if (isAwaitingReplacementRef.current) { - setReplacementFocus(false); - return; - } - inputRef.current?.blur(); - }, - isFocused: () => inputRef.current?.isFocused?.() ?? false, - getText: () => textRef.current, - replaceText: (nextText, selection) => { - textRef.current = nextText; - if (nextText === "") { - const autoFocus = inputRef.current?.isFocused?.() ?? false; + useImperativeHandle( + ref, + () => ({ + focus: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(true); + return; + } + inputRef.current?.focus(); + }, + blur: () => { + if (isAwaitingReplacementRef.current) { + setReplacementFocus(false); + return; + } + inputRef.current?.blur(); + }, + isFocused: () => inputRef.current?.isFocused?.() ?? false, + getText: () => textRef.current, + replaceText: (nextText, selection) => { + textRef.current = nextText; if (inputRef.current?.replaceText) { inputRef.current.replaceText(nextText, selection); + return; + } + if (nextText === "") { + inputRef.current?.clear?.(); + return; + } + inputRef.current?.setNativeProps?.({ + text: nextText, + ...(selection ? { selection } : {}), + }); + if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); + }, + reset: () => { + textRef.current = ""; + const autoFocus = inputRef.current?.isFocused?.() ?? false; + if (inputRef.current?.replaceText) { + inputRef.current.replaceText(""); } else { inputRef.current?.clear?.(); } isAwaitingReplacementRef.current = true; setReplacement((current) => ({ revision: current.revision + 1, autoFocus })); - return; - } - if (inputRef.current?.replaceText) { - inputRef.current.replaceText(nextText, selection); - return; - } - inputRef.current?.setNativeProps?.({ - text: nextText, - ...(selection ? { selection } : {}), - }); - if (selection) inputRef.current?.setSelection?.(selection.start, selection.end); - }, - getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, - })); + }, + getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current, + }), + [setReplacementFocus], + ); const handleChangeText = useCallback( (nextText: string) => { textRef.current = nextText; onChangeText?.(nextText); + bumpTextRevision(); }, [onChangeText], ); diff --git a/packages/app/src/components/ui/text-input/text-input.web.tsx b/packages/app/src/components/ui/text-input/text-input.web.tsx index 82f2f47c07..30ee0c6eba 100644 --- a/packages/app/src/components/ui/text-input/text-input.web.tsx +++ b/packages/app/src/components/ui/text-input/text-input.web.tsx @@ -57,26 +57,35 @@ export const EditingTextInput = forwardRef ({ - focus: () => inputRef.current?.focus(), - blur: () => inputRef.current?.blur(), - isFocused: () => document.activeElement === inputRef.current, - getText: () => { - const input = inputRef.current as WebTextInputElement | null; - const nextText = input?.value ?? textRef.current; - textRef.current = nextText; - return nextText; - }, - replaceText: (nextText, selection) => { - textRef.current = nextText; - const input = inputRef.current as WebTextInputElement | null; - if (input && "value" in input) input.value = nextText; - if (selection && typeof input?.setSelectionRange === "function") { - input.setSelectionRange(selection.start, selection.end); - } - }, - getNativeRef: () => inputRef.current, - })); + useImperativeHandle( + ref, + () => ({ + focus: () => inputRef.current?.focus(), + blur: () => inputRef.current?.blur(), + isFocused: () => document.activeElement === inputRef.current, + getText: () => { + const input = inputRef.current as WebTextInputElement | null; + const nextText = input?.value ?? textRef.current; + textRef.current = nextText; + return nextText; + }, + replaceText: (nextText, selection) => { + textRef.current = nextText; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = nextText; + if (selection && typeof input?.setSelectionRange === "function") { + input.setSelectionRange(selection.start, selection.end); + } + }, + reset: () => { + textRef.current = ""; + const input = inputRef.current as WebTextInputElement | null; + if (input && "value" in input) input.value = ""; + }, + getNativeRef: () => inputRef.current, + }), + [], + ); return ( ( () => ({ @@ -256,10 +257,10 @@ export function Tooltip({ setOpen: setIsOpen, triggerRef, enabled, - openOnPress: isCompact, + openOnPress: opensOnPress, delayDuration, }), - [isOpen, setIsOpen, enabled, isCompact, delayDuration], + [isOpen, setIsOpen, enabled, opensOnPress, delayDuration], ); return {children}; @@ -522,6 +523,7 @@ export function TooltipContent({ entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)} collapsable={false} + role="tooltip" testID={testID} onLayout={handleLayout} style={contentStyle} @@ -542,12 +544,13 @@ export function TooltipContent({ statusBarTranslucent={Platform.OS === "android"} onRequestClose={handleDismiss} > - + { - return new Promise((resolve) => { - element.measureInWindow((x, y, width, height) => { - resolve({ x, y, width, height }); - }); - }); -} - -function computeHoverCardPosition({ - triggerRect, - contentSize, - displayArea, - offset, -}: { - triggerRect: Rect; - contentSize: { width: number; height: number }; - displayArea: Rect; - offset: number; -}): { x: number; y: number } { - let x = triggerRect.x + triggerRect.width + offset; - let y = triggerRect.y; - - // If it overflows right, try left - if (x + contentSize.width > displayArea.width - 8) { - x = triggerRect.x - contentSize.width - offset; - } - - // Constrain to screen - const padding = 8; - x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x)); - y = Math.max( - displayArea.y + padding, - Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y), - ); - - return { x, y }; -} - -const HOVER_GRACE_MS = 100; const HOVER_CARD_WIDTH = 260; interface WorkspaceHoverCardProps { @@ -110,237 +56,80 @@ export function WorkspaceHoverCard({ disabled = false, children, }: PropsWithChildren): ReactNode { + const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - if (!isWeb || isCompact) { + if (isCompact) { return children; } return ( - - {children} - - ); -} - -function WorkspaceHoverCardDesktop({ - workspace, - prHint, - isDragging, - disabled = false, - children, -}: PropsWithChildren): ReactElement { - const triggerRef = useRef(null); - const contentRef = useRef(null); - const [open, setOpen] = useState(false); - const graceTimerRef = useRef | null>(null); - const triggerHoveredRef = useRef(false); - - const clearGraceTimer = useCallback(() => { - if (graceTimerRef.current) { - clearTimeout(graceTimerRef.current); - graceTimerRef.current = null; - } - }, []); - - const scheduleClose = useCallback(() => { - if (graceTimerRef.current) return; - graceTimerRef.current = setTimeout(() => { - graceTimerRef.current = null; - setOpen(false); - }, HOVER_GRACE_MS); - }, []); - - const handleTriggerEnter = useCallback(() => { - triggerHoveredRef.current = true; - clearGraceTimer(); - if (!isDragging && !disabled) { - setOpen(true); - } - }, [clearGraceTimer, disabled, isDragging]); - - const handleTriggerLeave = useCallback(() => { - triggerHoveredRef.current = false; - scheduleClose(); - }, [scheduleClose]); - - // While open, the safe zone covers trigger + content + the bridge between - // them. Close only fires when the pointer leaves the safe zone; re-entering - // it (including the bridge) cancels the pending close. - useHoverSafeZone({ - enabled: open, - triggerRef, - contentRef, - onEnterSafeZone: clearGraceTimer, - onLeaveSafeZone: scheduleClose, - }); - - // Close while another row interaction owns attention. - useEffect(() => { - if (isDragging || disabled) { - clearGraceTimer(); - setOpen(false); - } - }, [clearGraceTimer, disabled, isDragging]); - - // Cleanup on unmount - useEffect(() => { - return () => { - clearGraceTimer(); - }; - }, [clearGraceTimer]); - - return ( - - {children} - {open ? ( - - ) : null} - + + {children} + + + + ); } function WorkspaceHoverCardContent({ workspace, prHint, - triggerRef, - contentRef, }: { workspace: SidebarWorkspaceEntry; prHint: PrHint | null; - triggerRef: React.RefObject; - contentRef: React.RefObject; -}): ReactElement | null { +}): ReactElement { const { t } = useTranslation(); - const bottomSheetInternal = useBottomSheetModalInternal(true); - const [triggerRect, setTriggerRect] = useState(null); - const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); - const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - - // Measure trigger — same pattern as tooltip.tsx - useEffect(() => { - if (!triggerRef.current) return; - - let cancelled = false; - measureElement(triggerRef.current).then((rect) => { - if (cancelled) return; - setTriggerRect(rect); - return; - }); - - return () => { - cancelled = true; - }; - }, [triggerRef]); - - // Compute position when both measurements are available - useEffect(() => { - if (!triggerRect || !contentSize) return; - const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); - const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; - const result = computeHoverCardPosition({ - triggerRect, - contentSize, - displayArea, - offset: 4, - }); - setPosition(result); - }, [triggerRect, contentSize]); - - const handleLayout = useCallback( - (event: { nativeEvent: { layout: { width: number; height: number } } }) => { - const { width, height } = event.nativeEvent.layout; - setContentSize({ width, height }); - }, - [], - ); - - const frameStyle = useMemo( - () => ({ - position: "absolute" as const, - top: position?.y ?? -9999, - left: position?.x ?? -9999, - }), - [position?.x, position?.y], - ); - return ( - - - - - - {workspace.name} - - - {prHint ? : null} - {workspace.diffStat ? ( - - - - - ) : null} - - {workspace.currentBranch ? ( - - ) : null} - {workspace.workspaceDirectoryLabel ? ( - - ) : null} - {prHint?.checks && prHint.checks.length > 0 ? ( - <> - - - - ) : null} - + <> + + + {workspace.name} + - + {prHint ? : null} + {workspace.diffStat ? ( + + + + + ) : null} + + {workspace.currentBranch ? ( + + ) : null} + {workspace.workspaceDirectoryLabel ? ( + + ) : null} + {prHint?.checks && prHint.checks.length > 0 ? ( + <> + + + + ) : null} + ); } @@ -561,14 +350,6 @@ function checksSummaryPressableStyle({ hovered = false }: { pressed: boolean; ho } const styles = StyleSheet.create((theme) => ({ - portalOverlay: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - zIndex: 1000, - }, card: { backgroundColor: theme.colors.surface1, borderWidth: 1, @@ -581,7 +362,6 @@ const styles = StyleSheet.create((theme) => ({ shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, - zIndex: 1000, }, cardHeader: { flexDirection: "row", diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 4dabf067ba..1954394176 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,8 +1,11 @@ +import { KeyboardTranslateView } from "@/keyboard/shift"; +import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; @@ -12,7 +15,11 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -27,7 +34,6 @@ import type { DaemonClient, } from "@getpaseo/client/internal/daemon-client"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; -import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import type { MessagePayload } from "@/composer/types"; @@ -61,25 +67,18 @@ function resolveWorkspaceTitle({ function buildChatDraftComposerArgs({ serverId, - isConnected, workspaceDirectory, sourceDirectory, pendingWorkspaceSetup, }: { serverId: string; - isConnected: boolean; workspaceDirectory: string | undefined; sourceDirectory: string; pendingWorkspaceSetup: { creationMethod: string } | null; }) { return { initialServerId: serverId || null, - initialValues: - workspaceDirectory || sourceDirectory - ? { workingDir: workspaceDirectory || sourceDirectory } - : undefined, isVisible: pendingWorkspaceSetup !== null, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined, }; } @@ -87,20 +86,23 @@ function buildChatDraftComposerArgs({ async function callWorkspaceCreation({ creationMethod, connectedClient, + creationId, + worktreeSlug, input, }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; - input: { cwd: string }; + creationId: string; + worktreeSlug: string; + input: { cwd: string; agent?: CreateWorkspaceRequestOptions["agent"] }; }) { - if (creationMethod === "create_worktree") { - return connectedClient.createPaseoWorktree({ - cwd: input.cwd, - worktreeSlug: createNameId(), - }); - } return connectedClient.createWorkspace({ - source: { kind: "directory", path: input.cwd }, + idempotencyKey: creationId, + agent: input.agent, + source: + creationMethod === "create_worktree" + ? { kind: "worktree", cwd: input.cwd, worktreeSlug } + : { kind: "directory", path: input.cwd }, }); } @@ -164,9 +166,13 @@ export function WorkspaceSetupDialog() { const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); - const setAgents = useSessionStore((state) => state.setAgents); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState { clearWorkspaceSetup(); @@ -248,18 +253,25 @@ export function WorkspaceSetupDialog() { }, [client, isConnected, t]); const ensureWorkspace = useCallback( - async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + async (input: { + cwd: string; + attachments: MessagePayload["attachments"]; + agent?: CreateWorkspaceRequestOptions["agent"]; + onAgentCreated?: (agent: AgentSnapshotPayload) => void; + }) => { if (!pendingWorkspaceSetup) { throw new Error(t("workspaceSetup.errors.pendingRequired")); } - if (createdWorkspace) { + if (createdWorkspace && !input.agent) { return createdWorkspace; } const connectedClient = withConnectedClient(); const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, + creationId: pendingWorkspaceSetup.creationId, + worktreeSlug: pendingWorkspaceSetup.worktreeSlug, connectedClient, input, }); @@ -270,6 +282,7 @@ export function WorkspaceSetupDialog() { ); } + if (payload.agent) input.onAgentCreated?.(payload.agent); const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); if (pendingWorkspaceSetup.creationMethod === "open_project") { @@ -306,8 +319,7 @@ export function WorkspaceSetupDialog() { try { setPendingAction("chat"); setErrorMessage(null); - const ensuredWorkspace = await ensureWorkspace({ cwd, attachments }); - const connectedClient = withConnectedClient(); + if (!composerState) { throw new Error(t("workspaceSetup.errors.composerStateRequired")); } @@ -321,37 +333,42 @@ export function WorkspaceSetupDialog() { }), }); const encodedImages = await encodeImages(wirePayload.images); - const workspaceDirectory = requireWorkspaceDirectory({ - workspaceId: ensuredWorkspace.id, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, + if (!pendingWorkspaceSetup) throw new Error(t("workspaceSetup.errors.pendingRequired")); + let createdAgent: AgentSnapshotPayload | undefined; + const { workspaceId: _workspaceId, ...agentInput } = buildCreateAgentOptions({ + composerState, + text, + attachments: wirePayload.attachments, + encodedImages: encodedImages ?? null, + workspaceDirectory: cwd, + workspaceId: "", + provider: composerState.selectedProvider, }); - const agent = await connectedClient.createAgent( - buildCreateAgentOptions({ - composerState, - text, - attachments: wirePayload.attachments, - encodedImages: encodedImages ?? null, - workspaceDirectory, - workspaceId: ensuredWorkspace.id, - provider: composerState.selectedProvider, - }), - ); + const ensuredWorkspace = await ensureWorkspace({ + cwd, + attachments, + agent: { + ...agentInput, + clientMessageId: `${pendingWorkspaceSetup.creationId}:initial-message`, + }, + onAgentCreated: (value) => { + createdAgent = value; + }, + }); + if (!createdAgent) throw new Error("The daemon did not create the requested agent"); + const agent = createdAgent; if (!getIsStillActive()) { return; } - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set( - agent.id, - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: normalizeAgentSnapshot(agent, serverId), - }), - ); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot( + serverId, + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: normalizeAgentSnapshot(agent, serverId), + }), + ); navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); @@ -365,14 +382,13 @@ export function WorkspaceSetupDialog() { }, [ composerState, + pendingWorkspaceSetup, getIsStillActive, navigateAfterCreation, serverId, - setAgents, ensureWorkspace, t, toast, - withConnectedClient, supportsForgeSearch, ], ); @@ -430,25 +446,27 @@ export function WorkspaceSetupDialog() { desktopMaxWidth={640} > - + + + {errorMessage ? {errorMessage} : null} diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 2cceeae961..7124fd2f0a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { seedSessionWorkspaces } from "@/test/seed-session"; +import { seedRuntimeWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "srv", new Map([ [ @@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-a", new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), ); - seedSessionWorkspaces( + seedRuntimeWorkspaces( "host-b", new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), ); diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 688ef99925..697f848a09 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -18,6 +18,7 @@ import { type MessageSubmissionRecord, } from "@/composer/submission/model"; import { + uploadFileAttachments, cancelComposerAgent, dispatchComposerAgentMessage, editQueuedComposerMessage, @@ -1039,3 +1040,86 @@ describe("findForgeItemByOption / isAttachmentSelectedForForgeItem", () => { expect(isAttachmentSelectedForForgeItem(attachments, prItem)).toBe(false); }); }); + +describe("file upload preparation", () => { + it("waits for file bytes and daemon acknowledgement before returning the attachment", async () => { + let resolveRead!: (bytes: Uint8Array) => void; + const read = new Promise((resolve) => { + resolveRead = resolve; + }); + let acknowledge!: (result: Awaited>) => void; + const response = new Promise>>( + (resolve) => { + acknowledge = resolve; + }, + ); + const sent: string[] = []; + const upload = uploadFileAttachments({ + client: { + sendAgentMessage: async () => {}, + uploadFile: async (file) => { + sent.push(file.fileName); + expect(file.bytes).toEqual(new Uint8Array([1, 2])); + return response; + }, + }, + files: [ + { fileName: "sample.bin", mimeType: "application/octet-stream", readBytes: () => read }, + ], + }); + let completed = false; + void upload.then(() => { + completed = true; + return undefined; + }); + expect(sent).toEqual([]); + resolveRead(new Uint8Array([1, 2])); + await Promise.resolve(); + expect(sent).toEqual(["sample.bin"]); + expect(completed).toBe(false); + const file = { + type: "uploaded_file" as const, + id: "file-1", + fileName: "sample.bin", + mimeType: "application/octet-stream", + size: 2, + path: "/uploads/sample.bin", + }; + acknowledge({ requestId: "req-1", file, error: null }); + await expect(upload).resolves.toEqual([{ kind: "file", attachment: file }]); + }); + + it.each(["unreadable", "oversized"])( + "does not upload a batch containing a later %s file", + async (failure) => { + let sends = 0; + await expect( + uploadFileAttachments({ + client: { + sendAgentMessage: async () => {}, + uploadFile: async () => { + sends++; + throw new Error("unexpected send"); + }, + }, + files: [ + { + fileName: "valid.bin", + mimeType: "application/octet-stream", + readBytes: async () => new Uint8Array([1]), + }, + { + fileName: "missing.bin", + mimeType: "application/octet-stream", + readBytes: async () => { + if (failure === "oversized") return new Uint8Array(50 * 1024 * 1024 + 1); + throw new Error("read failed"); + }, + }, + ], + }), + ).rejects.toThrow(failure === "unreadable" ? "read failed" : "too large"); + expect(sends).toBe(0); + }, + ); +}); diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index d020e7b6b0..4ae1764a02 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -1,3 +1,4 @@ +import type { SelectedFile } from "@/attachments/selected-file"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import type { @@ -123,11 +124,26 @@ export async function pickAndPersistImages(input: { export async function uploadFileAttachments(input: { client: ComposerSendClient; - files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>; + files: SelectedFile[]; }): Promise[]> { const result: Extract[] = []; + const prepared: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }> = []; for (const file of input.files) { + const bytes = await file.readBytes(); + if (bytes.byteLength > 50 * 1024 * 1024) { + throw new Error( + i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }), + ); + } + prepared.push({ + fileName: file.fileName, + mimeType: file.mimeType, + bytes, + }); + } + + for (const file of prepared) { const response = await input.client.uploadFile(file); if (response.error || !response.file) { throw new Error(response.error ?? "Upload failed."); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 32941e46e7..72795289cf 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -55,6 +55,7 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getFeatureHighlightColor, + isFeatureActive, getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, @@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { } function getFeatureIconColor( - featureId: string, - enabled: boolean, + feature: AgentFeature, palette: { blue: { 400: string }; green: { 400: string }; @@ -220,11 +220,11 @@ function getFeatureIconColor( }, foregroundMuted: string, ): string { - if (!enabled) { + if (!isFeatureActive(feature)) { return foregroundMuted; } - switch (getFeatureHighlightColor(featureId)) { + switch (getFeatureHighlightColor(feature.id)) { case "blue": return palette.blue[400]; case "green": @@ -394,6 +394,7 @@ type AgentControlsSlice = { runtimeModelId: string | null; model: string | null | undefined; features: AgentFeature[] | undefined; + runtimeThinkingOptionId: string | null; thinkingOptionId: string | null | undefined; lastUsage: unknown; } | null; @@ -413,6 +414,7 @@ function selectAgentControlsSlice( runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, features: currentAgent.features, + runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: currentAgent.thinkingOptionId, lastUsage: currentAgent.lastUsage, }; @@ -1333,8 +1335,7 @@ function DesktopFeatureItem({ o.id === feature.value); + const iconOnly = feature.desktopTrigger === "icon"; + const tooltip = iconOnly + ? `${feature.label}: ${selectedOption?.label ?? feature.label}` + : getFeatureTooltip(feature); return ( <> @@ -1364,18 +1369,24 @@ function DesktopFeatureItem({ - {getFeatureTooltip(feature)} + {tooltip} 1 ? thinkingOptions : undefined} + thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} features={agent.features} diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index bd55e72c55..6929b447ea 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -2,12 +2,11 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Keyboard, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { Bot } from "lucide-react-native"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { AgentProfilePicker, AgentProfileSeed } from "@/agent-profiles"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { ModelBrowser, useModelBrowser } from "@/components/model-browser"; import { resolveModelBrowserScrolling } from "@/components/model-browser-view"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; @@ -130,9 +129,8 @@ export function CompactModelSheet({ profiles, serverId, }); - const ProviderIcon = - selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null; - const ModelIcon = ProviderIcon ?? Bot; + const ModelIcon = useProviderIcon(selectedProvider, serverId); + const hasSelectedProvider = selectedProvider.trim().length > 0; const rootHeader = useMemo( () => ({ ...rootBrowser.header, @@ -277,9 +275,9 @@ export function CompactModelSheet({ testID="combined-model-selector" chevron={null} > - {ProviderIcon ? ( + {hasSelectedProvider ? ( - + ) : null} @@ -300,7 +298,6 @@ export function CompactModelSheet({ onClose={close} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint={usesBottomSheet} contentStyle={styles.sheetBody} testID="agent-controls-model-sheet" > @@ -348,7 +345,6 @@ export function CompactModelSheet({ onClose={closeModelBrowser} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint presentation="push" contentStyle={styles.sheetBody} testID="agent-controls-model-browser-sheet" diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 5b566fdeb4..dfacec0ec7 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -3,6 +3,7 @@ import { getFeatureHighlightColor, getFeatureTooltip, getAgentControlHintKey, + isFeatureActive, normalizeModelId, resolveAgentModelSelection, } from "./utils"; @@ -35,9 +36,32 @@ describe("feature metadata helpers", () => { it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); + expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); + + it("treats a select feature as active when a non-default option is selected", () => { + const speed = { + type: "select" as const, + id: "service_tier", + label: "Speed", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "fast", label: "Fast" }, + ], + }; + expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); + expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); + expect( + isFeatureActive({ + type: "toggle", + id: "fast_mode", + label: "Fast", + value: true, + }), + ).toBe(true); + }); }); describe("normalizeModelId", () => { @@ -67,6 +91,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: "claude-fable-5[1m]", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -88,6 +113,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: "b", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -112,6 +138,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "high", }); @@ -119,6 +146,54 @@ describe("resolveAgentModelSelection", () => { expect(selection.displayThinking).toBe("High"); }); + it("shows the thinking level the agent runs on when none was chosen", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "openrouter/google/gemini-3.6-flash", + provider: "pi", + label: "Gemini 3.6 Flash", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], + runtimeModelId: "openrouter/google/gemini-3.6-flash", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + }); + + expect(selection.selectedThinkingId).toBe("high"); + expect(selection.displayThinking).toBe("High"); + }); + + it("falls back to the chosen thinking level when the runtime level is not a model option", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "a", + provider: "codex", + label: "Model A", + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", + }, + ], + runtimeModelId: "a", + configuredModelId: null, + runtimeThinkingOptionId: "xhigh", + explicitThinkingOptionId: "high", + }); + + expect(selection.selectedThinkingId).toBe("high"); + }); + it("formats raw thinking labels in the selected model display", () => { const selection = resolveAgentModelSelection({ models: [ @@ -134,6 +209,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "a", configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: "xhigh", }); @@ -155,6 +231,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: null, configuredModelId: null, + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -178,6 +255,7 @@ describe("resolveAgentModelSelection", () => { ], runtimeModelId: "claude-sonnet-4-6-20260101", configuredModelId: "default", + runtimeThinkingOptionId: null, explicitThinkingOptionId: null, }); @@ -186,4 +264,31 @@ describe("resolveAgentModelSelection", () => { expect(selection.selectedThinkingId).toBe("low"); expect(selection.displayThinking).toBe("Low"); }); + + it("shows the agent's own model when the catalog does not list it", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: "gpt-6-sol", + provider: "codex", + label: "GPT-6-Sol", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "medium" }, + { id: "max", label: "max" }, + ], + defaultThinkingOptionId: "medium", + }, + ], + runtimeModelId: "gpt-6.1-sol", + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "max", + }); + + expect(selection.selectedModel).toBeNull(); + expect(selection.activeModelId).toBe("gpt-6.1-sol"); + expect(selection.displayModel).toBe("gpt-6.1-sol"); + expect(selection.displayThinking).toBe("Max"); + }); }); diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index b28b693365..c0b1da7c47 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,7 +1,11 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy"; +import { + FAST_MODE_FEATURE_ID, + PLAN_MODE_FEATURE_ID, + SPEED_FEATURE_ID, +} from "@/agent-controls/policy"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; @@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick option.id === feature.value); + return selectedOption !== undefined && !selectedOption.isDefault; +} + export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: + case SPEED_FEATURE_ID: return "yellow"; case "auto_accept": return "green"; @@ -79,16 +90,23 @@ function pickSelectedModel( preferredModelId: string | null, fallbackModel: AgentModelDefinition | null, ): AgentModelDefinition | null { - if (!models || !preferredModelId) { + if (!preferredModelId) { return fallbackModel; } - return findModelById(models, preferredModelId) ?? fallbackModel; + return findModelById(models, preferredModelId); } function resolveThinkingId( + runtimeThinkingOptionId: string | null | undefined, explicitThinkingOptionId: string | null | undefined, selectedModel: AgentModelDefinition | null, ): string | null { + const runtimeThinkingOption = selectedModel?.thinkingOptions?.find( + (option) => option.id === runtimeThinkingOptionId, + ); + if (runtimeThinkingOption) { + return runtimeThinkingOption.id; + } if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") { return explicitThinkingOptionId; } @@ -139,9 +157,16 @@ export function resolveAgentModelSelection(input: { models: AgentModelDefinition[] | null; runtimeModelId: string | null | undefined; configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; explicitThinkingOptionId: string | null | undefined; }) { - const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; + const { + models, + runtimeModelId, + configuredModelId, + runtimeThinkingOptionId, + explicitThinkingOptionId, + } = input; const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); const normalizedConfiguredModelId = normalizeModelId(configuredModelId); @@ -162,9 +187,13 @@ export function resolveAgentModelSelection(input: { ); const thinkingOptions = selectedModel?.thinkingOptions ?? null; - const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel); + const resolvedThinkingId = resolveThinkingId( + runtimeThinkingOptionId, + explicitThinkingOptionId, + selectedModel, + ); const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId); - const selectedThinkingId = effectiveThinking?.id ?? null; + const selectedThinkingId = effectiveThinking?.id ?? resolvedThinkingId; const displayThinking = resolveThinkingDisplay( effectiveThinking, selectedThinkingId, diff --git a/packages/app/src/composer/attachments/drop.test.ts b/packages/app/src/composer/attachments/drop.test.ts index 31a26a7fe0..16a75e1f3d 100644 --- a/packages/app/src/composer/attachments/drop.test.ts +++ b/packages/app/src/composer/attachments/drop.test.ts @@ -1,28 +1,25 @@ import { describe, expect, it } from "vitest"; -import { droppedItemsToPickedFiles } from "./drop"; +import { droppedItemsToSelectedFiles } from "./drop"; describe("composer dropped attachments", () => { - it("turns non-image browser files into picked files and leaves raster images for image handling", async () => { + it("turns non-image browser files into selected files and leaves raster images for image handling", async () => { const jsonFile = new File([JSON.stringify({ ok: true })], "config.json", { type: "application/json", }); const imageFile = new File([new Uint8Array([0])], "screen.png", { type: "image/png" }); - const files = await droppedItemsToPickedFiles([ + const files = droppedItemsToSelectedFiles([ { kind: "web-file", file: jsonFile }, { kind: "web-file", file: imageFile }, ]); - expect(files).toEqual([ - { - fileName: "config.json", - mimeType: "application/json", - bytes: new Uint8Array(await jsonFile.arrayBuffer()), - }, + expect(files.map(({ fileName, mimeType }) => ({ fileName, mimeType }))).toEqual([ + { fileName: "config.json", mimeType: "application/json" }, ]); + expect(await files[0]?.readBytes()).toEqual(new Uint8Array(await jsonFile.arrayBuffer())); }); - it("turns non-image desktop paths into picked files and leaves raster images for image handling", async () => { + it("reads desktop paths lazily and leaves raster images for image handling", async () => { const windowsPath = "C:\\Users\\alice\\config.json"; const posixPath = "/Users/alice/notes/readme.txt"; const imagePath = "C:\\Users\\alice\\screen.png"; @@ -32,7 +29,7 @@ describe("composer dropped attachments", () => { ]); const readPaths: string[] = []; - const files = await droppedItemsToPickedFiles( + const files = droppedItemsToSelectedFiles( [ { kind: "desktop-path", path: windowsPath }, { kind: "desktop-path", path: imagePath }, @@ -50,18 +47,14 @@ describe("composer dropped attachments", () => { }, ); - expect(readPaths).toEqual([windowsPath, posixPath]); - expect(files).toEqual([ - { - fileName: "config.json", - mimeType: "application/octet-stream", - bytes: new Uint8Array([1, 2, 3]), - }, - { - fileName: "readme.txt", - mimeType: "application/octet-stream", - bytes: new Uint8Array([4, 5]), - }, + expect(files.map(({ fileName, mimeType }) => ({ fileName, mimeType }))).toEqual([ + { fileName: "config.json", mimeType: "application/octet-stream" }, + { fileName: "readme.txt", mimeType: "application/octet-stream" }, ]); + expect(readPaths).toEqual([]); + + expect(await files[0]?.readBytes()).toEqual(new Uint8Array([1, 2, 3])); + expect(await files[1]?.readBytes()).toEqual(new Uint8Array([4, 5])); + expect(readPaths).toEqual([windowsPath, posixPath]); }); }); diff --git a/packages/app/src/composer/attachments/drop.ts b/packages/app/src/composer/attachments/drop.ts index b5a0f1b6ad..dddff293e9 100644 --- a/packages/app/src/composer/attachments/drop.ts +++ b/packages/app/src/composer/attachments/drop.ts @@ -3,7 +3,7 @@ import { isRasterImageFile, isRasterImagePath, } from "@/attachments/file-types"; -import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; +import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; import type { DroppedItem } from "@/components/file-drop/types"; interface DroppedAttachmentsRuntime { @@ -25,21 +25,22 @@ function fileNameFromPath(path: string): string { return path; } -export async function droppedItemsToPickedFiles( +export function droppedItemsToSelectedFiles( items: DroppedItem[], runtime: DroppedAttachmentsRuntime = defaultRuntime, -): Promise { - const files: PickedFile[] = []; +): SelectedFile[] { + const files: SelectedFile[] = []; for (const item of items) { if (item.kind === "web-file") { if (isRasterImageFile(item.file)) { continue; } + const file = item.file; files.push({ - fileName: item.file.name, - mimeType: item.file.type || getMimeTypeFromPath(item.file.name), - bytes: new Uint8Array(await item.file.arrayBuffer()), + fileName: file.name, + mimeType: file.type || getMimeTypeFromPath(file.name), + readBytes: async () => new Uint8Array(await file.arrayBuffer()), }); continue; } @@ -47,10 +48,11 @@ export async function droppedItemsToPickedFiles( if (isRasterImagePath(item.path)) { continue; } + const path = item.path; files.push({ - fileName: fileNameFromPath(item.path), - mimeType: getMimeTypeFromPath(item.path), - bytes: await runtime.readDesktopFileBytes(item.path), + fileName: fileNameFromPath(path), + mimeType: getMimeTypeFromPath(path), + readBytes: () => runtime.readDesktopFileBytes(path), }); } diff --git a/packages/app/src/composer/dock/index.native.tsx b/packages/app/src/composer/dock/index.native.tsx new file mode 100644 index 0000000000..a668c00743 --- /dev/null +++ b/packages/app/src/composer/dock/index.native.tsx @@ -0,0 +1,186 @@ +import { ComposerDockBackground } from "./internal/background"; +export { ComposerDockBackground } from "./internal/background"; +import { ScrollView } from "@/components/ui/scroll-view"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; +import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings"; +import { KeyboardTranslateView } from "@/keyboard/shift"; +import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; +import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native"; +import Animated, { + useAnimatedStyle, + useSharedValue, + useDerivedValue, + useAnimatedReaction, + type SharedValue, +} from "react-native-reanimated"; +import { useKeyboardShift } from "@/keyboard/shift"; +import { + resolveCenteredClearance, + resolveInlineComposerCapacity, + updateComposerCapacity, + type ComposerCapacity, +} from "./internal/capacity"; + +const ViewportCapacity = createContext | null>(null); + +interface ComposerViewportProps extends ViewProps { + bottomInset?: number; + centered?: boolean; +} + +/** Measure the stationary space below the header, outside keyboard translation. */ +function ComposerViewport({ + children, + onLayout, + bottomInset = 0, + centered = false, + ...props +}: ComposerViewportProps) { + const measuredHeight = useSharedValue(0); + const sizing = useSharedValue(undefined); + const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift(); + useAnimatedReaction( + () => ({ + height: measuredHeight.value, + bottomInset, + safeAreaBottom: safeAreaBottom.value, + keyboardShift: layoutShift.value, + centered, + }), + (geometry) => { + if (geometry.height <= 0) return; + sizing.value = updateComposerCapacity(sizing.value, geometry); + }, + [], + ); + const capacity = useDerivedValue(() => sizing.value?.capacity); + const measureViewport = useCallback( + (event: LayoutChangeEvent) => { + measuredHeight.value = event.nativeEvent.layout.height; + onLayout?.(event); + }, + [measuredHeight, onLayout], + ); + + return ( + + {children} + + ); +} + +/** Bound the complete composer (including its controls), not just the text input. */ +function ComposerViewportContent({ style, ...props }: ViewProps) { + const compact = useIsCompactFormFactor(); + const capacity = useContext(ViewportCapacity); + if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport"); + const constraint = useAnimatedStyle(() => ({ + // Only viewport changes and keyboard start/end events trigger layout. Motion + // stays in KeyboardTranslateView and never changes this constraint per frame. + maxHeight: resolveInlineComposerCapacity(capacity.value, compact), + })); + + return ( + + ); +} + +/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */ +function useCenteredClearance(safeAreaBottom: number) { + const [viewportHeight, setViewportHeight] = useState(0); + const [formBottom, setFormBottom] = useState(0); + const measureViewport = useCallback((event: LayoutChangeEvent) => { + setViewportHeight(event.nativeEvent.layout.height); + }, []); + const measureForm = useCallback((event: LayoutChangeEvent) => { + const { y, height } = event.nativeEvent.layout; + setFormBottom(y + height); + }, []); + return { + value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }), + measureViewport, + measureForm, + }; +} + +interface ComposerDockProps { + children: [ReactNode, ReactNode, ReactNode?]; + centered?: boolean; +} + +/** The stationary viewport, translated surface, and bounded composer are one owner. */ +export function ComposerDock({ + children: [content, composer, overlay], + centered = false, +}: ComposerDockProps) { + const insets = useSafeAreaInsets(); + const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings); + const centeredClearance = useCenteredClearance(insets.bottom); + // Preserve the existing centered form's visual balance on tablets. + const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0; + if (centered) { + return ( + + + + + {content} + + + {composer} + + + {overlay} + + + ); + } + return ( + + + + {/* A responder ancestor intercepts native scroll drags on Android Fabric. + Only unclaimed background touches may reach this sibling. */} + + + {content} + + + + {composer} + + {overlay} + + + ); +} + +// Plain React Native styles: these land on Reanimated views, which must not carry +// theme-driven Unistyles (docs/unistyles.md). The content width arrives inline. +const dockStyles = StyleSheet.create({ + viewport: { flex: 1, overflow: "hidden" }, + surface: { flex: 1 }, + content: { flex: 1, justifyContent: "flex-end" }, + composer: { width: "100%", flexShrink: 1 }, + centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" }, + centered: { flexShrink: 1, width: "100%" }, + // Reserve the composer's own capped height before the setup scroll view shrinks. + centeredComposer: { width: "100%", flexShrink: 0 }, + setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/composer/dock/index.tsx b/packages/app/src/composer/dock/index.tsx new file mode 100644 index 0000000000..3f41118696 --- /dev/null +++ b/packages/app/src/composer/dock/index.tsx @@ -0,0 +1,64 @@ +export { ComposerDockBackground } from "./internal/background"; +import { ScrollView } from "@/components/ui/scroll-view"; +import { useState, useCallback, useMemo, type ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { resolveInlineComposerCapacity } from "./internal/capacity"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT } from "@/constants/layout"; + +interface ComposerDockProps { + children: [ReactNode, ReactNode, ReactNode?]; + centered?: boolean; +} + +export function ComposerDock({ + children: [content, composer, overlay], + centered = false, +}: ComposerDockProps) { + const compact = useIsCompactFormFactor(); + const [availableHeight, setAvailableHeight] = useState(); + const constraint = useMemo( + () => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }), + [availableHeight, compact], + ); + const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + if (height > 0) setAvailableHeight(height); + }, []); + if (centered) { + return ( + + + + {content} + + {composer} + {overlay} + + + ); + } + return ( + + {content} + {composer} + {overlay} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + surface: { flex: 1, overflow: "hidden" }, + content: { flex: 1, justifyContent: "flex-end" }, + composer: { width: "100%", flexShrink: 1 }, + centered: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingBottom: HEADER_INNER_HEIGHT + 24, + }, + form: { flexShrink: 1, width: "100%", maxWidth: theme.contentMaxWidth }, + centeredComposer: { flexShrink: 0 }, + setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 }, +})); diff --git a/packages/app/src/composer/dock/internal/background.native.tsx b/packages/app/src/composer/dock/internal/background.native.tsx new file mode 100644 index 0000000000..5e6219b28a --- /dev/null +++ b/packages/app/src/composer/dock/internal/background.native.tsx @@ -0,0 +1,6 @@ +import { Keyboard, Pressable, type ViewProps } from "react-native"; + +/** Use behind scrollable content as a sibling, never as its responder ancestor. */ +export function ComposerDockBackground(props: ViewProps) { + return ; +} diff --git a/packages/app/src/composer/dock/internal/background.tsx b/packages/app/src/composer/dock/internal/background.tsx new file mode 100644 index 0000000000..7a19f44a12 --- /dev/null +++ b/packages/app/src/composer/dock/internal/background.tsx @@ -0,0 +1,5 @@ +import { View, type ViewProps } from "react-native"; + +export function ComposerDockBackground(props: ViewProps) { + return ; +} diff --git a/packages/app/src/composer/dock/internal/capacity.test.ts b/packages/app/src/composer/dock/internal/capacity.test.ts new file mode 100644 index 0000000000..7e98b7ef3d --- /dev/null +++ b/packages/app/src/composer/dock/internal/capacity.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { + resolveCenteredClearance, + resolveInlineComposerCapacity, + resolveComposerCapacity, + updateComposerCapacity, +} from "./capacity"; + +describe("composer viewport", () => { + it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147); + expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245); + expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined(); + }); + it("preserves the editing capacity when the keyboard closes", () => { + const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false }; + const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }); + const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }); + expect(open.capacity).toBe(245); + expect(closed.capacity).toBe(open.capacity); + expect(updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }).capacity).toBe(303); + }); + + it("remeasures the viewport without forgetting the keyboard reservation", () => { + const open = updateComposerCapacity(undefined, { + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 308, + }); + expect( + updateComposerCapacity(open, { + height: 650, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 0, + }).capacity, + ).toBe(313); + expect( + updateComposerCapacity(open, { + height: 0, + bottomInset: 24, + safeAreaBottom: 0, + centered: false, + keyboardShift: 0, + }), + ).toEqual(open); + }); + it("leaves five points below the header for a bottom-anchored composer", () => { + const height = resolveComposerCapacity({ + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + keyboardShift: 308, + centered: false, + }); + expect(height).toBe(245); + expect(582 - 24 - 308 - height).toBe(5); + }); + + it("keeps a centered tablet form below the header once the keyboard reaches it", () => { + const viewport = 1000; + const bottomInset = 80; + const keyboardShift = 300; + const height = resolveComposerCapacity({ + height: viewport, + bottomInset, + safeAreaBottom: 0, + keyboardShift, + centered: true, + }); + expect(height).toBe(695); + const restingTop = (viewport - bottomInset - height) / 2; + const bottomClearance = resolveCenteredClearance({ + viewportHeight: viewport, + safeAreaBottom: 0, + formBottom: restingTop + height, + }); + expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5); + }); + + it("leaves a centered form room to edit on a short foldable viewport", () => { + expect( + resolveComposerCapacity({ + height: 725, + bottomInset: 72, + safeAreaBottom: 0, + keyboardShift: 313, + centered: true, + }), + ).toBe(407); + }); + + it("rests a centered form on the keyboard above the bottom safe area", () => { + // The keyboard shift excludes the safe area that the keyboard also covers. + const geometry = { + height: 660, + bottomInset: 60, + safeAreaBottom: 32, + keyboardShift: 288, + centered: true, + }; + const height = resolveComposerCapacity(geometry); + const restingTop = (geometry.height - geometry.bottomInset - height) / 2; + const formBottom = restingTop + height; + const clearance = resolveCenteredClearance({ + viewportHeight: geometry.height, + safeAreaBottom: geometry.safeAreaBottom, + formBottom, + }); + const translation = Math.max(0, geometry.keyboardShift - clearance); + const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift; + expect(formBottom - translation).toBe(keyboardTop); + expect(restingTop - translation).toBe(5); + }); + + it("uses the measured viewport before the first keyboard opening", () => { + expect( + resolveComposerCapacity({ + height: 582, + bottomInset: 24, + safeAreaBottom: 0, + keyboardShift: 0, + centered: false, + }), + ).toBe(553); + expect( + resolveComposerCapacity({ + height: 300, + bottomInset: 0, + safeAreaBottom: 0, + keyboardShift: 200, + centered: false, + }), + ).toBe(95); + }); +}); diff --git a/packages/app/src/composer/dock/internal/capacity.ts b/packages/app/src/composer/dock/internal/capacity.ts new file mode 100644 index 0000000000..ca4fed2991 --- /dev/null +++ b/packages/app/src/composer/dock/internal/capacity.ts @@ -0,0 +1,62 @@ +interface ComposerGeometry { + height: number; + bottomInset: number; + /** The keyboard also covers this space below its shift. */ + safeAreaBottom: number; + keyboardShift: number; + centered: boolean; +} + +export interface ComposerCapacity { + keyboardReserve: number; + capacity: number; +} + +export function resolveComposerCapacity(input: ComposerGeometry): number { + "worklet"; + // A centered form only translates by the keyboard overlap, so once the keyboard + // reaches it the form sits on the keyboard and fits above it, not twice over. + // A bottom-anchored composer pads the safe area inside its own capacity. + const available = input.centered + ? Math.min( + input.height - input.bottomInset, + input.height - input.safeAreaBottom - input.keyboardShift, + ) + : input.height - input.bottomInset - input.keyboardShift; + return Math.max(0, available - 5); +} + +/** Space below a centered form that the keyboard fills before the form moves. */ +export function resolveCenteredClearance(input: { + viewportHeight: number; + safeAreaBottom: number; + formBottom: number; +}): number { + return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom); +} + +export function updateComposerCapacity( + previous: ComposerCapacity | undefined, + input: ComposerGeometry, +): ComposerCapacity { + "worklet"; + if (input.height <= 0 && previous) return previous; + // Closing the keyboard moves the composer; it does not enlarge its editing + // capacity. A subsequent keyboard opening supplies the next reservation. + const keyboardReserve = + input.keyboardShift > 0 ? input.keyboardShift : (previous?.keyboardReserve ?? 0); + return { + keyboardReserve, + capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }), + }; +} + +/** Compact editing leaves conversation space above the complete composer. */ +export function resolveInlineComposerCapacity( + capacity: number | undefined, + compact: boolean, +): number | undefined { + "worklet"; + if (capacity === undefined) return undefined; + return compact ? capacity * 0.6 : capacity; +} diff --git a/packages/app/src/composer/draft/create-agent-request.ts b/packages/app/src/composer/draft/create-agent-request.ts new file mode 100644 index 0000000000..23fa22e8c9 --- /dev/null +++ b/packages/app/src/composer/draft/create-agent-request.ts @@ -0,0 +1,35 @@ +import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; +import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { encodeImages } from "@/utils/encode-images"; +import type { UserMessageImageAttachment } from "@/types/stream"; + +export interface WorkspaceDraftAgentRequest { + workspaceId: string; + config: AgentSessionConfig; + text: string; + clientMessageId: string; + images?: UserMessageImageAttachment[]; + attachments?: CreateAgentRequestMessage["attachments"]; +} + +/** + * Shared by the workspace draft tab and by the new-workspace screen when it finishes creation + * after the user has already navigated away and no draft tab will ever mount. + */ +export async function requestWorkspaceDraftAgent( + client: DaemonClient, + request: WorkspaceDraftAgentRequest, +): Promise { + const images = await encodeImages(request.images); + return await client.createAgent({ + config: request.config, + workspaceId: request.workspaceId, + clientMessageId: request.clientMessageId, + ...(request.text ? { initialPrompt: request.text } : {}), + ...(images && images.length > 0 ? { images } : {}), + ...(request.attachments && request.attachments.length > 0 + ? { attachments: request.attachments } + : {}), + }); +} diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 471d1fc8b9..8587fa3db6 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -88,6 +88,110 @@ describe("useDraftAgentCreateFlow", () => { expect(onCreateSuccess).toHaveBeenCalledTimes(1); }); + it("keeps the submitted preview when the pending provider selection disappears", () => { + const attempt: DraftCreateAttempt = { + clientMessageId: "msg-provider-handoff", + text: "build this", + timestamp: new Date("2026-05-25T00:00:00.000Z"), + }; + const { result, rerender } = renderHook( + ({ provider }: { provider: string | null }) => + useDraftAgentCreateFlow({ + draftId: "draft-handoff", + getPendingServerId: () => "server-1", + initialAttempt: attempt, + buildDraftAgent: () => { + if (!provider) throw new Error("Select a model"); + return { provider, model: "selected-model" }; + }, + createRequest: async () => ({ agentId: "agent-1", result: { id: "agent-1" } }), + onCreateSuccess: () => undefined, + }), + { initialProps: { provider: "codex" } as { provider: string | null } }, + ); + expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" }); + rerender({ provider: null }); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" }); + }); + + it("shows an invalid prepared selection as a form error instead of throwing in render", () => { + const createRequest = vi.fn(async () => ({ agentId: "agent-1", result: { id: "agent-1" } })); + const { result } = renderHook(() => + useDraftAgentCreateFlow({ + draftId: "draft-invalid", + getPendingServerId: () => "server-1", + initialAttempt: { + clientMessageId: "msg-invalid", + text: "build this", + timestamp: new Date(0), + }, + buildDraftAgent: () => { + throw new Error("Select a model"); + }, + createRequest, + onCreateSuccess: () => undefined, + }), + ); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.draftAgent).toBeNull(); + expect(result.current.formErrorMessage).toBe("Select a model"); + expect(createRequest).not.toHaveBeenCalled(); + }); + + it("keeps a manual submission stable and lets a failed request retry with a new selection", async () => { + let failRequest!: (error: Error) => void; + const request = new Promise((_resolve, reject) => { + failRequest = reject; + }); + let requestCount = 0; + const { result, rerender } = renderHook( + ({ provider }: { provider: string | null }) => + useDraftAgentCreateFlow({ + draftId: "draft-manual", + getPendingServerId: () => "server-1", + buildDraftAgent: () => { + if (!provider) throw new Error("Select a model"); + return { provider }; + }, + createRequest: async () => { + requestCount++; + if (requestCount === 1) return await request; + return { agentId: "agent-1", result: { id: "agent-1" } }; + }, + onCreateSuccess: () => undefined, + }), + { initialProps: { provider: "codex" } as { provider: string | null } }, + ); + let submission!: Promise; + const captureError = (error: unknown) => error; + await act(async () => { + submission = result.current + .handleCreateFromInput({ text: "build this", attachments: [], cwd: "/repo" }) + .catch(captureError); + }); + rerender({ provider: null }); + expect(result.current.draftAgent).toEqual({ provider: "codex" }); + await act(async () => { + failRequest(new Error("Provider unavailable")); + await submission; + }); + expect(result.current.isSubmitting).toBe(false); + expect(result.current.draftAgent).toBeNull(); + expect(result.current.formErrorMessage).toBe("Provider unavailable"); + rerender({ provider: "claude" }); + await act(async () => { + await result.current.handleCreateFromInput({ + text: "try again", + attachments: [], + cwd: "/repo", + }); + }); + expect(result.current.draftAgent).toEqual({ provider: "claude" }); + expect(result.current.formErrorMessage).toBe(""); + expect(requestCount).toBe(2); + }); + it("allows retrying an empty prompt when the draft still has context attachments", async () => { const attachment = { kind: "chat_history", diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index 89a237e786..bd4d0fe95c 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -5,12 +5,11 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, - generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; @@ -27,23 +26,23 @@ interface CreateAttempt { attachments?: AgentAttachment[]; } -type DraftAgentMachineState = +type DraftAgentMachineState = | { tag: "draft"; errorMessage: string } - | { tag: "creating"; attempt: CreateAttempt }; + | { tag: "creating"; attempt: CreateAttempt; draftAgent: TDraftAgent }; -type DraftAgentMachineEvent = +type DraftAgentMachineEvent = | { type: "DRAFT_SET_ERROR"; message: string } - | { type: "SUBMIT"; attempt: CreateAttempt } + | { type: "SUBMIT"; attempt: CreateAttempt; draftAgent: TDraftAgent } | { type: "CREATE_FAILED"; message: string }; function assertNever(value: never): never { throw new Error(`Unhandled state: ${JSON.stringify(value)}`); } -function reducer( - state: DraftAgentMachineState, - event: DraftAgentMachineEvent, -): DraftAgentMachineState { +function reducer( + state: DraftAgentMachineState, + event: DraftAgentMachineEvent, +): DraftAgentMachineState { switch (event.type) { case "DRAFT_SET_ERROR": { if (state.tag !== "draft") { @@ -52,7 +51,7 @@ function reducer( return { ...state, errorMessage: event.message }; } case "SUBMIT": { - return { tag: "creating", attempt: event.attempt }; + return { tag: "creating", attempt: event.attempt, draftAgent: event.draftAgent }; } case "CREATE_FAILED": { if (state.tag !== "creating") { @@ -65,6 +64,17 @@ function reducer( } } +function prepareCreateAttempt( + attempt: CreateAttempt, + buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent, +): DraftAgentMachineState { + try { + return { tag: "creating", attempt, draftAgent: buildDraftAgent(attempt) }; + } catch (error) { + return { tag: "draft", errorMessage: error instanceof Error ? error.message : String(error) }; + } +} + interface CreateRequestResult { agentId: string | null; result: TCreateResult; @@ -112,22 +122,34 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const { t } = useTranslation(); - const [machine, dispatch] = useReducer( - reducer, + const [localMachine, dispatch] = useReducer( + reducer, initialAttempt, - (attempt): DraftAgentMachineState => + (attempt): DraftAgentMachineState => attempt - ? { tag: "creating", attempt } + ? prepareCreateAttempt(attempt, buildDraftAgent) : { tag: "draft", errorMessage: "", }, ); + const pending = useCreateFlowStore((state) => state.pendingByDraftId[draftId]); + // Remounts can precede model hydration. Rebuild the preview when its inputs + // arrive, and observe the original request's failure through shared state. + const machine = useMemo>(() => { + if (pending?.lifecycle === "abandoned") { + return { tag: "draft", errorMessage: pending.errorMessage ?? "" }; + } + if (pending?.lifecycle === "active" && localMachine.tag === "draft" && initialAttempt) { + return prepareCreateAttempt(initialAttempt, buildDraftAgent); + } + return localMachine; + }, [pending, localMachine, initialAttempt, buildDraftAgent]); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; @@ -163,12 +185,18 @@ export function useDraftAgentCreateFlow({ ]; }, [machine]); - const draftAgent = useMemo(() => { - if (machine.tag !== "creating") { - return null; - } - return buildDraftAgent(machine.attempt); - }, [buildDraftAgent, machine]); + const draftAgent = machine.tag === "creating" ? machine.draftAgent : null; + const startCreateAttempt = useCallback( + (attempt: CreateAttempt) => { + const prepared = prepareCreateAttempt(attempt, buildDraftAgent); + if (prepared.tag === "draft") { + dispatch({ type: "DRAFT_SET_ERROR", message: prepared.errorMessage }); + throw new Error(prepared.errorMessage); + } + dispatch({ type: "SUBMIT", attempt, draftAgent: prepared.draftAgent }); + }, + [buildDraftAgent], + ); const runCreateAttempt = useCallback( async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { @@ -179,15 +207,14 @@ export function useDraftAgentCreateFlow({ throw error; } - await onBeforeSubmit?.({ - attempt, - text: attempt.text, - images: attempt.images, - attachments: attempt.attachments, - cwd, - }); - try { + await onBeforeSubmit?.({ + attempt, + text: attempt.text, + images: attempt.images, + attachments: attempt.attachments, + cwd, + }); const createResult = await createRequest({ attempt, text: attempt.text, @@ -217,14 +244,16 @@ export function useDraftAgentCreateFlow({ const resolved = error instanceof Error ? error : new Error(t("composer.errors.failedToCreateAgent")); dispatch({ type: "CREATE_FAILED", message: resolved.message }); - markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); - clearPendingCreateAttempt({ draftId }); + markPendingCreateLifecycle({ + draftId, + lifecycle: "abandoned", + errorMessage: resolved.message, + }); onCreateError?.(resolved); throw error; } }, [ - clearPendingCreateAttempt, createRequest, draftId, getPendingServerId, @@ -239,7 +268,11 @@ export function useDraftAgentCreateFlow({ const handleCreateFromInput = useCallback( async ({ text, attachments, cwd }: SubmitContext) => { - if (isSubmitting) { + const existing = useCreateFlowStore.getState().pendingByDraftId[draftId]; + if ( + isSubmitting || + isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId }) + ) { throw new Error(t("composer.errors.alreadyLoading")); } @@ -280,13 +313,14 @@ export function useDraftAgentCreateFlow({ } const attempt: CreateAttempt = { - clientMessageId: generateMessageId(), + clientMessageId: `${draftId}:initial-message`, text: trimmedPrompt, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(images.length > 0 ? { images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }; + startCreateAttempt(attempt); setPendingCreateAttempt({ draftId, serverId: pendingServerId, @@ -300,7 +334,6 @@ export function useDraftAgentCreateFlow({ : {}), }); - dispatch({ type: "SUBMIT", attempt }); onCreateStart?.(); await runCreateAttempt({ attempt, cwd }); }, @@ -312,6 +345,7 @@ export function useDraftAgentCreateFlow({ onCreateStart, runCreateAttempt, setPendingCreateAttempt, + startCreateAttempt, t, validateBeforeSubmit, ], @@ -320,11 +354,11 @@ export function useDraftAgentCreateFlow({ const continueCreateFromAttempt = useCallback( async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => { if (!isSubmitting) { - dispatch({ type: "SUBMIT", attempt }); + startCreateAttempt(attempt); } await runCreateAttempt({ attempt, cwd }); }, - [isSubmitting, runCreateAttempt], + [isSubmitting, runCreateAttempt, startCreateAttempt], ); return { diff --git a/packages/app/src/composer/draft/input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx index 65180dce35..33041cceb6 100644 --- a/packages/app/src/composer/draft/input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -1,8 +1,8 @@ +/** @vitest-environment jsdom */ import React, { act } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; import { createWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -34,8 +34,6 @@ vi.mock("@/attachments/service", () => ({ vi.mock("@/hooks/use-agent-form-state", () => ({ useAgentFormState: () => ({ selectedServerId: "host-1", - setSelectedServerId: () => undefined, - setSelectedServerIdFromUser: () => undefined, selectedProvider: "codex", setProviderFromUser: () => undefined, selectedMode: "auto", @@ -45,8 +43,6 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ selectedThinkingOptionId: "", setThinkingOptionFromUser: () => undefined, workingDir: "/repo", - setWorkingDir: () => undefined, - setWorkingDirFromUser: () => undefined, providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], providerDefinitionMap: new Map(), agentDefinition: undefined, @@ -116,26 +112,24 @@ vi.mock("@/hooks/use-agent-form-state", () => ({ }), })); +const mountedRoots = new Set(); +function createTestRoot(container: HTMLElement): Root { + const root = createRoot(container); + mountedRoots.add(root); + return root; +} + +afterEach(async () => { + await act(async () => { + for (const root of mountedRoots) root.unmount(); + mountedRoots.clear(); + }); +}); + let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft; type DraftRecordForTest = ReturnType["drafts"][string]; beforeAll(async () => { - const storage = new Map(); - - Object.defineProperty(globalThis, "window", { - value: { - localStorage: { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => { - storage.set(key, value); - }, - removeItem: (key: string) => { - storage.delete(key); - }, - }, - }, - configurable: true, - }); Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, configurable: true, @@ -147,18 +141,8 @@ beforeAll(async () => { describe("useAgentInputDraft live contract", () => { beforeEach(() => { asyncStorage.clear(); - const dom = new JSDOM("
", { - url: "http://localhost", - }); - - Object.defineProperty(globalThis, "document", { - value: dom.window.document, - configurable: true, - }); - Object.defineProperty(globalThis, "navigator", { - value: dom.window.navigator, - configurable: true, - }); + document.body.innerHTML = "
"; + localStorage.clear(); useDraftStore.setState({ drafts: {}, @@ -191,9 +175,7 @@ describe("useAgentInputDraft live contract", () => { draftKey, composer: { initialServerId: "host-1", - initialValues: { workingDir: "/repo" }, isVisible: true, - onlineServerIds: ["host-1"], lockedWorkingDir: "/repo", }, }); @@ -206,7 +188,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - let root: Root | null = createRoot(container); + let root: Root | null = createTestRoot(container); await act(async () => { root!.render( @@ -216,12 +198,15 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex"); - expect(getLatest().composerState?.commandDraftConfig).toEqual({ - provider: "codex", - cwd: "/repo", - modeId: "auto", - model: "gpt-5.4", - thinkingOptionId: "high", + expect(getLatest().composerState?.commandDraft).toEqual({ + status: "ready", + config: { + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }, }); const hydratedTextReplacement = getLatest().textReplacement; @@ -248,7 +233,7 @@ describe("useAgentInputDraft live contract", () => { root!.unmount(); }); - root = createRoot(container); + root = createTestRoot(container); await act(async () => { root.render( @@ -257,7 +242,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("hello world"); + expect(getLatest().textSource.getSnapshot()).toBe("hello world"); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); }); @@ -306,7 +291,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -315,7 +300,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("legacy text"); + expect(getLatest().textSource.getSnapshot()).toBe("legacy text"); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({ text: "legacy text", @@ -372,7 +357,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -381,7 +366,7 @@ describe("useAgentInputDraft live contract", () => { ); }); - expect(getLatest().text).toBe("new text"); + expect(getLatest().textSource.getSnapshot()).toBe("new text"); expect(getLatest().attachments).toEqual([githubIssue]); await act(async () => { @@ -412,7 +397,9 @@ describe("useAgentInputDraft live contract", () => { return latest; } + let renders = 0; function Probe() { + renders += 1; latest = useAgentInputDraft({ draftKey: "draft:attachments" }); return null; } @@ -423,7 +410,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -438,9 +425,32 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); - expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ - text: "with attachment", - attachments: [{ kind: "image", metadata: image }], + const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input; + await act(async () => { + // Web text publication occurs after paint; attachments save immediately. + await expect.poll(readPersistedInput).toEqual({ + text: "with attachment", + attachments: [{ kind: "image", metadata: image }], + }); + }); + const settledRenders = renders; + const attached = getLatest().attachments; + const source = getLatest().textSource; + let textNotifications = 0; + const unsubscribe = source.subscribe(() => { + textNotifications += 1; + }); + await act(async () => { + getLatest().editText("with attachment\n"); + await expect.poll(source.getSnapshot).toBe("with attachment\n"); + }); + expect(renders).toBe(settledRenders); + expect(getLatest().attachments).toBe(attached); + expect(getLatest().textSource).toBe(source); + expect(textNotifications).toBe(1); + unsubscribe(); + await act(async () => { + root.unmount(); }); }); @@ -505,7 +515,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -523,7 +533,7 @@ describe("useAgentInputDraft live contract", () => { getLatest().clear("sent"); }); - expect(getLatest().text).toBe(""); + expect(getLatest().textSource.getSnapshot()).toBe(""); expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({ text: "", @@ -559,7 +569,7 @@ describe("useAgentInputDraft live contract", () => { throw new Error("Missing root container"); } - const root = createRoot(container); + const root = createTestRoot(container); await act(async () => { root.render( @@ -577,7 +587,7 @@ describe("useAgentInputDraft live contract", () => { getLatest().clear("sent"); }); - expect(getLatest().text).toBe(""); + expect(getLatest().textSource.getSnapshot()).toBe(""); expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "sent", diff --git a/packages/app/src/composer/draft/input-draft.test.ts b/packages/app/src/composer/draft/input-draft.test.ts index d9bbb6838f..882f342199 100644 --- a/packages/app/src/composer/draft/input-draft.test.ts +++ b/packages/app/src/composer/draft/input-draft.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { hasDraftContent, resolveDraftKey } from "./input-draft-core"; import { - buildDraftCommandConfig, + buildDraftCommandTarget, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, } from "@/provider-selection/provider-selection"; @@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => { }); }); -describe("buildDraftComposerCommandConfig", () => { - it("returns undefined when cwd is empty", () => { +describe("buildDraftCommandTarget", () => { + const codexSelection = { + provider: "codex", + modelId: "gpt-5.4", + modeId: "auto", + thinkingOptionId: "high", + availableModels: [], + modeOptions: [{ id: "auto", label: "Auto" }], + }; + + it("needs a project when cwd is empty", () => { expect( - buildDraftCommandConfig({ - selection: { - provider: "codex", - modelId: "gpt-5.4", - modeId: "", - thinkingOptionId: "", - availableModels: [], - modeOptions: [], - }, + buildDraftCommandTarget({ + selection: codexSelection, cwd: " ", effectiveModelId: "gpt-5.4", effectiveThinkingOptionId: "high", }), - ).toBeUndefined(); + ).toEqual({ status: "needs-project" }); + }); + + it("needs a provider when none is selected", () => { + expect( + buildDraftCommandTarget({ + selection: { ...codexSelection, provider: null }, + cwd: "/repo", + effectiveModelId: "", + effectiveThinkingOptionId: "", + }), + ).toEqual({ status: "needs-provider" }); }); it("builds the draft command config from derived composer state", () => { expect( - buildDraftCommandConfig({ - selection: { - provider: "codex", - modelId: "gpt-5.4", - modeId: "auto", - thinkingOptionId: "high", - availableModels: [], - modeOptions: [{ id: "auto", label: "Auto" }], - }, + buildDraftCommandTarget({ + selection: codexSelection, cwd: "/repo", effectiveModelId: "gpt-5.4", effectiveThinkingOptionId: "high", }), ).toEqual({ - provider: "codex", - cwd: "/repo", - modeId: "auto", - model: "gpt-5.4", - thinkingOptionId: "high", + status: "ready", + config: { + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }, }); }); }); diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index c26e18696f..7e6805b0b3 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { UserComposerAttachment } from "@/attachments/types"; import type { TextReplacement } from "@/composer/types"; import type { DraftAgentControlsProps } from "@/composer/agent-controls"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import { useAgentFormState, type CreateAgentInitialValues, @@ -16,14 +16,15 @@ import { type DraftKeyInput, } from "@/composer/draft/input-draft-core"; import { - buildDraftCommandConfig, + buildDraftCommandTarget, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, type ProviderSelectionState, } from "@/provider-selection/provider-selection"; import { useDraftStore } from "@/stores/draft-store"; -import { toDraftInputIfReady } from "@/stores/draft-store/state"; import { AfterPaintPublication } from "@/composer/after-paint-publication"; +import { useShallow } from "zustand/shallow"; +import type { ComposerTextSource } from "@/composer/text-source"; import { isWeb } from "@/constants/platform"; type AttachmentUpdater = @@ -35,7 +36,6 @@ interface AgentInputDraftComposerOptions { initialValues?: CreateAgentInitialValues; initialFeatureValues?: Record; isVisible?: boolean; - onlineServerIds?: string[]; lockedWorkingDir?: string; } @@ -50,11 +50,11 @@ type DraftComposerState = UseAgentFormStateResult & { effectiveThinkingOptionId: string; featureValues: Record | undefined; agentControls: DraftAgentControlsProps; - commandDraftConfig: DraftCommandConfig | undefined; + commandDraft: DraftCommandTarget; }; export interface AgentInputDraft { - text: string; + textSource: ComposerTextSource; editText: (text: string) => void; replaceText: (text: string) => void; textReplacement: TextReplacement; @@ -68,12 +68,13 @@ export interface AgentInputDraft { export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { const composerOptions = input.composer ?? null; + const workingDir = composerOptions?.lockedWorkingDir?.trim() || ""; const formState = useAgentFormState({ - initialServerId: composerOptions?.initialServerId ?? null, + workingDir, + serverId: composerOptions?.initialServerId ?? null, initialValues: composerOptions?.initialValues, isVisible: composerOptions?.isVisible ?? false, isCreateFlow: true, - onlineServerIds: composerOptions?.onlineServerIds ?? [], }); const draftKey = useMemo( () => @@ -83,25 +84,47 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }), [formState.selectedServerId, input.draftKey], ); - const draftRecord = useDraftStore((state) => state.drafts[draftKey]); - const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]); + const attachments = useDraftStore( + useShallow((state) => + state.drafts[draftKey]?.lifecycle === "active" + ? (state.drafts[draftKey].input.attachments ?? []) + : [], + ), + ); + const textSource = useMemo( + () => ({ + getSnapshot: () => { + const record = useDraftStore.getState().drafts[draftKey]; + return record?.lifecycle === "active" ? record.input.text : ""; + }, + subscribe: (listener) => + useDraftStore.subscribe((state, previous) => { + if ( + state.drafts[draftKey]?.input.text !== previous.drafts[draftKey]?.input.text || + state.drafts[draftKey]?.lifecycle !== previous.drafts[draftKey]?.lifecycle + ) + listener(); + }), + }), + [draftKey], + ); const attachmentFocusRequestId = useDraftStore( (state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0, ); const [hydratedDraftKey, setHydratedDraftKey] = useState(null); - const text = draft?.text ?? ""; - const attachments = draft?.attachments ?? []; const isHydrated = hydratedDraftKey === draftKey; const textReplacementRevisionRef = useRef(0); const [textReplacement, setTextReplacement] = useState(() => ({ + kind: "initial", key: `${draftKey}:0`, - text, + text: textSource.getSnapshot(), })); const publishTextReplacement = useCallback( (nextText: string) => { textReplacementRevisionRef.current += 1; setTextReplacement({ + kind: "replace", key: `${draftKey}:${textReplacementRevisionRef.current}`, text: nextText, }); @@ -131,9 +154,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const textPublication = useMemo( () => new AfterPaintPublication((nextText) => { - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); }), - [saveDraft], + [draftKey], ); const editText = useCallback( @@ -141,19 +164,19 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr if (isWeb) { textPublication.stage(nextText); } else { - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); } }, - [saveDraft, textPublication], + [draftKey, textPublication], ); const replaceText = useCallback( (nextText: string) => { textPublication.cancel(); - saveDraft((current) => ({ ...current, text: nextText })); + useDraftStore.getState().editDraftText({ draftKey, text: nextText }); publishTextReplacement(nextText); }, - [publishTextReplacement, saveDraft, textPublication], + [draftKey, publishTextReplacement, textPublication], ); const setAttachments = useCallback( @@ -214,17 +237,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr }; }, [draftKey, publishTextReplacement]); - const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; - useEffect(() => { - if (!composerOptions || !lockedWorkingDir) { - return; - } - if (formState.workingDir.trim() === lockedWorkingDir) { - return; - } - formState.setWorkingDir(lockedWorkingDir); - }, [composerOptions, formState, lockedWorkingDir]); - const providerSelection = useMemo( () => ({ provider: formState.selectedProvider, @@ -254,7 +266,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [effectiveModelId, providerSelection], ); - const workingDir = lockedWorkingDir || formState.workingDir; const { features: draftFeatures, featureValues: draftFeatureValues, @@ -278,19 +289,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [applyProfileFeatureValues, formState], ); - const commandDraftConfig = useMemo( + const commandDraft = useMemo( () => - composerOptions - ? buildDraftCommandConfig({ - selection: providerSelection, - cwd: workingDir, - effectiveModelId, - effectiveThinkingOptionId, - featureValues: draftFeatureValues, - }) - : undefined, + buildDraftCommandTarget({ + selection: providerSelection, + cwd: workingDir, + effectiveModelId, + effectiveThinkingOptionId, + featureValues: draftFeatureValues, + }), [ - composerOptions, effectiveModelId, effectiveThinkingOptionId, draftFeatureValues, @@ -316,10 +324,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr onSetFeature: setDraftFeatureValue, onApplyAgentProfile: applyDraftAgentProfile, }), - commandDraftConfig, + commandDraft, }; }, [ - commandDraftConfig, + commandDraft, composerOptions, effectiveModelId, effectiveThinkingOptionId, @@ -332,7 +340,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ]); return { - text, + textSource, editText, replaceText, textReplacement, @@ -349,7 +357,6 @@ export const __private__ = { resolveDraftKey, resolveEffectiveComposerModelId, resolveEffectiveComposerThinkingOptionId, - buildDraftCommandConfig, - buildDraftComposerCommandConfig: buildDraftCommandConfig, + buildDraftCommandTarget, buildDraftAgentControls, }; diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 167a8886b8..6e9c45eab5 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,10 +1,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { ComposerDock } from "@/composer/dock"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import invariant from "tiny-invariant"; import { Composer } from "@/composer"; @@ -17,7 +15,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -42,11 +40,7 @@ import { useWorkspaceAttachmentsStore, } from "@/attachments/workspace-attachments-store"; import type { UserMessageImageAttachment } from "@/types/stream"; -import { - COMPACT_FORM_FACTOR_WIDTH, - MAX_CONTENT_WIDTH, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { buildWorkspaceTabPersistenceKey, @@ -56,7 +50,6 @@ import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; const EMPTY_PENDING_PERMISSIONS = new Map(); -const EMPTY_ONLINE_SERVER_IDS: string[] = []; const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -138,6 +131,7 @@ function resolveDraftModeId(input: { } async function submitDraftCreateRequest(input: { + draftId: string; attempt: { clientMessageId: string }; text: string; images?: UserMessageImageAttachment[]; @@ -196,16 +190,19 @@ async function submitDraftCreateRequest(input: { featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues, }); - const imagesData = await encodeImages(images); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; - const result = await client.createAgent({ + const imagesData = await encodeImages(images); + const options = { + idempotencyKey: input.draftId, config, workspaceId, - ...(text ? { initialPrompt: text } : {}), + initialPrompt: text, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), ...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}), - }); + }; + const creation = useWorkspaceDraftSubmissionStore.getState().creationByDraftId[input.draftId]; + const result = creation ? await creation.retry(options) : await client.createAgent(options); return { agentId: result.id, @@ -249,7 +246,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -271,17 +268,10 @@ function buildDraftAgentSnapshot(input: { } function buildDraftInitialValues(input: { - workingDir: string | null; initialSetup: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { - if (!input.workingDir) { - return undefined; - } - if (!input.initialSetup) { - return { workingDir: input.workingDir }; - } + if (!input.initialSetup) return undefined; return { - workingDir: input.workingDir, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, @@ -299,13 +289,6 @@ function resolveDraftWorkingDirectory(input: { return input.workspaceDirectory; } -function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] { - if (!input.isConnected) { - return EMPTY_ONLINE_SERVER_IDS; - } - return [input.serverId]; -} - interface WorkspaceDraftAgentTabProps { serverId: string; workspaceId: string; @@ -340,9 +323,7 @@ export function WorkspaceDraftAgentTab({ onOpenImportSheet, }: WorkspaceDraftAgentTabProps) { const { t } = useTranslation(); - const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ workspaceDirectory: w.workspaceDirectory, id: w.id, @@ -354,10 +335,8 @@ export function WorkspaceDraftAgentTab({ initialSetup: draftSetup, }); const draftInitialValues = buildDraftInitialValues({ - workingDir: draftWorkingDirectory, initialSetup: draftSetup, }); - const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId }); const draftStoreKey = useMemo( () => buildDraftStoreKey({ @@ -374,7 +353,6 @@ export function WorkspaceDraftAgentTab({ initialValues: draftInitialValues, initialFeatureValues: draftSetup?.featureValues, isVisible: true, - onlineServerIds, lockedWorkingDir: draftWorkingDirectory ?? undefined, }, }); @@ -410,10 +388,7 @@ export function WorkspaceDraftAgentTab({ ); const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit); const initialCreateAttempt = useMemo(() => { - if (!pendingAutoSubmit || !pendingCreateAttempt) { - return null; - } - if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) { + if (!pendingCreateAttempt) { return null; } return { @@ -427,7 +402,7 @@ export function WorkspaceDraftAgentTab({ ? { attachments: pendingCreateAttempt.attachments } : {}), }; - }, [pendingAutoSubmit, pendingCreateAttempt]); + }, [pendingCreateAttempt]); const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true; const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( @@ -507,8 +482,13 @@ export function WorkspaceDraftAgentTab({ composerState, selectModelMessage: t("workspaceSetup.errors.selectModel"), }), - createRequest: async ({ attempt, text, images, attachments, cwd }) => - submitDraftCreateRequest({ + createRequest: async ({ attempt, text, images, attachments, cwd }) => { + if (pendingAutoSubmit?.agentCreation) { + const result = await pendingAutoSubmit.agentCreation.result; + return { agentId: result.id, result }; + } + return submitDraftCreateRequest({ + draftId, attempt, text, images, @@ -521,7 +501,8 @@ export function WorkspaceDraftAgentTab({ composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), selectModelMessage: t("workspaceSetup.errors.selectModel"), - }), + }); + }, onCreateSuccess: ({ result }) => { clearDraftInput("sent"); clearWorkspaceAttachments({ scopeKey: draftAttachmentScopeKey }); @@ -624,19 +605,6 @@ export function WorkspaceDraftAgentTab({ focusInputRef.current = focus; }, []); - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const inputAreaWrapperStyle = useMemo( - () => [ - animatedStaticStyles.inputAreaWrapper, - { paddingBottom: insets.bottom }, - composerKeyboardStyle, - ], - [insets.bottom, composerKeyboardStyle], - ); - const handleDropdownCloseFocus = useCallback(() => { focusInputRef.current?.(); }, []); @@ -649,69 +617,73 @@ export function WorkspaceDraftAgentTab({ }), [composerState.agentControls, handleDropdownCloseFocus, isSubmitting], ); + const dockContent = ( + + {isSubmitting && draftAgent ? ( + + + + ) : ( + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + ); + return ( - - {isSubmitting && draftAgent ? ( - - - - ) : ( - - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} + + {dockContent} + + {importPillPress ? ( + + + + - - )} - - - - {importPillPress ? ( - - - - - - ) : null} - - + ) : null} + +
+ ); } @@ -719,6 +691,7 @@ export function WorkspaceDraftAgentTab({ const animatedStaticStyles = RNStyleSheet.create({ inputAreaWrapper: { width: "100%", + flexShrink: 1, }, }); @@ -760,7 +733,7 @@ const styles = StyleSheet.create((theme) => ({ }, importPillContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, flexDirection: "row", }, errorContainer: { diff --git a/packages/app/src/composer/editing-session/index.test.ts b/packages/app/src/composer/editing-session/index.test.ts new file mode 100644 index 0000000000..d593e5a64e --- /dev/null +++ b/packages/app/src/composer/editing-session/index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { ComposerEditingSession } from "./index"; + +function leaveLiveDraft(session: ComposerEditingSession) { + session.captureForRemount({ + snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } }, + focused: true, + replacementKey: "draft:applied", + }); +} + +describe("composer editing remount handoff", () => { + it("keeps live text and selection when remounting from an initial draft publication", () => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" }); + expect(handoff?.snapshot).toEqual({ + text: "Unpublished live draft", + selection: { start: 4, end: 17 }, + }); + expect(handoff?.focused).toBe(true); + }); + + it("keeps local edits when the last applied replacement is still current", () => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ + kind: "replace", + key: "draft:applied", + text: "old replacement", + }); + expect(handoff?.snapshot).toEqual({ + text: "Unpublished live draft", + selection: { start: 4, end: 17 }, + }); + }); + + it.each(["New explicit draft", ""])( + "honors an explicit replacement made while the editor is absent: %j", + (text) => { + const session = new ComposerEditingSession(); + leaveLiveDraft(session); + const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text }); + expect(handoff?.snapshot).toEqual({ + text, + selection: { start: text.length, end: text.length }, + }); + expect(handoff?.focused).toBe(true); + expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull(); + }, + ); +}); diff --git a/packages/app/src/composer/editing-session/index.tsx b/packages/app/src/composer/editing-session/index.tsx new file mode 100644 index 0000000000..97f7a79c51 --- /dev/null +++ b/packages/app/src/composer/editing-session/index.tsx @@ -0,0 +1,142 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import type { TextReplacement } from "@/composer/types"; +import type { ComposerInputMode } from "@/composer/input-mode"; + +export interface ComposerEditingHandoff { + snapshot: { text: string; selection: { start: number; end: number } }; + focused: boolean; + replacementKey: string; +} + +/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */ +export class ComposerEditingSession { + private fullscreen = false; + private handoff: ComposerEditingHandoff | null = null; + private listeners = new Set<() => void>(); + getFullscreen = () => this.fullscreen; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) { + this.captureForRemount(handoff); + if (fullscreen === this.fullscreen) return; + this.fullscreen = fullscreen; + for (const listener of this.listeners) listener(); + } + captureForRemount(handoff: ComposerEditingHandoff) { + this.handoff = handoff; + } + peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null { + const handoff = this.handoff; + if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey) + return handoff; + // A replacement command issued since capture supersedes the old editor's live buffer. + return { + snapshot: { + text: replacement.text, + selection: { start: replacement.text.length, end: replacement.text.length }, + }, + focused: handoff.focused, + replacementKey: replacement.key, + }; + } + takeHandoff(replacement: TextReplacement) { + const handoff = this.peekHandoff(replacement); + this.handoff = null; + return handoff; + } +} + +class ComposerEditingSessions { + private workspaces = new Map< + string, + Map }> + >(); + get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) { + let workspace = this.workspaces.get(workspaceKey); + if (!workspace) { + workspace = new Map(); + this.workspaces.set(workspaceKey, workspace); + } + let tab = workspace.get(tabId); + if (!tab || tab.targetKey !== targetKey) { + tab = { targetKey, sessions: new Map() }; + workspace.set(tabId, tab); + } + let session = tab.sessions.get(purpose); + if (!session) { + session = new ComposerEditingSession(); + tab.sessions.set(purpose, session); + } + return session; + } + retain(workspaceKey: string, tabIds: readonly string[]) { + const workspace = this.workspaces.get(workspaceKey); + if (!workspace) return; + const retained = new Set(tabIds); + for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId); + if (workspace.size === 0) this.workspaces.delete(workspaceKey); + } +} + +const SessionsContext = createContext(null); +const WorkspaceContext = createContext(null); +const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null); + +/** App-container ownership survives replacement of both the gesture shell and tab layout. */ +export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) { + const [sessions] = useState(() => new ComposerEditingSessions()); + return {children}; +} +export function WorkspaceComposerEditingSessionsProvider({ + workspaceKey, + tabIds, + children, +}: { + workspaceKey: string; + tabIds: readonly string[]; + children: ReactNode; +}) { + const sessions = useContext(SessionsContext); + useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]); + return {children}; +} +export function ComposerEditingScope({ + tabId, + targetKey, + children, +}: { + tabId: string; + targetKey: string; + children: ReactNode; +}) { + const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]); + return {children}; +} +export function useComposerEditingSession( + purpose: ComposerInputMode, + provided?: ComposerEditingSession, +): ComposerEditingSession { + const sessions = useContext(SessionsContext); + const scope = useContext(ScopeContext); + const workspaceKey = useContext(WorkspaceContext); + const [local] = useState(() => provided ?? new ComposerEditingSession()); + if (provided) return provided; + return sessions && scope && workspaceKey + ? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose) + : local; +} +export function useComposerFullscreen(session: ComposerEditingSession): boolean { + return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen); +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 989382fe79..c77b39d332 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,8 @@ +import { useComposerEditingSession, useComposerFullscreen } from "./editing-session"; +import type { ComposerTextSource } from "./text-source"; +import { createStore, type StoreApi } from "zustand/vanilla"; +import { useStore } from "zustand"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -10,16 +15,19 @@ import type { TFunction } from "i18next"; import { useState, useEffect, + useLayoutEffect, useRef, useCallback, useMemo, + useSyncExternalStore, + useImperativeHandle, memo, type ReactElement, - type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHasFinePointer } from "@/hooks/use-fine-pointer"; import { useShallow } from "zustand/shallow"; import { ArrowUp, @@ -34,8 +42,7 @@ import { Paperclip, } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; -import Animated from "react-native-reanimated"; -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { FOOTER_HEIGHT } from "@/constants/layout"; import { AgentControls, DraftAgentControls, @@ -55,7 +62,7 @@ import { } from "./input/input"; import type { ImageAttachment, MessagePayload, TextReplacement } from "./types"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; import { @@ -102,7 +109,6 @@ import { import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveActiveSendBehavior } from "./input/state"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; @@ -121,14 +127,19 @@ import type { WorkspaceFileComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; -import type { PickedFile } from "@/attachments/picked-file"; +import type { SelectedFile } from "@/attachments/selected-file"; import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store"; -import { droppedItemsToPickedFiles } from "@/composer/attachments/drop"; +import { droppedItemsToSelectedFiles } from "@/composer/attachments/drop"; import { getFileTypeLabel } from "@/attachments/file-types"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/components/attachment-pill"; +import { + AttachmentFrame, + AttachmentLabel, + AttachmentPill, + AttachmentThumbnail, +} from "@/components/attachment-pill"; import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; @@ -264,46 +275,10 @@ function buildAgentStateSelector(serverId: string, agentId: string) { contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, model: agent?.model ?? null, - provider: agent?.provider ?? null, }; }; } -function renderContextWindowMeter( - contextWindowMaxTokens: number | null, - contextWindowUsedTokens: number | null, - totalCostUsd: number | null, - showPercentage: boolean, - serverId: string, - provider: string | null, - pending: boolean, - glyphSize: number, -): ReactElement | null { - const hasData = contextWindowMaxTokens !== null && contextWindowUsedTokens !== null; - if (!hasData && !pending) { - return null; - } - return ( - - ); -} - -function resolveContextWindowPlacement( - meter: ReactElement | null, - reserveSlot: boolean, -): ReactNode { - return reserveSlot ? {meter} : null; -} - interface RenderLeftContentArgs { agentControls: DraftAgentControlsProps | undefined; agentId: string; @@ -329,8 +304,14 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { ); } +interface PendingFileAttachment { + id: number; + file: SelectedFile; +} + interface RenderAttachmentTrayArgs { selectedAttachments: ComposerAttachment[]; + pendingFiles: PendingFileAttachment[]; isComposerLocked: boolean; handleOpenAttachment: (attachment: ComposerAttachment) => void; handleRemoveAttachment: (index: number) => void; @@ -346,12 +327,13 @@ interface RenderAttachmentTrayArgs { function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, + pendingFiles, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, labels, } = args; - if (selectedAttachments.length === 0) return null; + if (selectedAttachments.length === 0 && pendingFiles.length === 0) return null; return ( {selectedAttachments.map((attachment, index) => @@ -364,6 +346,15 @@ function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | nu labels, }), )} + {pendingFiles.map(({ id, file }) => ( + + + + ))} ); } @@ -934,7 +925,7 @@ interface ComposerProps { submitBehavior?: "clear" | "preserve-and-lock"; /** When true, blurs the input immediately when submitting. */ blurOnSubmit?: boolean; - value: string; + textSource: ComposerTextSource; onChangeText: (text: string) => void; textReplacement: TextReplacement; attachments: UserComposerAttachment[]; @@ -951,8 +942,8 @@ interface ComposerProps { autoFocusKey?: string; /** Callback to expose a focus function to parent components (desktop only). */ onFocusInput?: (focus: () => void) => void; - /** Optional draft context for listing commands before an agent exists. */ - commandDraftConfig?: DraftCommandConfig; + /** Draft context for listing commands before an agent exists. Omitted for running agents. */ + commandDraft?: DraftCommandTarget; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ onMessageSent?: () => void; onComposerHeightChange?: (height: number) => void; @@ -962,8 +953,6 @@ interface ComposerProps { agentControls?: DraftAgentControlsProps; /** Extra styles merged onto the message input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; - /** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */ - externalKeyboardShift?: boolean; /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ isCompactLayout?: boolean; /** @@ -972,7 +961,7 @@ interface ComposerProps { * and nothing else — never branch on it at the call site. */ inputMode?: ComposerInputMode; - /** Renders `value` as static text on the same surface, for content there is nothing to type into. */ + /** Renders the current text as static text on the same surface, for content there is nothing to type into. */ readOnly?: boolean; /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */ submitLabel?: string; @@ -980,19 +969,85 @@ interface ComposerProps { placeholder?: string; } -const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; - const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); -function resolveContextWindowValues( - rawMax: number | null, - rawUsed: number | null, -): { contextWindowMaxTokens: number | null; contextWindowUsedTokens: number | null } { - if (typeof rawMax === "number" && typeof rawUsed === "number") { - return { contextWindowMaxTokens: rawMax, contextWindowUsedTokens: rawUsed }; - } - return { contextWindowMaxTokens: null, contextWindowUsedTokens: null }; +interface ComposerAutocompleteHandle { + onKeyPress: (event: ComposerKeyPressEvent) => boolean; +} + +function ComposerAutocompleteBinding({ + text, + cursor, + configuration, + inputRef, + anchorRef, + show, + ref, +}: { + text: ComposerTextSource; + cursor: StoreApi; + configuration: Omit< + Parameters[0], + "userInput" | "cursorIndex" | "onAutocompleteApplied" + >; + inputRef: React.RefObject; + anchorRef: React.RefObject; + show: boolean; + ref: React.Ref; +}) { + const userInput = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot); + const cursorIndex = useStore(cursor); + const autocomplete = useAgentAutocomplete({ + ...configuration, + userInput, + cursorIndex: Math.min(cursorIndex, userInput.length), + onAutocompleteApplied: () => inputRef.current?.focus(), + }); + const handleKeyPress = autocomplete.onKeyPress; + useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [ + show, + handleKeyPress, + ]); + const selectOption = autocomplete.onSelectOption; + const onSelect = useCallback( + (option: AutocompleteOption) => selectOption(option, inputRef.current?.getInputSnapshot()), + [selectOption, inputRef], + ); + return ( + + ); +} + +function ComposerForgeBinding({ + text, + configuration, + ref, + onResolvingChange, +}: { + text: ComposerTextSource; + configuration: Omit[0], "text">; + ref: React.Ref>; + onResolvingChange: (resolving: boolean) => void; +}) { + const value = useSyncExternalStore(text.subscribe, text.getSnapshot, text.getSnapshot); + const binding = useComposerForgeAutoAttach({ ...configuration, text: value }); + useImperativeHandle(ref, () => binding, [binding]); + useLayoutEffect( + () => onResolvingChange(binding.isResolving), + [binding.isResolving, onResolvingChange], + ); + return null; } interface ComposerCancelButtonProps { @@ -1156,7 +1211,7 @@ function ComposerContentImpl({ waitForForgeAutoAttachOnSubmit = false, submitBehavior = "clear", blurOnSubmit = false, - value, + textSource, onChangeText, textReplacement, attachments, @@ -1170,14 +1225,13 @@ function ComposerContentImpl({ autoFocus = false, autoFocusKey, onFocusInput, - commandDraftConfig, + commandDraft, onMessageSent, onComposerHeightChange, onAttentionInputFocus, onAttentionPromptSend, agentControls, inputWrapperStyle, - externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, inputMode = "chat", readOnly = false, @@ -1216,9 +1270,14 @@ function ComposerContentImpl({ const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); + const hasFinePointer = useHasFinePointer(); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); - const userInput = value; + const hasText = useSyncExternalStore( + textSource.subscribe, + () => textSource.getSnapshot().trim().length > 0, + () => textSource.getSnapshot().trim().length > 0, + ); const setUserInput = onChangeText; const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); const { @@ -1240,24 +1299,48 @@ function ComposerContentImpl({ const supportsForgeSearch = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, ); - const forgeAutoAttach = useComposerForgeAutoAttach({ - text: userInput, - remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), - attachments, - client, - isConnected, - serverId, - cwd, - supportsForgeSearch, - setAttachments: setSelectedAttachments, - onChangeRequestDetected: onForgeChangeRequestDetected, - onChangeRequestAdded: onForgeChangeRequestAutoAttach, - }); - const [cursorIndex, setCursorIndex] = useState(0); - const cursorPublication = useMemo(() => new AfterPaintPublication(setCursorIndex), []); + const forgeAutoAttachRef = useRef>(null); + const [isForgeResolving, setIsForgeResolving] = useState(false); + const forgeConfiguration = useMemo( + () => ({ + remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), + attachments, + client, + isConnected, + serverId, + cwd, + supportsForgeSearch, + setAttachments: setSelectedAttachments, + onChangeRequestDetected: onForgeChangeRequestDetected, + onChangeRequestAdded: onForgeChangeRequestAutoAttach, + }), + [ + checkoutStatusQuery.status, + attachments, + client, + isConnected, + serverId, + cwd, + supportsForgeSearch, + setSelectedAttachments, + onForgeChangeRequestDetected, + onForgeChangeRequestAutoAttach, + ], + ); + const cursor = useMemo(() => createStore(() => 0), []); + const cursorPublication = useMemo( + () => new AfterPaintPublication((position) => cursor.setState(position)), + [cursor], + ); + useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); + const autocompleteRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); - const [isUploadingFile, setIsUploadingFile] = useState(false); + const [pendingFiles, setPendingFiles] = useState([]); + const nextPendingFileId = useRef(0); + const isUploadingFile = pendingFiles.length > 0; const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0); + const editingSession = useComposerEditingSession(inputMode); + const isFullscreen = useComposerFullscreen(editingSession); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -1350,36 +1433,6 @@ function ComposerContentImpl({ [blurOnSubmit, clearDraft, replaceUserInput, resetSuppression, setSelectedAttachments], ); - const autocomplete = useAgentAutocomplete({ - userInput, - cursorIndex, - setUserInput: replaceUserInput, - serverId, - agentId, - draftConfig: commandDraftConfig, - canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, - onClientSlashCommand: runClientSlashCommand, - pluginClientSlashCommands, - onAutocompleteApplied: () => { - messageInputRef.current?.focus(); - }, - }); - const autocompleteOnKeyPressRef = useRef(autocomplete.onKeyPress); - autocompleteOnKeyPressRef.current = autocomplete.onKeyPress; - const selectAutocompleteOption = autocomplete.onSelectOption; - const handleAutocompleteSelect = useCallback( - (option: AutocompleteOption) => - selectAutocompleteOption(option, messageInputRef.current?.getInputSnapshot()), - [selectAutocompleteOption], - ); - - // Clear send error when user edits the input - useEffect(() => { - setCursorIndex((current) => Math.min(current, userInput.length)); - }, [userInput.length]); - - useEffect(() => () => cursorPublication.cancel(), [cursorPublication]); - const { pickImages } = useImageAttachmentPicker(); const { pickFiles } = useFilePicker(); const agentIdRef = useRef(agentId); @@ -1488,8 +1541,10 @@ function ComposerContentImpl({ activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" - ? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn - ?.turnId ?? undefined) + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + targetAgentId, + ).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); @@ -1506,8 +1561,6 @@ function ComposerContentImpl({ const isCancellingAgent = useSessionStore( (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, ); - const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); - const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); const isAgentRunning = hasActiveTurn; // Queueing behind a permission prompt would strand the message: the turn is // parked until the request is answered. @@ -1703,23 +1756,16 @@ function ComposerContentImpl({ [addImages, t], ); - const uploadPickedFiles = useCallback( - async (files: PickedFile[]) => { + const uploadSelectedFiles = useCallback( + async (files: SelectedFile[]) => { if (files.length === 0) return; if (!client) { toastErrorRef.current(t("composer.errors.daemonClientDisconnected")); return; } - const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES); - if (oversized) { - toastErrorRef.current( - t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }), - ); - return; - } - - setIsUploadingFile(true); + const placeholders = files.map((file) => ({ id: nextPendingFileId.current++, file })); + setPendingFiles((pending) => [...pending, ...placeholders]); try { const uploaded = await uploadFileAttachments({ client, files }); addFiles(uploaded); @@ -1729,7 +1775,7 @@ function ComposerContentImpl({ error instanceof Error ? error.message : t("composer.errors.uploadFailed"), ); } finally { - setIsUploadingFile(false); + setPendingFiles((pending) => pending.filter((entry) => !placeholders.includes(entry))); } }, [addFiles, client, t], @@ -1743,25 +1789,25 @@ function ComposerContentImpl({ try { const files = await pickFiles(); if (!files) return; - await uploadPickedFiles(files); + await uploadSelectedFiles(files); } catch (error) { console.error("[Composer] Failed to upload file:", error); toastErrorRef.current( error instanceof Error ? error.message : t("composer.errors.uploadFailed"), ); } - }, [client, pickFiles, t, uploadPickedFiles]); + }, [client, pickFiles, t, uploadSelectedFiles]); const handleGenericFilesDropped = useCallback( async (items: DroppedItem[]) => { try { - const files = await droppedItemsToPickedFiles(items); + const files = droppedItemsToSelectedFiles(items); if (files.length === 0) return; if (!client || !isConnected) { toastErrorRef.current(t("composer.errors.daemonClientDisconnected")); return; } - await uploadPickedFiles(files); + await uploadSelectedFiles(files); } catch (error) { console.error("[Composer] Failed to upload dropped files:", error); toastErrorRef.current( @@ -1769,12 +1815,12 @@ function ComposerContentImpl({ ); } }, - [client, isConnected, t, uploadPickedFiles], + [client, isConnected, t, uploadSelectedFiles], ); const handleRemoveAttachment = useCallback( (index: number) => { - forgeAutoAttach.markForgeAttachmentRemoved(selectedAttachments[index]); + forgeAutoAttachRef.current?.markForgeAttachmentRemoved(selectedAttachments[index]); const didRemoveWorkspaceAttachment = removeAttachment({ selectedAttachments, index, @@ -1786,7 +1832,7 @@ function ComposerContentImpl({ removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), ); }, - [forgeAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments], + [removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( @@ -1813,7 +1859,7 @@ function ComposerContentImpl({ isConnected, }); if (!cancellation) return; - const requestId = beginAgentCancellation(serverId, targetAgentId); + const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId); void cancellation .catch((error) => { const message = resolveErrorMessage(error); @@ -1822,28 +1868,15 @@ function ComposerContentImpl({ } }) .finally(() => { - settleAgentCancellation(serverId, targetAgentId, requestId); + getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId); }); messageInputRef.current?.focus(); - }, [ - beginAgentCancellation, - client, - isAgentRunning, - isCancellingAgent, - isConnected, - serverId, - settleAgentCancellation, - ]); + }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); }, []); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ - mode: "translate", - enabled: !externalKeyboardShift, - }); - const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId); const handleToggleRealtimeVoice = useCallback(() => { @@ -1918,11 +1951,11 @@ function ComposerContentImpl({ ], ); - const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; + const hasSendableContent = hasText || selectedAttachments.length > 0; // Handle keyboard navigation for command autocomplete. const handleCommandKeyPress = useCallback( - (event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event), + (event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false, [], ); @@ -1997,40 +2030,31 @@ function ComposerContentImpl({ ], ); - const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues( - agentState.contextWindowMaxTokens, - agentState.contextWindowUsedTokens, - ); - - const contextWindowPending = agentState.status === "initializing" || isAgentRunning; const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; - - const contextWindowMeter = useMemo( + const beforeVoiceContent = useMemo( () => - renderContextWindowMeter( - contextWindowMaxTokens, - contextWindowUsedTokens, - agentState.totalCostUsd, - false, - serverId, - agentState.provider, - contextWindowPending, - contextWindowMeterGlyphSize, - ), + hasAgent ? ( + + + + ) : null, [ - contextWindowMaxTokens, - contextWindowUsedTokens, - agentState.totalCostUsd, + hasAgent, serverId, - agentState.provider, - contextWindowPending, + agentId, + agentState.contextWindowMaxTokens, + agentState.contextWindowUsedTokens, + agentState.totalCostUsd, contextWindowMeterGlyphSize, ], ); - const beforeVoiceContent = useMemo( - () => resolveContextWindowPlacement(contextWindowMeter, hasAgent), - [contextWindowMeter, hasAgent], - ); const hasGithubAttachment = useMemo( () => @@ -2138,19 +2162,14 @@ function ComposerContentImpl({ const nextAttachments = toggleForgeAttachmentFromPicker({ current: attachments, item, - markForgeAttachmentRemoved: forgeAutoAttach.markForgeAttachmentRemoved, + markForgeAttachmentRemoved: (attachment) => + forgeAutoAttachRef.current?.markForgeAttachmentRemoved(attachment), }); setSelectedAttachments(nextAttachments); setIsGithubPickerOpen(false); setGithubSearchQuery(""); }, - [ - attachments, - forgeAutoAttach, - setSelectedAttachments, - setGithubSearchQuery, - setIsGithubPickerOpen, - ], + [attachments, setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], ); const leftContent = useMemo( @@ -2175,10 +2194,10 @@ function ComposerContentImpl({ if (isWeb) { cursorPublication.stage(selection.start); } else { - setCursorIndex(selection.start); + cursor.setState(selection.start); } }, - [cursorPublication], + [cursorPublication, cursor], ); const handleFocusChange = useCallback( @@ -2231,10 +2250,6 @@ function ComposerContentImpl({ [githubSearchItems, selectedAttachments, handleToggleGithubItem], ); - const composerContainerStyle = useMemo( - () => [animatedStaticStyles.container, keyboardAnimatedStyle], - [keyboardAnimatedStyle], - ); const inputAreaContainerStyle = useMemo( () => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked], [isComposerLocked], @@ -2244,6 +2259,7 @@ function ComposerContentImpl({ () => renderAttachmentTray({ selectedAttachments, + pendingFiles, isComposerLocked, handleOpenAttachment, handleRemoveAttachment, @@ -2257,7 +2273,14 @@ function ComposerContentImpl({ t("composer.attachments.removeGithub", { kind, number: numberLabel }), }, }), - [handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t], + [ + handleOpenAttachment, + handleRemoveAttachment, + isComposerLocked, + selectedAttachments, + pendingFiles, + t, + ], ); const queueList = useMemo( @@ -2272,12 +2295,33 @@ function ComposerContentImpl({ [handleEditQueuedMessage, handleSendQueuedNow, queuedMessages, t], ); + const autocompleteConfiguration = useMemo( + () => ({ + setUserInput: replaceUserInput, + serverId, + agentId, + draft: commandDraft, + canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, + onClientSlashCommand: runClientSlashCommand, + pluginClientSlashCommands, + }), + [ + replaceUserInput, + serverId, + agentId, + commandDraft, + buildOutgoingAttachments, + attachments, + runClientSlashCommand, + pluginClientSlashCommands, + ], + ); const messageInputContainerRef = useRef(null); const isSubmitLoadingVisible = isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0; const isSubmitDisabled = - isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && forgeAutoAttach.isResolving); + isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && isForgeResolving); // Disable drops while submitting/uploading: the submit path clears and restores attachments, // so a drop in that window would be lost or land on a locked draft. `disabled` hides the @@ -2291,7 +2335,8 @@ function ComposerContentImpl({ { disabled: isSubmitLoadingVisible }, ); - const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint; + // Focusing the composer on a touch screen raises the on-screen keyboard over the conversation. + const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint && hasFinePointer; const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined; const sendErrorNode = useMemo( () => @@ -2305,7 +2350,6 @@ function ComposerContentImpl({ const githubEmptyText = githubSearchResultsQuery.isFetching ? t("composer.github.searching") : t("composer.github.noResults"); - const autocompleteVisible = autocomplete.isVisible && mode.showAutocomplete; return ( <> @@ -2319,32 +2363,38 @@ function ComposerContentImpl({ focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction} isMessageInputFocused={isMessageInputFocused} /> - + {/* Input area */} {queueList} - {sendErrorNode} + {!isFullscreen && sendErrorNode} - + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} - + ); } @@ -2421,8 +2471,7 @@ function ComposerContentImpl({ const animatedStaticStyles = RNStyleSheet.create({ container: { flexDirection: "column", - // KeyboardDock reduces the available column height with bottom padding. - // Keep the composer's constraint chain shrinkable down to its scrolling input. + // Propagate the viewport's height constraint down to the scrolling input. flexShrink: 1, position: "relative", }, @@ -2450,7 +2499,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputAreaContent: { flexShrink: 1, width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, gap: theme.spacing[3], }, messageInputContainer: { @@ -2557,6 +2606,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, })) as unknown as Record; +const ThemedAttachmentSpinner = withUnistyles(LoadingSpinner); const ThemedPencil = withUnistyles(Pencil); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedGitPullRequest = withUnistyles(GitPullRequest); @@ -2583,3 +2633,7 @@ const githubIssuePillIcon = ( ); const filePillIcon = ; + +const pendingFilePillIcon = ( + +); diff --git a/packages/app/src/composer/input/fullscreen-host.native.tsx b/packages/app/src/composer/input/fullscreen-host.native.tsx new file mode 100644 index 0000000000..22ce0052bf --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-host.native.tsx @@ -0,0 +1,57 @@ +import { useEffect, type ReactNode } from "react"; +import { Portal } from "@gorhom/portal"; +import { BackHandler, View, useWindowDimensions } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal"; +import { useKeyboardShift } from "@/keyboard/shift"; + +/** Same-window host keeps the IME attached while escaping the dock's clipping. */ +export function ComposerFullscreenHost({ + children, + onExit, + active, +}: { + children: ReactNode; + onExit: () => void; + active: boolean; +}) { + useEffect(() => { + if (!active) return; + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + onExit(); + return true; + }); + return () => handler.remove(); + }, [active, onExit]); + const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); + const { layoutShift } = useKeyboardShift(); + const frame = useAnimatedStyle(() => ({ + height: Math.max(0, height - layoutShift.value), + paddingTop: insets.top, + paddingBottom: insets.bottom, + paddingLeft: insets.left, + paddingRight: insets.right, + })); + return ( + + + {children} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + hidden: { display: "none" }, + background: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: theme.colors.surface1, + }, +})); diff --git a/packages/app/src/composer/input/fullscreen-host.tsx b/packages/app/src/composer/input/fullscreen-host.tsx new file mode 100644 index 0000000000..ba634b268d --- /dev/null +++ b/packages/app/src/composer/input/fullscreen-host.tsx @@ -0,0 +1,77 @@ +import { useMemo, useLayoutEffect, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { View } from "react-native"; +import { + getOverlayRoot, + useOverlayLayer, + OverlayLayerProvider, + useWebOverlayRegistration, +} from "@/lib/overlay-root"; + +export function ComposerFullscreenHost({ + children, + onExit, + active, +}: { + children: ReactNode; + onExit: () => void; + active: boolean; +}) { + const layer = useOverlayLayer("modal"); + const registration = useWebOverlayRegistration({ + active, + layer, + onKeyDown: (event) => { + if (event.key !== "Escape") return false; + event.preventDefault(); + onExit(); + return true; + }, + }); + const [viewport, setViewport] = useState(readVisibleViewport); + useLayoutEffect(() => { + if (!active) return; + const update = () => setViewport(readVisibleViewport()); + const visualViewport = window.visualViewport; + update(); + visualViewport?.addEventListener("resize", update); + visualViewport?.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + visualViewport?.removeEventListener("resize", update); + visualViewport?.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + }; + }, [active]); + const frameStyle = useMemo( + () => ({ + display: active ? ("flex" as const) : ("none" as const), + position: "absolute" as const, + top: viewport.top, + left: viewport.left, + width: viewport.width, + zIndex: layer, + pointerEvents: "auto" as const, + height: viewport.height, + }), + [active, layer, viewport], + ); + return createPortal( + + + {children} + + , + getOverlayRoot(), + ); +} + +function readVisibleViewport() { + const viewport = window.visualViewport; + return { + top: viewport?.offsetTop ?? 0, + left: viewport?.offsetLeft ?? 0, + width: viewport?.width ?? window.innerWidth, + height: viewport?.height ?? window.innerHeight, + }; +} diff --git a/packages/app/src/composer/input/height.native.ts b/packages/app/src/composer/input/height.native.ts index f2bd88cd18..a36839e2e7 100644 --- a/packages/app/src/composer/input/height.native.ts +++ b/packages/app/src/composer/input/height.native.ts @@ -3,10 +3,12 @@ import type { RefObject } from "react"; import type { ComposerHeightResult } from "./height.types"; interface ComposerHeightArgs { - value: string; + getText: () => string; textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } export function useComposerHeight({ diff --git a/packages/app/src/composer/input/height.web.ts b/packages/app/src/composer/input/height.web.ts index 4e3bef363d..6b8c1ec561 100644 --- a/packages/app/src/composer/input/height.web.ts +++ b/packages/app/src/composer/input/height.web.ts @@ -3,10 +3,12 @@ import type { RefObject } from "react"; import type { ComposerHeightResult } from "./height.types"; interface ComposerHeightArgs { - value: string; + getText: () => string; textareaRef: RefObject; minHeight: number; maxHeight: number; + onRenderedLinesChange?: (lines: number) => void; + presentationKey?: boolean; } const COPIED_STYLES = [ @@ -32,15 +34,17 @@ const COPIED_STYLES = [ ] as const; export function useComposerHeight({ - value, + getText, textareaRef, minHeight, maxHeight, + onRenderedLinesChange, + presentationKey, }: ComposerHeightArgs): ComposerHeightResult { const [height, setHeight] = useState(minHeight); const heightRef = useRef(minHeight); - const paramsRef = useRef({ value, minHeight, maxHeight }); - paramsRef.current = { value, minHeight, maxHeight }; + const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange }); + paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange }; const mirrorRef = useRef(null); const setBoundedHeight = useCallback((nextHeight: number) => { @@ -65,6 +69,13 @@ export function useComposerHeight({ } mirror.style.width = `${sourceWidth}px`; mirror.value = text.endsWith("\n") ? `${text} ` : text; + const padding = + parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom); + const lineHeight = parseFloat(computedStyle.lineHeight); + if (lineHeight > 0) + paramsRef.current.onRenderedLinesChange?.( + Math.round((mirror.scrollHeight - padding) / lineHeight), + ); setBoundedHeight(mirror.scrollHeight); }, [setBoundedHeight, textareaRef], @@ -92,7 +103,7 @@ export function useComposerHeight({ }); document.body.appendChild(mirror); mirrorRef.current = mirror; - measure(paramsRef.current.value); + measure(paramsRef.current.getText()); return () => { mirror.remove(); mirrorRef.current = null; @@ -100,8 +111,8 @@ export function useComposerHeight({ }, [measure]); useLayoutEffect(() => { - measure(value); - }, [maxHeight, minHeight, value, measure]); + measure(getText()); + }, [maxHeight, minHeight, getText, measure, presentationKey]); useEffect(() => { const source = textareaRef.current; @@ -111,11 +122,11 @@ export function useComposerHeight({ const nextWidth = source.clientWidth; if (Math.abs(nextWidth - previousWidth) < 1) return; previousWidth = nextWidth; - measure(paramsRef.current.value); + measure(paramsRef.current.getText()); }); observer.observe(source); return () => observer.disconnect(); - }, [measure, textareaRef]); + }, [measure, textareaRef, presentationKey]); const onTextChange = useCallback( (_previousText: string, nextText: string) => measure(nextText), diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc8a260cfb..a0476ff7a7 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -1,3 +1,8 @@ +import { + useComposerEditingSession, + useComposerFullscreen, + type ComposerEditingSession, +} from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -56,6 +61,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { RenderProfile } from "@/utils/render-profiler"; import { useComposerHeight } from "./height"; +import { ComposerInputPresentation } from "./presentation"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { @@ -77,6 +83,7 @@ import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -106,6 +113,8 @@ export interface ComposerKeyPressEvent { } export interface MessageInputProps { + editingSession?: ComposerEditingSession; + submissionError?: React.ReactNode; value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; @@ -206,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent< >; interface TextAreaHandle { + value?: string; scrollHeight?: number; clientHeight?: number; offsetHeight?: number; @@ -215,7 +225,7 @@ interface TextAreaHandle { style?: { height?: string; overflowY?: string; - } & Record; + }; } function AttachButtonIcon({ @@ -433,7 +443,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM } interface PasteImagesEffectArgs { - getWebTextArea: () => TextAreaHandle | null; + textarea: HTMLElement | null; isConnected: boolean; disabled: boolean; isDictating: boolean; @@ -443,7 +453,7 @@ interface PasteImagesEffectArgs { function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { - getWebTextArea, + textarea, isConnected, disabled, isDictating, @@ -454,19 +464,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { useEffect(() => { if (!isWeb || !onAddImages) return; - const textarea = getWebTextArea() as - | (TextAreaHandle & { - addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; - }) - | null; - if ( - !textarea || - typeof textarea.addEventListener !== "function" || - typeof textarea.removeEventListener !== "function" - ) { - return; - } + if (!textarea) return; let disposed = false; const handlePaste = (event: ClipboardEvent) => { @@ -493,14 +491,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; - }, [ - disabled, - getWebTextArea, - isConnected, - isDictating, - isRealtimeVoiceForCurrentAgent, - onAddImages, - ]); + }, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]); } function useAutoFocusOnWebEffect( @@ -628,6 +619,7 @@ function FocusHint({ interface ComposerTextSurfaceProps { readOnly: boolean; value: string; + textReplacementKey: string; textInputRef: React.Ref; textInputStyle: EditingTextInputProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; @@ -643,6 +635,9 @@ interface ComposerTextSurfaceProps { onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteError: (message: string) => void; + fullscreen: boolean; + compact: boolean; + onRenderedLinesChange: (lines: number) => void; focusHintVisible: boolean; focusInputKeys: ShortcutChord | null | undefined; focusHintLabel: string; @@ -654,6 +649,23 @@ interface ComposerTextSurfaceProps { * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + const { onChangeText, onRenderedLinesChange } = props; + const [measurementText, setMeasurementText] = useState(props.value); + useEffect(() => { + if (!isWeb) setMeasurementText(props.value); + }, [props.value, props.textReplacementKey]); + const handleTextChange = useCallback( + (text: string) => { + if (!isWeb) setMeasurementText(text); + onChangeText(text); + }, + [onChangeText, setMeasurementText], + ); + const measureLines = useCallback( + (event: import("react-native").TextLayoutEvent) => + onRenderedLinesChange(event.nativeEvent.lines.length), + [onRenderedLinesChange], + ); if (props.readOnly) { return ( @@ -664,12 +676,28 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen ); } return ( - + + {!isWeb && props.compact && !props.fullscreen ? ( + + {`${measurementText}\u200b`} + + ) : null} Promise | void; toast: { error: (msg: string) => void }; interruptBeforeVoiceMessage: string; + exitFullscreen: () => void; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { @@ -858,6 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } + ctx.exitFullscreen(); void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); @@ -872,6 +902,7 @@ interface StartDictationContext { canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; + exitFullscreen: () => void; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { @@ -882,6 +913,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis if (!ctx.canStartDictation()) { return; } + ctx.exitFullscreen(); await ctx.startDictation(); } @@ -983,7 +1015,8 @@ function computeIsDictationStartEnabled( return (isReadyForDictation ?? isConnected) && !disabled; } -function resolveMaxInputHeight(windowHeight: number): number { +function resolveMaxInputHeight(windowHeight: number, compact: boolean): number { + if (compact) return DEFAULT_MAX_INPUT_HEIGHT; if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } @@ -1007,9 +1040,12 @@ function getComposerInputSnapshot( current: ComposerTextInputHandle | null, fallbackText: string, fallbackSelection: ComposerInputSnapshot["selection"], + mountedTextArea?: TextAreaHandle | null, ): ComposerInputSnapshot { - const text = current?.getText() ?? fallbackText; - const textArea = getWebTextAreaImpl(current); + // The inner DOM ref can detach before the outer editing handle. Keep the mounted + // element available until this owner's ref callback captures the remount handoff. + const textArea = mountedTextArea ?? getWebTextAreaImpl(current); + const text = textArea?.value ?? current?.getText() ?? fallbackText; const start = textArea?.selectionStart ?? fallbackSelection.start; const end = textArea?.selectionEnd ?? fallbackSelection.end; return { text, selection: { start, end } }; @@ -1139,6 +1175,28 @@ function extractErrorMessage(error: unknown): string | null { return null; } +function resolvePresentationSendDisabled(input: { + fullscreen: boolean; + disabled: boolean; + hasText: boolean; + attachments: ComposerAttachment[]; + hasExternalContent: boolean; + allowEmpty: boolean; +}): boolean { + return ( + input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input)) + ); +} + +function shouldShowFullscreenControl( + compact: boolean, + readOnly: boolean, + overlayVisible: boolean, + renderedLines: number, +) { + return compact && !readOnly && !overlayVisible && renderedLines > 2; +} + export const MessageInput = forwardRef( function MessageInput(props, ref) { const { @@ -1186,11 +1244,15 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); + const { isActiveComposer } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); - const maxInputHeight = resolveMaxInputHeight(windowHeight); + const editingSession = useComposerEditingSession(inputMode, props.editingSession); + const isFullscreen = useComposerFullscreen(editingSession); + const [renderedLines, setRenderedLines] = useState(1); + const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); @@ -1207,26 +1269,19 @@ export const MessageInput = forwardRef( const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); - const valueRef = useRef(value); - const selectionRef = useRef({ start: value.length, end: value.length }); - const appliedTextReplacementKeyRef = useRef(textReplacement.key); - const webTextareaRef = useRef(null); - const composerHeight = useComposerHeight({ - value, - textareaRef: webTextareaRef, - minHeight: MIN_INPUT_HEIGHT, - maxHeight: maxInputHeight, - }); - const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; - const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; - const updateComposerHeightForText = measuredComposerHeight?.onTextChange; - const resetComposerHeight = measuredComposerHeight?.reset; - - const handleComposerLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], + const handoff = editingSession.peekHandoff(textReplacement); + const focusIntentRef = useRef(Boolean(handoff?.focused)); + const valueRef = useRef(handoff?.snapshot.text ?? value); + const selectionRef = useRef( + handoff?.snapshot.selection ?? { start: value.length, end: value.length }, ); - + const activeComposerRef = useRef(isActiveComposer); + activeComposerRef.current = isActiveComposer; + const [webTextArea, setWebTextArea] = useState(null); + const appliedTextReplacementKeyRef = useRef(textReplacement.key); + const textReplacementRef = useRef(textReplacement); + textReplacementRef.current = textReplacement; + const webTextareaRef = useRef(null); const updateLiveTextPresence = useCallback((text: string) => { const nextHasLiveText = text.trim().length > 0; if (hasLiveTextRef.current === nextHasLiveText) return; @@ -1234,13 +1289,93 @@ export const MessageInput = forwardRef( setHasLiveText(nextHasLiveText); }, []); + const assignTextInput = useCallback( + (input: ComposerTextInputHandle | null) => { + const previous = textInputRef.current; + if (!input && previous) { + editingSession.captureForRemount({ + snapshot: getComposerInputSnapshot( + previous, + valueRef.current, + selectionRef.current, + webTextareaRef.current, + ), + focused: previous.isFocused() || isInputFocusedRef.current, + replacementKey: appliedTextReplacementKeyRef.current, + }); + } + textInputRef.current = input; + if (isWeb) { + const element = getWebTextAreaImpl(input) as HTMLElement | null; + webTextareaRef.current = element; + setWebTextArea(element); + } + if (!input) return; + const transfer = editingSession.takeHandoff(textReplacementRef.current); + if (transfer) { + appliedTextReplacementKeyRef.current = textReplacementRef.current.key; + valueRef.current = transfer.snapshot.text; + updateLiveTextPresence(transfer.snapshot.text); + selectionRef.current = transfer.snapshot.selection; + input.replaceText(transfer.snapshot.text, transfer.snapshot.selection); + if (transfer.focused && activeComposerRef.current) input.focus(); + } + }, + [editingSession, updateLiveTextPresence], + ); + useLayoutEffect(() => { + if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); + }, [isActiveComposer]); + const getLiveText = useCallback(() => valueRef.current, []); + const composerHeight = useComposerHeight({ + getText: getLiveText, + textareaRef: webTextareaRef, + minHeight: MIN_INPUT_HEIGHT, + maxHeight: maxInputHeight, + onRenderedLinesChange: setRenderedLines, + presentationKey: isFullscreen, + }); + const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight; + const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined; + const updateComposerHeightForText = measuredComposerHeight?.onTextChange; + const resetComposerHeight = measuredComposerHeight?.reset; + + const changePresentation = useCallback( + (fullscreen: boolean) => { + if (editingSession.getFullscreen() === fullscreen) return; + const snapshot = getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ); + editingSession.changePresentation(fullscreen, { + snapshot, + focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current, + replacementKey: appliedTextReplacementKeyRef.current, + }); + valueRef.current = snapshot.text; + selectionRef.current = snapshot.selection; + }, + [editingSession], + ); + const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]); + + const handleComposerLayout = useCallback( + (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), + [onHeightChange], + ); + const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { updateComposerHeightForText?.(valueRef.current, nextText); valueRef.current = nextText; updateLiveTextPresence(nextText); selectionRef.current = selection ?? { start: nextText.length, end: nextText.length }; - textInputRef.current?.replaceText(nextText, selection); + if (nextText === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(nextText, selection); + } onChangeText(nextText); }, [onChangeText, updateComposerHeightForText, updateLiveTextPresence], @@ -1292,7 +1427,11 @@ export const MessageInput = forwardRef( updateComposerHeightForText?.(valueRef.current, textReplacement.text); valueRef.current = textReplacement.text; updateLiveTextPresence(textReplacement.text); - textInputRef.current?.replaceText(textReplacement.text); + if (textReplacement.text === "") { + textInputRef.current?.reset(); + } else { + textInputRef.current?.replaceText(textReplacement.text); + } }, [textReplacement, updateComposerHeightForText, updateLiveTextPresence]); useEffect(() => { @@ -1403,8 +1542,9 @@ export const MessageInput = forwardRef( canStartDictation, toast, startDictation, + exitFullscreen, }), - [canStartDictation, dictationUnavailableMessage, startDictation, toast], + [canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen], ); const handleVoicePress = useCallback( @@ -1476,10 +1616,12 @@ export const MessageInput = forwardRef( handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), + exitFullscreen, }); }, [ disabled, handleStopRealtimeVoice, + exitFullscreen, isAgentRunning, isConnected, t, @@ -1563,10 +1705,10 @@ export const MessageInput = forwardRef( if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } - }, [getWebTextArea]); + }, [getWebTextArea, isFullscreen]); usePasteImagesEffect({ - getWebTextArea, + textarea: webTextArea, isConnected, disabled, isDictating, @@ -1587,16 +1729,30 @@ export const MessageInput = forwardRef( const shouldHandleWebKeyPress = isWeb; const shouldSubmitOnEnter = isWeb && !isCompact; - function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - if (!shouldHandleWebKeyPress) return; - handleDesktopKeyPressImpl(event, { + const handleDesktopKeyPress = useCallback( + (event: WebTextInputKeyPressEvent) => { + if (!shouldHandleWebKeyPress) return; + handleDesktopKeyPressImpl(event, { + onKeyPressCallback, + input: getComposerInputSnapshot( + textInputRef.current, + valueRef.current, + selectionRef.current, + ), + submitOnEnter: shouldSubmitOnEnter, + isAgentRunning, + onQueue, + isSubmitDisabled, + isSubmitLoading, + disabled, + handleAlternateSendAction, + handleDefaultSendAction, + }); + }, + [ + shouldHandleWebKeyPress, onKeyPressCallback, - input: getComposerInputSnapshot( - textInputRef.current, - valueRef.current, - selectionRef.current, - ), - submitOnEnter: shouldSubmitOnEnter, + shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, @@ -1604,8 +1760,8 @@ export const MessageInput = forwardRef( disabled, handleAlternateSendAction, handleDefaultSendAction, - }); - } + ], + ); const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ @@ -1669,16 +1825,18 @@ export const MessageInput = forwardRef( ); const handleInputFocus = useCallback(() => { + focusIntentRef.current = true; isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { + if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [onFocusChange]); + }, [isFullscreen, onFocusChange]); const handlePasteError = useCallback( (message: string) => { @@ -1726,8 +1884,12 @@ export const MessageInput = forwardRef( // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle], - [composerHeightStyle, mode.isMonospace], + () => [ + styles.textInput, + mode.isMonospace && styles.textInputMonospace, + isFullscreen ? styles.fullscreenTextInput : composerHeightStyle, + ], + [composerHeightStyle, mode.isMonospace, isFullscreen], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. @@ -1771,107 +1933,192 @@ export const MessageInput = forwardRef( [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); - return ( - - ( + - {/* Regular input */} - - {attachmentSlot} - {/* Text input */} - - ( + + + + ), + [ + readOnly, + textReplacement, + editingSession, + isActiveComposer, + assignTextInput, + textInputStyle, + readOnlyTextStyle, + placeholder, + t, + mode.accessibilityLabelKey, + handleInputChange, + handleInputFocus, + handleInputBlur, + isDictating, + isRealtimeVoiceForCurrentAgent, + disabled, + isComposerScrollEnabled, + shouldHandleWebKeyPress, + handleDesktopKeyPress, + handleSelectionChange, + onPasteImages, + handlePasteError, + isFullscreen, + isCompact, + isInputFocused, + value, + focusInputKeys, + ], + ); + const toolbar = useMemo( + () => ( + + {/* Toolbar left: attachment button + agent controls */} + + - + {leftContent} + - {/* Button row */} - - {/* Toolbar left: attachment button + agent controls */} - - - {leftContent} - - - {/* Right: voice button, contextual button (realtime/send/cancel) */} - - {beforeVoiceContent} - - {rightContent} - - + {/* Right: voice button, contextual button (realtime/send/cancel) */} + + {beforeVoiceContent} + + {rightContent} + {sendButton} - + ), + [ + mode.showAttachments, + isConnected, + disabled, + attachButtonStyle, + renderAttachButtonIcon, + attachmentMenuItems, + t, + leftContent, + beforeVoiceContent, + mode.showVoice, + handleVoicePress, + isDictationStartEnabled, + voiceButtonAccessibilityLabel, + voiceButtonStyle, + renderVoiceButtonIcon, + voiceTooltipText, + isRealtimeVoiceForCurrentAgent, + voiceMuteToggleKeys, + dictationToggleKeys, + rightContent, + sendButton, + ], + ); + const voiceOverlay = useMemo( + () => ( ( onRealtimeVoiceStop={handleRealtimeVoiceStop} /> + ), + [ + overlayContainerStyle, + surfacePresentation.overlay.pointerEvents, + showDictationOverlay, + showRealtimeOverlay, + voice, + dictationVolume, + dictationDuration, + isDictating, + isDictationProcessing, + dictationStatus, + dictationError, + handleCancelRecording, + handleAcceptRecording, + handleAcceptAndSendRecording, + handleRetryFailedRecording, + handleDiscardFailedRecording, + handleRealtimeVoiceStop, + ], + ); + const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]); + return ( + + + ); }, @@ -1912,10 +2215,15 @@ const styles = StyleSheet.create((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius["2xl"], - paddingVertical: { + paddingTop: { xs: theme.spacing[2], md: theme.spacing[4], }, + // The button row bleeds 6px horizontally, so match its corner inset at the bottom. + paddingBottom: { + xs: theme.spacing[2], + md: theme.spacing[3], + }, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[4], @@ -1933,6 +2241,19 @@ const styles = StyleSheet.create((theme: Theme) => ({ inputWrapperReadOnly: { borderStyle: "dotted", }, + compactTextSurface: { paddingRight: 36 }, + fullscreenTextSurface: { flex: 1, minHeight: 0 }, + fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" }, + lineMeasurement: { + position: "absolute", + top: 0, + left: 0, + right: 36, + opacity: 0, + maxHeight: undefined, + minHeight: undefined, + height: undefined, + }, textInputScrollWrapper: { flexShrink: 1, position: "relative", @@ -1952,9 +2273,13 @@ const styles = StyleSheet.create((theme: Theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.content, fontWeight: theme.fontWeight.normal, - lineHeight: theme.fontSize.content * 1.4, + // No lineHeight on native. React Native applies it as a span over the text, and an + // empty trailing line is laid out from the font's own metrics on some devices, so + // the input jumps when the first character lands on a new line. The font's natural + // line box is the same for every line. Web keeps the CSS value. ...(isWeb ? ({ + lineHeight: theme.fontSize.content * 1.4, outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent", diff --git a/packages/app/src/composer/input/presentation.tsx b/packages/app/src/composer/input/presentation.tsx new file mode 100644 index 0000000000..d02821696a --- /dev/null +++ b/packages/app/src/composer/input/presentation.tsx @@ -0,0 +1,146 @@ +import { useCallback, useMemo, type ReactNode, type Ref } from "react"; +import { View, type ViewProps } from "react-native"; +import { Maximize2, Minimize2 } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerFullscreenHost } from "./fullscreen-host"; + +function keepEditorFocused(event: import("react-native").PointerEvent) { + if (isWeb) event.preventDefault(); +} + +/** Both presentations receive the same editing surface and send capability. */ +export function ComposerInputPresentation({ + fullscreen, + iconSize, + active, + surface, + toolbar, + send, + error, + onExit, + inputWrapperRef, + inputWrapperStyle, + inputPointerEvents, + attachmentSlot, + showFullscreenButton, + onEnter, + overlay, +}: { + fullscreen: boolean; + iconSize: number; + active: boolean; + surface: ReactNode; + toolbar: ReactNode; + send: ReactNode; + error: ReactNode; + onExit: () => void; + inputWrapperRef: Ref; + inputWrapperStyle: ViewProps["style"]; + inputPointerEvents: ViewProps["pointerEvents"]; + attachmentSlot: ReactNode; + showFullscreenButton: boolean; + onEnter: () => void; + overlay: ReactNode; +}) { + const { t } = useTranslation(); + // ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox. + // Compensate for that difference to align visible ink with Send, not just boxes. + const railAdjustment = iconSize / 12; + const maximizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + const minimizeIcon = useCallback( + (color: string) => , + [iconSize], + ); + const enterStyle = useMemo( + () => [styles.fullscreenButton, { right: -6 + railAdjustment }], + [railAdjustment], + ); + const exitStyle = useMemo( + () => [styles.iconButton, { marginRight: railAdjustment }], + [railAdjustment], + ); + if (!fullscreen) + return ( + <> + + {attachmentSlot} + + {surface} + {showFullscreenButton ? ( + + ) : null} + + {toolbar} + + {overlay} + + ); + return ( + + + + + + {surface} + {error} + {send} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + inlineTextSurface: { flexShrink: 1, position: "relative" }, + iconButton: { + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreenButton: { + position: "absolute", + top: 0, + width: 28, + height: 28, + padding: 0, + borderWidth: 0, + borderRadius: theme.borderRadius.full, + }, + fullscreen: { + flex: 1, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[4], + gap: theme.spacing[3], + }, + exitRow: { alignItems: "flex-end", flexShrink: 0 }, + sendRow: { flexShrink: 0, alignItems: "flex-end" }, +})); diff --git a/packages/app/src/composer/input/state.test.ts b/packages/app/src/composer/input/state.test.ts index 5503ea1512..a7f4113b28 100644 --- a/packages/app/src/composer/input/state.test.ts +++ b/packages/app/src/composer/input/state.test.ts @@ -4,6 +4,7 @@ import { computeCanStartDictation, resolveActiveSendBehavior, resolveComposerSurfacePresentation, + resolveComposerSurfaceText, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, @@ -316,3 +317,26 @@ describe("stopRealtimeVoice", () => { expect(calls).toEqual(["cancel agent", "stop voice"]); }); }); + +describe("composer surface text", () => { + it("measures a new nonempty replacement before it reaches the editing handle", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe( + replacement.text, + ); + }); + + it("keeps live typing once the replacement is applied", () => { + const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" }; + expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe( + "Edited live draft", + ); + }); + + it("uses the live handoff when its replacement has already been applied", () => { + const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" }; + expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe( + "Live remount handoff", + ); + }); +}); diff --git a/packages/app/src/composer/input/state.ts b/packages/app/src/composer/input/state.ts index a63aeb02dc..3786613eb2 100644 --- a/packages/app/src/composer/input/state.ts +++ b/packages/app/src/composer/input/state.ts @@ -1,6 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; -import type { MessagePayload } from "@/composer/types"; +import type { MessagePayload, TextReplacement } from "@/composer/types"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; export type SendBehavior = ActiveTurnBehavior | "queue"; @@ -192,3 +192,11 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise< await ctx.voice.stopVoice(); } + +export function resolveComposerSurfaceText( + liveText: string, + replacement: TextReplacement, + appliedReplacementKey: string, +): string { + return replacement.key === appliedReplacementKey ? liveText : replacement.text; +} diff --git a/packages/app/src/composer/input/text-input.web.browser.test.tsx b/packages/app/src/composer/input/text-input.web.browser.test.tsx index 91dec513e6..29df20012c 100644 --- a/packages/app/src/composer/input/text-input.web.browser.test.tsx +++ b/packages/app/src/composer/input/text-input.web.browser.test.tsx @@ -82,6 +82,49 @@ afterEach(() => { }); describe("ComposerTextInput web IME composition", () => { + it("resets the composer text while preserving focus", () => { + const mounted = mountInput(ignoreTextChange); + act(() => { + mounted.inputRef.current?.focus(); + typeFromIme(mounted.textarea, "line one\nline two"); + }); + + act(() => mounted.inputRef.current?.reset()); + + expect(mounted.textarea.value).toBe(""); + expect(mounted.inputRef.current?.getText()).toBe(""); + expect(document.activeElement).toBe(mounted.textarea); + }); + + it("keeps its editing handle and selection across a rerender during composition", () => { + const recorder = createTextRecorder(); + const mounted = mountInput(recorder.onChangeText); + const handle = mounted.inputRef.current; + act(() => { + handle?.focus(); + dispatchComposition(mounted.textarea, "compositionstart"); + typeFromIme(mounted.textarea, "你好"); + mounted.textarea.setSelectionRange(1, 2); + mounted.root.render( + , + ); + }); + expect(mounted.inputRef.current).toBe(handle); + expect(handle?.getNativeRef()).toBe(mounted.textarea); + expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]); + expect(document.activeElement).toBe(mounted.textarea); + expect(recorder.changes).toEqual([]); + act(() => dispatchComposition(mounted.textarea, "compositionend")); + expect(recorder.changes).toEqual(["你好"]); + expect(handle?.getText()).toBe("你好"); + }); + it("keeps locally typed text when its parent rerenders with a stale value", () => { const recorder = createTextRecorder(); const mounted = mountInput(recorder.onChangeText); diff --git a/packages/app/src/composer/text-source.ts b/packages/app/src/composer/text-source.ts new file mode 100644 index 0000000000..9046fd5744 --- /dev/null +++ b/packages/app/src/composer/text-source.ts @@ -0,0 +1,5 @@ +/** Live text is read by editing consumers; containers subscribe only to what they display. */ +export interface ComposerTextSource { + getSnapshot: () => string; + subscribe: (listener: () => void) => () => void; +} diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 3ff9e765c6..e33cd6ba53 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -11,7 +11,6 @@ import { } from "@/components/ui/menu"; import { StatusRing } from "@/components/status-ring"; import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; @@ -328,7 +327,7 @@ const styles = StyleSheet.create((theme) => { }, track: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 2f03b7e89a..ee01144ed7 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -10,6 +10,7 @@ export interface MessagePayload { } export interface TextReplacement { + kind: "initial" | "replace"; key: string; text: string; } diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 3e159041e2..9e9a15f6b4 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -11,8 +11,6 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56; export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36; export const HEADER_TOP_PADDING_MOBILE = 8; -// Max width for chat content (stream view, input area, new agent form) -export const MAX_CONTENT_WIDTH = 820; export const COMPACT_FORM_FACTOR_WIDTH = 500; // Settings uses the canonical desktop list + detail layout. Its sidebar and @@ -35,13 +33,18 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; +/** Compact layouts are the breakpoints below `md`. */ +export function isCompactBreakpoint(breakpoint: string | undefined): boolean { + return breakpoint === "xs" || breakpoint === "sm"; +} + /** * Reactive hook — re-renders the component when the breakpoint changes. * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactBreakpoint(rt.breakpoint); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 475eb26f27..430e4ade74 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,6 +1,7 @@ import { useRef, ReactNode, useCallback, useEffect } from "react"; import { Buffer } from "buffer"; import { AppState } from "react-native"; +import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useClientActivity } from "@/hooks/use-client-activity"; @@ -14,13 +15,8 @@ import type { StreamItem } from "@/types/stream"; import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; -import { - consumeForcedTimelineTailReplacement, - type TimelineDeliveryMode, - type TimelineResponsePayload, - type ViewedTimelineOwner, -} from "@/timeline/viewed-timeline-sync"; -import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { type ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, @@ -30,14 +26,13 @@ import { } from "@getpaseo/protocol/agent-attention-notification"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; -import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; +import type { AudioPlaybackSource } from "@/audio"; import { selectAgentTimelineState, + toDaemonServerInfo, useSessionStore, type SessionState, } from "@/stores/session-store"; @@ -50,15 +45,12 @@ import { createInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; -import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import type { AttachmentMetadata } from "@/attachments/types"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; -import { useProviderSubagentStore } from "@/subagents/provider-store"; -import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; +import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -81,11 +73,6 @@ interface BufferedAudioChunk { id: string; } -// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. -function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode { - return selectiveAgentTimeline ? "selective" : "legacy"; -} - function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } @@ -231,11 +218,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); - const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const setAgents = useSessionStore((state) => state.setAgents); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); @@ -253,7 +237,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const forcedTimelineTailReplacements = useRef(new Set()); const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); @@ -273,23 +256,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current?.setActive(isAppVisible); }, [isAppVisible]); - const handleAppResumed = useCallback( - (awayMs: number) => { - void revalidateSessionAfterResume({ - awayMs, - serverId, - bumpHistorySyncGeneration, - }); - }, - [bumpHistorySyncGeneration, serverId], - ); - // Client activity tracking (heartbeat, push token registration) useClientActivity({ client, focusedAgentId, focusedTerminalId, - onAppResumed: handleAppResumed, }); useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]); @@ -348,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId], ); - useEffect(() => { - const serverInfo = client.getLastServerInfoMessage(); - if (!serverInfo) { - return; - } - - updateSessionServerInfo(serverId, { - serverId: serverInfo.serverId, - hostname: serverInfo.hostname, - version: serverInfo.version, - ...(serverInfo.desktopManaged !== undefined - ? { desktopManaged: serverInfo.desktopManaged } - : {}), - ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), - ...(serverInfo.features ? { features: serverInfo.features } : {}), - }); - }, [client, serverId, updateSessionServerInfo]); - useEffect(() => { const unregister = voiceRuntime?.registerSession({ serverId, @@ -412,15 +365,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - useEffect( - () => - client.subscribeConnectionStatus((connection) => { - if (connection.status === "connected") return; - clearAgentTurnLiveness(serverId); - }), - [clearAgentTurnLiveness, client, serverId], - ); - const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -428,24 +372,62 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); - const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { - const payload = consumeForcedTimelineTailReplacement( - receivedPayload, - forcedTimelineTailReplacements.current, - ); - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - owner.applyTimelineResponse(payload); - }, []); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); - const initialDeliveryMode = getTimelineDeliveryMode( - client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, - ); + const onStream = (message: SessionOutboundMessage) => { + if (message.type !== "agent_stream") return; + const { agentId, event, timestamp, seq, epoch } = message.payload; + const parsedTimestamp = new Date(timestamp); + const streamEvent = event; + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + voiceRuntime?.onTurnEvent(serverId, agentId, event.type); + } + const turnLiveness = deriveAgentStreamTurnLiveness([ + { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, + ]); + if (turnLiveness.length > 0) { + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); + } + sync.enqueueStreamEvent(agentId, { + event: streamEvent, + seq, + epoch, + timestamp: parsedTimestamp, + }); + + // NOTE: We don't update lastActivityAt on every stream event to prevent + // cascading rerenders. The agent_update handler updates agent.lastActivityAt + // on status changes, which is sufficient for sorting and display purposes. + }; + const onReplacement = (message: SessionOutboundMessage) => { + if (message.type !== "agent.timeline.replacement") return; + void requestTimelineReplacement( + { + fetchAgentTimeline: (agentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), + }, + message.payload.agentId, + ).catch((error: unknown) => { + console.warn("[Session] timeline replacement refresh failed", { serverId, error }); + }); + }; const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { - initialDeliveryMode, - setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), + observe: (agentIds) => { + const observation = client.observeTimeline(agentIds); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_stream") onStream(message); + else if (message.type === "agent.timeline.replacement") onReplacement(message); + }, + }); + return observation; + }, readCursor: (agentId) => { const timeline = selectAgentTimelineState( useSessionStore.getState().sessions[serverId], @@ -485,18 +467,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider throw error; } }, - fetchLatestTail: async (agentId) => { - forcedTimelineTailReplacements.current.add(agentId); - try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); - } finally { - forcedTimelineTailReplacements.current.delete(agentId); - } - }, + fetchLatestTail: (agentId) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()), reportError: (error) => { console.warn("[Session] viewed timeline synchronization failed", { serverId, error }); }, @@ -508,15 +480,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current = sync; setViewedTimelineSync(serverId, sync); sync.setActive(getIsAppVisible(appStateRef.current)); + const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) => + sync.replaceOpenTabAgentIds(agentIds), + ); return () => { + stopObservingOpenChats(); if (viewedTimelineSyncRef.current === sync) { viewedTimelineSyncRef.current = null; } setViewedTimelineSync(serverId, null); sync.dispose(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -524,113 +500,68 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Daemon message handlers - directly update Zustand store useEffect(() => { - const owner = viewedTimelineSyncRef.current; - if (!owner) throw new Error("Viewed timeline owner is unavailable"); - - const unsubAgentStream = client.on("agent_stream", (message) => { - if (message.type !== "agent_stream") return; - const { agentId, event, timestamp, seq, epoch } = message.payload; - const parsedTimestamp = new Date(timestamp); - const streamEvent = event; - if ( - event.type === "turn_started" || - event.type === "turn_completed" || - event.type === "turn_failed" || - event.type === "turn_canceled" - ) { - voiceRuntime?.onTurnEvent(serverId, agentId, event.type); - } - const turnLiveness = deriveAgentStreamTurnLiveness([ - { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, - ]); - if (turnLiveness.length > 0) { - applyAgentTurnLiveness(serverId, agentId, turnLiveness); - } - owner.enqueueStreamEvent(agentId, { - event: streamEvent, - seq, - epoch, - timestamp: parsedTimestamp, + const feeds = client.observeEvents( + [ + "agent_attention_required", + "terminal_attention_required", + "agent_permission_request", + "agent_permission_resolved", + "agent.provider_subagents.update", + "checkout_status_update", + "workspace_setup_progress", + "status.server_info", + ], + { notifications: true }, + ); + const onFeed = ( + type: SessionOutboundMessage["type"], + handler: (message: SessionOutboundMessage) => void, + ) => + feeds.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === type) handler(message); + }, }); - // NOTE: We don't update lastActivityAt on every stream event to prevent - // cascading rerenders. The agent_update handler updates agent.lastActivityAt - // on status changes, which is sufficient for sorting and display purposes. - }); - - const unsubAgentAttention = client.onAgentAttentionRequired((notification) => { + const unsubAgentAttention = onFeed("agent_attention_required", (message) => { + if (message.type !== "agent_attention_required") return; + const notification = message.payload; if (notification.shouldNotify) { notifyAgentAttention(notification); } }); - const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { - if (message.type !== "fetch_agent_timeline_response") return; - owner.flushStreamAgent(message.payload.agentId); - applyTimelineResponse(message.payload); + const unsubProviderSubagentUpdate = feeds.subscribe({ + snapshot: () => { + void resyncProviderSubagents(client, serverId); + }, + update: (message) => { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }, }); - const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => { - if (message.type !== "agent.timeline.replacement") return; - void requestTimelineReplacement( - { - fetchAgentTimeline: (agentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), - }, - message.payload.agentId, - ).catch((error: unknown) => { - console.warn("[Session] timeline replacement refresh failed", { serverId, error }); - }); - }); - - const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => { - if (message.type !== "agent.provider_subagents.update") return; - useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); - }); - - const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => { + const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => { if (message.type !== "checkout_status_update") return; applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); }); - const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => { if (message.type !== "workspace_setup_progress") return; applyWorkspaceSetupProgress(message.payload); }); - const unsubWorkspaceSetupStatusResponse = client.on( - "workspace_setup_status_response", - (message) => { - if (message.type !== "workspace_setup_status_response") return; - const { workspaceId, snapshot } = message.payload; - if (snapshot) { - applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); - } - }, - ); - - const unsubStatus = client.on("status", (message) => { + const unsubStatus = onFeed("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); if (serverInfo) { - viewedTimelineSyncRef.current?.setDeliveryMode( - getTimelineDeliveryMode(serverInfo.features?.selectiveAgentTimeline), - ); - updateSessionServerInfo(serverId, { - serverId: serverInfo.serverId, - hostname: serverInfo.hostname, - version: serverInfo.version, - ...(serverInfo.desktopManaged !== undefined - ? { desktopManaged: serverInfo.desktopManaged } - : {}), - ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), - ...(serverInfo.features ? { features: serverInfo.features } : {}), - }); + updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo)); return; } }); - const unsubPermissionRequest = client.on("agent_permission_request", (message) => { + const unsubPermissionRequest = onFeed("agent_permission_request", (message) => { if (message.type !== "agent_permission_request") return; const { agentId, request } = message.payload; @@ -642,7 +573,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }); - const unsubPermissionResolved = client.on("agent_permission_resolved", (message) => { + const unsubPermissionResolved = onFeed("agent_permission_resolved", (message) => { if (message.type !== "agent_permission_resolved") return; const { requestId, agentId } = message.payload; @@ -751,7 +682,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); - const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { + const unsubTerminalAttention = onFeed("terminal_attention_required", (message) => { if (message.type !== "terminal_attention_required") { return; } @@ -773,14 +704,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); return () => { - unsubTimelineReplacement(); - unsubAgentStream(); - unsubAgentTimeline(); + void feeds + .release() + .catch((error) => console.warn("[Session] Failed to release feeds", error)); unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); - unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -797,14 +727,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setIsPlayingAudio, setAgentStreamTail, setAgentStreamHead, - applyAgentTurnLiveness, clearAgentStreamHead, setInitializingAgents, setAgents, setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, - applyTimelineResponse, updateSessionServerInfo, toast, voiceRuntime, @@ -863,48 +791,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [client], ); - const _createAgent = useCallback( - async ({ - config, - initialPrompt, - images, - attachments, - git, - worktreeName, - requestId, - }: { - config: AgentSessionConfig; - initialPrompt: string; - images?: AttachmentMetadata[]; - attachments?: AgentAttachment[]; - git?: GitSetupOptions; - worktreeName?: string; - requestId?: string; - }) => { - if (!client) { - console.warn("[Session] createAgent skipped: daemon unavailable"); - return; - } - const trimmedPrompt = initialPrompt.trim(); - let imagesData: Array<{ data: string; mimeType: string }> | undefined; - try { - imagesData = await encodeImages(images); - } catch (error) { - console.error("[Session] Failed to prepare images for agent creation:", error); - } - await client.createAgent({ - config, - ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - ...(git ? { git } : {}), - ...(worktreeName ? { worktreeName } : {}), - ...(requestId ? { requestId } : {}), - }); - }, - [client], - ); - const _setAgentMode = useCallback( (agentId: string, modeId: string) => { if (!client) { diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts deleted file mode 100644 index 4c32bcf115..0000000000 --- a/packages/app/src/contexts/session-resume-revalidation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - revalidateSessionAfterResume, - SESSION_STALE_AFTER_MS, -} from "./session-resume-revalidation"; - -describe("session resume revalidation", () => { - it("refreshes demanded timeline history without manufacturing directory demand", async () => { - const calls: string[] = []; - - const revalidated = await revalidateSessionAfterResume({ - awayMs: SESSION_STALE_AFTER_MS, - serverId: "server", - bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), - }); - - expect(revalidated).toBe(true); - expect(calls).toEqual(["history:server"]); - }); - - it("does nothing after a brief background interval", async () => { - const calls: string[] = []; - - const revalidated = await revalidateSessionAfterResume({ - awayMs: SESSION_STALE_AFTER_MS - 1, - serverId: "server", - bumpHistorySyncGeneration: () => calls.push("history"), - }); - - expect(revalidated).toBe(false); - expect(calls).toEqual([]); - }); -}); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts deleted file mode 100644 index 8766ce4cc7..0000000000 --- a/packages/app/src/contexts/session-resume-revalidation.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const SESSION_STALE_AFTER_MS = 60_000; - -export async function revalidateSessionAfterResume(input: { - awayMs: number; - serverId: string; - bumpHistorySyncGeneration: (serverId: string) => void; -}): Promise { - if (input.awayMs < SESSION_STALE_AFTER_MS) { - return false; - } - - input.bumpHistorySyncGeneration(input.serverId); - return true; -} diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index 346127b1ff..dcd150d764 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,7 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 7bb0b9b7e1..4c8d04e2f1 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -9,8 +9,8 @@ import { } from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { useSessionStore } from "@/stores/session-store"; -import { createAudioEngine } from "@/voice/audio-engine"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import { createAudioEngine } from "@/audio"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceRuntime, diff --git a/packages/app/src/data/provider-icons.ts b/packages/app/src/data/provider-icons.ts new file mode 100644 index 0000000000..9556be7ab2 --- /dev/null +++ b/packages/app/src/data/provider-icons.ts @@ -0,0 +1,17 @@ +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { createStore } from "zustand/vanilla"; + +export const providerSnapshotIcons = createStore>>( + () => new Map(), +); + +export function replaceProviderSnapshotIcons( + serverId: string, + entries: readonly Pick[], +): void { + const icons = new Map(); + for (const entry of entries) { + if (entry.iconSvg) icons.set(entry.provider, entry.iconSvg); + } + providerSnapshotIcons.setState((previous) => new Map(previous).set(serverId, icons), true); +} diff --git a/packages/app/src/data/provider-snapshot-cache.test.ts b/packages/app/src/data/provider-snapshot-cache.test.ts index f92e7e9bf9..424657e3fa 100644 --- a/packages/app/src/data/provider-snapshot-cache.test.ts +++ b/packages/app/src/data/provider-snapshot-cache.test.ts @@ -2,16 +2,10 @@ import { describe, expect, it } from "vitest"; import { Buffer } from "buffer"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -import { z } from "zod"; import { createProviderSnapshotCache, type ProviderSnapshotCache } from "./provider-snapshot-cache"; -const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v1:"; -const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const SnapshotIndexSchema = z.object({ - version: z.literal(1), - entries: z.array(z.object({ key: z.string(), bytes: z.number(), writtenAt: z.string() })), -}); - +const SNAPSHOT_KEY_PREFIX = "@paseo/provider-snapshot/v2:"; +const SNAPSHOT_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; function createStorage(maxSnapshotBytes = Number.POSITIVE_INFINITY) { const values = new Map(); const stats = { getAllKeysCalls: 0 }; @@ -109,21 +103,17 @@ function snapshotBytes(values: Map): number { ); } -function expectIndexMatchesSnapshots(values: Map): void { - const storedIndex = values.get(SNAPSHOT_INDEX_KEY); - if (!storedIndex) throw new Error("Expected provider snapshot index"); - const index = SnapshotIndexSchema.parse(JSON.parse(storedIndex)); - const storedSnapshots = [...values] - .filter(([key]) => key.startsWith(SNAPSHOT_KEY_PREFIX)) - .map(([key, value]) => ({ - key, - bytes: Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"), - })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - const indexedSnapshots = index.entries - .map(({ key, bytes }) => ({ key, bytes })) - .toSorted((left, right) => left.key.localeCompare(right.key)); - expect(indexedSnapshots).toEqual(storedSnapshots); +function expectConsistentSnapshots(values: Map): void { + expect(values.has(SNAPSHOT_INDEX_KEY)).toBe(false); + for (const [key, value] of values) { + if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) continue; + const [serverId, kind] = JSON.parse(key.slice(SNAPSHOT_KEY_PREFIX.length)); + if (kind !== "cwd") continue; + const { hash } = JSON.parse(value); + expect(values.has(`${SNAPSHOT_KEY_PREFIX}${JSON.stringify([serverId, "hash", hash])}`)).toBe( + true, + ); + } } function writeSnapshot( @@ -140,6 +130,131 @@ function writeSnapshot( } describe("provider snapshot cache", () => { + it("normalizes duplicate model identities in live, legacy and persisted catalogs", async () => { + const storage = createStorage(); + const entries = snapshotEntries("shared"); + const first = entries[0].models![0]; + entries[0].models!.push({ ...first, label: "Duplicate" }); + const compactSnapshot = compactProviderSnapshot(entries); + const payload = { requestId: "catalog", generatedAt: "2026-09-01T00:00:00.000Z", entries }; + const cache = createProviderSnapshotCache(storage); + const legacy = await cache.materialize("server-1", payload); + expect(legacy.entries[0].models).toEqual([first]); + const live = await cache.materialize("server-1", { + ...payload, + snapshotHash: "old-hash", + compactSnapshot, + }); + expect(live.entries[0].models).toEqual([first]); + expect(live.compactSnapshot).toEqual(compactSnapshot); + await cache.write({ + serverId: "server-1", + cwd: "/repo", + hash: "old-hash", + generatedAt: payload.generatedAt, + compactSnapshot, + }); + const restarted = createProviderSnapshotCache(storage); + const saved = await restarted.read("server-1", "/repo"); + expect(saved?.entries[0].models).toEqual([first]); + const unchanged = await restarted.materialize("server-1", { + ...payload, + entries: [], + snapshotHash: "old-hash", + notModified: true, + }); + expect(unchanged.entries[0].models).toEqual([first]); + expect(unchanged.snapshotHash).toBe("old-hash"); + }); + + it("removes externally lost body references and retains independent orphan bodies on reconciliation", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + await writeSnapshot(cache, { + cwd: "/lost", + label: "lost", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/orphan", + label: "orphan", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","hash","lost"]`); + storage.values.delete(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/orphan"]`); + + await expect(cache.read("server-1", "/lost")).resolves.toBeNull(); + expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/lost"]`)).toBe(false); + const restarted = createProviderSnapshotCache(storage); + await expect(restarted.readHash("server-1", "orphan")).resolves.not.toBeNull(); + expectConsistentSnapshots(storage.values); + }); + + it.each(["live", "restart", "late association"])( + "retains recently reused bodies under pressure (%s)", + async (variant) => { + const storage = createStorage(); + const seed = async (cache: ProviderSnapshotCache) => { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + await writeSnapshot(cache, { + cwd: "/b", + label: "bbb", + generatedAt: "2026-09-02T00:00:00.000Z", + }); + }; + await seed(createProviderSnapshotCache(storage)); + const maxBytes = snapshotBytes(storage.values); + let cache = createProviderSnapshotCache(storage, { maxBytes }); + await writeSnapshot(cache, { + cwd: "/c", + label: "aaa", + generatedAt: "2026-09-03T00:00:00.000Z", + }); + if (variant === "restart") cache = createProviderSnapshotCache(storage, { maxBytes }); + if (variant === "late association") { + await writeSnapshot(cache, { + cwd: "/a", + label: "aaa", + generatedAt: "2026-09-01T00:00:00.000Z", + }); + } + await writeSnapshot(cache, { + cwd: "/d", + label: "ccc", + generatedAt: "2026-09-04T00:00:00.000Z", + }); + + await expect(cache.read("server-1", "/c")).resolves.toMatchObject({ hash: "aaa" }); + await expect(cache.read("server-1", "/d")).resolves.toMatchObject({ hash: "ccc" }); + await expect(cache.readHash("server-1", "bbb")).resolves.toBeNull(); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + }, + ); + + it("keeps a reusable body when its directory association cannot fit beside it", async () => { + const input = { cwd: "/repo", label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }; + const stagingStorage = createStorage(); + await writeSnapshot(createProviderSnapshotCache(stagingStorage), input); + const maxBytes = snapshotBytes(stagingStorage.values) - 1; + const storage = createStorage(maxBytes); + const cache = createProviderSnapshotCache(storage, { maxBytes }); + + await writeSnapshot(cache, input); + + await expect(cache.readHash("server-1", "shared")).resolves.not.toBeNull(); + await expect(cache.read("server-1", "/repo")).resolves.toBeNull(); + expect([...storage.values.keys()].filter((key) => key.includes('"cwd"'))).toEqual([]); + expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); + expectConsistentSnapshots(storage.values); + const restarted = createProviderSnapshotCache(storage, { maxBytes }); + await expect(restarted.readHash("server-1", "shared")).resolves.not.toBeNull(); + }); + it("round-trips compact snapshots with shared thinking option references", async () => { const thinkingOptions = [{ id: "high", label: "High", isDefault: true }]; const entries: ProviderSnapshotEntry[] = [ @@ -217,7 +332,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/oldest")).resolves.toBeNull(); await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -236,7 +351,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("clears unindexed snapshots on the first read", async () => { @@ -247,7 +362,7 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/legacy")).resolves.toBeNull(); expect(storage.values.has('@paseo/provider-snapshot/v1:["server-1","/legacy"]')).toBe(false); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -266,7 +381,7 @@ describe("provider snapshot cache", () => { ]); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -292,8 +407,8 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); expect( [...storage.values.keys()].filter((key) => key.startsWith(SNAPSHOT_KEY_PREFIX)), - ).toHaveLength(1); - expectIndexMatchesSnapshots(storage.values); + ).toHaveLength(2); + expectConsistentSnapshots(storage.values); }); it("removes the previous value when a replacement exceeds the entire budget", async () => { @@ -314,10 +429,10 @@ describe("provider snapshot cache", () => { await expect(tinyCache.read("server-1", "/repo")).resolves.toBeNull(); expect(snapshotBytes(storage.values)).toBe(0); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); - it("resets corrupted index state before accepting another snapshot", async () => { + it("removes obsolete index data before accepting another snapshot", async () => { const storage = createStorage(); storage.values.set(SNAPSHOT_INDEX_KEY, "corrupt"); storage.values.set(`${SNAPSHOT_KEY_PREFIX}legacy`, "legacy"); @@ -331,7 +446,7 @@ describe("provider snapshot cache", () => { expect(storage.values.has(`${SNAPSHOT_KEY_PREFIX}legacy`)).toBe(false); await expect(cache.read("server-1", "/current")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -361,7 +476,7 @@ describe("provider snapshot cache", () => { }); await expect(cache.read("server-1", "/emoji")).resolves.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it("keeps hundreds of concurrent writes bounded without rescanning snapshots", async () => { @@ -380,7 +495,7 @@ describe("provider snapshot cache", () => { expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); await expect(cache.read("server-1", "/repo-249")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); expect(storage.stats.getAllKeysCalls).toBe(1); }); @@ -394,7 +509,7 @@ describe("provider snapshot cache", () => { label: "stable", generatedAt: "2026-08-01T00:00:00.000Z", }); - const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","/failed"]`; + const failedKey = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/failed"]`; storage.failNext("setItem", timing, failedKey); await writeSnapshot(cache, { @@ -412,34 +527,7 @@ describe("provider snapshot cache", () => { await expect(restarted.read("server-1", "/stable")).resolves.not.toBeNull(); await expect(restarted.read("server-1", "/next")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(2_000); - expectIndexMatchesSnapshots(storage.values); - }, - ); - - it.each(["before", "after"] as const)( - "reconciles snapshot records when the derived index write fails %s its side effect", - async (timing) => { - const storage = createStorage(); - const cache = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await writeSnapshot(cache, { - cwd: "/stable", - label: "stable", - generatedAt: "2026-08-01T00:00:00.000Z", - }); - storage.failNext("setItem", timing, SNAPSHOT_INDEX_KEY); - - await writeSnapshot(cache, { - cwd: "/orphan-after-index-failure", - label: "orphan", - generatedAt: "2026-08-02T00:00:00.000Z", - }); - - const restarted = createProviderSnapshotCache(storage, { maxBytes: 2_000 }); - await expect( - restarted.read("server-1", "/orphan-after-index-failure"), - ).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); - expect(storage.stats.getAllKeysCalls).toBe(2); + expectConsistentSnapshots(storage.values); }, ); @@ -481,7 +569,7 @@ describe("provider snapshot cache", () => { expect(storage.values.get("@paseo/unrelated")).toBe("keep-me"); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); await expect(restarted.read("server-1", "/final")).resolves.not.toBeNull(); }); @@ -508,14 +596,14 @@ describe("provider snapshot cache", () => { await expect(cache.read("server-1", "/newest")).resolves.not.toBeNull(); expect(snapshotBytes(storage.values)).toBeLessThanOrEqual(maxBytes); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }); it.each(["before", "after"] as const)( "recovers when invalid-record cleanup fails %s deletion", async (timing) => { const storage = createStorage(); - const key = `${SNAPSHOT_KEY_PREFIX}["server-1","/invalid"]`; + const key = `${SNAPSHOT_KEY_PREFIX}["server-1","cwd","/invalid"]`; storage.values.set(key, "invalid"); storage.failNext("multiRemove", timing); const cache = createProviderSnapshotCache(storage); @@ -529,7 +617,165 @@ describe("provider snapshot cache", () => { expect(storage.values.has(key)).toBe(false); await expect(cache.read("server-1", "/recovered")).resolves.not.toBeNull(); - expectIndexMatchesSnapshots(storage.values); + expectConsistentSnapshots(storage.values); }, ); }); + +it("stores and expands identical content once across directory associations", async () => { + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + for (const cwd of ["/a", "/b", "/c"]) { + await writeSnapshot(cache, { cwd, label: "shared", generatedAt: "2026-09-06T12:00:00.000Z" }); + } + const a = await cache.read("server-1", "/a"); + const b = await cache.read("server-1", "/b"); + expect(a?.entries[0]?.models).toBe(b?.entries[0]?.models); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); +}); + +it("coalesces missing bodies and keeps a newer pushed association when an older pull completes", async () => { + const { QueryClient } = await import("@tanstack/react-query"); + const { applyProvidersSnapshotUpdate } = await import("./push-router"); + const { fetchProvidersSnapshot, providersSnapshotQueryKey } = + await import("./providers-snapshot"); + const storage = createStorage(); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const serverId = "shared-host"; + const snapshot = (hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => ({ + entries: [], + snapshotHash: hash, + compactSnapshot: compactProviderSnapshot(snapshotEntries(hash)), + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + requestId: hash, + }); + let transfers = 0; + const client = { + async getProvidersSnapshot() { + transfers++; + return snapshot("shared"); + }, + }; + const announce = (cwd: string, hash: string, fetchedAt = "2026-09-06T12:00:00.000Z") => + applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd, + entries: [], + snapshotHash: hash, + generatedAt: fetchedAt, + fetchedAt: { pi: fetchedAt }, + }, + }, + }); + try { + await Promise.all(["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared"))); + expect(transfers).toBe(1); + const first = await cache.read(serverId, "/a"); + const second = await cache.read(serverId, "/b"); + expect(first!.entries[0]!.models).toBe(second!.entries[0]!.models); + expect( + queryClient.getQueryData<{ compactSnapshot: unknown }>( + providersSnapshotQueryKey(serverId, "/a"), + )!.compactSnapshot, + ).toBe(first!.compactSnapshot); + expect( + [...storage.values.values()].filter((value) => value.includes('"compactSnapshot"')), + ).toHaveLength(1); + await Promise.all( + ["/a", "/b", "/c"].map((cwd) => announce(cwd, "shared", "2026-09-06T13:00:00.000Z")), + ); + expect(transfers).toBe(1); + expect((await cache.read(serverId, "/a"))!.entries[0]!.fetchedAt).toBe( + "2026-09-06T13:00:00.000Z", + ); + + await queryClient.refetchQueries({ + queryKey: providersSnapshotQueryKey(serverId, "/b"), + exact: true, + }); + expect(transfers).toBe(2); + + let release!: (value: ReturnType) => void; + let began!: () => void; + const started = new Promise((resolve) => { + began = resolve; + }); + const oldClient = { + getProvidersSnapshot() { + began(); + return new Promise>((resolve) => { + release = resolve; + }); + }, + }; + const queryKey = providersSnapshotQueryKey(serverId, "/a"); + const old = queryClient.fetchQuery({ + queryKey, + staleTime: 0, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + serverId, + cwd: "/a", + queryClient, + cache, + client: oldClient, + signal, + }), + }); + const cancelled = old.catch(() => undefined); + await started; + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { type: "providers_snapshot_update", payload: { ...snapshot("new"), cwd: "/a" } }, + }); + release(snapshot("old")); + await cancelled; + // Drain the same public cache queue after the old transport finishes. + await cache.read(serverId, "/a"); + expect((await cache.read(serverId, "/a"))!.hash).toBe("new"); + expect(queryClient.getQueryData(queryKey)).toMatchObject({ + snapshotHash: "new", + entries: [{ models: [{ id: "new" }] }], + }); + expect((await cache.read(serverId, "/b"))!.hash).toBe("shared"); + + const restarted = createProviderSnapshotCache(storage); + expect((await restarted.read(serverId, "/a"))!.hash).toBe("new"); + const evicted = createProviderSnapshotCache(storage, { maxBytes: 1 }); + expect(await evicted.read(serverId, "/b")).toBeNull(); + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache: evicted, + client, + message: { + type: "providers_snapshot_update", + payload: { + cwd: "/b", + entries: [], + snapshotHash: "shared", + generatedAt: "2026-09-06T14:00:00.000Z", + }, + }, + }); + expect(transfers).toBe(3); + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/b"))).toMatchObject({ + entries: [{ models: [{ id: "shared" }] }], + }); + } finally { + queryClient.clear(); + } +}); diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index ed4823a39c..3871cbb1e0 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -1,17 +1,21 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { Buffer } from "buffer"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import { + normalizeAgentModelCatalog, + type ProviderSnapshotEntry, +} from "@getpaseo/protocol/agent-types"; import { expandProviderSnapshot, type CompactProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; import { z } from "zod"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +type SnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; -const CACHE_VERSION = 1; -const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; -const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v1"; -const CACHE_INDEX_VERSION = 1; +const CACHE_VERSION = 2; +const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v2"; +const CACHE_INDEX_KEY = "@paseo/provider-snapshot-index/v2"; const DEFAULT_MAX_CACHE_BYTES = 4 * 1024 * 1024; interface ProviderSnapshotStorage { @@ -31,7 +35,8 @@ interface StoredProviderSnapshot { version: typeof CACHE_VERSION; hash: string; generatedAt: string; - compactSnapshot: CompactProviderSnapshot; + compactSnapshot?: CompactProviderSnapshot; + fetchedAt?: Record; } interface ProviderSnapshotIndexEntry { @@ -40,30 +45,31 @@ interface ProviderSnapshotIndexEntry { writtenAt: string; } -interface ProviderSnapshotIndex { - version: typeof CACHE_INDEX_VERSION; - entries: ProviderSnapshotIndexEntry[]; -} - const StoredProviderSnapshotSchema: z.ZodType = z.strictObject({ version: z.literal(CACHE_VERSION), hash: z.string(), generatedAt: z.string().datetime({ offset: true }), - compactSnapshot: CompactProviderSnapshotSchema, + compactSnapshot: CompactProviderSnapshotSchema.optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), }); export interface CachedProviderSnapshot extends StoredProviderSnapshot { + compactSnapshot: CompactProviderSnapshot; entries: ProviderSnapshotEntry[]; } export interface ProviderSnapshotCache { read(serverId: string, cwd: string | null): Promise; + readHash(serverId: string, hash: string): Promise; + materialize(serverId: string, snapshot: SnapshotPayload): Promise; write(input: { serverId: string; cwd: string | null; hash: string; generatedAt: string; compactSnapshot: CompactProviderSnapshot; + fetchedAt?: Record; + signal?: AbortSignal; }): Promise; } @@ -74,23 +80,53 @@ export class ProviderSnapshotCacheMissError extends Error { } } +// COMPAT(duplicateModelCatalog): added in v0.8.1, remove after 2027-03-14 once +// the daemon floor is v0.8.1 and pre-fix cached catalogs are no longer supported. +// Keep the original compact body/hash: normalization belongs to its decoded view. +function normalizeCatalogs(entries: ProviderSnapshotEntry[]): ProviderSnapshotEntry[] { + return entries.map((entry) => { + if (!entry.models) return entry; + const models = normalizeAgentModelCatalog(entry.models); + return models === entry.models ? entry : { ...entry, models }; + }); +} + function cacheKey(serverId: string, cwd: string | null): string { - return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, cwd])}`; + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "cwd", cwd])}`; +} + +function bodyKey(serverId: string, hash: string): string { + return `${CACHE_KEY_PREFIX}:${JSON.stringify([serverId, "hash", hash])}`; } function storedBytes(key: string, value: string): number { return Buffer.byteLength(key, "utf8") + Buffer.byteLength(value, "utf8"); } -function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { - const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); - return writtenAtOrder !== 0 ? writtenAtOrder : left.key.localeCompare(right.key); +const CacheKeySchema = z.tuple([z.string(), z.enum(["cwd", "hash"]), z.string().nullable()]); + +function decodeCacheKey(key: string) { + return CacheKeySchema.parse(JSON.parse(key.slice(CACHE_KEY_PREFIX.length + 1))); } -function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { +function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIndexEntry): number { + const writtenAtOrder = left.writtenAt.localeCompare(right.writtenAt); + if (writtenAtOrder !== 0) return writtenAtOrder; + // References must be evicted before equally recent bodies, regardless of key encoding. + const leftKind = decodeCacheKey(left.key)[1]; + const rightKind = decodeCacheKey(right.key)[1]; + if (leftKind !== rightKind) return leftKind === "cwd" ? -1 : 1; + return left.key.localeCompare(right.key); +} + +function parseStoredProviderSnapshot(key: string, value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); const result = StoredProviderSnapshotSchema.safeParse(parsed); - return result.success ? result.data : null; + if (!result.success) return null; + const [, kind, identity] = decodeCacheKey(key); + const stored = result.data; + if (kind === "hash") return stored.compactSnapshot && identity === stored.hash ? stored : null; + return stored.compactSnapshot ? null : stored; } export function createProviderSnapshotCache( @@ -98,50 +134,76 @@ export function createProviderSnapshotCache( options: ProviderSnapshotCacheOptions = {}, ): ProviderSnapshotCache { const maxBytes = options.maxBytes ?? DEFAULT_MAX_CACHE_BYTES; - let index: ProviderSnapshotIndex | null = null; + let index: ProviderSnapshotIndexEntry[] | null = null; let operationQueue = Promise.resolve(); - - async function persistIndex(entries: ProviderSnapshotIndexEntry[]): Promise { - const nextIndex: ProviderSnapshotIndex = { version: CACHE_INDEX_VERSION, entries }; - await storage.setItem(CACHE_INDEX_KEY, JSON.stringify(nextIndex)); - index = nextIndex; - } + const bodies = new Map(); async function reconcileCache(): Promise { - const keys = (await storage.getAllKeys()).filter((key) => - key.startsWith(`${CACHE_KEY_PREFIX}:`), + const allKeys = await storage.getAllKeys(); + // COMPAT(providerSnapshotCache): added in v0.7.2, remove legacy cleanup after 2027-03-06. + const legacyKeys = allKeys.filter( + (key) => + key.startsWith("@paseo/provider-snapshot/v1:") || + key === "@paseo/provider-snapshot-index/v1" || + key === CACHE_INDEX_KEY, ); + if (legacyKeys.length) await storage.multiRemove(legacyKeys); + const keys = allKeys.filter((key) => key.startsWith(`${CACHE_KEY_PREFIX}:`)); const values = await storage.multiGet(keys); const keysToRemove: string[] = []; const entries: ProviderSnapshotIndexEntry[] = []; + const references: Array<{ key: string; body: string; writtenAt: string }> = []; for (const [key, value] of values) { if (value === null) continue; try { - const stored = parseStoredProviderSnapshot(value); + const stored = parseStoredProviderSnapshot(key, value); if (!stored) { keysToRemove.push(key); continue; } + const [serverId, kind] = decodeCacheKey(key); entries.push({ key, bytes: storedBytes(key, value), writtenAt: stored.generatedAt }); + if (kind === "cwd") + references.push({ + key, + body: bodyKey(serverId, stored.hash), + writtenAt: stored.generatedAt, + }); } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + if ( + !(error instanceof SyntaxError) && + !(error instanceof RangeError) && + !(error instanceof z.ZodError) + ) + throw error; keysToRemove.push(key); } } - let totalBytes = entries.reduce((total, entry) => total + entry.bytes, 0); - const retainedEntries = [...entries].sort(oldestFirst); + // Reconstruct the dependency ordering before applying a possibly smaller budget. + for (const reference of references) { + const body = entries.find((entry) => entry.key === reference.body); + if (!body) keysToRemove.push(reference.key); + else if (body.writtenAt < reference.writtenAt) body.writtenAt = reference.writtenAt; + } + const retainedEntries = entries + .filter((entry) => !keysToRemove.includes(entry.key)) + .sort(oldestFirst); + let totalBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); while (totalBytes > maxBytes) { const evicted = retainedEntries.shift(); if (!evicted) break; totalBytes -= evicted.bytes; keysToRemove.push(evicted.key); } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - await persistIndex(retainedEntries); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + index = retainedEntries; } - async function ensureCacheIndex(): Promise { + async function ensureCacheIndex(): Promise { if (index === null) await reconcileCache(); if (index === null) throw new Error("Provider snapshot cache reconciliation failed"); return index; @@ -158,83 +220,210 @@ export function createProviderSnapshotCache( return result; } - async function writeSnapshot(input: { - serverId: string; - cwd: string | null; - hash: string; - generatedAt: string; - compactSnapshot: CompactProviderSnapshot; - }): Promise { + async function writeRecord( + key: string, + input: StoredProviderSnapshot, + requiredBody?: { key: string; input: StoredProviderSnapshot }, + ): Promise { const currentIndex = await ensureCacheIndex(); - const key = cacheKey(input.serverId, input.cwd); - const stored = StoredProviderSnapshotSchema.safeParse({ - version: CACHE_VERSION, - hash: input.hash, - generatedAt: input.generatedAt, - compactSnapshot: input.compactSnapshot, - }); - if (!stored.success) { - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return; + function prepare(recordKey: string, record: StoredProviderSnapshot) { + const value = JSON.stringify(record); + return { + key: recordKey, + bytes: storedBytes(recordKey, value), + writtenAt: record.generatedAt, + value, + }; } - const value = JSON.stringify(stored.data); - const incomingBytes = storedBytes(key, value); - const canStoreIncoming = incomingBytes <= maxBytes; - const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); - let projectedBytes = retainedEntries.reduce((total, entry) => total + entry.bytes, 0); - if (canStoreIncoming) projectedBytes += incomingBytes; - - const keysToRemove: string[] = []; + const incoming = prepare(key, input); + // The index is committed last: membership proves a body was stored. Reusing it + // needs only its byte count, not another parse, expansion, or storage write. + const body = requiredBody + ? (currentIndex.find((entry) => entry.key === requiredBody.key) ?? + prepare(requiredBody.key, requiredBody.input)) + : undefined; + let admitted: Array = body + ? [body, incoming] + : [incoming]; + if (admitted.reduce((bytes, entry) => bytes + entry.bytes, 0) > maxBytes) { + admitted = body && body.bytes <= maxBytes ? [body] : []; + } else if (body) { + admitted = [ + { + ...body, + writtenAt: body.writtenAt > input.generatedAt ? body.writtenAt : input.generatedAt, + }, + incoming, + ]; + } + const replacedKeys = new Set([key, ...(requiredBody ? [requiredBody.key] : [])]); + const retainedEntries = currentIndex.filter((entry) => !replacedKeys.has(entry.key)); + let projectedBytes = [...retainedEntries, ...admitted].reduce( + (total, entry) => total + entry.bytes, + 0, + ); + const keysToRemove = [...replacedKeys].filter( + (replaced) => !admitted.some((entry) => entry.key === replaced), + ); for (const candidate of [...retainedEntries].sort(oldestFirst)) { if (projectedBytes <= maxBytes) break; projectedBytes -= candidate.bytes; keysToRemove.push(candidate.key); } const removedKeys = new Set(keysToRemove); - const nextEntries = retainedEntries.filter((entry) => !removedKeys.has(entry.key)); + if (keysToRemove.length > 0) { + for (const removedKey of keysToRemove) bodies.delete(removedKey); + await storage.multiRemove(keysToRemove); + } + // Body, then reference, then in-memory index. A failed write can leave a reusable + // orphan body; reconciliation never retains a reference without its body. + for (const entry of admitted) { + if (entry.value !== undefined) await storage.setItem(entry.key, entry.value); + } + index = [ + ...retainedEntries.filter((entry) => !removedKeys.has(entry.key)), + ...admitted.map(({ key: recordKey, bytes, writtenAt }) => ({ + key: recordKey, + bytes, + writtenAt, + })), + ]; + } - if (!canStoreIncoming && currentIndex.entries.some((entry) => entry.key === key)) { - keysToRemove.push(key); + async function readRecord(key: string): Promise { + const currentIndex = await ensureCacheIndex(); + const value = await storage.getItem(key); + if (value === null) return null; + try { + const stored = parseStoredProviderSnapshot(key, value); + if (!stored) throw new SyntaxError("Invalid provider snapshot"); + return stored; + } catch (error) { + if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; + bodies.delete(key); + await storage.removeItem(key); + index = currentIndex.filter((entry) => entry.key !== key); + return null; } - if (keysToRemove.length > 0) await storage.multiRemove(keysToRemove); - if (canStoreIncoming) { - await storage.setItem(key, value); - nextEntries.push({ key, bytes: incomingBytes, writtenAt: input.generatedAt }); + } + + async function readBody(serverId: string, hash: string): Promise { + const key = bodyKey(serverId, hash); + const resident = bodies.get(key); + if (resident) return resident; + const stored = await readRecord(key); + if (!stored?.compactSnapshot) return null; + try { + const body = { + ...stored, + compactSnapshot: stored.compactSnapshot, + entries: normalizeCatalogs(expandProviderSnapshot(stored.compactSnapshot)), + }; + bodies.set(key, body); + return body; + } catch (error) { + if (!(error instanceof RangeError)) throw error; + await storage.removeItem(key); + index = null; + return null; + } + } + + async function safely(operation: () => Promise, fallback: T): Promise { + try { + return await runSerialized(operation); + } catch (error) { + if (!(error instanceof Error)) throw error; + bodies.clear(); + return fallback; } - await persistIndex(nextEntries); } return { - async read(serverId, cwd) { - try { - return await runSerialized(async () => { - const currentIndex = await ensureCacheIndex(); - const key = cacheKey(serverId, cwd); - const value = await storage.getItem(key); - if (value === null) return null; - try { - const stored = parseStoredProviderSnapshot(value); - if (!stored) throw new SyntaxError("Invalid provider snapshot"); - return { ...stored, entries: expandProviderSnapshot(stored.compactSnapshot) }; - } catch (error) { - if (!(error instanceof SyntaxError) && !(error instanceof RangeError)) throw error; - await storage.removeItem(key); - await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); - return null; - } + async materialize(serverId, snapshot) { + const { snapshotHash: hash, compactSnapshot } = snapshot; + if (!hash) return { ...snapshot, entries: normalizeCatalogs(snapshot.entries) }; + let body = await safely(async () => { + const cached = await readBody(serverId, hash); + if (cached) return cached; + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + await writeRecord(bodyKey(serverId, hash), { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, }); - } catch (error) { - if (error instanceof Error) return null; - throw error; + return await readBody(serverId, hash); + }, null); + if (!body) { + if (!compactSnapshot) throw new ProviderSnapshotCacheMissError(); + body = { + version: CACHE_VERSION, + hash, + generatedAt: snapshot.generatedAt, + compactSnapshot, + entries: normalizeCatalogs(expandProviderSnapshot(compactSnapshot)), + }; } + return { + ...snapshot, + compactSnapshot: body.compactSnapshot, + entries: snapshot.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: snapshot.fetchedAt![entry.provider], + })) + : body.entries, + }; }, - async write(input) { - try { - await runSerialized(() => writeSnapshot(input)); - } catch (error) { - if (!(error instanceof Error)) throw error; - } + readHash(serverId, hash) { + return safely(() => readBody(serverId, hash), null); + }, + read(serverId, cwd) { + return safely(async () => { + const association = await readRecord(cacheKey(serverId, cwd)); + if (!association) return null; + const body = await readBody(serverId, association.hash); + if (!body) { + // Storage may be removed or corrupted outside this owner's write queue. + await reconcileCache(); + return null; + } + return { + ...body, + ...association, + compactSnapshot: body.compactSnapshot, + entries: association.fetchedAt + ? body.entries.map((entry) => ({ + ...entry, + fetchedAt: association.fetchedAt![entry.provider], + })) + : body.entries, + }; + }, null); + }, + write(input) { + return safely(async () => { + if (input.signal?.aborted) return; + await writeRecord( + cacheKey(input.serverId, input.cwd), + { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + fetchedAt: input.fetchedAt, + }, + { + key: bodyKey(input.serverId, input.hash), + input: { + version: CACHE_VERSION, + hash: input.hash, + generatedAt: input.generatedAt, + compactSnapshot: input.compactSnapshot, + }, + }, + ); + }, undefined); }, }; } diff --git a/packages/app/src/data/providers-snapshot.test.ts b/packages/app/src/data/providers-snapshot.test.ts index 1e62958440..c69eff9225 100644 --- a/packages/app/src/data/providers-snapshot.test.ts +++ b/packages/app/src/data/providers-snapshot.test.ts @@ -1,5 +1,14 @@ +import { createRequire } from "node:module"; +import { QueryClient, CancelledError } from "@tanstack/react-query"; +import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { applyProvidersSnapshotUpdate } from "./push-router"; +import { createProviderSnapshotCache } from "./provider-snapshot-cache"; +import { providerSnapshotIcons, replaceProviderSnapshotIcons } from "@/data/provider-icons"; import { describe, expect, it } from "vitest"; import { + fetchProvidersSnapshot, isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, @@ -49,3 +58,274 @@ describe("providers snapshot query scope", () => { ); }); }); + +// Resolve the exact CommonJS implementation installed by React Native's setUpXHR. +// Its runtime signal deliberately lacks the newer methods in the DOM typings. +const nativeRequire = createRequire(require.resolve("react-native/package.json")); +const { AbortController: NativeAbortController }: { AbortController: typeof AbortController } = + nativeRequire("abort-controller/dist/abort-controller"); + +function createStorage(onWrite: () => void = () => {}) { + const values = new Map(); + return { + async getItem(key: string) { + return values.get(key) ?? null; + }, + async setItem(key: string, value: string) { + values.set(key, value); + onWrite(); + }, + async removeItem(key: string) { + values.delete(key); + }, + async getAllKeys() { + return [...values.keys()]; + }, + async multiGet(keys: readonly string[]) { + return keys.map((key) => [key, values.get(key) ?? null] as const); + }, + async multiRemove(keys: readonly string[]) { + for (const key of keys) values.delete(key); + }, + }; +} + +const entries: ProviderSnapshotEntry[] = [ + { + provider: "test-provider", + status: "ready", + enabled: true, + iconSvg: '', + models: [ + { provider: "test-provider", id: "test-model", label: "Test model", thinkingOptions: [] }, + ], + }, +]; +const snapshot = { + requestId: "test-request", + entries: [], + cwd: "/repo", + generatedAt: "2026-09-07T00:00:00.000Z", + snapshotHash: "test-hash", + compactSnapshot: compactProviderSnapshot(entries), +} satisfies GetProvidersSnapshotResponseMessage["payload"]; + +describe("provider fetch with runtime abort signals", () => { + it.each([ + ["React Native", NativeAbortController], + ["default", AbortController], + ])( + "publishes ready models and persists the real compact snapshot with %s", + async (_, Controller) => { + const controller = new Controller(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "successful-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + try { + const result = await queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { getProvidersSnapshot: async () => snapshot }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }); + expect(result.entries).toEqual(entries); + expect(queryClient.getQueryData(queryKey)).toEqual(result); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(entries); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + entries[0].iconSvg, + ); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +describe.each([ + ["React Native", NativeAbortController], + ["default", AbortController], +])("provider fetch cancellation with %s", (_, Controller) => { + it.each([ + { boundary: "response", abortOnWrite: 0, hasBody: false, hasReference: false }, + { boundary: "body persistence", abortOnWrite: 1, hasBody: true, hasReference: false }, + { boundary: "directory persistence", abortOnWrite: 2, hasBody: true, hasReference: true }, + ])( + "does not publish after cancellation at $boundary", + async ({ abortOnWrite, hasBody, hasReference }) => { + const controller = new Controller(); + let writes = 0; + const storage = createStorage(() => { + writes += 1; + if (writes === abortOnWrite) controller.abort(); + }); + const cache = createProviderSnapshotCache(storage); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + const serverId = "cancelled-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + const previous = { ...snapshot, entries: [{ ...entries[0], iconSvg: "previous-icon" }] }; + queryClient.setQueryData(queryKey, previous); + replaceProviderSnapshotIcons(serverId, previous.entries); + try { + await expect( + queryClient.fetchQuery({ + queryKey, + queryFn: () => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + if (abortOnWrite === 0) controller.abort(); + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }), + }), + ).rejects.toBeInstanceOf(CancelledError); + expect(controller.signal.aborted).toBe(true); + expect(queryClient.getQueryData(queryKey)).toEqual(previous); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + "previous-icon", + ); + expect((await cache.readHash(serverId, "test-hash"))?.entries ?? null).toEqual( + hasBody ? entries : null, + ); + expect((await cache.read(serverId, snapshot.cwd))?.entries ?? null).toEqual( + hasReference ? entries : null, + ); + } finally { + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } + }, + ); +}); + +it("keeps a replacement query when the cancelled native fetch finishes late", async () => { + const controller = new NativeAbortController(); + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + const serverId = "superseded-provider-fetch"; + const queryKey = providersSnapshotQueryKey(serverId, snapshot.cwd); + let finishResponse = () => {}; + const responseReady = new Promise((resolve) => { + finishResponse = resolve; + }); + let markStarted = () => {}; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let oldFetch: Promise | undefined; + try { + const oldQuery = queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + oldFetch = fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => { + markStarted(); + await responseReady; + return snapshot; + }, + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal: controller.signal, + }); + return oldFetch; + }, + }); + const cancelledQuery = expect(oldQuery).rejects.toBeInstanceOf(CancelledError); + await started; + await queryClient.cancelQueries({ queryKey, exact: true }); + await cancelledQuery; + expect(controller.signal.aborted).toBe(true); + const replacementEntries = [{ ...entries[0], iconSvg: "replacement-icon", models: [] }]; + const replacement = await queryClient.fetchQuery({ + queryKey, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ + client: { + getProvidersSnapshot: async () => ({ + ...snapshot, + snapshotHash: "replacement-hash", + compactSnapshot: compactProviderSnapshot(replacementEntries), + }), + }, + serverId, + cwd: snapshot.cwd, + queryClient, + cache, + signal, + }), + }); + finishResponse(); + await expect(oldFetch).rejects.toBeInstanceOf(CancelledError); + expect(queryClient.getQueryData(queryKey)).toEqual(replacement); + expect(replacement.entries).toEqual(replacementEntries); + expect((await cache.read(serverId, snapshot.cwd))?.entries).toEqual(replacementEntries); + expect(providerSnapshotIcons.getState().get(serverId)?.get("test-provider")).toEqual( + "replacement-icon", + ); + } finally { + finishResponse(); + queryClient.clear(); + replaceProviderSnapshotIcons(serverId, []); + } +}); + +describe("provider snapshot publication identity", () => { + it("keeps the hash cache's canonical objects across a catalog change and later announcements", async () => { + const cache = createProviderSnapshotCache(createStorage()); + const queryClient = new QueryClient(); + const serverId = "canonical-provider-snapshot"; + const client = { + async getProvidersSnapshot() { + throw new Error("Unexpected content fetch"); + }, + }; + const changed = { + ...snapshot, + snapshotHash: "next-hash", + compactSnapshot: compactProviderSnapshot([{ ...entries[0]!, label: "Updated provider" }]), + }; + try { + for (const body of [snapshot, changed, changed]) { + await applyProvidersSnapshotUpdate({ + serverId, + queryClient, + cache, + client, + message: { type: "providers_snapshot_update", payload: body }, + }); + const canonical = await cache.materialize(serverId, body); + const published = queryClient.getQueryData( + providersSnapshotQueryKey(serverId, body.cwd), + )!; + expect(published.entries).toBe(canonical.entries); + expect(published.compactSnapshot).toBe(canonical.compactSnapshot); + expect(published.entries[0]!.models).toBe(canonical.entries[0]!.models); + } + } finally { + queryClient.clear(); + } + }); +}); diff --git a/packages/app/src/data/providers-snapshot.ts b/packages/app/src/data/providers-snapshot.ts index 5a59be037c..68b389fe15 100644 --- a/packages/app/src/data/providers-snapshot.ts +++ b/packages/app/src/data/providers-snapshot.ts @@ -1,3 +1,14 @@ +import { CancelledError, type QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages"; +import { + providerSnapshotCache, + ProviderSnapshotCacheMissError, + type ProviderSnapshotCache, +} from "./provider-snapshot-cache"; +import { queryClient as singletonQueryClient } from "./query-client"; +import { replaceProviderSnapshotIcons } from "@/data/provider-icons"; +import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; @@ -34,3 +45,108 @@ export function providersSnapshotRequestOptions(input: { export function isProvidersSnapshotHomeScope(cwd?: string | null): boolean { return normalizeProvidersSnapshotCwd(cwd) === null; } + +type Snapshot = GetProvidersSnapshotResponseMessage["payload"]; +type SnapshotClient = Pick; + +export async function fetchProvidersSnapshot(input: { + client: SnapshotClient; + serverId: string; + cwd: string | null; + queryClient?: QueryClient; + cache?: ProviderSnapshotCache; + signal?: AbortSignal; + snapshot?: Snapshot; +}): Promise { + const cache = input.cache ?? providerSnapshotCache; + const queryClient = input.queryClient ?? singletonQueryClient; + const cached = input.snapshot ? null : await cache.read(input.serverId, input.cwd); + let snapshot = + input.snapshot ?? + (await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), + )); + if (snapshot.snapshotHash && !snapshot.compactSnapshot) { + const known = + cached?.hash === snapshot.snapshotHash + ? cached + : await cache.readHash(input.serverId, snapshot.snapshotHash); + if (known) snapshot = { ...snapshot, compactSnapshot: known.compactSnapshot }; + else if (snapshot.notModified) throw new ProviderSnapshotCacheMissError(); + else { + const hash = snapshot.snapshotHash; + const body = await queryClient.fetchQuery({ + queryKey: ["providerSnapshotContent", input.serverId, hash], + gcTime: 0, + structuralSharing: false, + staleTime: 0, + retry: false, + queryFn: async () => { + const response = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + // Materialize before settling so simultaneous directory announcements share + // both the transfer and decoded body through the existing query deduplication. + return cache.materialize(input.serverId, response); + }, + }); + if (body.snapshotHash === hash) { + snapshot = { ...snapshot, compactSnapshot: body.compactSnapshot }; + } else if (normalizeProvidersSnapshotCwd(body.cwd) === input.cwd) { + snapshot = body; + } else { + snapshot = await input.client.getProvidersSnapshot( + providersSnapshotRequestOptions({ cwd: input.cwd }), + ); + } + } + } + if (input.signal?.aborted) throw new CancelledError(); + snapshot = await cache.materialize(input.serverId, snapshot); + if (snapshot.compactSnapshot && snapshot.snapshotHash) { + await cache.write({ + serverId: input.serverId, + cwd: input.cwd, + hash: snapshot.snapshotHash, + generatedAt: snapshot.generatedAt, + fetchedAt: snapshot.fetchedAt, + compactSnapshot: snapshot.compactSnapshot, + signal: input.signal, + }); + } + if (input.signal?.aborted) throw new CancelledError(); + replaceProviderSnapshotIcons(input.serverId, snapshot.entries); + return snapshot; +} + +export async function refreshAndApplyProvidersSnapshot(input: { + client: SnapshotClient & Pick; + queryClient: QueryClient; + serverId: string; + cwd: string | null; + providers?: AgentProvider[]; + cache?: ProviderSnapshotCache; +}) { + const result = await input.client.refreshProvidersSnapshot( + providersSnapshotRequestOptions(input), + ); + const queryKey = providersSnapshotQueryKey(input.serverId, input.cwd); + await input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + structuralSharing: false, + queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }), + }); + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, + }); + if (isProvidersSnapshotHomeScope(input.cwd)) { + void input.queryClient.invalidateQueries({ + queryKey: providersSnapshotQueryRoot(input.serverId), + exact: false, + }); + } + return result; +} diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index f468a3f928..3bf9e99f9b 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; import { QueryClient, QueryObserver, skipToken } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { MutableDaemonConfig, SessionOutboundMessage } from "@getpaseo/protocol/messages"; @@ -94,34 +95,89 @@ function createFakeClient(config: { rejectCheckoutDiffSubscribe?: boolean } = {} } } - return { - client: { - on, - async subscribeCheckoutDiff(cwd, compare, requestOptions) { - subscribeCheckoutDiffCalls.push({ - cwd, - compare, - subscriptionId: requestOptions.subscriptionId, - }); - if (config.rejectCheckoutDiffSubscribe) { - throw new Error("subscribe failed"); + function observe( + snapshot: T, + types: RouterMessageType[], + released: () => void, + matches: (message: RouterMessage) => boolean = () => true, + ): OwnedSubscription { + const observers = new Set>(); + let active = true; + const cleanups = types.map((type) => + on(type, (message) => { + if (!matches(message)) return; + for (const observer of observers) { + if (message.type === "subscribe_checkout_diff_response") + observer.snapshot({ ...snapshot, ...message.payload }); + else observer.update(message); } - return { - subscriptionId: requestOptions.subscriptionId, - cwd, - files: [], - error: null, - requestId: requestOptions.requestId ?? "subscribe-checkout-diff", + }), + ); + return { + subscriptionId: snapshot.subscriptionId, + ready: Promise.resolve(snapshot), + subscribe: (observer) => { + observers.add(observer); + observer.snapshot(snapshot); + return () => { + observers.delete(observer); }; }, - unsubscribeCheckoutDiff(subscriptionId) { - unsubscribeCheckoutDiffCalls.push(subscriptionId); + release: async () => { + if (!active) return; + active = false; + cleanups.forEach((cleanup) => cleanup()); + observers.clear(); + released(); }, - subscribeTerminals(subscription) { - subscribeTerminalCalls.push(subscription); + }; + } + + return { + client: { + async getProvidersSnapshot() { + throw new Error("Unexpected snapshot pull"); }, - unsubscribeTerminals(subscription) { - unsubscribeTerminalCalls.push(subscription); + observeEvents: () => + observe( + { subscriptionId: "events", requestId: "events", events: [] }, + ["status", "providers_snapshot_update"], + () => {}, + ), + observeCheckoutDiff(cwd, compare) { + const subscriptionId = `server-diff-${subscribeCheckoutDiffCalls.length + 1}`; + subscribeCheckoutDiffCalls.push({ cwd, compare, subscriptionId }); + const handle = observe( + { subscriptionId, cwd, files: [], error: null, requestId: "subscribe-checkout-diff" }, + ["checkout_diff_update", "subscribe_checkout_diff_response"], + () => unsubscribeCheckoutDiffCalls.push(subscriptionId), + (message) => + "subscriptionId" in message.payload && + message.payload.subscriptionId === subscriptionId, + ); + if (!config.rejectCheckoutDiffSubscribe) return handle; + return { + ...handle, + subscribe: (observer) => { + queueMicrotask(() => observer.error?.(new Error("subscribe failed"))); + return () => {}; + }, + }; + }, + observeTerminals(input) { + const { signal: _signal, ...query } = input; + subscribeTerminalCalls.push(query); + return observe( + { + ...query, + subscriptionId: `terminal-${subscribeTerminalCalls.length}`, + requestId: "terminals", + terminals: [], + }, + ["terminals_changed"], + () => unsubscribeTerminalCalls.push(query), + (message) => message.type === "terminals_changed" && message.payload.cwd === query.cwd, + ); }, }, emit, @@ -143,7 +199,151 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage { } describe("server data push router", () => { - it("routes provider snapshot and daemon config payloads until detached", () => { + it("keeps a visible diff subscribed when a hidden panel switches to the same comparison", () => { + const queryClient = new QueryClient(); + const fake = createFakeClient(); + const serverId = "server-1"; + const cwd = "/repo"; + const options = (mode: "base" | "uncommitted", enabled: boolean) => { + const queryKey = checkoutDiffQueryKey( + serverId, + cwd, + mode, + mode === "base" ? "main" : undefined, + ); + return { + queryKey, + queryFn: skipToken as typeof skipToken, + gcTime: Infinity, + meta: checkoutDiffPushRoute({ + enabled, + serverId, + cwd, + subscriptionId: `checkoutDiff:${JSON.stringify(queryKey)}`, + compare: { mode, ...(mode === "base" ? { baseRef: "main" } : {}) }, + }), + }; + }; + const visible = new QueryObserver(queryClient, options("base", true)); + const hidden = new QueryObserver(queryClient, options("base", false)); + const unsubscribeVisible = visible.subscribe(() => {}); + const unsubscribeHidden = hidden.subscribe(() => {}); + const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); + try { + visible.setOptions(options("uncommitted", true)); + hidden.setOptions(options("uncommitted", false)); + const subscription = fake.subscribeCheckoutDiffCalls.findLast( + (call) => call.compare.mode === "uncommitted", + )!; + const files = [ + { + path: "README.md", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [], + }, + ]; + fake.emit({ + type: "checkout_diff_update", + payload: { subscriptionId: subscription.subscriptionId, cwd, files, error: null }, + }); + expect(queryClient.getQueryData(options("uncommitted", true).queryKey)).toMatchObject({ + files, + }); + expect(fake.unsubscribeCheckoutDiffCalls).not.toContain(subscription.subscriptionId); + + unsubscribeVisible(); + expect(fake.unsubscribeCheckoutDiffCalls).toContain(subscription.subscriptionId); + } finally { + unsubscribeVisible(); + unsubscribeHidden(); + unmount(); + queryClient.clear(); + } + }); + + it("retains equal diff files without reconstructing changed file bodies", () => { + const queryClient = new QueryClient(); + const fake = createFakeClient(); + const serverId = "diff-sharing"; + const cwd = "/repo"; + const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false); + const subscriptionId = "diff-sharing"; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: skipToken, + meta: checkoutDiffPushRoute({ + enabled: true, + serverId, + cwd, + subscriptionId, + compare: { mode: "uncommitted" }, + }), + }); + const unsubscribe = observer.subscribe(() => {}); + const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId }); + type Payload = SubscribeCheckoutDiffResponseMessage["payload"]; + const files: Payload["files"] = ["a.ts", "b.ts"].map((path) => ({ + path, + isNew: true, + isDeleted: false, + additions: 2, + deletions: 0, + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 2, + lines: [ + { + type: "add", + content: "const answer = 42", + tokens: [ + { text: "const", style: "keyword" }, + { text: " answer = 42", style: null }, + ], + }, + { type: "add", content: "answer", tokens: [{ text: "answer", style: null }] }, + ], + }, + ], + })); + const publish = (incomingFiles: Payload["files"], requestId: string) => { + fake.emit({ + type: "subscribe_checkout_diff_response", + payload: { + subscriptionId: fake.subscribeCheckoutDiffCalls[0]!.subscriptionId, + cwd, + files: incomingFiles, + requestId, + error: null, + }, + }); + return queryClient.getQueryData(queryKey)!; + }; + try { + const first = publish(files, "first"); + const same = publish(structuredClone(files), "reopen"); + expect(same.files).toBe(first.files); + expect(same.requestId).toBe("reopen"); + const changed = structuredClone(files); + changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable"; + const next = publish(changed, "edit"); + expect(next.files[1]).toBe(first.files[1]); + expect(next.files[0]).toBe(changed[0]); + expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable"); + expect(publish([], "deleted").files).toEqual([]); + } finally { + unsubscribe(); + unmount(); + queryClient.clear(); + } + }); + + it("routes provider snapshot and daemon config payloads until detached", async () => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; @@ -157,22 +357,26 @@ describe("server data push router", () => { payload: { status: "daemon_config_changed", config: daemonConfig }, }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); expect(queryClient.getQueryData(daemonConfigQueryKey(serverId))).toEqual(daemonConfig); expect(queryClient.getQueryState(pairingOfferKey)?.isInvalidated).toBe(true); unmount(); fake.emit(providerUpdate("2026-01-01T00:00:01.000Z")); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId))).toEqual({ - entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], - generatedAt: "2026-01-01T00:00:00.000Z", - requestId: "providers_snapshot_update", - }); + await expect + .poll(() => queryClient.getQueryData(providersSnapshotQueryKey(serverId))) + .toEqual({ + entries: [{ provider: "codex", status: "ready", enabled: true, models: [] }], + generatedAt: "2026-01-01T00:00:00.000Z", + requestId: "providers_snapshot_update", + }); }); it("subscribes active checkout diff queries and writes matching diff events", () => { @@ -199,18 +403,19 @@ describe("server data push router", () => { const unsubscribeObserver = observer.subscribe(() => undefined); const unmount = mountServerDataPushRouter({ client: fake.client, queryClient, serverId }); + const serverSubscriptionId = fake.subscribeCheckoutDiffCalls[0]!.subscriptionId; expect(fake.subscribeCheckoutDiffCalls).toEqual([ { cwd, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId, + subscriptionId: serverSubscriptionId, }, ]); fake.emit({ type: "subscribe_checkout_diff_response", payload: { - subscriptionId, + subscriptionId: serverSubscriptionId, cwd, files: [], error: null, @@ -229,7 +434,13 @@ describe("server data push router", () => { fake.emit({ type: "checkout_diff_update", - payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true }, + payload: { + subscriptionId: serverSubscriptionId, + cwd, + files: [], + error: null, + diffTooLarge: true, + }, }); expect(queryClient.getQueryData(queryKey)).toEqual({ @@ -237,12 +448,12 @@ describe("server data push router", () => { files: [], error: null, diffTooLarge: true, - requestId: `subscription:${subscriptionId}`, + requestId: `subscription:${serverSubscriptionId}`, }); unsubscribeObserver(); - expect(fake.unsubscribeCheckoutDiffCalls).toEqual([subscriptionId]); + expect(fake.unsubscribeCheckoutDiffCalls).toEqual([serverSubscriptionId]); unmount(); }); @@ -333,7 +544,7 @@ describe("server data push router", () => { unmount(); }); - it("re-sends active push subscriptions after reconnect", () => { + it("keeps stable handles while invalidating caches after reconnect", () => { const queryClient = new QueryClient(); const fake = createFakeClient(); const serverId = "server-1"; @@ -397,18 +608,10 @@ describe("server data push router", () => { { cwd, compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, - }, - { - cwd, - compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, - subscriptionId: checkoutDiffSubscriptionId, + subscriptionId: "server-diff-1", }, ]); - expect(fake.subscribeTerminalCalls).toEqual([ - { cwd, workspaceId }, - { cwd, workspaceId }, - ]); + expect(fake.subscribeTerminalCalls).toEqual([{ cwd, workspaceId }]); fake.emit({ type: "terminals_changed", diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index 0af2e8e684..5ab3069bd0 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query"; import type { ListTerminalsResponse, @@ -5,12 +6,14 @@ import type { SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; +import { shareCheckoutDiff } from "@/git/diff-sharing"; import { orderCheckoutDiffFiles } from "@/git/diff-order"; import { daemonConfigQueryKey } from "@/data/daemon-config"; import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing"; -import { providerSnapshotCache, type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; +import { type ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { normalizeProvidersSnapshotCwd, + fetchProvidersSnapshot, providersSnapshotQueryKey, providersSnapshotQueryRoot, } from "@/data/providers-snapshot"; @@ -19,19 +22,12 @@ type ProvidersSnapshotUpdateMessage = Extract< SessionOutboundMessage, { type: "providers_snapshot_update" } >; -type CheckoutDiffUpdateMessage = Extract; type SubscribeCheckoutDiffResponseMessage = Extract< SessionOutboundMessage, { type: "subscribe_checkout_diff_response" } >; type StatusMessage = Extract; type TerminalsChangedMessage = Extract; -type ServerDataEventType = - | "providers_snapshot_update" - | "checkout_diff_update" - | "subscribe_checkout_diff_response" - | "status" - | "terminals_changed"; type CheckoutDiffResponsePayload = SubscribeCheckoutDiffResponseMessage["payload"]; type CheckoutDiffCachePayload = Omit; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -51,6 +47,10 @@ interface CheckoutDiffRoute { compare: CheckoutDiffCompare; } +interface CheckoutDiffRegistration extends CheckoutDiffRoute { + subscription: OwnedSubscription; +} + interface WorkspaceTerminalsRoute { domain: "workspaceTerminals"; enabled: boolean; @@ -59,6 +59,10 @@ interface WorkspaceTerminalsRoute { workspaceId?: string; } +interface WorkspaceTerminalsRegistration extends WorkspaceTerminalsRoute { + subscription: OwnedSubscription; +} + type ServerDataRoute = CheckoutDiffRoute | WorkspaceTerminalsRoute; export interface ServerDataQueryMeta extends Record { @@ -68,18 +72,10 @@ export interface ServerDataQueryMeta extends Record { export type ProvidersSnapshotUpdate = ProvidersSnapshotUpdateMessage; interface ServerDataPushClient { - on( - type: TType, - handler: (message: Extract) => void, - ): () => void; - subscribeCheckoutDiff( - cwd: string, - compare: CheckoutDiffCompare, - options: { subscriptionId: string; requestId?: string }, - ): Promise; - unsubscribeCheckoutDiff(subscriptionId: string): void; - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void; - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void; + getProvidersSnapshot: import("@getpaseo/client/internal/daemon-client").DaemonClient["getProvidersSnapshot"]; + observeEvents: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeEvents"]; + observeCheckoutDiff: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeCheckoutDiff"]; + observeTerminals: import("@getpaseo/client/internal/daemon-client").DaemonClient["observeTerminals"]; } interface PushRouterInput { @@ -134,7 +130,6 @@ const RECONNECT_REPAIR_POLICIES: ReconnectRepairPolicy[] = [ }, }, ]; -const reconnectSubscriptionRepairsByServerId = new Map void>>(); export function checkoutDiffPushRoute(input: { enabled: boolean; @@ -179,46 +174,48 @@ export function invalidateServerDataQueriesAfterReconnect(input: { for (const policy of RECONNECT_REPAIR_POLICIES) { policy.invalidate(input); } - for (const repairSubscriptions of reconnectSubscriptionRepairsByServerId.get(input.serverId) ?? - []) { - repairSubscriptions(); - } } -export function applyProvidersSnapshotUpdate(input: { +export async function applyProvidersSnapshotUpdate(input: { serverId: string; queryClient: QueryClient; message: ProvidersSnapshotUpdate; + client: Pick; cache?: ProviderSnapshotCache; -}): void { - if (input.message.type !== "providers_snapshot_update") { - return; - } - const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd); - input.queryClient.setQueryData(queryKey, { - entries: input.message.payload.entries, - generatedAt: input.message.payload.generatedAt, - requestId: "providers_snapshot_update", +}): Promise { + const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" }; + const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd); + const queryKey = providersSnapshotQueryKey(input.serverId, cwd); + const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey); + let announcement: typeof snapshot | undefined = snapshot; + void input.queryClient.cancelQueries({ queryKey, exact: true }); + await input.queryClient.fetchQuery({ + queryKey, + staleTime: 0, + structuralSharing: false, + retry: false, + queryFn: ({ signal }) => { + const incoming = announcement; + announcement = undefined; + return fetchProvidersSnapshot({ + ...input, + cwd, + snapshot: incoming, + signal, + }); + }, }); - const { compactSnapshot, snapshotHash } = input.message.payload; - if (compactSnapshot && snapshotHash) { - void (input.cache ?? providerSnapshotCache).write({ - serverId: input.serverId, - cwd: normalizeProvidersSnapshotCwd(input.message.payload.cwd), - hash: snapshotHash, - generatedAt: input.message.payload.generatedAt, - compactSnapshot, + if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) { + void input.queryClient.invalidateQueries({ + queryKey: agentCommandsQueryRoot(input.serverId), + exact: false, }); } - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); } export function mountServerDataPushRouter(input: PushRouterInput): () => void { - const activeCheckoutDiffSubscriptions = new Map(); - const activeTerminalSubscriptions = new Map(); + const activeCheckoutDiffSubscriptions = new Map(); + const activeTerminalSubscriptions = new Map(); let disposed = false; function reconcileSubscriptions( @@ -253,24 +250,24 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { client: input.client, desired: desiredCheckoutDiffSubscriptions, serverId: input.serverId, + queryClient: input.queryClient, }); reconcileTerminalSubscriptions({ active: activeTerminalSubscriptions, client: input.client, desired: desiredTerminalSubscriptions, + apply: (route, payload) => + applyTerminalsChanged({ + activeCheckoutDiffSubscriptions, + activeTerminalSubscriptions, + queryClient: input.queryClient, + serverId: input.serverId, + route, + message: { type: "terminals_changed", payload }, + }), }); } - function resetSubscriptionsAfterReconnect(): void { - const fallbackActive = { - checkoutDiff: new Map(activeCheckoutDiffSubscriptions), - workspaceTerminals: new Map(activeTerminalSubscriptions), - }; - activeCheckoutDiffSubscriptions.clear(); - activeTerminalSubscriptions.clear(); - reconcileSubscriptions(fallbackActive); - } - const unsubscribeQueryCache = input.queryClient.getQueryCache().subscribe((event) => { if ( !shouldReconcileSubscriptionsForCacheEvent(event, input.serverId, { @@ -282,134 +279,131 @@ export function mountServerDataPushRouter(input: PushRouterInput): () => void { } reconcileSubscriptions(); }); - const unsubscribeProviders = input.client.on("providers_snapshot_update", (message) => { - applyProvidersSnapshotUpdate({ - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); - }); - const unsubscribeDaemonConfig = input.client.on("status", (message) => { - applyDaemonConfigStatus({ queryClient: input.queryClient, serverId: input.serverId, message }); - }); - const unsubscribeCheckoutDiffUpdate = input.client.on("checkout_diff_update", (message) => { - applyCheckoutDiffUpdate({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); - }); - const unsubscribeCheckoutDiffResponse = input.client.on( - "subscribe_checkout_diff_response", - (message) => { - applyCheckoutDiffSubscribeResponse({ - activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); + const events = input.client.observeEvents([ + "providers_snapshot_update", + "status.daemon_config_changed", + ]); + events.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") { + void applyProvidersSnapshotUpdate({ + client: input.client, + queryClient: input.queryClient, + serverId: input.serverId, + message, + }).catch(() => { + /* Query state owns fetch failures; reconnect/refetch repairs them. */ + }); + } + if (message.type === "status") + applyDaemonConfigStatus({ + queryClient: input.queryClient, + serverId: input.serverId, + message, + }); }, - ); - const unsubscribeTerminalsChanged = input.client.on("terminals_changed", (message) => { - applyTerminalsChanged({ - activeCheckoutDiffSubscriptions, - activeTerminalSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - message, - }); }); - let reconnectSubscriptionRepairs = reconnectSubscriptionRepairsByServerId.get(input.serverId); - if (!reconnectSubscriptionRepairs) { - reconnectSubscriptionRepairs = new Set(); - reconnectSubscriptionRepairsByServerId.set(input.serverId, reconnectSubscriptionRepairs); - } - reconnectSubscriptionRepairs.add(resetSubscriptionsAfterReconnect); reconcileSubscriptions(); return () => { disposed = true; - reconnectSubscriptionRepairs.delete(resetSubscriptionsAfterReconnect); - if (reconnectSubscriptionRepairs.size === 0) { - reconnectSubscriptionRepairsByServerId.delete(input.serverId); - } unsubscribeQueryCache(); - unsubscribeProviders(); - unsubscribeDaemonConfig(); - unsubscribeCheckoutDiffUpdate(); - unsubscribeCheckoutDiffResponse(); - unsubscribeTerminalsChanged(); - for (const subscriptionId of activeCheckoutDiffSubscriptions.keys()) { - unsubscribeCheckoutDiff(input.client, subscriptionId); + void events.release().catch(console.error); + for (const current of activeCheckoutDiffSubscriptions.values()) { + void current.subscription.release().catch(console.error); } activeCheckoutDiffSubscriptions.clear(); for (const route of activeTerminalSubscriptions.values()) { - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(route)); + void route.subscription.release().catch(console.error); } activeTerminalSubscriptions.clear(); }; } function reconcileCheckoutDiffSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; serverId: string; + queryClient: QueryClient; }): void { - for (const [subscriptionId, current] of input.active) { - const desired = input.desired.get(subscriptionId); - if (desired && areCheckoutDiffRoutesEqual(current, desired)) { - continue; - } - unsubscribeCheckoutDiff(input.client, subscriptionId); - input.active.delete(subscriptionId); + for (const [key, current] of input.active) { + const desired = input.desired.get(key); + if (desired && areCheckoutDiffRoutesEqual(current, desired)) continue; + input.active.delete(key); + void current.subscription.release().catch(console.error); } - - for (const [subscriptionId, desired] of input.desired) { - if (input.active.has(subscriptionId)) { - continue; - } - input.active.set(subscriptionId, desired); - void input.client - .subscribeCheckoutDiff(desired.cwd, desired.compare, { - subscriptionId, - requestId: `push-router:${input.serverId}:${subscriptionId}`, - }) - .catch((error) => { - if (areCheckoutDiffRoutesEqual(input.active.get(subscriptionId), desired)) { - input.active.delete(subscriptionId); - } - console.error("[server-data] subscribeCheckoutDiff failed", { + for (const [key, desired] of input.desired) { + if (input.active.has(key)) continue; + const subscription = input.client.observeCheckoutDiff(desired.cwd, desired.compare); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = ( + payload: Omit & { requestId?: string }, + ) => { + if (input.active.get(key) !== registration) return; + const { subscriptionId, ...snapshot } = payload; + setCheckoutDiffPayload({ + activeCheckoutDiffSubscriptions: input.active, + queryClient: input.queryClient, + serverId: input.serverId, + subscriptionId: key, + payload: { + ...snapshot, + files: orderCheckoutDiffFiles(payload.files), + requestId: payload.requestId ?? `subscription:${subscriptionId}`, + }, + }); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "checkout_diff_update") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeCheckoutDiff failed", { serverId: input.serverId, cwd: desired.cwd, error, }); - }); + }, + }); } } function reconcileTerminalSubscriptions(input: { - active: Map; + active: Map; client: ServerDataPushClient; desired: Map; + apply(route: WorkspaceTerminalsRoute, payload: TerminalsChangedMessage["payload"]): void; }): void { for (const [key, current] of input.active) { const desired = input.desired.get(key); - if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) { - continue; - } - input.client.unsubscribeTerminals(workspaceTerminalSubscriptionInput(current)); + if (desired && areWorkspaceTerminalsRoutesEqual(current, desired)) continue; input.active.delete(key); + void current.subscription.release().catch(console.error); } - for (const [key, desired] of input.desired) { - if (input.active.has(key)) { - continue; - } - input.active.set(key, desired); - input.client.subscribeTerminals(workspaceTerminalSubscriptionInput(desired)); + if (input.active.has(key)) continue; + const subscription = input.client.observeTerminals(workspaceTerminalSubscriptionInput(desired)); + const registration = { ...desired, subscription }; + input.active.set(key, registration); + const apply = (payload: TerminalsChangedMessage["payload"]) => { + if (input.active.get(key) === registration) input.apply(desired, payload); + }; + subscription.subscribe({ + snapshot: apply, + update: (message) => { + if (message.type === "terminals_changed") apply(message.payload); + }, + error: (error) => { + if (input.active.get(key) === registration) input.active.delete(key); + console.error("[server-data] observeTerminals failed", { cwd: desired.cwd, error }); + }, + }); } } @@ -431,52 +425,6 @@ function applyDaemonConfigStatus(input: { }); } -function applyCheckoutDiffUpdate(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: CheckoutDiffUpdateMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: `subscription:${input.message.payload.subscriptionId}`, - }, - }); -} - -function applyCheckoutDiffSubscribeResponse(input: { - activeCheckoutDiffSubscriptions: Map; - queryClient: QueryClient; - serverId: string; - message: SubscribeCheckoutDiffResponseMessage; -}): void { - setCheckoutDiffPayload({ - activeCheckoutDiffSubscriptions: input.activeCheckoutDiffSubscriptions, - queryClient: input.queryClient, - serverId: input.serverId, - subscriptionId: input.message.payload.subscriptionId, - payload: { - cwd: input.message.payload.cwd, - files: orderCheckoutDiffFiles(input.message.payload.files), - error: input.message.payload.error, - ...(input.message.payload.diffTooLarge !== undefined - ? { diffTooLarge: input.message.payload.diffTooLarge } - : {}), - requestId: input.message.payload.requestId, - }, - }); -} - function setCheckoutDiffPayload(input: { activeCheckoutDiffSubscriptions: Map; queryClient: QueryClient; @@ -500,6 +448,7 @@ function setCheckoutDiffPayload(input: { ) { continue; } + query.setOptions({ ...query.options, structuralSharing: shareCheckoutDiff }); input.queryClient.setQueryData(query.queryKey, input.payload); } } @@ -510,6 +459,7 @@ function applyTerminalsChanged(input: { queryClient: QueryClient; serverId: string; message: TerminalsChangedMessage; + route: WorkspaceTerminalsRoute; }): void { for (const query of input.queryClient.getQueryCache().getAll()) { const route = getActiveServerDataRoute(query, input.serverId, { @@ -519,7 +469,7 @@ function applyTerminalsChanged(input: { if ( !route || route.domain !== "workspaceTerminals" || - route.cwd !== input.message.payload.cwd + !areWorkspaceTerminalsRoutesEqual(route, input.route) ) { continue; } @@ -544,6 +494,14 @@ function getActiveServerDataRoute( if (query.getObserversCount() === 0) { return null; } + // Query metadata belongs to the last observer that updated its options. A + // retained hidden panel must not cancel demand from another visible panel. + const observerRoutes = query.observers + .map((observer) => getServerDataRoute({ meta: observer.options.meta })) + .filter((route): route is ServerDataRoute => route !== null); + if (observerRoutes.length > 0) { + return observerRoutes.find((route) => route.enabled && route.serverId === serverId) ?? null; + } const route = getServerDataRoute(query); if (route) { return route.enabled && route.serverId === serverId ? route : null; @@ -640,7 +598,7 @@ function canEventChangeDesiredSubscriptions(type: QueryCacheNotifyEvent["type"]) ); } -function getServerDataRoute(query: Query): ServerDataRoute | null { +function getServerDataRoute(query: Pick): ServerDataRoute | null { const meta = query.meta; if (!isRecord(meta) || !isRecord(meta.serverData)) { return null; @@ -763,14 +721,6 @@ function workspaceTerminalSubscriptionInput(route: WorkspaceTerminalsRoute): { }; } -function unsubscribeCheckoutDiff(client: ServerDataPushClient, subscriptionId: string): void { - try { - client.unsubscribeCheckoutDiff(subscriptionId); - } catch { - // Disconnect cleanup can race with explicit subscription teardown. - } -} - function isQueryForServer(queryKey: QueryKey, kind: string, serverId: string): boolean { return queryKey.length >= 2 && queryKey[0] === kind && queryKey[1] === serverId; } diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index e1e28ee3b7..2a645f8666 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -31,8 +31,10 @@ type FetchQueryInput = > & { dataShape: "list" | "value"; queryFn: QueryFnOption; - staleTimeMs: number; -}; +} & ( + | { staleTimeMs: number; immutableWhen?: never } + | { staleTimeMs?: never; immutableWhen: (data: TQueryFnData) => boolean } + ); export function useReplicaQuery< TQueryFnData, @@ -88,7 +90,7 @@ function replicaQueryOptions< }; } -function fetchQueryOptions< +export function fetchQueryOptions< TQueryFnData, TError = Error, TData = TQueryFnData, @@ -96,11 +98,11 @@ function fetchQueryOptions< >( input: FetchQueryInput, ): UseQueryOptions { - if (!Number.isFinite(input.staleTimeMs)) { + if (!input.immutableWhen && !Number.isFinite(input.staleTimeMs)) { throw new Error("Fetch queries must declare a finite staleTimeMs."); } - const { dataShape, meta, staleTimeMs, ...options } = input; + const { dataShape, meta, staleTimeMs, immutableWhen, ...options } = input; return { ...options, ...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}), @@ -111,7 +113,10 @@ function fetchQueryOptions< dataShape, }, }, - refetchOnMount: "always", - staleTime: staleTimeMs, + refetchOnMount: immutableWhen ? true : "always", + staleTime: immutableWhen + ? (query) => + query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0 + : staleTimeMs, }; } diff --git a/packages/app/src/desktop/browser/automation/handler.ts b/packages/app/src/desktop/browser/automation/handler.ts index bc30fdadb4..08bb9927e5 100644 --- a/packages/app/src/desktop/browser/automation/handler.ts +++ b/packages/app/src/desktop/browser/automation/handler.ts @@ -1,3 +1,4 @@ +import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host"; import { @@ -69,13 +70,33 @@ export function mountBrowserAutomationHandler( } export function mountBrowserAutomationDaemonClientHandler( - client: unknown, + client: import("@getpaseo/client/internal/daemon-client").DaemonClient, options?: { serverId?: string }, ): () => void { - return mountBrowserAutomationHandler({ - client: client as BrowserAutomationClient, + const observation = client.registerBrowserHost({ + hostKind: "desktop app", + supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES], + }); + const unmount = mountBrowserAutomationHandler({ + client: { + on: (_type, handler) => + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "browser.automation.execute.request") handler(message); + }, + }), + sendBrowserAutomationExecuteResponse: (response) => + client.sendBrowserAutomationExecuteResponse(response), + }, ...(options?.serverId ? { serverId: options.serverId } : {}), }); + return () => { + unmount(); + void observation + .release() + .catch((error) => console.warn("Failed to release browser host", error)); + }; } async function handleBrowserAutomationRequest(params: { diff --git a/packages/app/src/desktop/browser/settings/browser-data-section.tsx b/packages/app/src/desktop/browser/settings/browser-data-section.tsx index 0efc3ebf8e..ad4140d683 100644 --- a/packages/app/src/desktop/browser/settings/browser-data-section.tsx +++ b/packages/app/src/desktop/browser/settings/browser-data-section.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { useToast } from "@/contexts/toast-context"; import { getDesktopHost } from "@/desktop/host"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useBrowserStore } from "@/desktop/browser/store"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -65,7 +65,7 @@ export function BrowserDataSection() {
+
+ + ) : null} + {daemonVersionMismatch ? ( {t("desktop.daemon.versionMismatch")} diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 7d859beef4..609aa9add4 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Check, Terminal } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { useCliInstall } from "@/desktop/hooks/use-install-status"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { openExternalUrl } from "@/utils/open-external-url"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index a036870d56..9209f6fcbf 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) { function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { const { t } = useTranslation(); return ( - + @@ -197,9 +197,11 @@ function RelayConsent(props: PairDeviceBodyProps) { accessibilityLabel={t("pairing.device.relayDocsAccessibility")} /> - {props.enableError ? : null} + {props.enableError ? ( + + ) : null} {!props.canConfigureRelay ? ( - + ) : null} - + ); } diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts index c63f4e13de..831e40f25f 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.test.ts @@ -11,10 +11,16 @@ const runningManagedStatus: DesktopDaemonStatus = { home: "/tmp/paseo", version: "1.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }; -const stoppedStatus: DesktopDaemonStatus = { ...runningManagedStatus, status: "stopped" }; +const stoppedStatus: DesktopDaemonStatus = { + ...runningManagedStatus, + status: "stopped", + ownedByDesktop: false, +}; function makeDeps(overrides?: { confirm?: () => Promise; @@ -128,9 +134,9 @@ describe("executeDaemonManagementToggle", () => { expect(result).toEqual({ kind: "disabled", newStatus: stoppedStatus }); }); - it("skips stop when daemon is running but not desktop-managed", async () => { + it("skips stop for an attached daemon even with a legacy desktop-managed flag", async () => { const { deps, calls } = makeDeps(); - const manuallyManagedStatus = { ...runningManagedStatus, desktopManaged: false }; + const manuallyManagedStatus = { ...runningManagedStatus, ownedByDesktop: false }; const result = await executeDaemonManagementToggle(true, manuallyManagedStatus, deps); diff --git a/packages/app/src/desktop/daemon/daemon-management-toggle.ts b/packages/app/src/desktop/daemon/daemon-management-toggle.ts index efec0ad20c..ea08cbfcdb 100644 --- a/packages/app/src/desktop/daemon/daemon-management-toggle.ts +++ b/packages/app/src/desktop/daemon/daemon-management-toggle.ts @@ -14,7 +14,7 @@ export type DaemonManagementToggleResult = export async function executeDaemonManagementToggle( currentlyManaging: boolean, - daemonStatus: Pick | null, + daemonStatus: Pick | null, deps: DaemonManagementToggleDeps, ): Promise { if (!currentlyManaging) { @@ -32,7 +32,7 @@ export async function executeDaemonManagementToggle( // state reflects what was actually applied if the stop fails. await deps.persistSettings({ manageBuiltInDaemon: false }); - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.ownedByDesktop && ["running", "starting"].includes(daemonStatus.status)) { const newStatus = await deps.stopDaemon(); return { kind: "disabled", newStatus }; } diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 83fd0864cf..87cc058a0c 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,3 +1,5 @@ +import { i18n } from "@/i18n/i18next"; +import { confirmDialog } from "@/utils/confirm-dialog"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; @@ -21,6 +23,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -34,6 +38,25 @@ export interface DesktopAppLogs { contents: string; } +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: string; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + export interface LocalTransportTarget { [key: string]: unknown; transportType: "socket" | "pipe"; @@ -109,6 +132,8 @@ function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { home: toStringOrNull(raw.home) ?? "", version: toStringOrNull(raw.version), desktopManaged: raw.desktopManaged === true, + ownedByDesktop: raw.ownedByDesktop === true, + startedAt: typeof raw.startedAt === "string" ? raw.startedAt : null, error: toStringOrNull(raw.error), }; } @@ -123,6 +148,36 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs { }; } +function parseDesktopUpdaterDiagnosticFile(raw: unknown): DesktopUpdaterDiagnosticFile | null { + if (raw === null) return null; + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostic file response."); + } + return { + path: toStringOrNull(raw.path) ?? "", + exists: raw.exists === true, + modifiedAt: toStringOrNull(raw.modifiedAt), + contents: typeof raw.contents === "string" ? raw.contents : "", + error: toStringOrNull(raw.error), + }; +} + +function parseDesktopUpdaterDiagnostics(raw: unknown): DesktopUpdaterDiagnostics { + if (!isRecord(raw)) { + throw new Error("Unexpected desktop updater diagnostics response."); + } + return { + platform: toStringOrNull(raw.platform) ?? "unknown", + currentVersion: toStringOrNull(raw.currentVersion) ?? "unknown", + targetVersion: toStringOrNull(raw.targetVersion), + targetVersionError: toStringOrNull(raw.targetVersionError), + shipItDirectory: toStringOrNull(raw.shipItDirectory), + state: parseDesktopUpdaterDiagnosticFile(raw.state), + stdout: parseDesktopUpdaterDiagnosticFile(raw.stdout), + stderr: parseDesktopUpdaterDiagnosticFile(raw.stderr), + }; +} + export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -141,6 +196,33 @@ export async function stopDesktopDaemon( return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon", { reason })); } +export async function confirmAndStopDesktopDaemon(): Promise { + const captured = await getDesktopDaemonStatus(); + if (!captured.pid || !captured.startedAt) return captured; + const ownership = captured.ownedByDesktop + ? i18n.t("desktop.daemon.lifecycle.ownedMessage") + : i18n.t("desktop.daemon.lifecycle.attachedMessage"); + const confirmed = await confirmDialog({ + title: i18n.t("desktop.daemon.lifecycle.stopTitle"), + message: i18n.t("desktop.daemon.lifecycle.stopMessage", { + ownership, + home: captured.home, + pid: captured.pid, + }), + confirmLabel: i18n.t("desktop.daemon.lifecycle.stop"), + cancelLabel: i18n.t("common.actions.cancel"), + destructive: true, + }); + if (!confirmed) return null; + return parseDesktopDaemonStatus( + await invokeDesktopCommand("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }), + ); +} + export async function restartDesktopDaemon(): Promise { return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon")); } @@ -160,6 +242,10 @@ export async function getDesktopAppLogs(): Promise { }; } +export async function getDesktopUpdaterDiagnostics(): Promise { + return parseDesktopUpdaterDiagnostics(await invokeDesktopCommand("desktop_update_diagnostics")); +} + export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { @@ -249,3 +335,20 @@ export function readLegacySkillSelection(): Promise export async function deleteLegacySkillSelection(): Promise { await invokeDesktopCommand("delete_legacy_skill_selection"); } + +export interface DesktopSandboxDiagnostics { + enabled: boolean; + reason: string; +} + +export async function getDesktopSandboxDiagnostics(): Promise { + const result: unknown = await invokeDesktopCommand("desktop_sandbox_diagnostics"); + if ( + !isRecord(result) || + typeof result.enabled !== "boolean" || + typeof result.reason !== "string" + ) { + throw new Error("Unexpected desktop sandbox diagnostics response."); + } + return { enabled: result.enabled, reason: result.reason }; +} diff --git a/packages/app/src/desktop/daemon/local-credential.ts b/packages/app/src/desktop/daemon/local-credential.ts new file mode 100644 index 0000000000..005abdc21f --- /dev/null +++ b/packages/app/src/desktop/daemon/local-credential.ts @@ -0,0 +1,29 @@ +import type { HostConnection } from "@/types/host-connection"; +import { getDesktopHost } from "@/desktop/host"; + +function connectionListen(connection: HostConnection): string | null { + switch (connection.type) { + case "directTcp": + return connection.endpoint; + case "directSocket": + return `unix://${connection.path}`; + case "directPipe": + return `pipe://${connection.path}`; + case "remoteSsh": + case "relay": + return null; + } +} + +// Desktop main checks that this target matches the live desktop-managed daemon. +// A plain browser has no bridge and falls back to its saved host password. +export async function readDesktopManagedLocalCredential( + connection: HostConnection, + invoke: (listen: string) => Promise = async (listen) => + getDesktopHost()?.invoke?.("desktop_local_credential", { listen }), +): Promise { + const listen = connectionListen(connection); + if (!listen) return undefined; + const credential = await invoke(listen); + return typeof credential === "string" ? credential : undefined; +} diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index 16076b4d6d..71bbce378c 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -58,8 +58,16 @@ export function useBuiltInDaemonManagement( confirm: () => confirmDialog({ title: t("desktop.daemon.management.pauseTitle"), - message: t("desktop.daemon.management.pauseMessage"), - confirmLabel: t("desktop.daemon.management.pauseAndStop"), + message: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseMessage" + : "desktop.daemon.lifecycle.pauseAttached", + ), + confirmLabel: t( + daemonStatus?.ownedByDesktop + ? "desktop.daemon.management.pauseAndStop" + : "desktop.daemon.lifecycle.pause", + ), cancelLabel: t("common.actions.cancel"), destructive: true, }), diff --git a/packages/app/src/desktop/updates/update-callout-source.tsx b/packages/app/src/desktop/updates/update-callout-source.tsx index d442651cb8..62cf7fede6 100644 --- a/packages/app/src/desktop/updates/update-callout-source.tsx +++ b/packages/app/src/desktop/updates/update-callout-source.tsx @@ -14,10 +14,9 @@ import { } from "@/desktop/updates/resolve-update-callout"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { openExternalUrl } from "@/utils/open-external-url"; +import { openChangelog } from "@/changelog"; const CHECK_INTERVAL_MS = 30 * 60 * 1000; -const CHANGELOG_URL = "https://paseo.sh/changelog"; function renderBody(body: UpdateCalloutBody, t: ReturnType["t"]): ReactNode { if (body.kind === "installing") return t("desktop.updates.callout.installingDescription"); @@ -52,9 +51,6 @@ export function UpdateCalloutSource() { } = useDesktopAppUpdater(); const intervalRef = useRef | null>(null); - const openChangelog = useStableEvent(() => { - void openExternalUrl(CHANGELOG_URL); - }); const install = useStableEvent(() => { void installUpdate(); }); @@ -111,7 +107,6 @@ export function UpdateCalloutSource() { install, isDesktopApp, isInstalling, - openChangelog, retry, status, theme.colors.foregroundMuted, diff --git a/packages/app/src/diagnostics/app-diagnostic-report.test.ts b/packages/app/src/diagnostics/app-diagnostic-report.test.ts index bc6cad94b6..df10b01fed 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.test.ts @@ -11,6 +11,7 @@ import { defaultHostAppearance } from "@/hosts/appearance"; function makeHost(): HostProfile { return { serverId: "srv-secret", + password: "tcp-password", label: "Secret host", appearance: defaultHostAppearance(), lifecycle: {}, @@ -23,7 +24,6 @@ function makeHost(): HostProfile { type: "directTcp", endpoint: "secret.example.test:6767", useTls: true, - password: "tcp-password", }, { id: "relay:relay.secret.test:443", diff --git a/packages/app/src/diagnostics/app-diagnostic-report.ts b/packages/app/src/diagnostics/app-diagnostic-report.ts index 6a5764ff99..afb38e7aad 100644 --- a/packages/app/src/diagnostics/app-diagnostic-report.ts +++ b/packages/app/src/diagnostics/app-diagnostic-report.ts @@ -125,11 +125,11 @@ export function redactAppDiagnosticReport(report: string, hosts: HostProfile[]): function collectSensitiveHostValues(hosts: HostProfile[]): string[] { const values = new Set(); for (const host of hosts) { + if (host.password) values.add(host.password); for (const connection of host.connections) { values.add(connection.id); if (connection.type === "directTcp") { values.add(connection.endpoint); - if (connection.password) values.add(connection.password); } else if (connection.type === "relay") { values.add(connection.relayEndpoint); values.add(connection.daemonPublicKeyB64); diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts index e26e1e776f..295344458f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.test.ts @@ -6,6 +6,7 @@ import { function makeSources(): DesktopDiagnosticSources { return { + getSandboxDiagnostics: async () => ({ enabled: true, reason: "user namespaces available" }), getStatus: async () => ({ serverId: "server-1", status: "running", @@ -15,6 +16,8 @@ function makeSources(): DesktopDiagnosticSources { home: "/paseo/home", version: "1.2.3", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, }), getDaemonLogs: async () => ({ @@ -25,6 +28,34 @@ function makeSources(): DesktopDiagnosticSources { logPath: "/logs/Paseo/main.log", contents: "[login-shell-env] start\n[login-shell-env] failed", }), + getUpdaterDiagnostics: async () => ({ + platform: "darwin", + currentVersion: "0.7.0", + targetVersion: "0.7.2", + targetVersionError: null, + shipItDirectory: "/cache/sh.paseo.desktop.ShipIt", + state: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipItState.plist", + exists: true, + modifiedAt: "2026-09-04T11:22:19.000Z", + contents: '{"launchAfterInstallation":true}', + error: null, + }, + stdout: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stdout.log", + exists: false, + modifiedAt: null, + contents: "", + error: null, + }, + stderr: { + path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stderr.log", + exists: true, + modifiedAt: "2026-09-01T08:39:20.000Z", + contents: "Installation completed successfully", + error: null, + }, + }), }; } @@ -50,15 +81,33 @@ describe("desktop diagnostic report", () => { await appLogGate; return makeSources().getAppLogs(); }, + getUpdaterDiagnostics: async () => { + calls.push("updater"); + return makeSources().getUpdaterDiagnostics(); + }, }; const resultPromise = collectDesktopDiagnosticSections(sources); - expect(calls).toEqual(["status", "daemonLogs", "appLogs"]); + expect(calls).toEqual(["status", "daemonLogs", "appLogs", "updater"]); releaseAppLogs(); await expect(resultPromise).resolves.toMatchObject({ status: "done" }); }); + test("includes target version lookup errors", async () => { + const sources: DesktopDiagnosticSources = { + ...makeSources(), + getUpdaterDiagnostics: async () => ({ + ...(await makeSources().getUpdaterDiagnostics()), + targetVersion: null, + targetVersionError: "plutil failed", + }), + }; + + const result = await collectDesktopDiagnosticSections(sources); + expect(result.sections.join("\n\n")).toContain("Target version error: plutil failed"); + }); + test("includes the Electron main-process log after the daemon log", async () => { const result = await collectDesktopDiagnosticSections(makeSources()); const report = result.sections.join("\n\n"); @@ -73,6 +122,14 @@ describe("desktop diagnostic report", () => { expect(report.indexOf("Desktop app log tail")).toBeGreaterThan( report.indexOf("Desktop daemon log tail"), ); + expect(report).toContain("Desktop updater\n Platform: darwin"); + expect(report).toContain(" Current version: 0.7.0"); + expect(report).toContain(" Target version: 0.7.2"); + expect(report).toContain("ShipItState.plist"); + expect(report).toContain('{"launchAfterInstallation":true}'); + expect(report).toContain("ShipIt stdout log tail"); + expect(report).toContain(" File not found"); + expect(report).toContain("Installation completed successfully"); }); test("keeps daemon diagnostics when the Electron app log fails", async () => { @@ -91,3 +148,20 @@ describe("desktop diagnostic report", () => { expect(report).toContain("Desktop app log tail\n Error: app log unavailable"); }); }); + +test("reports sandbox degradation and its reason even if daemon diagnostics fail", async () => { + const result = await collectDesktopDiagnosticSections({ + ...makeSources(), + getStatus: async () => { + throw new Error("daemon unavailable"); + }, + getSandboxDiagnostics: async () => ({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }), + }); + expect(result.sections.join("\n")).toContain( + "Chromium sandbox\n State: Disabled\n Reason: user namespaces unavailable; no usable SUID helper", + ); + expect(result.status).toBe("failed"); +}); diff --git a/packages/app/src/diagnostics/desktop-diagnostic-report.ts b/packages/app/src/diagnostics/desktop-diagnostic-report.ts index 8fd26c6746..22eb7e388f 100644 --- a/packages/app/src/diagnostics/desktop-diagnostic-report.ts +++ b/packages/app/src/diagnostics/desktop-diagnostic-report.ts @@ -1,10 +1,15 @@ import { + getDesktopSandboxDiagnostics, + type DesktopSandboxDiagnostics, getDesktopAppLogs, getDesktopDaemonLogs, getDesktopDaemonStatus, + getDesktopUpdaterDiagnostics, type DesktopAppLogs, type DesktopDaemonLogs, type DesktopDaemonStatus, + type DesktopUpdaterDiagnosticFile, + type DesktopUpdaterDiagnostics, } from "@/desktop/daemon/desktop-daemon"; import { formatDiagnosticSection } from "./app-diagnostic-report"; @@ -16,15 +21,19 @@ export interface DesktopDiagnosticCollectionResult { } export interface DesktopDiagnosticSources { + getSandboxDiagnostics: () => Promise; getStatus: () => Promise; getDaemonLogs: () => Promise; getAppLogs: () => Promise; + getUpdaterDiagnostics: () => Promise; } const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = { + getSandboxDiagnostics: getDesktopSandboxDiagnostics, getStatus: getDesktopDaemonStatus, getDaemonLogs: getDesktopDaemonLogs, getAppLogs: getDesktopAppLogs, + getUpdaterDiagnostics: getDesktopUpdaterDiagnostics, }; export async function collectDesktopDiagnosticSections( @@ -33,11 +42,33 @@ export async function collectDesktopDiagnosticSections( const sections: string[] = []; let failed = false; - const [daemonResult, appLogsResult] = await Promise.allSettled([ + const [daemonResult, appLogsResult, updaterResult, sandboxResult] = await Promise.allSettled([ Promise.all([sources.getStatus(), sources.getDaemonLogs()]), sources.getAppLogs(), + sources.getUpdaterDiagnostics(), + sources.getSandboxDiagnostics(), ]); + if (sandboxResult.status === "fulfilled") { + const sandbox = sandboxResult.value; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { + label: "State", + value: sandbox.enabled ? "Enabled" : "Disabled", + }, + { label: "Reason", value: sandbox.reason }, + ]), + ); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Chromium sandbox", [ + { label: "Error", value: toMessage(sandboxResult.reason) }, + ]), + ); + } + if (daemonResult.status === "fulfilled") { const [status, daemonLogs] = daemonResult.value; const appLogs = appLogsResult.status === "fulfilled" ? appLogsResult.value : null; @@ -62,12 +93,63 @@ export async function collectDesktopDiagnosticSections( ); } + if (updaterResult.status === "fulfilled") { + sections.push(...formatDesktopUpdaterSections(updaterResult.value)); + } else { + failed = true; + sections.push( + formatDiagnosticSection("Desktop updater", [ + { label: "Error", value: toMessage(updaterResult.reason) }, + ]), + ); + } + return { status: failed ? "failed" : "done", sections, }; } +function formatDesktopUpdaterSections(diagnostics: DesktopUpdaterDiagnostics): string[] { + const updaterDetails = [ + { label: "Platform", value: diagnostics.platform }, + { label: "Current version", value: diagnostics.currentVersion }, + { label: "Target version", value: diagnostics.targetVersion ?? "unknown" }, + { label: "ShipIt directory", value: diagnostics.shipItDirectory ?? "not applicable" }, + ]; + if (diagnostics.targetVersionError) { + updaterDetails.push({ label: "Target version error", value: diagnostics.targetVersionError }); + } + const sections = [formatDiagnosticSection("Desktop updater", updaterDetails)]; + + if (diagnostics.platform !== "darwin") return sections; + + sections.push( + formatUpdaterFileSection("ShipItState.plist", diagnostics.state), + formatUpdaterFileSection("ShipIt stdout log tail", diagnostics.stdout), + formatUpdaterFileSection("ShipIt stderr log tail", diagnostics.stderr), + ); + return sections; +} + +function formatUpdaterFileSection( + title: string, + file: DesktopUpdaterDiagnosticFile | null, +): string { + if (!file) { + return formatDiagnosticSection(title, [{ label: "Status", value: "unavailable" }]); + } + + const header = formatDiagnosticSection(title, [ + { label: "Path", value: file.path || "unknown" }, + { label: "Modified", value: file.modifiedAt ?? "unknown" }, + ]); + if (file.error) return `${header}\n Error: ${file.error}`; + if (!file.exists) return `${header}\n File not found`; + if (!file.contents) return `${header}\n No contents found`; + return `${header}\n${indentBlock(file.contents)}`; +} + function formatDesktopDaemonSections(input: { status: DesktopDaemonStatus; daemonLogs: DesktopDaemonLogs; diff --git a/packages/app/src/file-pane/editor/extensions.web.ts b/packages/app/src/file-pane/editor/extensions.web.ts index 47fc0ab3c5..e9310bd554 100644 --- a/packages/app/src/file-pane/editor/extensions.web.ts +++ b/packages/app/src/file-pane/editor/extensions.web.ts @@ -5,7 +5,6 @@ import { indentOnInput, syntaxHighlighting, } from "@codemirror/language"; -import { searchKeymap } from "@codemirror/search"; import { EditorView, drawSelection, @@ -42,7 +41,6 @@ export function editorBaseExtensions(onSave: () => void) { indentWithTab, ...defaultKeymap, ...historyKeymap, - ...searchKeymap, ]), ]; } @@ -63,6 +61,13 @@ export function editorTheme(theme: EditorVisualTheme) { fontFamily: theme.monoFont, lineHeight: "1.45", }, + ".cm-panels": { backgroundColor: theme.background, color: theme.foreground }, + ".cm-panels-top": { borderBottom: "none" }, + ".cm-searchMatch": { backgroundColor: theme.selection }, + ".cm-searchMatch-selected": { + backgroundColor: theme.selection, + outline: `1px solid ${theme.cursor}`, + }, ".cm-content": { caretColor: theme.foreground, padding: "16px 0" }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: theme.cursor }, ".cm-gutters": { diff --git a/packages/app/src/file-pane/editor/view.web.tsx b/packages/app/src/file-pane/editor/view.web.tsx index 03ab27a778..d396697c41 100644 --- a/packages/app/src/file-pane/editor/view.web.tsx +++ b/packages/app/src/file-pane/editor/view.web.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { FileFind, FileFindModel } from "../find/index.web"; import { Annotation, Compartment, EditorState, Transaction } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; @@ -38,6 +39,7 @@ export function FileEditorView({ onCursorChange, onVimModeChange, }: FileEditorViewProps) { + const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); @@ -54,6 +56,7 @@ export function FileEditorView({ doc: values.content, extensions: [ vimCompartment.of(values.vimEnabled ? vim() : []), + find.extension, ...editorBaseExtensions(() => void values.model.save()), languageCompartment.of(getLanguageForFile(values.filename)?.extension ?? []), wrappingCompartment.of(wrappingForFile(values.filename)), @@ -81,7 +84,7 @@ export function FileEditorView({ view.destroy(); viewRef.current = null; }; - }, []); + }, [find]); useEffect(() => { const view = viewRef.current; @@ -141,15 +144,25 @@ export function FileEditorView({ }, [onVimModeChange, vimEnabled]); return ( -
+
+
+ +
); } const remoteUpdate = Annotation.define(); +const FRAME_STYLE = { + display: "flex", + position: "relative", + flex: 1, + minHeight: 0, + minWidth: 0, +} as const; const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; diff --git a/packages/app/src/file-pane/find/index.web.tsx b/packages/app/src/file-pane/find/index.web.tsx new file mode 100644 index 0000000000..dedf58389f --- /dev/null +++ b/packages/app/src/file-pane/find/index.web.tsx @@ -0,0 +1,110 @@ +import { + useCallback, + useMemo, + useEffect, + useRef, + useSyncExternalStore, + type RefObject, +} from "react"; +import { EditorView } from "@codemirror/view"; +import { View, type View as ViewInstance } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find"; +import { usePaneFocus } from "@/panels/pane-context"; +import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import { FileFindModel } from "./model.web"; + +export { FileFindModel } from "./model.web"; + +export function FileFind({ + model, + editor, +}: { + model: FileFindModel; + editor: RefObject; +}) { + const { t } = useTranslation(); + const state = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + const widget = useRef(null); + const { isInteractive } = usePaneFocus(); + const active = useRetainedPanelActive(); + // RN Web hands the underlying DOM element to a View ref; the model measures it. + const setWidgetNode = useCallback( + (node: ViewInstance | null) => model.setWidgetNode(node as unknown as HTMLElement | null), + [model], + ); + useEffect(() => { + if (!isInteractive || !active) return; + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + return; + if (isFindShortcut(event, findShortcutPlatform())) { + event.preventDefault(); + model.open(editor.current); + widget.current?.focus(); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [active, editor, isInteractive, model]); + + const replace = useMemo( + () => + state.readOnly + ? undefined + : { + value: state.replacement, + onChange: model.setReplacement, + onReplace: model.replace, + onReplaceAll: model.replaceAll, + }, + [model, state.readOnly, state.replacement], + ); + if (!state.open) return null; + const total = `${state.total}${state.limited ? "+" : ""}`; + let status = ""; + if (state.query) { + if (state.total === 0) status = t("paneFind.noMatches"); + else if (state.current) status = t("paneFind.position", { current: state.current, total }); + else status = t("paneFind.total", { total }); + } + return ( + + + 0} + onQueryChange={model.setSearch} + onNext={model.next} + onPrevious={model.previous} + onClose={model.close} + replace={replace} + /> + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The widget floats over the content; the model flips the corner when it would + // otherwise sit on top of the active match. + overlay: { + position: "absolute", + left: theme.spacing[2], + right: theme.spacing[2], + alignItems: "flex-end", + zIndex: 1, + }, + // Bounds the widget to the pane; PaneFind's own maxWidth resolves against this. + widget: { maxWidth: "100%" }, + overlayTop: { top: theme.spacing[2] }, + overlayBottom: { bottom: theme.spacing[2] }, +})); diff --git a/packages/app/src/file-pane/find/model.web.ts b/packages/app/src/file-pane/find/model.web.ts new file mode 100644 index 0000000000..0ce22500a9 --- /dev/null +++ b/packages/app/src/file-pane/find/model.web.ts @@ -0,0 +1,255 @@ +import { EditorState, type Extension } from "@codemirror/state"; +import { EditorView, keymap, type Panel, type ViewUpdate } from "@codemirror/view"; +import { + closeSearchPanel, + findNext, + findPrevious, + getSearchQuery, + openSearchPanel, + replaceAll, + replaceNext, + search, + searchKeymap, + SearchQuery, + setSearchQuery, +} from "@codemirror/search"; + +const MATCH_STATUS_LIMIT = 10_000; + +/** Which corner of the editor the floating Find widget occupies. */ +export type FindPlacement = "top" | "bottom"; + +/** CodeMirror owns query, matching, selection, replacement, and panel lifetime. */ +export class FileFindModel { + private view: EditorView | null = null; + private listeners = new Set<() => void>(); + private matches: Array<{ from: number; to: number }> = []; + private widget: HTMLElement | null = null; + private widgetResize: ResizeObserver | null = null; + private snapshot = { + open: false, + placement: "top" as FindPlacement, + query: "", + replacement: "", + current: 0, + total: 0, + limited: false, + readOnly: true, + }; + readonly subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + readonly getSnapshot = () => this.snapshot; + + readonly extension: Extension = [ + // The widget floats over the editor as a sibling overlay, so this panel is an + // inert placeholder that only reports whether Find is open. Hide the placeholder + // itself, never the shared panel container — Go to line's dialog lives there too. + EditorView.theme({ ".cm-panel.paseo-file-find": { display: "none" } }), + // Reveal matches clear of the widget instead of underneath it. + EditorView.scrollMargins.of((view) => { + const clearance = this.clearance(view); + if (!clearance) return null; + return this.snapshot.placement === "top" ? { top: clearance } : { bottom: clearance }; + }), + search({ literal: true, top: true, createPanel: (view) => this.createPanel(view) }), + EditorState.transactionExtender.of((transaction) => { + // CodeMirror seeds every search-opening command from the selection. A + // single-line field cannot represent CR/LF, so keep the previous query. + const multilineSeed = transaction.effects.some( + (effect) => effect.is(setSearchQuery) && /[\r\n]/.test(effect.value.search), + ); + if (!multilineSeed) return null; + return { effects: setSearchQuery.of(getSearchQuery(transaction.startState)) }; + }), + keymap.of(searchKeymap.filter((binding) => binding.key !== "Mod-f")), + ]; + + readonly open = (view = this.view) => { + if (view) openSearchPanel(view); + }; + readonly close = () => { + if (this.view) { + closeSearchPanel(this.view); + this.view.focus(); + } + }; + readonly next = () => { + if (this.view) findNext(this.view); + }; + readonly previous = () => { + if (this.view) findPrevious(this.view); + }; + readonly replace = () => { + if (this.view) replaceNext(this.view); + }; + readonly replaceAll = () => { + if (this.view) replaceAll(this.view); + }; + readonly setReplacement = (replacement: string) => + this.setQuery(this.snapshot.query, replacement); + readonly setSearch = (query: string) => { + this.setQuery(query, this.snapshot.replacement); + if (this.view && this.matches.length > 0) { + this.view.dispatch({ selection: { anchor: this.view.state.selection.main.from } }); + findNext(this.view); + } + }; + + private setQuery(query: string, replacement: string) { + this.view?.dispatch({ + effects: setSearchQuery.of( + new SearchQuery({ search: query, replace: replacement, literal: true }), + ), + }); + } + + /** + * The widget renders outside CodeMirror, so the editor cannot see it. Handing the + * node here lets this model — which already owns reveal — measure the obstruction. + */ + readonly setWidgetNode = (node: HTMLElement | null) => { + if (this.widget === node) return; + this.widgetResize?.disconnect(); + this.widgetResize = null; + this.widget = node; + if (!node) return; + this.widgetResize = new ResizeObserver(() => this.reposition()); + this.widgetResize.observe(node); + this.reposition(); + }; + + private createPanel(view: EditorView): Panel { + this.view = view; + const dom = document.createElement("div"); + dom.className = "paseo-file-find"; + return { + dom, + top: true, + mount: () => { + this.snapshot = { ...this.snapshot, open: true, placement: "top" }; + this.update(view, true); + }, + update: (update: ViewUpdate) => { + const queryChanged = !getSearchQuery(update.startState).eq(getSearchQuery(update.state)); + if ( + update.docChanged || + update.selectionSet || + queryChanged || + update.geometryChanged || + update.viewportChanged + ) + this.update(view, update.docChanged || queryChanged); + }, + destroy: () => { + this.snapshot = { ...this.snapshot, open: false, placement: "top" }; + this.publish(); + }, + }; + } + + /** Vertical space the widget takes out of the editor, including its inset. */ + private clearance(view: EditorView): number { + const widget = this.widget; + if (!widget || !this.snapshot.open) return 0; + const box = widget.getBoundingClientRect(); + if (!box.height) return 0; + const editor = view.scrollDOM.getBoundingClientRect(); + const clearance = + this.snapshot.placement === "top" ? box.bottom - editor.top : editor.bottom - box.top; + return Math.max(0, clearance); + } + + /** + * Move the widget to the opposite corner when it sits on top of the active match + * and the editor has no scroll range left to reveal it. Placement only changes + * when the current corner collides, so navigating matches never makes it bounce. + * + * Measurement runs through `requestMeasure` because CodeMirror refuses layout + * reads while an update is in progress, and every caller here sits inside one. + */ + private reposition() { + const view = this.view; + if (!view || !this.widget || !this.snapshot.open) return; + view.requestMeasure({ + key: this, + read: () => this.measurePlacement(view), + write: (placement) => { + if (placement === this.snapshot.placement) return; + this.snapshot = { ...this.snapshot, placement }; + this.publish(); + }, + }); + } + + private measurePlacement(view: EditorView): FindPlacement { + const current = this.snapshot.placement; + const widget = this.widget; + if (!widget) return current; + const selection = view.state.selection.main; + if (selection.empty) return current; + const from = view.coordsAtPos(selection.from); + const to = view.coordsAtPos(selection.to); + if (!from || !to) return current; + const clearance = this.clearance(view); + if (!clearance) return current; + const box = widget.getBoundingClientRect(); + const editor = view.scrollDOM.getBoundingClientRect(); + const match = { + top: Math.min(from.top, to.top), + bottom: Math.max(from.bottom, to.bottom), + right: Math.max(from.right, to.right), + }; + const hits = (placement: FindPlacement) => { + if (match.right <= box.left) return false; + return placement === "top" + ? match.top < editor.top + clearance && match.bottom > editor.top + : match.bottom > editor.bottom - clearance && match.top < editor.bottom; + }; + if (!hits(current)) return current; + const other: FindPlacement = current === "top" ? "bottom" : "top"; + return hits(other) ? current : other; + } + + private update(view: EditorView, recount: boolean) { + const query = getSearchQuery(view.state); + let limited = this.snapshot.limited; + if (recount) { + this.matches = []; + limited = false; + if (query.valid) { + const cursor = query.getCursor(view.state); + for (let match = cursor.next(); !match.done; match = cursor.next()) { + if (this.matches.length === MATCH_STATUS_LIMIT) { + limited = true; + break; + } + this.matches.push(match.value); + } + } + } + const selection = view.state.selection.main; + const current = + this.matches.findIndex( + (match) => match.from === selection.from && match.to === selection.to, + ) + 1; + this.snapshot = { + ...this.snapshot, + query: query.search, + replacement: query.replace, + current, + total: this.matches.length, + limited, + readOnly: view.state.readOnly, + }; + this.publish(); + this.reposition(); + } + + private publish() { + for (const listener of this.listeners) listener(); + } +} diff --git a/packages/app/src/file-pane/markdown-preview/image-source.test.ts b/packages/app/src/file-pane/markdown-preview/image-source.test.ts new file mode 100644 index 0000000000..8277e57492 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMarkdownFileDirectory, resolveMarkdownPreviewImageSource } from "./image-source"; + +describe("resolveMarkdownFileDirectory", () => { + it("uses the parent of an absolute markdown path", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "/Users/test/project/docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs"); + }); + + it("joins a workspace-relative markdown path to the workspace root", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "docs/星谷云/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/星谷云"); + }); + + it("preserves the host home directory for home-relative markdown paths", () => { + expect( + resolveMarkdownFileDirectory({ + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans"); + }); +}); + +describe("resolveMarkdownPreviewImageSource", () => { + it("passes through remote and data URIs", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "https://example.com/image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("https://example.com/image.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "data:image/png;base64,abc", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("resolves a sibling image against the markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/docs/images/hero.png"); + }); + + it("ignores local URL query strings and fragments", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./images/hero.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/images/hero.png"); + }); + + it("preserves encoded filename characters when stripping URL suffixes", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "./hero%23draft%3Fv.png?v=2#preview", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/workspace/docs/hero#draft?v.png"); + }); + + it("preserves remote URL query strings and fragments", () => { + const source = "https://example.com/hero.png?v=2#preview"; + expect(resolveMarkdownPreviewImageSource({ source, markdownPath: "docs/guide.md" })).toBe( + source, + ); + }); + + it("resolves parent-relative images that stay inside the workspace", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../../data/cache/xinggu_docs/12-linkedin/token.png", + markdownPath: "docs/星谷云/12-LinkedIn.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/Users/test/project/data/cache/xinggu_docs/12-linkedin/token.png"); + }); + + it("keeps absolute and home-relative image paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "/tmp/screenshot.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/screenshot.png"); + expect( + resolveMarkdownPreviewImageSource({ + source: "~/.paseo/screenshots/output.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/screenshots/output.png"); + }); + + it("normalizes file URIs into host paths", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/paseo-preview.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("/tmp/paseo-preview.png"); + }); + + it("resolves images beside a home-relative document without using the workspace root", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "screenshots/output.png", + markdownPath: "~/.paseo/plans/note.md", + workspaceRoot: "/Users/test/project", + }), + ).toBe("~/.paseo/plans/screenshots/output.png"); + }); + + it("decodes a file URI exactly once", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "file:///tmp/hero%2520image.png", + markdownPath: "docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("/tmp/hero%20image.png"); + }); + + it("resolves parent segments from a home-relative document", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "../images/hero.png", + markdownPath: "~/docs/guide.md", + workspaceRoot: "/workspace", + }), + ).toBe("~/images/hero.png"); + }); + + it("resolves Windows relative images against a drive-rooted markdown file", () => { + expect( + resolveMarkdownPreviewImageSource({ + source: "..\\images\\hero.png", + markdownPath: "C:\\Users\\test\\repo\\docs\\guide.md", + workspaceRoot: "C:\\Users\\test\\repo", + }), + ).toBe("C:/Users/test/repo/images/hero.png"); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/image-source.ts b/packages/app/src/file-pane/markdown-preview/image-source.ts new file mode 100644 index 0000000000..5316d94279 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/image-source.ts @@ -0,0 +1,150 @@ +import { localFileSourceToPath } from "@/attachments/utils"; +import { isAbsolutePath } from "@/utils/path"; + +function trimTrailingSeparators(value: string): string { + if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) { + return value.replace(/\\/g, "/"); + } + return value.replace(/[\\/]+$/, ""); +} + +function normalizePathSegments(value: string): string { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length > 0) { + segments.pop(); + } + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSeparators(value.replace(/\\/g, "/")); + if (isHomeRelativePath(normalizedInput)) { + return `~/${normalizePathSegments(normalizedInput.slice(2))}`; + } + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + return trimTrailingSeparators(`${drivePath[1]}/${normalizePathSegments(drivePath[2])}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + return trimTrailingSeparators( + `${prefix}${normalizePathSegments(normalizedInput.replace(/^\/+/, ""))}`, + ); +} + +function parentDirectory(path: string): string | null { + const normalized = trimTrailingSeparators(path.replace(/\\/g, "/")); + if (!normalized) { + return null; + } + if (normalized === "/" || /^[A-Za-z]:\/?$/.test(normalized)) { + return normalized.endsWith("/") ? normalized : `${normalized}/`; + } + + const index = normalized.lastIndexOf("/"); + if (index < 0) { + return null; + } + if (index === 0) { + return "/"; + } + const parent = normalized.slice(0, index); + if (/^[A-Za-z]:$/.test(parent)) { + return `${parent}/`; + } + return parent; +} + +function joinAbsolute(baseDirectory: string, relativePath: string): string | null { + const directory = trimTrailingSeparators(baseDirectory.replace(/\\/g, "/")); + const relative = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + if (!directory) { + return null; + } + return normalizeAbsolutePath(`${directory}/${relative}`); +} + +function isHomeRelativePath(value: string): boolean { + return value === "~" || value.startsWith("~/") || value.startsWith("~\\"); +} + +function decodeRelativeSource(source: string): string { + try { + return decodeURIComponent(source); + } catch { + return source; + } +} + +export function resolveMarkdownFileDirectory(input: { + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const markdownPath = input.markdownPath.trim().replace(/\\/g, "/"); + if (!markdownPath) { + return null; + } + + if (isAbsolutePath(markdownPath) || isHomeRelativePath(markdownPath)) { + const absolute = normalizeAbsolutePath(markdownPath); + return absolute ? parentDirectory(absolute) : null; + } + + const workspaceRoot = input.workspaceRoot?.trim(); + if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) { + return null; + } + + const absoluteMarkdown = joinAbsolute(workspaceRoot, markdownPath); + return absoluteMarkdown ? parentDirectory(absoluteMarkdown) : null; +} + +export function resolveMarkdownPreviewImageSource(input: { + source: string; + markdownPath: string; + workspaceRoot?: string; +}): string | null { + const source = input.source.trim(); + if (!source) { + return null; + } + + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } + + const pathSource = source.replace(/[?#].*$/, ""); + const path = localFileSourceToPath( + pathSource.startsWith("file://") ? pathSource : decodeRelativeSource(pathSource), + ); + if (!path) { + return null; + } + + if (isHomeRelativePath(path) || isAbsolutePath(path)) { + return path; + } + + const directory = resolveMarkdownFileDirectory({ + markdownPath: input.markdownPath, + workspaceRoot: input.workspaceRoot, + }); + if (!directory) { + return path; + } + + return joinAbsolute(directory, path); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx index f1247f1c91..a79705323f 100644 --- a/packages/app/src/file-pane/markdown-preview/index.tsx +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -1,16 +1,63 @@ import { useMemo } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ASTNode, RenderRules } from "react-native-markdown-display"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { + createSharedMarkdownRules, + MarkdownRenderer, + type MarkdownStyles, +} from "@/components/markdown/renderer"; import { parseMarkdownPreviewDocument } from "./document"; +import { resolveMarkdownPreviewImageSource } from "./image-source"; +import { FileMarkdownPreviewImage } from "./preview-image"; -export function FileMarkdownPreview({ source }: { source: string }) { +export function FileMarkdownPreview({ + source, + filePath, + workspaceRoot, + client, + serverId, +}: { + source: string; + filePath: string; + workspaceRoot?: string; + client?: DaemonClient | null; + serverId?: string; +}) { const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + const rules = useMemo( + () => ({ + ...createSharedMarkdownRules(), + image: (node: ASTNode, _children, _parent: ASTNode[], _styles: MarkdownStyles) => { + const resolved = resolveMarkdownPreviewImageSource({ + source: String(node.attributes?.src ?? ""), + markdownPath: filePath, + workspaceRoot, + }); + if (!resolved) { + return null; + } + + return ( + + ); + }, + }), + [client, filePath, serverId, workspaceRoot], + ); return ( - + {document.frontMatter.length > 0 ? ( {document.frontMatter.map((row, index) => ( @@ -32,7 +79,7 @@ export function FileMarkdownPreview({ source }: { source: string }) { ))} ) : null} - + ); @@ -49,7 +96,7 @@ const styles = StyleSheet.create((theme) => ({ }, readingFrame: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, alignSelf: "center", paddingHorizontal: theme.spacing[2], }, diff --git a/packages/app/src/file-pane/markdown-preview/preview-image.tsx b/packages/app/src/file-pane/markdown-preview/preview-image.tsx new file mode 100644 index 0000000000..ef27c14978 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/preview-image.tsx @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useTranslation } from "react-i18next"; +import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { Theme } from "@/styles/theme"; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +const PREVIEW_IMAGE_MIN_HEIGHT = 160; + +export function FileMarkdownPreviewImage({ + source, + occurrenceKey, + alt, + client, + workspaceRoot, + serverId, +}: { + source: string; + occurrenceKey: string; + alt?: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +}) { + const { t } = useTranslation(); + const [viewerOpen, setViewerOpen] = useState(false); + const openViewer = useCallback(() => setViewerOpen(true), []); + const closeViewer = useCallback(() => setViewerOpen(false), []); + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, + }); + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: PREVIEW_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [styles.surface, imageSizeStyle], + [imageSizeStyle], + ); + const lightboxSource = useMemo(() => { + if (!viewerOpen || !imageUri) return null; + return { + type: "uri", + uri: imageUri, + contentSize: aspectRatio ? { width: aspectRatio, height: 1 } : undefined, + }; + }, [aspectRatio, imageUri, viewerOpen]); + + if (image.status === "failed") { + return ( + + {image.message} + + ); + } + + if (!binding) { + return ( + + + + ); + } + + return ( + + + + + {image.status === "loading" ? ( + + + + ) : null} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + frame: { + width: "100%", + marginBottom: theme.spacing[4], + }, + surface: { + width: "100%", + overflow: "hidden", + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + image: { + width: "100%", + height: "100%", + }, + state: { + minHeight: PREVIEW_IMAGE_MIN_HEIGHT, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[6], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + loadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + textAlign: "center", + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..ea3c5b1edb 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -54,6 +54,9 @@ interface FilePreviewBodyProps { location: WorkspaceFileLocation; navigationRevision: number; imagePreviewUri: string | null; + workspaceRoot: string; + client: DaemonClient | null; + serverId: string; } type TextExplorerFile = ExplorerFile & { kind: "text" }; @@ -135,6 +138,9 @@ function FilePreviewBody({ location, navigationRevision, imagePreviewUri, + workspaceRoot, + client, + serverId, }: FilePreviewBodyProps) { const { t } = useTranslation(); const filePath = location.path; @@ -182,7 +188,13 @@ function FilePreviewBody({ style={styles.previewContent} showsVerticalScrollIndicator > - + ); @@ -297,6 +309,7 @@ export function FilePane({ return ( ); } function EditableFilePane({ + serverId, + workspaceRoot, client, cwd, path, @@ -470,6 +492,8 @@ function EditableFilePane({ location, navigationRevision, }: { + serverId: string; + workspaceRoot: string; client: DaemonClient; cwd: string; path: string; @@ -622,6 +646,9 @@ function EditableFilePane({ location={location} navigationRevision={navigationRevision} imagePreviewUri={null} + workspaceRoot={workspaceRoot} + client={client} + serverId={serverId} /> )} diff --git a/packages/app/src/file-pane/source/view.web.tsx b/packages/app/src/file-pane/source/view.web.tsx index 3a38b5d073..3499ac7d8e 100644 --- a/packages/app/src/file-pane/source/view.web.tsx +++ b/packages/app/src/file-pane/source/view.web.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { FileFind, FileFindModel } from "../find/index.web"; import { Compartment, EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { getLanguageForFile } from "@getpaseo/highlight"; @@ -59,6 +60,7 @@ function ReadonlyCodeMirror({ }: Omit & { presentation: Exclude; }) { + const [find] = useState(() => new FileFindModel()); const hostRef = useRef(null); const viewRef = useRef(null); const initial = useRef({ content, filename, presentation, theme }); @@ -71,7 +73,12 @@ function ReadonlyCodeMirror({ state: EditorState.create({ doc: values.content, extensions: [ + find.extension, EditorState.readOnly.of(true), + EditorView.contentAttributes.of({ + tabindex: "0", + "aria-label": `Source for ${values.filename}`, + }), EditorView.editable.of(false), languageCompartment.of( languageFor({ filename: values.filename, presentation: values.presentation }), @@ -85,7 +92,7 @@ function ReadonlyCodeMirror({ view.destroy(); viewRef.current = null; }; - }, []); + }, [find]); useEffect(() => { const view = viewRef.current; @@ -110,7 +117,12 @@ function ReadonlyCodeMirror({ view.dispatch({ effects: EditorView.scrollIntoView(from, { y: "center" }) }); }, [location.lineStart, navigationRevision]); - return
; + return ( +
+
+ +
+ ); } function languageFor(input: { @@ -122,6 +134,13 @@ function languageFor(input: { : []; } +const FRAME_STYLE = { + display: "flex", + position: "relative", + flex: 1, + minHeight: 0, + minWidth: 0, +} as const; const HOST_STYLE = { flex: 1, minHeight: 0, overflow: "hidden" } as const; const UNSUPPORTED_STYLE = { alignItems: "center", diff --git a/packages/app/src/git/changed-files-tree.tsx b/packages/app/src/git/changed-files-tree.tsx new file mode 100644 index 0000000000..0b88d33e96 --- /dev/null +++ b/packages/app/src/git/changed-files-tree.tsx @@ -0,0 +1,158 @@ +import { useCallback, useMemo, useState } from "react"; +import { StyleSheet } from "react-native-unistyles"; +// The sheet-aware list: inside a bottom sheet the tree scrolls with the sheet +// gesture, and outside one it is the ordinary React Native FlatList. +import { FlatList } from "@/components/ui/scroll-view"; +import type { WorkingDiffMode } from "@/git/diff-document"; +import { DiffFolderRow } from "@/git/diff-folder-row"; +import { + buildDiffTree, + collectDirPaths, + compressSingleChildChains, + flattenDiffTree, + type DiffTreeRow, +} from "@/git/diff-tree"; +import { FileHeader } from "@/git/file-header"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; + +export interface ChangedFilesTreeProps { + files: ParsedDiffFile[]; + mode: WorkingDiffMode; + onSelectFile: (path: string) => void; + collapsedFolderPaths: string[]; + onCollapsedFolderPathsChange: (paths: string[]) => void; +} + +/** The changed-files tree shared by the desktop Changes rail and the compact Jump to file sheet. */ +export function ChangedFilesTree({ + files, + mode, + onSelectFile, + collapsedFolderPaths, + onCollapsedFolderPathsChange, +}: ChangedFilesTreeProps) { + const [selectedPath, setSelectedPath] = useState(null); + const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); + const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); + const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]); + const items = useMemo( + () => flattenDiffTree(compressedTree, collapsedFolders), + [collapsedFolders, compressedTree], + ); + const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); + const handleSelectFile = useCallback( + (path: string) => { + setSelectedPath(path); + onSelectFile(path); + }, + [onSelectFile], + ); + const handleToggleFolder = useCallback( + (dirPath: string) => { + const next = collapsedFolders.has(dirPath) + ? Array.from(collapsedFolders).filter((path) => path !== dirPath) + : [...collapsedFolders, dirPath]; + onCollapsedFolderPathsChange(next); + }, + [collapsedFolders, onCollapsedFolderPathsChange], + ); + const handleCollapseFolder = useCallback( + (dirPath: string) => { + const prefix = `${dirPath}/`; + onCollapsedFolderPathsChange([ + ...new Set([ + ...collapsedFolders, + ...allFolderPaths.filter( + (folderPath) => folderPath === dirPath || folderPath.startsWith(prefix), + ), + ]), + ]); + }, + [allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange], + ); + const renderItem = useCallback( + ({ item }: { item: DiffTreeRow }) => { + if (item.kind === "folder") { + return ( + + ); + } + return ( + + ); + }, + [ + handleCollapseFolder, + handleSelectFile, + handleSelectPath, + handleToggleFolder, + collapsedFolders, + mode, + selectedPath, + ], + ); + const keyExtractor = useCallback( + (item: DiffTreeRow) => + item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`, + [], + ); + + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + scrollView: { + flex: 1, + }, + contentContainer: { + paddingBottom: theme.spacing[8], + }, +})); diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 8ab38178e4..ba4ddd2429 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -16,6 +16,7 @@ import { resolveWorkingDiffComparison, selectWorkingDiffComparison, } from "@/git/working-diff-comparison"; +import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; import { applyCheckoutStatusUpdateFromEvent, ensureCheckoutStatus, @@ -197,6 +198,51 @@ describe("applyCheckoutStatusUpdateFromEvent", () => { ).toBe(false); }); + it("drops the checkout's cached draft slash commands when its branch changes", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + checkoutStatusQueryKey(serverId, cwd), + checkoutStatus({ currentBranch: "chore/build-paseo" }), + ); + const thisCheckout = draftAgentCommandsQueryKey({ + serverId, + draftConfig: { provider: "claude", cwd, model: "haiku" }, + }); + const otherCheckout = draftAgentCommandsQueryKey({ + serverId, + draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" }, + }); + queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]); + queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })), + }); + + expect(queryClient.getQueryData(thisCheckout)).toBeUndefined(); + expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]); + }); + + it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus()); + const thisCheckout = draftAgentCommandsQueryKey({ + serverId, + draftConfig: { provider: "claude", cwd, model: "haiku" }, + }); + queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]); + + applyCheckoutStatusUpdateFromEvent({ + queryClient, + serverId, + message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })), + }); + + expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]); + }); + it("writes the PR status cache when prStatus is present, and skips it otherwise", () => { const queryClient = createQueryClient(); const pushedPr = prStatus({ requestId: "pr-1" }); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 97a26e099b..c06499fef5 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -8,6 +8,7 @@ import { invalidatePrPaneTimelineForCheckout, } from "@/git/query-keys"; import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status"; +import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query"; import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison"; export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -67,7 +68,13 @@ export function applyCheckoutStatusUpdateFromEvent({ ? normalizeCheckoutPrStatusPayload(payload.prStatus) : undefined; const cachePayload = prStatus ? { ...payload, prStatus } : payload; + const previousStatus = queryClient.getQueryData( + checkoutStatusQueryKey(serverId, payload.cwd), + ); queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload); + if (previousStatus?.currentBranch !== payload.currentBranch) { + void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd }); + } void queryClient.invalidateQueries({ queryKey: checkoutCommitsQueryKey(serverId, payload.cwd), }); diff --git a/packages/app/src/git/commit-file-diff-query.test.ts b/packages/app/src/git/commit-file-diff-query.test.ts new file mode 100644 index 0000000000..173592639d --- /dev/null +++ b/packages/app/src/git/commit-file-diff-query.test.ts @@ -0,0 +1,76 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { fetchQueryOptions } from "@/data/query"; +import { commitFileDiffQueryOptions } from "./commit-file-diff-query"; + +const file = { path: "a.ts", isNew: true, isDeleted: false, additions: 0, deletions: 0, hunks: [] }; + +describe("commit file diff fetching", () => { + it("reuses a cached commit body on remount and fetches on explicit refresh", async () => { + const queryClient = new QueryClient(); + let calls = 0; + const options = fetchQueryOptions( + commitFileDiffQueryOptions({ + serverId: "host", + cwd: "/repo", + sha: "abc", + path: "a.ts", + enabled: true, + client: { + async getCommitFileDiff() { + calls++; + return { file }; + }, + }, + }), + ); + queryClient.setQueryData( + options.queryKey, + { file }, + { updatedAt: Date.now() - 24 * 60 * 60 * 1000 }, + ); + const observer = new QueryObserver(queryClient, options); + const detach = observer.subscribe(() => {}); + try { + await Promise.resolve(); + expect(calls).toBe(0); + expect(observer.getCurrentResult().data?.file).toBe(file); + await observer.refetch(); + expect(calls).toBe(1); + } finally { + detach(); + queryClient.clear(); + } + }); + + it("retries a cached null result when reopened", async () => { + const queryClient = new QueryClient(); + const options = fetchQueryOptions( + commitFileDiffQueryOptions({ + serverId: "host", + cwd: "/repo", + sha: "abc", + path: "a.ts", + enabled: true, + client: { + async getCommitFileDiff() { + return { file }; + }, + }, + }), + ); + queryClient.setQueryData(options.queryKey, { file: null }); + const observer = new QueryObserver(queryClient, options); + const settled = Promise.withResolvers(); + const detach = observer.subscribe((result) => { + if (result.fetchStatus === "idle") settled.resolve(); + }); + try { + await settled.promise; + expect(observer.getCurrentResult().data?.file).toEqual(file); + } finally { + detach(); + queryClient.clear(); + } + }); +}); diff --git a/packages/app/src/git/commit-file-diff-query.ts b/packages/app/src/git/commit-file-diff-query.ts new file mode 100644 index 0000000000..5277551234 --- /dev/null +++ b/packages/app/src/git/commit-file-diff-query.ts @@ -0,0 +1,28 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "./query-keys"; +import { shareCommitFileDiff } from "./diff-sharing"; + +export function commitFileDiffQueryOptions(input: { + serverId: string; + cwd: string; + sha: string; + path: string; + client: Pick | null; + enabled: boolean; +}) { + return { + queryKey: checkoutCommitFileDiffQueryKey(input.serverId, input.cwd, input.sha, input.path), + queryFn: () => { + if (!input.client) throw new Error("Host disconnected"); + return input.client.getCommitFileDiff(input.cwd, input.sha, input.path); + }, + enabled: input.enabled, + gcTime: COMMIT_FILE_DIFF_STALE_TIME, + // Only successful file bodies are immutable. Null/error results can recover. + immutableWhen: (data: Awaited>) => + data.file !== null, + dataShape: "value" as const, + refetchOnWindowFocus: false, + structuralSharing: shareCommitFileDiff, + }; +} diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx index 092651b526..aab76da3d0 100644 --- a/packages/app/src/git/commits-section/commit-row.tsx +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -4,14 +4,13 @@ import { StyleSheet } from "react-native-unistyles"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; import type { ClassifiedCheckoutCommit } from "@/git/use-commits-query"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import { CommitGraphNode } from "./commit-graph-node"; interface CommitRowProps { commit: ClassifiedCheckoutCommit; isFirst: boolean; isLast: boolean; - now: Date; onCommitPress: (sha: string) => void; } @@ -26,7 +25,6 @@ export const CommitRow = memo(function CommitRow({ commit, isFirst, isLast, - now, onCommitPress, }: CommitRowProps) { const handlePress = useCallback(() => { @@ -49,7 +47,7 @@ export const CommitRow = memo(function CommitRow({ {commit.subject} - {formatTimeAgo(new Date(commit.authorDate), now)} + @@ -57,6 +55,11 @@ export const CommitRow = memo(function CommitRow({ ); }); +function CommitTimestamp({ authorDate }: { authorDate: string }) { + const label = useTimeAgo(new Date(authorDate)); + return {label}; +} + const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", diff --git a/packages/app/src/git/commits-section/commits-section.test.tsx b/packages/app/src/git/commits-section/commits-section.test.tsx index cb95c91d33..3ae1288f2c 100644 --- a/packages/app/src/git/commits-section/commits-section.test.tsx +++ b/packages/app/src/git/commits-section/commits-section.test.tsx @@ -4,27 +4,6 @@ import { JSDOM } from "jsdom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CommitsSection } from "./commits-section"; -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" - ? factory({ - borderRadius: { full: 9999, sm: 4 }, - borderWidth: { 1: 1 }, - colors: { - border: "#333", - foreground: "#fff", - foregroundMuted: "#aaa", - statusDanger: "#c33", - surface2: "#222", - }, - fontSize: { base: 15, sm: 13 }, - spacing: [0, 4, 8, 12], - }) - : factory, - }, -})); - vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 34, left: 0 }), })); diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx index c344d44551..a7b32c7772 100644 --- a/packages/app/src/git/commits-section/commits-section.tsx +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useTranslation } from "react-i18next"; -import { useRetainedPanelActive } from "@/components/retained-panel"; import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query"; import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import { treeRowPaddingLeft } from "@/components/tree-primitives"; import { normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { CommitRow } from "./commit-row"; @@ -39,11 +39,9 @@ function CommitsSectionSkeleton() { function CommitsSectionContent({ query, - now, onCommitPress, }: { query: Exclude; - now: Date; onCommitPress: (sha: string) => void; }) { const { t } = useTranslation(); @@ -76,7 +74,6 @@ function CommitsSectionContent({ commit={commit} isFirst={index === 0} isLast={index === workspaceCommits.length - 1} - now={now} onCommitPress={onCommitPress} /> ))} @@ -93,9 +90,6 @@ export function CommitsSection({ }: CommitsSectionProps) { const { t } = useTranslation(); const insets = useSafeAreaInsets(); - const isPanelActive = useRetainedPanelActive(); - const [now, setNow] = useState(() => new Date()); - const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]); const query = useCheckoutCommitsQuery({ serverId, cwd, @@ -103,20 +97,9 @@ export function CommitsSection({ }); const handleToggleSection = useCallback(() => { - if (collapsed) { - setNow(new Date()); - } onCollapsedChange?.(!collapsed); }, [collapsed, onCollapsedChange]); - useEffect(() => { - if (collapsed || !isPanelActive) { - return; - } - const interval = setInterval(() => setNow(new Date()), 10_000); - return () => clearInterval(interval); - }, [collapsed, isPanelActive]); - const headerChevronStyle = useMemo( () => [styles.headerChevron, !collapsed && styles.headerChevronExpanded], [collapsed], @@ -159,9 +142,7 @@ export function CommitsSection({ )} - {collapsed ? null : ( - - )} + {collapsed ? null : } ); } @@ -175,7 +156,9 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingLeft: theme.spacing[2], + // Same leading rail as a depth-0 tree row, so the disclosure chevron lines up + // with the folder chevrons above it. + paddingLeft: treeRowPaddingLeft(0), paddingRight: theme.spacing[3], paddingVertical: theme.spacing[2], flexShrink: 0, diff --git a/packages/app/src/git/diff-document/hit-testing.test.ts b/packages/app/src/git/diff-document/hit-testing.test.ts index a0ed9c1578..d728c2f2d7 100644 --- a/packages/app/src/git/diff-document/hit-testing.test.ts +++ b/packages/app/src/git/diff-document/hit-testing.test.ts @@ -315,6 +315,7 @@ function reviewActionsForFirstAddition(): NonNullable< commentsByTarget: new Map(), editor: { target, commentId: null, body: "" }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-document/horizontal-scroll.native.tsx b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx index 993b2048bb..0aa42e01c6 100644 --- a/packages/app/src/git/diff-document/horizontal-scroll.native.tsx +++ b/packages/app/src/git/diff-document/horizontal-scroll.native.tsx @@ -26,9 +26,10 @@ export function HorizontalScroll({ const scrollId = useId(); const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); const edgeRef = useRef(true); + const path = file.path; const handler = useAnimatedScrollHandler({ onScroll(event) { - offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x }; + offsets.value = { ...offsets.value, [path]: event.contentOffset.x }; }, }); useEffect(() => { diff --git a/packages/app/src/git/diff-document/measurement-cost.test.ts b/packages/app/src/git/diff-document/measurement-cost.test.ts index ef2f6231df..b870fbe9cb 100644 --- a/packages/app/src/git/diff-document/measurement-cost.test.ts +++ b/packages/app/src/git/diff-document/measurement-cost.test.ts @@ -1,6 +1,6 @@ import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; -import { buildDiffDocumentModel } from "./model"; +import { buildDiffDocumentModel, measureFragments } from "./model"; import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types"; /** @@ -92,6 +92,33 @@ function shapedCharactersFor(options: { lineCount: number; lineLength: number }) } describe("diff document measurement cost", () => { + it("keeps additive wrapped search work proportional to long source lines", () => { + function measuredGraphemes(length: number): number { + let count = 0; + measureFragments({ + text: "a".repeat(length), + availableWidth: 200, + wrapLines: true, + lineHeight: 18, + measureText: { + measure: (text) => text.length * 10, + measureWidth(graphemes) { + count += graphemes.length; + return graphemes.length * 10; + }, + measureAdvances(graphemes) { + count += graphemes.length; + return graphemes.map((_, index) => (index + 1) * 10); + }, + }, + }); + return count; + } + const short = measuredGraphemes(100); + const long = measuredGraphemes(1600); + expect(long / short).toBeLessThan(32); + }); + it("shapes a bounded number of characters per character of text", () => { // The shape of the CHANGELOG.md working diff that froze the renderer: 42 // lines, longest 597 characters. diff --git a/packages/app/src/git/diff-document/model.test.ts b/packages/app/src/git/diff-document/model.test.ts index 77d6ae391d..ace965c1ce 100644 --- a/packages/app/src/git/diff-document/model.test.ts +++ b/packages/app/src/git/diff-document/model.test.ts @@ -71,6 +71,127 @@ function input(overrides: Partial = {}): BuildDiffD } describe("diff document model", () => { + it("preserves shaped wrap breaks when joining makes a longer prefix narrower", () => { + const measure = (text: string) => { + if (text === "لا") return 30; + if (text === "لام") return 15; + return text.length * 10; + }; + const fragments = measureFragments({ + text: "لام", + availableWidth: 20, + wrapLines: true, + lineHeight: 18, + measureText: { measure, measureWidth: (graphemes) => measure(graphemes.join("")) }, + }); + expect(fragments.map((fragment) => fragment.text)).toEqual(["ل", "ام"]); + expect(fragments.map((fragment) => fragment.width)).toEqual([10, 20]); + }); + + it("does not search wrap boundaries when the remaining text already fits", () => { + let widthQueries = 0; + const fragments = measureFragments({ + text: "const answer = value + 1;", + availableWidth: 300, + wrapLines: true, + lineHeight: 18, + measureText: { + measure: measurer.measure, + measureWidth(graphemes) { + widthQueries++; + return graphemes.length * 10; + }, + }, + }); + expect(fragments).toHaveLength(1); + expect(fragments[0]?.width).toBe(250); + expect(widthQueries).toBe(1); + }); + + it("builds character geometry only for displayed wrapped fragments", () => { + let advancedGraphemes = 0; + const measureWidth = (graphemes: readonly string[]) => graphemes.length * 10; + const measureText = { + // Whole-string widths need not match the chunked advance contract. + measure: () => 999, + measureWidth, + measureAdvances(graphemes: readonly string[]) { + advancedGraphemes += graphemes.length; + return graphemes.map((_, index) => (index + 1) * 10); + }, + }; + const text = "const next = (value) => value + 1;".repeat(4); + const fragments = measureFragments({ + text, + availableWidth: 80, + wrapLines: true, + lineHeight: 18, + measureText, + }); + + expect(fragments.map((fragment) => fragment.text).join("")).toBe(text); + expect(fragments.every((fragment) => fragment.width <= 80)).toBe(true); + expect(fragments).toEqual( + measureFragments({ + text, + availableWidth: 80, + wrapLines: true, + lineHeight: 18, + measureText: { measure: measurer.measure }, + }), + ); + expect(advancedGraphemes).toBe(text.length); + }); + + it("materializes only visible rows of a large file while preserving full horizontal extent", () => { + const source = file("large.ts"); + source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({ + type: "add", + content: index === 900 ? "a very wide offscreen line ".repeat(20) : `line ${index}`, + tokens: [{ text: `line ${index}`, style: null }], + })); + let advancedCharacters = 0; + const measureText: TextMeasurer = { + measure: (text) => text.length * 10, + measureAdvances(graphemes) { + advancedCharacters += graphemes.join("").length; + return graphemes.map((_, index) => (index + 1) * 10); + }, + }; + const base = input({ files: [source], wrapLines: false, viewportWidth: 300, measureText }); + const eager = buildDiffDocumentModel(base); + advancedCharacters = 0; + const first = buildDiffDocumentModel({ + ...base, + materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 }, + }); + expect(measuredFragments(first, source.path)).toBe(3); + expect(advancedCharacters).toBeLessThan(100); + expect(first.height).toBe(eager.height); + expect(first.files[0]!.contentWidth).toBe(eager.files[0]!.contentWidth); + const priorCells = firstLineCells(first, source.path); + advancedCharacters = 0; + const next = buildDiffDocumentModel({ + ...base, + reuseFrom: [first], + materializationWindow: { top: FILE_HEADER_HEIGHT + 900 * 18, height: 54 }, + }); + expect(firstLineCells(next, source.path)).toBe(priorCells); + expect(next.rows[900]).toMatchObject({ + kind: "line", + cells: [{ fragments: [{ text: source.hunks[0]!.lines[900]!.content }] }], + }); + expect(advancedCharacters).toBeLessThan(600); + expect(next.height).toBe(eager.height); + advancedCharacters = 0; + buildDiffDocumentModel({ + ...base, + reuseFrom: [next], + materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 }, + }); + expect(advancedCharacters).toBe(0); + }); + it("does not imperatively scroll for a no-op relayout", () => { expect(shouldApplyRelayoutScroll(320, 320)).toBe(false); expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false); @@ -268,6 +389,7 @@ describe("diff document model", () => { commentId: null, }, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, @@ -669,6 +791,7 @@ function reviewActionsWithEditor( commentsByTarget: new Map(), editor: target ? { target, body: "", commentId: null } : null, onStartComment() {}, + onChangeEditorBody() {}, onCancelEditor() {}, onSaveEditor() {}, onEditComment() {}, diff --git a/packages/app/src/git/diff-document/model.ts b/packages/app/src/git/diff-document/model.ts index 6267519796..fb2b9a04f0 100644 --- a/packages/app/src/git/diff-document/model.ts +++ b/packages/app/src/git/diff-document/model.ts @@ -8,7 +8,7 @@ import { } from "@/utils/diff-layout"; import { compactHighlightTokens } from "@/utils/diff-rendering"; import { getInlineReviewThreadState, getSplitInlineReviewThreadState } from "@/review/geometry"; -import { advancesFor } from "./text-measurement"; +import { advancesFor, requiresShaping } from "./text-measurement"; import type { BuildDiffDocumentModelInput, DiffCell, @@ -157,21 +157,26 @@ function appendReusableFileRows(candidate: { !candidate.input.wrapLines && candidate.model.reviewGeometryKey === candidate.reviewGeometryKey && candidate.file.top === candidate.fileTop && + candidate.file.fileIndex === candidate.fileIndex && candidate.file.rowStart === candidate.rows.length; - if (retainsRowGeometry) { - candidate.rows.push(...reusedRows); - return { - bottom: candidate.bodyTop + candidate.file.bodyHeight, - maximumHorizontalOverflow: Math.max( - 0, - candidate.file.contentWidth - candidate.input.viewportWidth, - ), - }; - } - let top = candidate.bodyTop; for (const reusedRow of reusedRows) { - const nextRow = repositionReusedRow(reusedRow, candidate, top); + let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top); + if ( + nextRow.kind === "line" && + intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) && + nextRow.cells.some((cell) => cell && cell.fragments.length === 0) + ) { + nextRow = { + ...nextRow, + cells: materializeCells( + nextRow.cells, + candidate.file.file, + candidate.file.gutterWidth, + candidate.input, + ), + }; + } candidate.rows.push(nextRow); top += nextRow.height; } @@ -232,71 +237,45 @@ function appendNewFileRows(candidate: { }; } - const deferredLines = - candidate.input.materializationWindow && !candidate.input.wrapLines - ? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input) - : null; - const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0; - const shouldMaterialize = - deferredLines === null || - intersectsMaterializationWindow( - candidate.input, - candidate.fileTop, - candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT, - ); - return shouldMaterialize - ? appendMeasuredFileRows(candidate) - : appendGeometryFileRows(candidate, deferredLines); -} - -function appendGeometryFileRows( - candidate: Parameters[0], - lines: readonly GeometryLine[], -): FileRowsResult { - let top = candidate.bodyTop; - for (const line of lines) { - candidate.rows.push({ - kind: "line", - index: candidate.rows.length, - fileIndex: candidate.fileIndex, - path: candidate.file.path, - top, - height: line.height, - cells: line.cells, - reviewHeight: line.reviewHeight, - }); - top += line.height; - } - return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 }; -} - -function appendMeasuredFileRows( - candidate: Parameters[0], -): FileRowsResult { + const lines = geometryLines( + lineSources(candidate.file, candidate.input.layout, false), + candidate.input, + ); + const fileBottom = + candidate.bodyTop + + lines.reduce((height, line) => height + line.height, 0) + + DIFF_BODY_BORDER_HEIGHT; + const measureWidth = + !candidate.input.wrapLines && + intersectsMaterializationWindow(candidate.input, candidate.fileTop, fileBottom); let top = candidate.bodyTop; let maximumHorizontalOverflow = 0; - for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) { - const columnCount = sourceCells.length; - const columnWidth = candidate.input.viewportWidth / columnCount; - const availableWidth = Math.max( - candidate.input.measureText.measure("M"), - columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING, - ); - const cells = sourceCells.map((source) => { - if (!source) return null; - const cell = measureCell({ source, availableWidth, input: candidate.input }); - maximumHorizontalOverflow = Math.max( - maximumHorizontalOverflow, - candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth, - ); - return cell; - }) as DiffLineRow["cells"]; + for (const line of lines) { + const shouldMaterialize = + candidate.input.wrapLines || + intersectsMaterializationWindow(candidate.input, top, top + line.height); + const cells = shouldMaterialize + ? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input) + : line.cells; + const columnWidth = candidate.input.viewportWidth / cells.length; + if (measureWidth) { + for (const cell of cells) { + if (!cell) continue; + const width = + cell.fragments.length > 0 + ? measureCellWidth(cell) + : candidate.input.measureText.measure(displayLineText(cell.content)); + maximumHorizontalOverflow = Math.max( + maximumHorizontalOverflow, + candidate.gutterWidth + CODE_HORIZONTAL_PADDING + width - columnWidth, + ); + } + } const textHeight = Math.max( candidate.input.typography.lineHeight, ...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight), ); - const reviewHeight = reviewHeightForCells(cells, candidate.input); - const height = textHeight + reviewHeight; + const height = textHeight + line.reviewHeight; candidate.rows.push({ kind: "line", index: candidate.rows.length, @@ -305,13 +284,46 @@ function appendMeasuredFileRows( top, height, cells, - reviewHeight, + reviewHeight: line.reviewHeight, }); top += height; } return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow }; } +function materializeCells( + cells: DiffLineRow["cells"], + file: BuildDiffDocumentModelInput["files"][number], + gutterWidth: number, + input: BuildDiffDocumentModelInput, +): DiffLineRow["cells"] { + const availableWidth = Math.max( + input.measureText.measure("M"), + input.viewportWidth / cells.length - gutterWidth - CODE_HORIZONTAL_PADDING, + ); + return cells.map((cell) => { + if (!cell || cell.fragments.length > 0) return cell; + const sourceLine = + file.hunks[cell.sourceIdentity.hunkIndex]!.lines[cell.sourceIdentity.lineIndex]!; + return measureCell({ + source: { + ...cell, + tokenText: cell.type === "header" ? [] : compactHighlightTokens(sourceLine.tokens ?? []), + }, + availableWidth, + input, + }); + }) as DiffLineRow["cells"]; +} + +function displayLineText(text: string): string { + return text.includes("\t") + ? segmentGraphemes(text) + .map((segment) => segment.text) + .join("") + : text; +} + export function reviewGeometryKey( reviewActions: BuildDiffDocumentModelInput["reviewActions"], ): string { @@ -334,7 +346,11 @@ export function retainReusableModels( previous: readonly DiffDocumentModel[] | undefined, next: DiffDocumentModel, ): DiffDocumentModel[] { - const fullest = [...(previous ?? []), next].reduce((best, candidate) => + const sources = new Set(next.files.map((file) => file.file)); + const compatible = previous?.filter((model) => + model.files.every((file) => sources.has(file.file)), + ); + const fullest = [...(compatible ?? []), next].reduce((best, candidate) => measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best, ); return fullest === next ? [next] : [fullest, next]; @@ -437,16 +453,17 @@ function intersectsMaterializationWindow( function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean { if (file.isCollapsed) return false; - return model.rows.slice(file.rowStart, file.rowEnd).every((row) => { + return model.rows.slice(file.rowStart, file.rowEnd).some((row) => { if (row.kind !== "line") return true; - return row.cells.every((cell) => !cell || cell.fragments.length > 0); + return row.cells.some((cell) => cell && cell.fragments.length > 0); }); } function indexReusableModels( models: readonly DiffDocumentModel[] | undefined, ): ReusableModelIndex[] { - return (models ?? []).map((model) => { + // The latest model includes text prepared by later scroll windows. + return (models ?? []).toReversed().map((model) => { const filesBySource = new Map(); const measuredFiles = new Set(); for (const file of model.files) { @@ -554,14 +571,31 @@ export function measureFragments(input: { return [createFragment(graphemes, 0, graphemes.length, 0, input.lineHeight, input.measureText)]; } const fragments: DiffFragment[] = []; + const displayGraphemes = graphemes.map((grapheme) => grapheme.text); + const measureWidth = + input.measureText.measureWidth?.bind(input.measureText) ?? + ((slice: readonly string[]) => advancesFor(input.measureText)(slice).at(-1) ?? 0); + // Joining/ligatures can make a longer prefix narrower. Preserve the existing + // search order for those runs; changing the probes can change their breaks. + const canBoundSearch = !requiresShaping(input.text); let graphemeIndex = 0; while (graphemeIndex < graphemes.length) { let low = graphemeIndex + 1; - let high = graphemes.length; + // A small first probe finishes short lines in one query. Grow it only when + // it fits, so narrow wrapping never repeatedly measures a huge line's tail. + let high = canBoundSearch ? Math.min(graphemes.length, graphemeIndex + 64) : graphemes.length; let fitting = low; + if (canBoundSearch) { + while (measureWidth(displayGraphemes.slice(graphemeIndex, high)) <= input.availableWidth) { + fitting = high; + low = high + 1; + if (high === graphemes.length) break; + high = Math.min(graphemes.length, graphemeIndex + (high - graphemeIndex) * 2); + } + } while (low <= high) { const middle = (low + high) >>> 1; - const width = measureGraphemeSlice(graphemes, graphemeIndex, middle, input.measureText).width; + const width = measureWidth(displayGraphemes.slice(graphemeIndex, middle)); if (width <= input.availableWidth || middle === graphemeIndex + 1) { fitting = middle; low = middle + 1; diff --git a/packages/app/src/git/diff-document/surface.native.tsx b/packages/app/src/git/diff-document/surface.native.tsx index 49c2697169..2063198712 100644 --- a/packages/app/src/git/diff-document/surface.native.tsx +++ b/packages/app/src/git/diff-document/surface.native.tsx @@ -20,7 +20,7 @@ import Animated, { } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; import { InlineReviewThread } from "@/review"; -import { useKeyboardShift } from "@/hooks/keyboard-shift-context"; +import { useKeyboardShift } from "@/keyboard/shift"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { DocumentFileHeader } from "./document-file-header"; import { @@ -110,7 +110,6 @@ export function DiffSurface(props: DiffSurfaceProps) { const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined; const model = useMemo(() => { const dependencies = [ - props.files, props.displayPreferences.layout, props.displayPreferences.wrapLines, viewport.width, @@ -172,8 +171,13 @@ export function DiffSurface(props: DiffSurfaceProps) { [family, props.palette, typography.lineHeight, typography.size], ); const textLayout = useMemo( - () => prepareNativeTextLayout(textLayoutStore, model), - [model, textLayoutStore], + () => + prepareNativeTextLayout( + textLayoutStore, + model, + diffMaterializationWindow(fileWindowTop, viewport.height), + ), + [model, textLayoutStore, fileWindowTop, viewport.height], ); useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]); const headerTextLayout = useMemo( @@ -252,9 +256,13 @@ export function DiffSurface(props: DiffSurfaceProps) { consumedFocusRef.current = requestKey; } }, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]); + const contentInsetBottom = props.contentInsetBottom ?? 0; const contentStyle = useMemo( - () => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }), - [model.height, viewport.height], + () => ({ + minHeight: Math.max(model.height, viewport.height) + contentInsetBottom, + backgroundColor: "transparent", + }), + [contentInsetBottom, model.height, viewport.height], ); return ( @@ -386,11 +394,12 @@ function NativeCanvasFileHeader({ () => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }), [file, paints, textLayout, viewportWidth], ); + const { top, bottom, headerHeight } = file; const stickyStyle = useAnimatedStyle(() => { - const pinOffset = Math.max(0, scrollTop.value - file.top); - const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top); + const pinOffset = Math.max(0, scrollTop.value - top); + const maximumPinOffset = Math.max(0, bottom - headerHeight - top); return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] }; - }, [file.bottom, file.headerHeight, file.top, scrollTop]); + }, [bottom, headerHeight, top, scrollTop]); const style = useMemo( () => ({ position: "absolute", diff --git a/packages/app/src/git/diff-document/surface.web.tsx b/packages/app/src/git/diff-document/surface.web.tsx index bcabe0ee4f..97801b708f 100644 --- a/packages/app/src/git/diff-document/surface.web.tsx +++ b/packages/app/src/git/diff-document/surface.web.tsx @@ -10,6 +10,7 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { useToast } from "@/contexts/toast-context"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { InlineReviewAddButton, InlineReviewThread } from "@/review"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; import type { ReviewableDiffTarget } from "@/utils/diff-layout"; @@ -557,23 +558,26 @@ export function DiffSurface(props: DiffSurfaceProps) { (event: React.PointerEvent) => pointHitAt(event.clientX, event.clientY), [pointHitAt], ); - const setSelection = useCallback( - (selection: DiffSelection | null) => { - selectionRef.current = selection; - const currentModel = modelRef.current; - setHasSelection( - Boolean( - selection && currentModel && selectedSourceText(currentModel, selection).length > 0, - ), - ); - schedulePaint(); - }, - [schedulePaint], - ); + const setSelection = useStableEvent((selection: DiffSelection | null) => { + selectionRef.current = selection; + const currentModel = modelRef.current; + setHasSelection( + Boolean(selection && currentModel && selectedSourceText(currentModel, selection).length > 0), + ); + schedulePaint(); + }); + // Source and code-layout changes invalidate selection coordinates. A new + // repaint callback (header typography, palette, viewport height) does not. useEffect(() => { dragRef.current = null; setSelection(null); - }, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]); + }, [ + props.collapsedFilePaths, + props.displayPreferences.layout, + props.files, + desiredTypography, + setSelection, + ]); const pointerDown = useCallback( (event: React.PointerEvent) => { if (event.button !== 0) return; @@ -768,9 +772,13 @@ export function DiffSurface(props: DiffSurfaceProps) { () => ({ ...ROOT_STYLE, background: props.palette.surface }), [props.palette.surface], ); + const contentInsetBottom = props.contentInsetBottom ?? 0; const contentStyle = useMemo( - () => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }), - [model.height, viewport.height], + () => ({ + ...CONTENT_STYLE, + height: Math.max(model.height, viewport.height) + contentInsetBottom, + }), + [contentInsetBottom, model.height, viewport.height], ); const affordanceStyle = useMemo( () => ({ diff --git a/packages/app/src/git/diff-document/text-measurement.test.ts b/packages/app/src/git/diff-document/text-measurement.test.ts index 476ef3e45d..414054e76c 100644 --- a/packages/app/src/git/diff-document/text-measurement.test.ts +++ b/packages/app/src/git/diff-document/text-measurement.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createCachedAsciiTextMetrics, + createChunkedWidthMeasurer, createFallbackAwareTextMeasurer, createMeasuredAdvances, requiresShaping, @@ -55,6 +56,8 @@ describe("cached ASCII text metrics", () => { expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]); expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]); + expect(metrics.measure("abba")).toBe(40); + expect(metrics.measureWidth(["a", "b", "b", "a"])).toBe(40); expect(measured).toEqual(["a", "b"]); }); @@ -104,6 +107,27 @@ describe("chunked advance measurement", () => { return Array.from(text, (_, index) => shapingMeasure(text.slice(0, index + 1))); } + it.each([ + "", + "a => b ", + `${"a".repeat(63)}=>${"b".repeat(70)}`, + "=>".repeat(100), + "中文 é 👩‍💻 مرحبا".repeat(10), + ])("keeps width-only measurement equal to the last shaped advance: %s", (text) => { + const graphemes = Array.from(text); + const width = createChunkedWidthMeasurer((chunk) => shapingMeasure(chunk.join(""))); + expect(width(graphemes)).toBe(createMeasuredAdvances(shapingMeasure)(graphemes).at(-1) ?? 0); + }); + + it("keeps expanded tabs on the same additive width path", () => { + const metrics = createCachedAsciiTextMetrics({ + glyphIds: (text) => Array.from(text, () => 1), + measure: (text) => text.length * 3.7, + }); + const graphemes = ["a", " ", "b", " "]; + expect(metrics.measureWidth(graphemes)).toBe(metrics.measureAdvances(graphemes).at(-1)); + }); + it("measures short runs in one shaping context", () => { expect(advancesOf("a => b")).toEqual(expected("a => b")); }); diff --git a/packages/app/src/git/diff-document/text-measurement.ts b/packages/app/src/git/diff-document/text-measurement.ts index 92b245ad73..905a8915d1 100644 --- a/packages/app/src/git/diff-document/text-measurement.ts +++ b/packages/app/src/git/diff-document/text-measurement.ts @@ -6,8 +6,11 @@ export interface PrimaryTextFace { } export interface CachedAsciiTextMetrics { + /** Whole-line width after checking glyph coverage and excluding shaping. */ + measure(text: string): number; hasEveryGlyph(text: string): boolean; measureAdvances(graphemes: readonly string[]): number[]; + measureWidth(graphemes: readonly string[]): number; } const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/; @@ -66,6 +69,22 @@ export function createChunkedAdvanceMeasurer( }; } +/** Preserve advance chunk boundaries when only the final width is needed. */ +export function createChunkedWidthMeasurer( + measure: (graphemes: readonly string[]) => number, +): (graphemes: readonly string[]) => number { + return (graphemes) => { + let width = 0; + let start = 0; + while (start < graphemes.length) { + const end = chunkEnd(graphemes, start); + width += measure(graphemes.slice(start, end)); + start = end; + } + return width; + }; +} + /** * Advance measurement for a backend that can only measure a whole string, which * is every backend except a native paragraph shaper. @@ -97,24 +116,39 @@ export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string export function createAdditiveAdvances( measure: (text: string) => number, ): (graphemes: readonly string[]) => number[] { + return additiveAdvances(cachedWidths(measure)); +} + +function cachedWidths(measure: (text: string) => number): (text: string) => number { const widths = new Map(); + return (text) => { + let width = widths.get(text); + if (width === undefined) { + width = measure(text); + widths.set(text, width); + } + return width; + }; +} + +function additiveAdvances( + measure: (text: string) => number, +): (graphemes: readonly string[]) => number[] { return (graphemes) => { let advance = 0; - return graphemes.map((grapheme) => { - let width = widths.get(grapheme); - if (width === undefined) { - width = measure(grapheme); - widths.set(grapheme, width); - } - advance += width; - return advance; - }); + return graphemes.map((grapheme) => (advance += measure(grapheme))); }; } export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics { const glyphCoverage = new Map(); + const measure = cachedWidths((text) => primary.measure(text)); return { + measure(text) { + let width = 0; + for (const character of text) width += measure(character); + return width; + }, hasEveryGlyph(text) { for (const character of text) { let hasGlyph = glyphCoverage.get(character); @@ -126,7 +160,12 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs } return true; }, - measureAdvances: createAdditiveAdvances((text) => primary.measure(text)), + measureAdvances: additiveAdvances(measure), + measureWidth(graphemes) { + let width = 0; + for (const grapheme of graphemes) width += measure(grapheme); + return width; + }, }; } diff --git a/packages/app/src/git/diff-document/text.native.ts b/packages/app/src/git/diff-document/text.native.ts index 2f0252b384..c482bed1a2 100644 --- a/packages/app/src/git/diff-document/text.native.ts +++ b/packages/app/src/git/diff-document/text.native.ts @@ -5,12 +5,12 @@ import { type SkFont, type SkParagraph, } from "@shopify/react-native-skia"; -import { fragmentTextForRange } from "./model"; +import { fragmentTextForRange, visibleRowRange } from "./model"; import { codeTextColor } from "./palette"; import { createCachedAsciiTextMetrics, createChunkedAdvanceMeasurer, - createFallbackAwareTextMeasurer, + createChunkedWidthMeasurer, requiresShaping, type CachedAsciiTextMetrics, } from "./text-measurement"; @@ -86,7 +86,8 @@ export function shapeNativeHeaderText(input: { export interface NativeTextLayoutStore { font: SkFont; asciiMetrics: CachedAsciiTextMetrics; - paragraphsByCell: WeakMap>; + paragraphsByCell: Map>; + textStyles: Map>; ownedParagraphs: Set; families: string[]; fontSize: number; @@ -97,6 +98,8 @@ export interface NativeTextLayoutStore { export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void { for (const paragraph of layout.ownedParagraphs) paragraph.dispose(); layout.ownedParagraphs.clear(); + layout.paragraphsByCell.clear(); + layout.textStyles.clear(); layout.font.dispose(); } @@ -114,7 +117,8 @@ export function createNativeTextLayoutStore(input: { glyphIds: (text) => font.getGlyphIDs(text), measure: (text) => font.getTextWidth(text), }), - paragraphsByCell: new WeakMap(), + paragraphsByCell: new Map(), + textStyles: new Map(), ownedParagraphs: new Set(), families, fontSize: input.fontSize, @@ -126,30 +130,64 @@ export function createNativeTextLayoutStore(input: { export function prepareNativeTextLayout( store: NativeTextLayoutStore, model: DiffDocumentModel, + window?: { top: number; height: number }, ): NativeTextLayout { - const paragraphs = model.rows.map((row) => { - if (row.kind !== "line") return []; - return row.cells.map((cell) => { + const range = window + ? visibleRowRange(model.rows, window.top, window.height) + : { start: 0, end: model.rows.length }; + const retainedCells = new Set(); + const retainedParagraphs = new Set(); + const paragraphs: NativeTextLayout["paragraphs"] = []; + paragraphs.length = model.rows.length; + for (let index = range.start; index < range.end; index++) { + const row = model.rows[index]!; + if (row.kind !== "line") continue; + paragraphs[index] = row.cells.map((cell) => { if (!cell) return []; + retainedCells.add(cell); const cached = store.paragraphsByCell.get(cell); - if (cached) return cached; - const cellParagraphs = cell.fragments.map((fragment) => { - if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null; - const paragraph = createFragmentParagraph({ - cell, - fragment, - families: store.families, - fontSize: store.fontSize, - lineHeight: store.lineHeight, - palette: store.palette, - }); + const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0; + const end = window + ? Math.min( + cell.fragments.length, + Math.ceil((window.top + window.height - row.top) / model.lineHeight), + ) + : cell.fragments.length; + const cellParagraphs: Array = []; + cellParagraphs.length = cell.fragments.length; + for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) { + const fragment = cell.fragments[fragmentIndex]!; + if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) { + cellParagraphs[fragmentIndex] = null; + continue; + } + const paragraph = + cached?.[fragmentIndex] ?? + createFragmentParagraph({ + cell, + fragment, + families: store.families, + fontSize: store.fontSize, + lineHeight: store.lineHeight, + palette: store.palette, + textStyles: store.textStyles, + }); store.ownedParagraphs.add(paragraph); - return paragraph; - }); + retainedParagraphs.add(paragraph); + cellParagraphs[fragmentIndex] = paragraph; + } store.paragraphsByCell.set(cell, cellParagraphs); return cellParagraphs; }); - }); + } + for (const paragraph of store.ownedParagraphs) { + if (retainedParagraphs.has(paragraph)) continue; + paragraph.dispose(); + store.ownedParagraphs.delete(paragraph); + } + for (const cell of store.paragraphsByCell.keys()) { + if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell); + } return { font: store.font, paragraphs }; } @@ -164,42 +202,41 @@ export function createNativeTextMeasurer(input: { measure: (text: string) => font.getTextWidth(text), }; const asciiMetrics = createCachedAsciiTextMetrics(primary); - const fallbackAware = createFallbackAwareTextMeasurer({ - primary, - measureWithSystemFallback(text) { - const paragraph = createParagraph({ - text, - families, - fontSize: input.fontSize, - lineHeight: Math.round(input.fontSize * 1.5), - color: Skia.Color("black"), - }); + const black = Skia.Color("black"); + const shapeParagraph = (text: string) => + createParagraph({ + text, + families, + fontSize: input.fontSize, + lineHeight: Math.round(input.fontSize * 1.5), + color: black, + }); + return { + measure(text) { + if (!requiresRetainedParagraph(text, asciiMetrics)) return asciiMetrics.measure(text); + const paragraph = shapeParagraph(text); const width = paragraph.getLongestLine(); paragraph.dispose(); return width; }, - }); - return { - ...fallbackAware, + measureWidth: createChunkedWidthMeasurer((graphemes) => { + const text = graphemes.join(""); + if (!requiresRetainedParagraph(text, asciiMetrics)) + return asciiMetrics.measureWidth(graphemes); + const paragraph = shapeParagraph(text); + const width = paragraphAdvance(paragraph, text.length); + paragraph.dispose(); + return width; + }), measureAdvances: createChunkedAdvanceMeasurer({ requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics), measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes), measureShaped(graphemes) { - const paragraph = createParagraph({ - text: graphemes.join(""), - families, - fontSize: input.fontSize, - lineHeight: Math.round(input.fontSize * 1.5), - color: Skia.Color("black"), - }); + const paragraph = shapeParagraph(graphemes.join("")); let end = 0; const advances = graphemes.map((grapheme) => { end += grapheme.length; - const rectangles = paragraph.getRectsForRange(0, end); - return rectangles.reduce( - (right, rectangle) => Math.max(right, rectangle.x + rectangle.width), - 0, - ); + return paragraphAdvance(paragraph, end); }); paragraph.dispose(); return advances; @@ -208,6 +245,12 @@ export function createNativeTextMeasurer(input: { }; } +function paragraphAdvance(paragraph: SkParagraph, end: number): number { + return paragraph + .getRectsForRange(0, end) + .reduce((right, rectangle) => Math.max(right, rectangle.x + rectangle.width), 0); +} + function requiresRetainedParagraph(text: string, asciiMetrics: CachedAsciiTextMetrics): boolean { return requiresShaping(text) || !asciiMetrics.hasEveryGlyph(text); } @@ -219,23 +262,21 @@ function createFragmentParagraph(input: { fontSize: number; lineHeight: number; palette: DiffPalette; + textStyles: NativeTextLayoutStore["textStyles"]; }): SkParagraph { const builder = Skia.ParagraphBuilder.Make( paragraphStyle(input.families, input.fontSize, input.lineHeight), ); if (input.cell.tokens.length === 0 || input.cell.type === "header") { const color = codeTextColor(input.cell, input.palette); - builder - .pushStyle(textStyle(input.families, input.fontSize, color)) - .addText(input.fragment.text) - .pop(); + builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop(); } else { for (const run of input.cell.tokens) { const start = Math.max(input.fragment.start, run.start); const end = Math.min(input.fragment.end, run.end); if (end <= start) continue; builder - .pushStyle(textStyle(input.families, input.fontSize, run.color)) + .pushStyle(retainedTextStyle(input, run.color)) .addText(fragmentTextForRange(input.fragment, start, end)) .pop(); } @@ -281,6 +322,18 @@ function paragraphStyle(families: string[], fontSize: number, lineHeight: number }; } +function retainedTextStyle( + input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] }, + color: string, +) { + let style = input.textStyles.get(color); + if (!style) { + style = textStyle(input.families, input.fontSize, color); + input.textStyles.set(color, style); + } + return style; +} + function textStyle(families: string[], fontSize: number, color: string) { return { fontFamilies: families, fontSize, color: Skia.Color(color) }; } diff --git a/packages/app/src/git/diff-document/types.ts b/packages/app/src/git/diff-document/types.ts index c307628d0c..4ac8125445 100644 --- a/packages/app/src/git/diff-document/types.ts +++ b/packages/app/src/git/diff-document/types.ts @@ -4,6 +4,8 @@ import type { ReviewableDiffTarget } from "@/utils/diff-layout"; interface DiffDocumentBaseProps { files: ParsedDiffFile[]; + /** Scrollable space kept below the last line so a floating control never traps content. */ + contentInsetBottom?: number; displayPreferences: { layout: "unified" | "split"; wrapLines: boolean; @@ -167,6 +169,8 @@ export interface DiffDocumentModel { export interface TextMeasurer { measure(text: string, weight?: "regular" | "semibold"): number; measureAdvances?(graphemes: readonly string[]): number[]; + /** Same width as the last cumulative advance, without computing every position. */ + measureWidth?(graphemes: readonly string[]): number; } export interface BuildDiffDocumentModelInput { diff --git a/packages/app/src/git/diff-document/workspace-cache.test.ts b/packages/app/src/git/diff-document/workspace-cache.test.ts index 4a27cc3d44..ddf9972a46 100644 --- a/packages/app/src/git/diff-document/workspace-cache.test.ts +++ b/packages/app/src/git/diff-document/workspace-cache.test.ts @@ -78,6 +78,40 @@ function modelInput( } describe("diff document workspace cache", () => { + it("keeps text prepared in each visited window of the same file", () => { + const cache = createDiffDocumentWorkspaceCache(); + const source = diffFile(); + source.hunks[0]!.lines = Array.from({ length: 100 }, (_, index) => ({ + type: "add", + content: `const value${index} = ${index};`, + })); + const { measureText, stats } = countingMeasurer(); + const input = modelInput([source], measureText); + for (const top of [30, 300, 600]) { + cache.buildModel({ ...input, materializationWindow: { top, height: 54 } }); + } + stats.calls = 0; + cache.buildModel({ ...input, materializationWindow: { top: 300, height: 54 } }); + expect(stats.calls).toBe(0); + }); + + it("retains text cells for unchanged files when another file changes", () => { + const cache = createDiffDocumentWorkspaceCache(); + const { measureText } = countingMeasurer(); + const files = [diffFile(), { ...diffFile(), path: "b.ts" }]; + const first = cache.buildModel(modelInput(files, measureText)); + const replacement = structuredClone(files[0]!); + replacement.hunks[0]!.lines[0]!.content = "changed text"; + const next = cache.buildModel(modelInput([replacement, files[1]!], measureText)); + const oldRow = first.rows[first.files[1]!.rowStart]!; + const nextRow = next.rows[next.files[1]!.rowStart]!; + expect(oldRow.kind).toBe("line"); + expect(nextRow.kind).toBe("line"); + if (oldRow.kind !== "line" || nextRow.kind !== "line") throw new Error("Missing file text"); + expect(nextRow.cells).toBe(oldRow.cells); + expect(next.files[0]!.file).toBe(replacement); + }); + it("reuses measured rows until a model-building input changes", () => { const cache = createDiffDocumentWorkspaceCache(); const files = [diffFile()]; @@ -115,7 +149,7 @@ describe("diff document workspace cache", () => { stats.calls = 0; cache.buildModel({ ...input, files: [...files] }); - expect(stats.calls).toBeGreaterThan(0); + expect(stats.calls).toBe(0); }); it("bounds retained geometry variants for one diff payload", () => { @@ -152,7 +186,7 @@ describe("diff document workspace cache", () => { const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }]; const { measureText, stats } = countingMeasurer(); const first = cache.buildModel( - modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }), + modelInput(files, measureText, { materializationWindow: { top: 30, height: 20 } }), ); const firstCalls = stats.calls; const secondFile = first.files[1]!; diff --git a/packages/app/src/git/diff-document/workspace-cache.tsx b/packages/app/src/git/diff-document/workspace-cache.tsx index 270c7392ca..bd52000af3 100644 --- a/packages/app/src/git/diff-document/workspace-cache.tsx +++ b/packages/app/src/git/diff-document/workspace-cache.tsx @@ -13,6 +13,7 @@ const MAX_MODEL_VARIANTS = 4; const MAX_TYPOGRAPHY_RESOURCES = 4; interface ModelVariant { + measureText: TextMeasurer; key: string; exactKey: string; model: DiffDocumentModel; @@ -38,23 +39,29 @@ export interface DiffDocumentWorkspaceCache { } export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache { - const variantsByFiles = new WeakMap(); + let variants: ModelVariant[] = []; const typographyResources = new Map(); return { buildModel(input) { const key = modelVariantKey(input); const exactKey = exactModelKey(input); - const variants = variantsByFiles.get(input.files) ?? []; - const variantIndex = variants.findIndex((candidate) => candidate.key === key); + const variantIndex = variants.findIndex( + (candidate) => candidate.key === key && candidate.measureText === input.measureText, + ); const variant = variantIndex === -1 ? undefined : variants[variantIndex]; - if (variant?.exactKey === exactKey) { + if ( + variant?.exactKey === exactKey && + variant.model.files.length === input.files.length && + variant.model.files.every((file, index) => file.file === input.files[index]) + ) { variants.splice(variantIndex, 1); variants.unshift(variant); return variant.model; } const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models }); const nextVariant = { + measureText: input.measureText, key, exactKey, model, @@ -62,7 +69,7 @@ export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache { }; if (variantIndex !== -1) variants.splice(variantIndex, 1); variants.unshift(nextVariant); - variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS)); + variants = variants.slice(0, MAX_MODEL_VARIANTS); return model; }, typography(input) { diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index b934d56b25..6bafd3eee3 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -1,3 +1,5 @@ +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { floatingActionsClearance } from "@/components/ui/floating-action-button"; import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -8,7 +10,6 @@ import { View, Text, Pressable, - FlatList, type PressableStateCallbackType, type StyleProp, type ViewStyle, @@ -32,16 +33,10 @@ import { import { type ParsedDiffFile } from "@/git/use-diff-query"; import type { ChangesState } from "@/panels/changes/state"; import { defaultChangesState } from "@/panels/changes/state"; +import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review"; import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document"; -import { FileHeader } from "@/git/file-header"; -import { - buildDiffTree, - collectDirPaths, - compressSingleChildChains, - flattenDiffTree, - type DiffTreeRow, -} from "@/git/diff-tree"; -import { DiffFolderRow } from "@/git/diff-folder-row"; +import { ChangedFilesTree } from "@/git/changed-files-tree"; +import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file"; import { selectPrHintFromStatus, type PrHint, @@ -81,6 +76,7 @@ import { paneContentToolbarIconSize, paneContentToolbarIconButtonStyle, paneContentToolbarTrailingPadding, + type ToolbarTrailingControl, ToolbarButton, ToolbarControls, } from "@/components/ui/pane-content-toolbar"; @@ -568,7 +564,12 @@ function ChangesDiffOnlyToolbar({ sidebarSurface: boolean; }) { return ( - + @@ -582,19 +583,22 @@ function ChangesToolbarRow({ compact, sidebarSurface, testID, + trailing, }: { children: ReactNode; compact: boolean; sidebarSurface: boolean; testID: string; + /** What the row's last control paints, so it can pad to the shared trailing rail. */ + trailing: ToolbarTrailingControl; }) { const toolbarStyle = useMemo( () => [ styles.changesToolbar, - { paddingRight: paneContentToolbarTrailingPadding(compact) }, + { paddingRight: paneContentToolbarTrailingPadding(compact, trailing) }, sidebarSurface ? styles.changesToolbarSidebar : null, ], - [compact, sidebarSurface], + [compact, sidebarSurface, trailing], ); return ( @@ -625,6 +629,7 @@ function ChangesRepositoryToolbar({ compact={compact} sidebarSurface={sidebarSurface} testID="changes-repository-header" + trailing={model.gitActions ? "framed" : "glyph"} > + string; - }, -): string { - if (hideWhitespace) { - return labels.hiddenWhitespace; - } - if (diffMode === "uncommitted") { - return labels.uncommitted; - } - return labels.againstBase(baseRefLabel); -} - interface DiffBodyContentProps { isStatusLoading: boolean; statusErrorMessage: string | null; @@ -1258,134 +1249,23 @@ function buildToggleButtonStyle( return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)]; } -function ChangedFilesTree({ - files, - mode, - onSelectFile, - collapsedFolderPaths, - onCollapsedFolderPathsChange, -}: { - files: ParsedDiffFile[]; - mode: WorkingDiffMode; - onSelectFile: (path: string) => void; - collapsedFolderPaths: string[]; - onCollapsedFolderPathsChange: (paths: string[]) => void; -}) { - const [selectedPath, setSelectedPath] = useState(null); - const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); - const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); - const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]); - const items = useMemo( - () => flattenDiffTree(compressedTree, collapsedFolders), - [collapsedFolders, compressedTree], - ); - const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []); - const handleSelectFile = useCallback( - (path: string) => { - setSelectedPath(path); - onSelectFile(path); - }, - [onSelectFile], - ); - const handleToggleFolder = useCallback( - (dirPath: string) => { - const next = collapsedFolders.has(dirPath) - ? Array.from(collapsedFolders).filter((path) => path !== dirPath) - : [...collapsedFolders, dirPath]; - onCollapsedFolderPathsChange(next); - }, - [collapsedFolders, onCollapsedFolderPathsChange], - ); - const handleCollapseFolder = useCallback( - (dirPath: string) => { - const prefix = `${dirPath}/`; - onCollapsedFolderPathsChange([ - ...new Set([ - ...collapsedFolders, - ...allFolderPaths.filter( - (folderPath) => folderPath === dirPath || folderPath.startsWith(prefix), - ), - ]), - ]); - }, - [allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange], - ); - const renderItem = useCallback( - ({ item }: { item: DiffTreeRow }) => { - if (item.kind === "folder") { - return ( - - ); - } - return ( - - ); - }, - [ - handleCollapseFolder, - handleSelectFile, - handleSelectPath, - handleToggleFolder, - collapsedFolders, - mode, - selectedPath, - ], - ); - const keyExtractor = useCallback( - (item: DiffTreeRow) => - item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`, - [], - ); +interface JumpToFileVisibility { + isCompact: boolean; + presentation: ChangesPresentation; + hasChanges: boolean; + isDiffLoading: boolean; +} - return ( - - ); +/** + * The room the floating Jump to file action needs over the diff, and zero where + * it is not offered. Compact users never see the tree rail, so the diff is their + * only view of what changed; the tree and diff presentations are already halves + * of a desktop split. + */ +function jumpToFileClearance(visibility: JumpToFileVisibility): number { + if (!visibility.isCompact || visibility.presentation !== "combined") return 0; + if (!visibility.hasChanges || visibility.isDiffLoading) return 0; + return JUMP_TO_FILE_CLEARANCE; } function ChangesTreeRail({ @@ -1671,7 +1551,9 @@ export function ChangesSurface({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, } = useWorkingDiff({ + presentation, serverId, workspaceId: workspaceId ?? undefined, cwd, @@ -1852,7 +1734,15 @@ export function ChangesSurface({ ], ); + const { bottom: bottomInset } = useSafeAreaInsets(); const hasChanges = files.length > 0; + const jumpToFileInset = jumpToFileClearance({ + isCompact: isMobile, + presentation, + hasChanges, + isDiffLoading, + }); + const showJumpToFile = jumpToFileInset > 0; const selectedDiffStat = useMemo( () => computeSelectedDiffStat(files, isDiffLoading), [files, isDiffLoading], @@ -1882,11 +1772,7 @@ export function ChangesSurface({ () => computeCommittedDiffDescription(branchLabel, baseRefLabel), [baseRefLabel, branchLabel], ); - const emptyMessage = computeEmptyMessage(preferences.hideWhitespace, diffMode, baseRefLabel, { - hiddenWhitespace: t("workspace.git.diff.emptyHiddenWhitespace"), - uncommitted: t("workspace.git.diff.emptyUncommitted"), - againstBase: (label) => t("workspace.git.diff.emptyAgainstBase", { baseRef: label }), - }); + const emptyMessage = t("diffViewer.empty"); const emptyAction = computeChangesEmptyAction({ hideWhitespace: preferences.hideWhitespace, diffMode, @@ -1898,26 +1784,41 @@ export function ChangesSurface({ }); const diffContent: ReactElement = ( - - + + + + - + ); const bodyContent = ( {prErrorMessage} : null} - {bodyContent} + + {bodyContent} + {showJumpToFile ? ( + + ) : null} + + + ({ minHeight: 0, position: "relative", }, - scrollView: { - flex: 1, - }, scrollContainer: { flex: 1, minHeight: 0, position: "relative", }, - contentContainer: { - paddingBottom: theme.spacing[8], - }, loadingContainer: { flex: 1, alignItems: "center", diff --git a/packages/app/src/git/diff-sharing.test.ts b/packages/app/src/git/diff-sharing.test.ts new file mode 100644 index 0000000000..210c0f1a4b --- /dev/null +++ b/packages/app/src/git/diff-sharing.test.ts @@ -0,0 +1,148 @@ +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { describe, expect, it } from "vitest"; +import { retainDiffFiles, shareCheckoutDiff, shareCommitFileDiff } from "./diff-sharing"; + +const file: ParsedDiffFile = { + path: "a.ts", + oldPath: "old-a.ts", + status: "ok", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { + type: "add", + content: "const a", + tokens: [ + { text: "const", style: "keyword" }, + { text: " a", style: null }, + ], + }, + ], + }, + ], +}; + +// Exhaustive keys make a protocol-field addition require a sharing-policy test. +const fileChanges = { + path: (value) => { + value.path = "b.ts"; + }, + oldPath: (value) => { + delete value.oldPath; + }, + status: (value) => { + value.status = "binary"; + }, + isNew: (value) => { + value.isNew = true; + }, + isDeleted: (value) => { + value.isDeleted = true; + }, + additions: (value) => { + value.additions = 2; + }, + deletions: (value) => { + value.deletions = 2; + }, + hunks: (value) => { + value.hunks = []; + }, +} satisfies Record void>; +type Hunk = ParsedDiffFile["hunks"][number]; +const hunkChanges = { + oldStart: (value) => { + value.oldStart = 2; + }, + oldCount: (value) => { + value.oldCount = 2; + }, + newStart: (value) => { + value.newStart = 2; + }, + newCount: (value) => { + value.newCount = 2; + }, + lines: (value) => { + value.lines = []; + }, +} satisfies Record void>; +type Line = Hunk["lines"][number]; +const lineChanges = { + type: (value) => { + value.type = "remove"; + }, + content: (value) => { + value.content = "const b"; + }, + tokens: (value) => { + delete value.tokens; + }, +} satisfies Record void>; +type Token = NonNullable[number]; +const tokenChanges = { + text: (value) => { + value.text = "class"; + }, + style: (value) => { + value.style = null; + }, +} satisfies Record void>; + +describe("diff sharing", () => { + it.each(Object.entries(fileChanges))("replaces a file when %s changes", (_, change) => { + const next = structuredClone(file); + change(next); + expect(retainDiffFiles([file], [next])[0]).toBe(next); + }); + it.each(Object.entries(hunkChanges))("replaces a file when hunk %s changes", (_, change) => { + const next = structuredClone(file); + change(next.hunks[0]!); + expect(retainDiffFiles([file], [next])[0]).toBe(next); + }); + it.each(Object.entries(lineChanges))("replaces a file when line %s changes", (_, change) => { + const next = structuredClone(file); + change(next.hunks[0]!.lines[0]!); + expect(retainDiffFiles([file], [next])[0]).toBe(next); + }); + it.each(Object.entries(tokenChanges))("replaces a file when token %s changes", (_, change) => { + const next = structuredClone(file); + change(next.hunks[0]!.lines[0]!.tokens![0]!); + expect(retainDiffFiles([file], [next])[0]).toBe(next); + }); + it("retains moved files and removes missing files", () => { + const second = { ...file, path: "b.ts" }; + const reordered = retainDiffFiles([file, second], structuredClone([second, file])); + expect(reordered[0]).toBe(second); + expect(reordered[1]).toBe(file); + expect(retainDiffFiles(reordered, [structuredClone(file)])[0]).toBe(file); + }); + it("updates response metadata without invalidating unchanged text", () => { + const previous = { cwd: "/repo", files: [file], error: null, requestId: "a" }; + expect(shareCheckoutDiff(previous, structuredClone(previous))).toBe(previous); + const next = shareCheckoutDiff(previous, { ...structuredClone(previous), requestId: "b" }); + expect(next.requestId).toBe("b"); + expect(next.files).toBe(previous.files); + const rejected = { + ...previous, + files: [], + error: { code: "UNKNOWN", message: "Too large" }, + diffTooLarge: true, + }; + expect(shareCheckoutDiff(next, rejected)).toEqual(rejected); + }); + it("preserves cached commit bodies and publishes null transitions", () => { + const previous = { file }; + expect(shareCommitFileDiff(previous, structuredClone(previous))).toBe(previous); + expect(shareCommitFileDiff(previous, { file: null })).toEqual({ file: null }); + expect(shareCommitFileDiff({ file: null }, previous)).toBe(previous); + }); +}); diff --git a/packages/app/src/git/diff-sharing.ts b/packages/app/src/git/diff-sharing.ts new file mode 100644 index 0000000000..6bdac10ddf --- /dev/null +++ b/packages/app/src/git/diff-sharing.ts @@ -0,0 +1,98 @@ +import { replaceEqualDeep } from "@tanstack/react-query"; +import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; + +type DiffPayload = Omit; + +/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */ +export function retainDiffFiles( + previous: ParsedDiffFile[], + incoming: ParsedDiffFile[], +): ParsedDiffFile[] { + if (previous === incoming) return previous; + const byPath = new Map(previous.map((file) => [file.path, file])); + let unchanged = previous.length === incoming.length; + const files = incoming.map((file, index) => { + const old = byPath.get(file.path); + const retained = old && equalFile(old, file) ? old : file; + unchanged &&= retained === previous[index]; + return retained; + }); + return unchanged ? previous : files; +} + +export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPayload { + const next = incoming as DiffPayload; + const old = previous as DiffPayload | undefined; + if (!old || old === next) return next; + const files = retainDiffFiles(old.files, next.files); + const error = replaceEqualDeep(old.error, next.error); + return files === old.files && + error === old.error && + old.cwd === next.cwd && + old.requestId === next.requestId && + old.diffTooLarge === next.diffTooLarge + ? old + : { ...next, files, error }; +} + +export function shareCommitFileDiff( + previous: unknown, + incoming: unknown, +): { file: ParsedDiffFile | null } { + const next = incoming as { file: ParsedDiffFile | null }; + const old = previous as typeof next | undefined; + return old && + (old.file === next.file || (old.file && next.file && equalFile(old.file, next.file))) + ? old + : next; +} + +function equalFile(left: ParsedDiffFile, right: ParsedDiffFile): boolean { + if (left === right) return true; + if ( + left.path !== right.path || + left.oldPath !== right.oldPath || + left.status !== right.status || + left.isNew !== right.isNew || + left.isDeleted !== right.isDeleted || + left.additions !== right.additions || + left.deletions !== right.deletions || + left.hunks.length !== right.hunks.length + ) + return false; + for (let h = 0; h < left.hunks.length; h++) { + const a = left.hunks[h]!; + const b = right.hunks[h]!; + if (a === b) continue; + if ( + a.oldStart !== b.oldStart || + a.oldCount !== b.oldCount || + a.newStart !== b.newStart || + a.newCount !== b.newCount || + a.lines.length !== b.lines.length + ) + return false; + if (!equalLines(a.lines, b.lines)) return false; + } + return true; +} + +function equalLines( + left: ParsedDiffFile["hunks"][number]["lines"], + right: ParsedDiffFile["hunks"][number]["lines"], +): boolean { + for (let l = 0; l < left.length; l++) { + const x = left[l]!; + const y = right[l]!; + if (x === y) continue; + if (x.type !== y.type || x.content !== y.content) return false; + if (x.tokens === y.tokens) continue; + if (!x.tokens || !y.tokens || x.tokens.length !== y.tokens.length) return false; + for (let t = 0; t < x.tokens.length; t++) { + const p = x.tokens[t]!; + const q = y.tokens[t]!; + if (p.text !== q.text || p.style !== q.style) return false; + } + } + return true; +} diff --git a/packages/app/src/git/file-header.tsx b/packages/app/src/git/file-header.tsx index 5c2f7f8fb5..d19d58cbf2 100644 --- a/packages/app/src/git/file-header.tsx +++ b/packages/app/src/git/file-header.tsx @@ -376,10 +376,10 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[1], minWidth: 0, zIndex: 2, - elevation: 2, userSelect: "none", }, documentHeader: { + elevation: 2, height: 28, paddingLeft: 0, paddingRight: 0, diff --git a/packages/app/src/git/jump-to-file/index.tsx b/packages/app/src/git/jump-to-file/index.tsx new file mode 100644 index 0000000000..f3363b91ec --- /dev/null +++ b/packages/app/src/git/jump-to-file/index.tsx @@ -0,0 +1,130 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { FolderTree } from "lucide-react-native"; +import { + AdaptiveModalSheet, + SHEET_HEADER_CLOSE_PADDING_SCALE, + SHEET_HORIZONTAL_PADDING_SCALE, +} from "@/components/adaptive-modal-sheet"; +import { + treeRowPaddingLeft, + WORKSPACE_PANE_TRAILING_GLYPH_RAIL, +} from "@/components/tree-primitives"; +import { + FLOATING_ACTION_BUTTON_CLEARANCE, + FloatingActionButton, +} from "@/components/ui/floating-action-button"; +import { ChangedFilesTree } from "@/git/changed-files-tree"; +import type { WorkingDiffMode } from "@/git/diff-document"; +import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme"; + +// The sheet header indents its leading glyph by the sheet's own content inset, +// while a tree row indents itself by `treeRowPaddingLeft`. The body carries the +// difference so a depth-0 file icon lands on the header glyph's rail. +const TREE_LEADING_INSET = SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - treeRowPaddingLeft(0); + +// The header's close glyph sits at the content inset plus the button's own +// padding, while a tree row ends its trailing glyph at +// `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body carries the difference so row +// change icons land on the header X's rail. +const TREE_TRAILING_INSET = + SPACING[SHEET_HORIZONTAL_PADDING_SCALE] + + SPACING[SHEET_HEADER_CLOSE_PADDING_SCALE] - + WORKSPACE_PANE_TRAILING_GLYPH_RAIL; + +const ThemedFolderTree = withUnistyles(FolderTree); + +const foregroundMutedIconColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +/** Scrollable space the diff keeps below its last line while the action is shown. */ +export const JUMP_TO_FILE_CLEARANCE = FLOATING_ACTION_BUTTON_CLEARANCE; + +export interface JumpToFileProps { + files: ParsedDiffFile[]; + mode: WorkingDiffMode; + onSelectFile: (path: string) => void; +} + +/** Compact overview of a loaded diff: a floating action that opens the changed-files tree. */ +export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) { + const { t } = useTranslation(); + const { bottom } = useSafeAreaInsets(); + const [isOpen, setIsOpen] = useState(false); + // The sheet is a transient overview, so it always opens fully expanded and + // never writes to the panel's persisted folder state. + const [collapsedFolderPaths, setCollapsedFolderPaths] = useState([]); + const title = t("workspace.git.diff.jumpToFile.title"); + const header = useMemo( + () => ({ + title, + leading: , + }), + [title], + ); + + const open = useCallback(() => { + setCollapsedFolderPaths([]); + setIsOpen(true); + }, []); + const close = useCallback(() => setIsOpen(false), []); + const handleSelectFile = useCallback( + (path: string) => { + setIsOpen(false); + onSelectFile(path); + }, + [onSelectFile], + ); + + return ( + <> + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Rows own their rails, so the sheet's horizontal inset is zeroed here. + sheetBody: { + paddingHorizontal: 0, + paddingTop: 0, + paddingBottom: 0, + gap: 0, + }, + tree: { + flex: 1, + minHeight: 0, + paddingLeft: TREE_LEADING_INSET, + paddingRight: TREE_TRAILING_INSET, + paddingTop: theme.spacing[2], + }, +})); diff --git a/packages/app/src/git/pull-request-panel/checks-section.tsx b/packages/app/src/git/pull-request-panel/checks-section.tsx index 9e4f2dd942..ac1ba078c5 100644 --- a/packages/app/src/git/pull-request-panel/checks-section.tsx +++ b/packages/app/src/git/pull-request-panel/checks-section.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -71,7 +72,8 @@ export function ChecksSection({ loadingCheckKeys, onAddLogsToChat, }: ChecksSectionProps) { - const summary = useMemo(() => summarizeChecks(checks), [checks]); + const { t } = useTranslation(); + const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]); const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState(); return ( @@ -91,13 +93,14 @@ export function ChecksSection({ {summary.parts.length > 0 ? ( + {summary.detailLead} {summary.parts.map((part, index) => ( {index > 0 ? ", " : ""} {part.text} ))} - {` ${summary.countNoun}`} + {summary.detailTrail} ) : null} @@ -186,6 +189,7 @@ function CheckRow({ isAddingLogsToChat: boolean; onAddLogsToChat: (check: PrPaneCheck) => void; }) { + const { t } = useTranslation(); const handlePress = useCallback(() => { void openExternalUrl(check.url); }, [check.url]); @@ -217,7 +221,9 @@ function CheckRow({ onPress={handleAddLogsToChat} style={styles.addButton} > - {isAddingLogsToChat ? "Adding..." : "Add to chat"} + {isAddingLogsToChat + ? t("workspace.git.pr.actions.addingToChat") + : t("workspace.git.pr.actions.addToChat")} ) : null} {check.timing && {check.timing}} diff --git a/packages/app/src/git/pull-request-panel/checks-summary.test.ts b/packages/app/src/git/pull-request-panel/checks-summary.test.ts index f014500753..747947b907 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.test.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { CheckStatus } from "./check-status"; import { summarizeChecks } from "./checks-summary"; import type { PrPaneCheck } from "./data"; @@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck { describe("summarizeChecks", () => { it("reports no checks without claiming the run passed", () => { - const summary = summarizeChecks([]); + const summary = summarizeChecks([], i18n.t); expect(summary.outcome).toBe("none"); expect(summary.headline).toBe("No checks"); @@ -18,11 +19,10 @@ describe("summarizeChecks", () => { }); it("leads with failures when anything failed", () => { - const summary = summarizeChecks([ - check("build", "success"), - check("lint", "failure"), - check("e2e", "pending"), - ]); + const summary = summarizeChecks( + [check("build", "success"), check("lint", "failure"), check("e2e", "pending")], + i18n.t, + ); expect(summary.outcome).toBe("failure"); expect(summary.headline).toBe("Some checks were not successful"); @@ -31,7 +31,7 @@ describe("summarizeChecks", () => { }); it("reports an unfinished run as in progress when nothing failed", () => { - const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]); + const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t); expect(summary.outcome).toBe("pending"); expect(summary.headline).toBe("Some checks haven't completed yet"); @@ -39,7 +39,10 @@ describe("summarizeChecks", () => { }); it("treats a run of only successes and skips as passed", () => { - const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]); + const summary = summarizeChecks( + [check("build", "success"), check("deploy", "skipped")], + i18n.t, + ); expect(summary.outcome).toBe("success"); expect(summary.headline).toBe("All checks have passed"); @@ -47,11 +50,10 @@ describe("summarizeChecks", () => { }); it("keeps the checks of each group together under a counted label", () => { - const summary = summarizeChecks([ - check("build", "failure"), - check("lint", "success"), - check("test", "failure"), - ]); + const summary = summarizeChecks( + [check("build", "failure"), check("lint", "success"), check("test", "failure")], + i18n.t, + ); const [failing, successful] = summary.groups; expect(failing?.label).toBe("2 failing checks"); @@ -60,9 +62,34 @@ describe("summarizeChecks", () => { }); it("says check rather than checks for a run of one", () => { - const summary = summarizeChecks([check("build", "failure")]); + const summary = summarizeChecks([check("build", "failure")], i18n.t); - expect(summary.countNoun).toBe("check"); expect(summary.detail).toBe("1 failing check"); }); + + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + const summary = summarizeChecks( + [ + check("build", "success"), + check("deploy", "skipped"), + check("lint", "failure"), + check("test", "failure"), + ], + i18n.t, + ); + + expect(summary.headline).toBe("Certaines vérifications ont échoué"); + expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)"); + expect(summary.groups.map((group) => group.label)).toEqual([ + "2 vérifications en échec", + "1 vérification réussie", + "1 vérification ignorée", + ]); + expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification"); + } finally { + await i18n.changeLanguage("en"); + } + }); }); diff --git a/packages/app/src/git/pull-request-panel/checks-summary.ts b/packages/app/src/git/pull-request-panel/checks-summary.ts index f2f9b30a38..977706a9af 100644 --- a/packages/app/src/git/pull-request-panel/checks-summary.ts +++ b/packages/app/src/git/pull-request-panel/checks-summary.ts @@ -1,4 +1,5 @@ import { classifyCheck, type CheckPresentation } from "@/git/check-presentation"; +import type { TFunction } from "i18next"; import type { PrPaneCheck } from "./data"; /** @@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [ "ignored", ] as const satisfies readonly CheckPresentation[]; -/** How each status reads inside a count phrase: "2 failing", "4 in progress". */ -const PRESENTATION_NOUN: Record = { - actionRequired: "needs action", - warning: "warning", - failure: "failing", - pending: "in progress", - manual: "manual", - success: "successful", - ignored: "skipped", -}; - /** The worst thing happening in the run, which is what the headline and the ring report. */ export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none"; -const OUTCOME_HEADLINE: Record = { - actionRequired: "Some checks need your attention", - failure: "Some checks were not successful", - pending: "Some checks haven't completed yet", - success: "All checks have passed", - none: "No checks", -}; +const COPY = "workspace.git.pr.checksOverview"; export interface ChecksCountPart { status: CheckPresentation; @@ -58,8 +42,12 @@ export interface ChecksSummary { headline: string; /** The count phrases behind `detail`, kept apart so the header can label each one. */ parts: readonly ChecksCountPart[]; - /** The noun closing the detail line — "checks", or "check" for a run of one. */ - countNoun: string; + /** + * The words around the count phrases in `detail`: "" and " checks" in English, where the noun + * closes the line, while other languages lead with it. + */ + detailLead: string; + detailTrail: string; /** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */ detail: string; total: number; @@ -72,7 +60,7 @@ export interface ChecksSummary { * header, the ring, and the grouped list all read from one derivation instead of each * filtering the array again with its own idea of what counts. */ -export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { +export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary { const groups: ChecksGroup[] = []; const parts: ChecksCountPart[] = []; @@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary { } groups.push({ status, - label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`, + label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, { + count: matching.length, + }), checks: matching, }); parts.push({ status, count: matching.length, - text: `${matching.length} ${PRESENTATION_NOUN[status]}`, + text: t(`${COPY}.count.${status}`, { count: matching.length }), }); } const outcome = selectOutcome(checks); - const noun = countNoun(checks.length); + const { lead, trail } = detailFrame(checks.length, t); return { outcome, - headline: OUTCOME_HEADLINE[outcome], + headline: t(`${COPY}.headline.${outcome}`), parts, - countNoun: noun, - detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`, + detailLead: lead, + detailTrail: trail, + detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`, total: checks.length, groups, }; @@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome { return "success"; } -function countNoun(count: number): string { - return count === 1 ? "check" : "checks"; +/** + * The header renders each count phrase on its own, so the translated line is split around + * where the phrases go. + */ +function detailFrame(total: number, t: TFunction): { lead: string; trail: string } { + const marker = "\u0000"; + const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker }); + const [lead = "", trail = ""] = line.split(marker); + return { lead, trail }; } diff --git a/packages/app/src/git/pull-request-panel/data.test.ts b/packages/app/src/git/pull-request-panel/data.test.ts index 21b145bedc..837433417e 100644 --- a/packages/app/src/git/pull-request-panel/data.test.ts +++ b/packages/app/src/git/pull-request-panel/data.test.ts @@ -9,12 +9,13 @@ import { isPipelineActiveStatus, mapPipelineStatus, } from "@/git/forges/gitlab"; +import { i18n } from "@/i18n/i18next"; import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors"; import { deriveAvatarColor, formatAge, - getActivityVerb, - getStateLabel, + getActivityVerbKey, + getStateLabelKey, mapPrPaneData, } from "./data"; @@ -779,37 +780,34 @@ describe("formatAge", () => { }); }); -describe("getStateLabel", () => { +const english = i18n.getFixedT("en"); +const french = i18n.getFixedT("fr"); + +describe("getStateLabelKey", () => { it.each([ - ["open", "Open"], - ["draft", "Draft"], - ["merged", "Merged"], - ["closed", "Closed"], - ] as const)("maps %s → %s", (state, expected) => { - expect(getStateLabel(state)).toBe(expected); + ["open", "Open", "Ouverte"], + ["draft", "Draft", "Brouillon"], + ["merged", "Merged", "Fusionnée"], + ["closed", "Closed", "Fermée"], + ] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => { + expect(english(getStateLabelKey(state))).toBe(englishLabel); + expect(french(getStateLabelKey(state))).toBe(frenchLabel); }); }); -describe("getActivityVerb", () => { - it("returns Commented for comment kind", () => { - expect(getActivityVerb({ kind: "comment" })).toBe("Commented"); - }); - - it("returns Approved for approved review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved"); - }); - - it("returns Requested changes for changes_requested review", () => { - expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe( +describe("getActivityVerbKey", () => { + it.each([ + [{ kind: "comment" }, "Commented", "A commenté"], + [{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"], + [ + { kind: "review", reviewState: "changes_requested" }, "Requested changes", - ); - }); - - it("returns Reviewed for a commented review with body (generic case)", () => { - expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed"); - }); - - it("returns Reviewed when reviewState is undefined", () => { - expect(getActivityVerb({ kind: "review" })).toBe("Reviewed"); + "A demandé des modifications", + ], + [{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"], + [{ kind: "review" }, "Reviewed", "A relu"], + ] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => { + expect(english(getActivityVerbKey(item))).toBe(englishVerb); + expect(french(getActivityVerbKey(item))).toBe(frenchVerb); }); }); diff --git a/packages/app/src/git/pull-request-panel/data.ts b/packages/app/src/git/pull-request-panel/data.ts index 0f1e2014e9..d1275f2f1e 100644 --- a/packages/app/src/git/pull-request-panel/data.ts +++ b/packages/app/src/git/pull-request-panel/data.ts @@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null { } } -export function getStateLabel(state: PrState): string { - if (state === "draft") return "Draft"; - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return "Open"; +export function getStateLabelKey(state: PrState) { + if (state === "draft") return "workspace.git.pr.states.draft"; + if (state === "merged") return "workspace.git.pr.states.merged"; + if (state === "closed") return "workspace.git.pr.states.closed"; + return "workspace.git.pr.states.open"; } -export function getActivityVerb(item: Pick): string { - if (item.kind === "comment") return "Commented"; - if (item.reviewState === "approved") return "Approved"; - if (item.reviewState === "changes_requested") return "Requested changes"; - return "Reviewed"; +export function getActivityVerbKey(item: Pick) { + if (item.kind === "comment") return "workspace.git.pr.activity.commented"; + if (item.reviewState === "approved") return "workspace.git.pr.activity.approved"; + if (item.reviewState === "changes_requested") { + return "workspace.git.pr.activity.requestedChanges"; + } + return "workspace.git.pr.activity.reviewed"; } diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..429dd2be0e 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -63,7 +63,7 @@ import { canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; -import { getActivityVerb, getStateLabel } from "./data"; +import { getActivityVerbKey, getStateLabelKey } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; @@ -538,7 +538,7 @@ export function PullRequestPane({ - {getStateLabel(data.state)} + {t(getStateLabelKey(data.state))} {nativeHeaderMeta} {repoIdentity ? ( @@ -568,7 +568,7 @@ export function PullRequestPane({
- Add all to chat + {t("workspace.git.pr.actions.addAllToChat")} ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( - No activity yet + {t("workspace.git.pr.empty.noActivity")} ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( @@ -702,14 +702,14 @@ function ActivityKebab({ {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {activity.body.trim() !== "" ? ( @@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { - const verb = getActivityVerb(activity).toLowerCase(); + const { t } = useTranslation(); + const verb = t(getActivityVerbKey(activity)).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( @@ -815,6 +816,7 @@ function SingleActivityCard({ entry: Extract; collapsed: boolean; }) { + const { t } = useTranslation(); const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -884,7 +886,7 @@ function SingleActivityCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -935,6 +937,7 @@ function ReviewCard({ collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { + const { t } = useTranslation(); const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); @@ -961,7 +964,7 @@ function ReviewCard({ onPress={handleAddToChat} style={styles.checkAddButton} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} {collapsed ? ( @@ -995,7 +998,7 @@ function ReviewCard({ leftIcon={MessageSquarePlus} onPress={handleAddToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} @@ -1063,8 +1066,12 @@ function ThreadBlock({ ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} - {thread.isResolved ? : null} - {thread.location?.isOutdated ? : null} + {thread.isResolved ? ( + + ) : null} + {thread.location?.isOutdated ? ( + + ) : null} {collapsed ? ( @@ -1080,7 +1087,7 @@ function ThreadBlock({ {renderKebabTriggerIcon} @@ -1124,7 +1131,7 @@ function ThreadBlock({ leftIcon={MessageSquarePlus} onPress={handleAddThreadToChat} > - Add to chat + {t("workspace.git.pr.actions.addToChat")} ) : null} diff --git a/packages/app/src/git/use-diff-files.ts b/packages/app/src/git/use-diff-files.ts index 14a17edcb1..9623a31171 100644 --- a/packages/app/src/git/use-diff-files.ts +++ b/packages/app/src/git/use-diff-files.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQueries } from "@/data/query"; -import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; +import { commitFileDiffQueryOptions } from "./commit-file-diff-query"; import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -79,18 +79,16 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles Boolean(client) && isConnected; const fileDiffResults = useFetchQueries( - commitFiles.map((file) => ({ - queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path), - queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => { - if (!client) { - throw new Error("Host disconnected"); - } - return client.getCommitFileDiff(cwd, sha, file.path); - }, - enabled: fileDiffsEnabled, - staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME, - dataShape: "value" as const, - })), + commitFiles.map((file) => + commitFileDiffQueryOptions({ + serverId, + cwd, + sha, + path: file.path, + client, + enabled: fileDiffsEnabled, + }), + ), ); const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading"; const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null; diff --git a/packages/app/src/git/use-diff-query.test.ts b/packages/app/src/git/use-diff-query.test.ts new file mode 100644 index 0000000000..94b18a7d16 --- /dev/null +++ b/packages/app/src/git/use-diff-query.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { deriveCheckoutDiffResult } from "./use-diff-query"; + +function createParsedDiffFile(path: string): ParsedDiffFile { + return { + path, + isNew: false, + isDeleted: false, + additions: 3, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [{ type: "add", content: "x", tokens: [] }], + }, + ], + } as ParsedDiffFile; +} + +function createPayload(files: ParsedDiffFile[]) { + return { requestId: "req-1", cwd: "/repo", files, error: null }; +} + +describe("deriveCheckoutDiffResult", () => { + // No payload covers every not-yet-fetched case: the query is disabled because the + // retained panel is still inactive, the request is in flight, or the host is + // disconnected. None of them mean "the diff is empty". + it("reports loading while no payload has arrived, so an empty diff is never implied", () => { + const result = deriveCheckoutDiffResult(null); + + expect(result.isLoading).toBe(true); + expect(result.files).toEqual([]); + }); + + it("reports a settled empty diff once a payload with zero files arrives", () => { + const result = deriveCheckoutDiffResult(createPayload([])); + + expect(result.isLoading).toBe(false); + expect(result.files).toEqual([]); + }); + + it("passes the payload files through once the diff arrives", () => { + const files = [createParsedDiffFile("src/app.ts")]; + + const result = deriveCheckoutDiffResult(createPayload(files)); + + expect(result.isLoading).toBe(false); + expect(result.files).toEqual(files); + }); + + it("surfaces a payload error and the too-large flag", () => { + const error = { code: "NOT_GIT_REPO", message: "not a git repository" } as const; + + const result = deriveCheckoutDiffResult({ ...createPayload([]), error, diffTooLarge: true }); + + expect(result).toEqual({ + files: [], + payloadError: error, + diffTooLarge: true, + isLoading: false, + isFetching: false, + isError: true, + error: null, + }); + }); +}); diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 7002bbfc55..091d053307 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -1,3 +1,4 @@ +import { shareCheckoutDiff } from "./diff-sharing"; import { useMemo } from "react"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useReplicaQuery } from "@/data/query"; @@ -74,6 +75,7 @@ export function useCheckoutDiffQuery({ const query = useReplicaQuery({ queryKey, + structuralSharing: shareCheckoutDiff, enabled: routeEnabled, pushEvent: "checkout_diff_update", meta: checkoutDiffPushRoute({ @@ -89,14 +91,35 @@ export function useCheckoutDiffQuery({ }), }); - const payload = query.data ?? null; - const payloadError = payload?.error ?? null; + return deriveCheckoutDiffResult(query.data ?? null); +} +export interface CheckoutDiffResult { + files: ParsedDiffFile[]; + payloadError: CheckoutDiffQueryPayload["error"]; + diffTooLarge: boolean; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + error: null; +} + +/** + * The result derives from the payload alone. Until a payload arrives there is no + * diff to describe, so the boundary reports loading rather than an empty diff — + * whether the query is idle (an inactive retained panel), in flight, or the host + * is disconnected. Reporting `files: []` as settled made callers render "+0 -0" + * and "No changes" for a diff nobody has fetched yet. + */ +export function deriveCheckoutDiffResult( + payload: CheckoutDiffQueryPayload | null, +): CheckoutDiffResult { + const payloadError = payload?.error ?? null; return { files: payload?.files ?? [], payloadError, diffTooLarge: payload?.diffTooLarge === true, - isLoading: payload === null && queryEnabled && isConnected, + isLoading: payload === null, isFetching: false, isError: Boolean(payloadError), error: null, diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 341cc4946d..daeda1479d 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -7,6 +7,7 @@ import { buildReviewDraftKey, useInlineReviewController, useReviewAttachmentSnapshot, + useReviewFeedback, } from "@/review"; import { useCheckoutDiffQuery } from "@/git/use-diff-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; @@ -19,6 +20,7 @@ interface UseWorkingDiffOptions { ignoreWhitespace: boolean; enabled: boolean; queryScope?: string; + presentation?: string; } export function useWorkingDiff({ @@ -28,6 +30,7 @@ export function useWorkingDiff({ ignoreWhitespace, enabled, queryScope, + presentation, }: UseWorkingDiffOptions) { const { status, @@ -81,7 +84,7 @@ export function useWorkingDiff({ }), [baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId], ); - const reviewActions = useInlineReviewController({ reviewDraftKey }); + const reviewActions = useInlineReviewController({ reviewDraftKey, presentation }); const reviewAttachment = useReviewAttachmentSnapshot({ key: reviewDraftKey, diffFiles: files, @@ -90,6 +93,13 @@ export function useWorkingDiff({ baseRef, }); + const reviewFeedback = useReviewFeedback({ + serverId, + workspaceId, + reviewDraftKey, + attachment: reviewAttachment, + }); + return { status, isStatusLoading, @@ -107,6 +117,7 @@ export function useWorkingDiff({ isDiffLoading, reviewActions, reviewAttachment, + reviewFeedback, }; } diff --git a/packages/app/src/hooks/agent-commands-query.test.ts b/packages/app/src/hooks/agent-commands-query.test.ts index 9d880b8dab..e8d8ee0b94 100644 --- a/packages/app/src/hooks/agent-commands-query.test.ts +++ b/packages/app/src/hooks/agent-commands-query.test.ts @@ -38,9 +38,10 @@ describe("agent command query keys", () => { "agentCommands", "server-1", "draft", - "codex", "cwd", "/repo", + "provider", + "codex", "mode", null, "model", diff --git a/packages/app/src/hooks/agent-commands-query.ts b/packages/app/src/hooks/agent-commands-query.ts index 38cbd97a10..276c0fd9ba 100644 --- a/packages/app/src/hooks/agent-commands-query.ts +++ b/packages/app/src/hooks/agent-commands-query.ts @@ -1,3 +1,4 @@ +import type { QueryClient } from "@tanstack/react-query"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; @@ -24,17 +25,24 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const; } +function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) { + return [ + ...agentCommandsQueryRoot(input.serverId), + "draft", + "cwd", + normalizeAgentCommandsCwd(input.cwd), + ] as const; +} + export function draftAgentCommandsQueryKey(input: { serverId: string; draftConfig: AgentCommandsDraftConfig; }) { const { draftConfig } = input; return [ - ...agentCommandsQueryRoot(input.serverId), - "draft", + ...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }), + "provider", draftConfig.provider, - "cwd", - normalizeAgentCommandsCwd(draftConfig.cwd), "mode", draftConfig.modeId ?? null, "model", @@ -46,6 +54,16 @@ export function draftAgentCommandsQueryKey(input: { ] as const; } +// Draft commands include project skills read from the checkout. When the checkout moves to +// another branch the cached list describes files that are gone, so it is dropped rather than +// shown while a refetch runs. +export function resetDraftAgentCommandsForCheckout( + queryClient: QueryClient, + input: { serverId: string; cwd: string }, +): Promise { + return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) }); +} + export function agentCommandsQueryKey(input: { serverId: string; agentId: string; diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts index b4fdc3df00..184ba8db87 100644 --- a/packages/app/src/hooks/hover-safe-zone-tracker.test.ts +++ b/packages/app/src/hooks/hover-safe-zone-tracker.test.ts @@ -94,14 +94,30 @@ describe("hover safe-zone tracker", () => { expect(handle.leaves).toBe(1); }); + it("bridges the gap to content above or below the trigger", () => { + const handle = createHandle({ + trigger: { left: 100, right: 130, top: 60, bottom: 90 }, + content: { left: 0, right: 300, top: 0, bottom: 52 }, + }); + + // In the gap between the card and the trigger below it. + handle.pointerMoved(115, 56); + expect(handle.enters).toBe(1); + expect(handle.leaves).toBe(0); + + // Beside the trigger, past the gap — outside. + handle.pointerMoved(200, 75); + expect(handle.leaves).toBe(1); + }); + it("treats overlapping trigger and content as having no bridge", () => { const handle = createHandle({ trigger: { left: 0, right: 200, top: 0, bottom: 50 }, - content: { left: 100, right: 300, top: 60, bottom: 100 }, + content: { left: 100, right: 300, top: 40, bottom: 100 }, }); - // Outside both rects, in what would be a bridge — should be outside. - handle.pointerMoved(150, 55); + // Outside both rects, in the corner neither of them covers — should be outside. + handle.pointerMoved(250, 20); expect(handle.enters).toBe(0); expect(handle.leaves).toBe(1); }); diff --git a/packages/app/src/hooks/hover-safe-zone-tracker.ts b/packages/app/src/hooks/hover-safe-zone-tracker.ts index d097413c50..f9c4db4708 100644 --- a/packages/app/src/hooks/hover-safe-zone-tracker.ts +++ b/packages/app/src/hooks/hover-safe-zone-tracker.ts @@ -62,9 +62,20 @@ function isInsideSafeZone( if (isInsideRect(trigger, x, y)) return true; if (isInsideRect(content, x, y)) return true; if (!trigger || !content) return false; + return ( + isInsideHorizontalBridge(trigger, content, x, y) || + isInsideVerticalBridge(trigger, content, x, y) + ); +} - // Bridge: the horizontal strip connecting trigger and content, stretched - // vertically to span both. If they overlap horizontally there's no bridge. +// A card beside its trigger: the strip between them, stretched vertically to +// span both. If they overlap horizontally there's no horizontal bridge. +function isInsideHorizontalBridge( + trigger: RectLike, + content: RectLike, + x: number, + y: number, +): boolean { const bridgeLeft = Math.min(trigger.right, content.right); const bridgeRight = Math.max(trigger.left, content.left); if (bridgeLeft >= bridgeRight) return false; @@ -72,3 +83,19 @@ function isInsideSafeZone( const bridgeBottom = Math.max(trigger.bottom, content.bottom); return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; } + +// A card above or below its trigger: the strip between them, stretched +// horizontally to span both. If they overlap vertically there's no vertical bridge. +function isInsideVerticalBridge( + trigger: RectLike, + content: RectLike, + x: number, + y: number, +): boolean { + const bridgeTop = Math.min(trigger.bottom, content.bottom); + const bridgeBottom = Math.max(trigger.top, content.top); + if (bridgeTop >= bridgeBottom) return false; + const bridgeLeft = Math.min(trigger.left, content.left); + const bridgeRight = Math.max(trigger.right, content.right); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +} diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index 62dc7142a8..c5bd8dcd11 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; +import { i18n } from "@/i18n/i18next"; import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model"; import { buildStatusGroups, buildStatusShortcutIndex, - STATUS_BUCKET_LABELS, + getStatusBucketLabel, STATUS_BUCKET_ORDER, type StatusGroup, } from "./sidebar-status-view-model"; @@ -55,7 +56,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]); expect(groups[0]?.label).toBe("Needs input"); @@ -69,7 +70,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:b", statusBucket: "running" }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]); }); @@ -93,7 +94,7 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]); }); @@ -109,7 +110,7 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([ "srv:ts", @@ -145,13 +146,13 @@ describe("buildStatusGroups", () => { }), ]; - const groups = buildStatusGroups(workspaces, projectNames); + const groups = buildStatusGroups(workspaces, projectNames, i18n.t); expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]); }); it("returns empty array for no workspaces", () => { - const groups = buildStatusGroups([], emptyProjectNames); + const groups = buildStatusGroups([], emptyProjectNames, i18n.t); expect(groups).toEqual([]); }); @@ -180,11 +181,11 @@ describe("buildStatusGroups", () => { ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }), ]; - const groups = buildStatusGroups(workspaces, emptyProjectNames); + const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t); expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER); expect(groups.map((g) => g.label)).toEqual( - STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]), + STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)), ); // Each group has exactly one row with the matching bucket for (const group of groups) { @@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => { expect(index.size).toBe(0); }); }); + +describe("getStatusBucketLabel", () => { + it("reads in the active app language", async () => { + await i18n.changeLanguage("fr"); + try { + expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([ + "Attend une réponse", + "Échec", + "À relire", + "En cours", + "Terminé", + ]); + } finally { + await i18n.changeLanguage("en"); + } + }); +}); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index 7bcbe3d345..5736c7f391 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model"; @@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"]; export { STATUS_BUCKET_ORDER }; -export const STATUS_BUCKET_LABELS: Record = { - needs_input: "Needs input", - failed: "Failed", - attention: "Ready to review", - running: "Working", - done: "Done", -}; +const STATUS_BUCKET_LABEL_KEYS = { + needs_input: "sidebar.statusBucket.needsInput", + failed: "sidebar.statusBucket.failed", + attention: "sidebar.statusBucket.readyToReview", + running: "sidebar.statusBucket.working", + done: "sidebar.statusBucket.done", +} as const satisfies Record; + +export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string { + return t(STATUS_BUCKET_LABEL_KEYS[bucket]); +} export interface StatusGroup { bucket: StatusBucket; @@ -22,6 +27,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], projectNamesByViewKey: Map, + t: TFunction, ): StatusGroup[] { const bucketRows = new Map(); @@ -42,7 +48,7 @@ export function buildStatusGroups( if (!rows || rows.length === 0) continue; rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey)); - groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); + groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows }); } return groups; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f9d192dbb9..637d3a035b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -701,7 +701,10 @@ function agent(input: { id: input.id, provider: "claude" as Agent["provider"], status: input.status, - activeTurn: null, + turn: + input.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: input.updatedAt ?? new Date(1_000), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 842844cdc2..24ecc34461 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement { workspaceDirectory?: string; projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; + background?: boolean; name: string; } @@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: { input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, + background: input.workspace.background, name: input.workspace.name, title: input.workspace.title ?? null, pinnedAt: input.workspace.pinnedAt, diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 5ab515d8b1..fe6f98c3f1 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -7,6 +7,7 @@ import { useAgentCommandsQuery, type AgentSlashCommand, type DraftCommandConfig, + type DraftCommandTarget, } from "./use-agent-commands-query"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; @@ -34,7 +35,7 @@ interface UseAgentAutocompleteInput { setUserInput: (nextValue: string) => void; serverId: string; agentId: string; - draftConfig?: DraftCommandConfig; + draft?: DraftCommandTarget; onAutocompleteApplied?: () => void; onClientSlashCommand?: (command: ClientSlashCommand) => void; canExecuteClientSlashCommand?: boolean; @@ -117,18 +118,13 @@ type AvailableCommand = | { source: "plugin"; command: PluginClientSlashCommand } | { source: "provider"; command: AgentSlashCommand }; -function normalizeDraftCommandConfig( - draftConfig?: DraftCommandConfig, -): DraftCommandConfig | undefined { - if (!draftConfig) { +function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig | undefined { + if (draft?.status !== "ready") { return undefined; } + const draftConfig = draft.config; const cwd = draftConfig.cwd.trim(); - if (!cwd) { - return undefined; - } - const modeId = draftConfig.modeId?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; @@ -337,6 +333,35 @@ function resolveAutocompleteErrorMessage(args: { return undefined; } +// A draft's agentId is a draft key the daemon does not know, so a draft lists +// commands by its config only, and not at all until that config is complete. +function resolveDraftCommandContext(draft: DraftCommandTarget | undefined) { + const queryDraftConfig = resolveDraftQueryConfig(draft); + const isDraftContext = draft !== undefined; + return { + isDraftContext, + queryDraftConfig, + canListCommands: !isDraftContext || queryDraftConfig !== undefined, + }; +} + +function resolveAutocompleteEmptyText(args: { + mode: AutocompleteMode; + draft: DraftCommandTarget | undefined; + t: TFunction; +}): string { + if (args.mode === "file") { + return args.t("agentAutocomplete.noFiles"); + } + if (args.draft?.status === "needs-project") { + return args.t("agentAutocomplete.chooseProjectForCommands"); + } + if (args.draft?.status === "needs-provider") { + return args.t("agentAutocomplete.chooseModelForCommands"); + } + return args.t("agentAutocomplete.noCommands"); +} + export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { const { t } = useTranslation(); const { @@ -345,7 +370,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut setUserInput, serverId, agentId, - draftConfig, + draft, onAutocompleteApplied, onClientSlashCommand, canExecuteClientSlashCommand, @@ -380,13 +405,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut return () => clearTimeout(timer); }, [fileFilterQuery]); - const normalizedDraftConfig = useMemo( - () => normalizeDraftCommandConfig(draftConfig), - [draftConfig], + const { isDraftContext, queryDraftConfig, canListCommands } = useMemo( + () => resolveDraftCommandContext(draft), + [draft], ); - - const isDraftContext = normalizedDraftConfig !== undefined; - const queryDraftConfig = normalizedDraftConfig; const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext }); const agentCwd = useSessionStore( @@ -410,17 +432,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut autocompleteCwd, }); - const { - commands, - isLoading: isCommandsLoading, - isError, - error, - } = useAgentCommandsQuery({ + const commandsQuery = useAgentCommandsQuery({ serverId, agentId, - enabled: mode === "command" && canLoadCommands, + enabled: mode === "command" && canLoadCommands && canListCommands, draftConfig: queryDraftConfig, }); + const { commands, isError, error } = commandsQuery; + const isCommandsLoading = canListCommands && commandsQuery.isLoading; const isVisible = canShowAutocomplete && !(mode === "command" && isCommandsLoading); @@ -587,8 +606,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut mode === "file" ? t("agentAutocomplete.searchingWorkspace") : t("agentAutocomplete.loadingCommands"); - const emptyText = - mode === "file" ? t("agentAutocomplete.noFiles") : t("agentAutocomplete.noCommands"); + const emptyText = resolveAutocompleteEmptyText({ mode, draft, t }); return { isVisible, diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index e57184f536..eddc0ab02b 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -17,6 +17,12 @@ export interface AgentSlashCommand { export type DraftCommandConfig = AgentCommandsDraftConfig; +/** What a draft composer can list commands for, before an agent exists. */ +export type DraftCommandTarget = + | { status: "ready"; config: DraftCommandConfig } + | { status: "needs-project" } + | { status: "needs-provider" }; + interface ListAgentCommandsOptions { agentId: string; draftConfig?: DraftCommandConfig; diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 456ce249c2..26908bd6a0 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -6,7 +6,6 @@ import type { AgentProvider, ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; -import { useHosts } from "@/runtime/host-runtime"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildSelectableProviderSelectorProviders, @@ -27,7 +26,6 @@ import { normalizeSelectedModelId, resolveDefaultModelId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, INITIAL_AGENT_FORM_RESOLUTION, @@ -43,18 +41,15 @@ import type { MaterializedAgentProfile } from "@/agent-profiles"; export type { FormInitialValues } from "@/provider-selection/resolve-agent-form"; export interface UseAgentFormStateOptions { - initialServerId?: string | null; + serverId: string | null; + workingDir: string; initialValues?: FormInitialValues; isVisible?: boolean; isCreateFlow?: boolean; - isTargetDaemonReady?: boolean; - onlineServerIds?: string[]; } export interface UseAgentFormStateResult { selectedServerId: string | null; - setSelectedServerId: (value: string | null) => void; - setSelectedServerIdFromUser: (value: string | null) => void; selectedProvider: AgentProvider | null; selectedMode: string; setModeFromUser: (modeId: string) => void; @@ -63,8 +58,6 @@ export interface UseAgentFormStateResult { selectedThinkingOptionId: string; setThinkingOptionFromUser: (thinkingOptionId: string) => void; workingDir: string; - setWorkingDir: (value: string) => void; - setWorkingDirFromUser: (value: string) => void; providerDefinitions: AgentProviderDefinition[]; providerDefinitionMap: Map; agentDefinition?: AgentProviderDefinition; @@ -87,59 +80,6 @@ export interface UseAgentFormStateResult { persistFormPreferences: () => Promise; } -function shouldAutoSelectServerId(input: { - isVisible: boolean; - isCreateFlow: boolean; - isPreferencesLoading: boolean; - userModifiedServerId: boolean; - initialServerId: string | null | undefined; - currentServerId: string | null; -}): boolean { - const { - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId, - initialServerId, - currentServerId, - } = input; - if (!isVisible || !isCreateFlow) return false; - if (isPreferencesLoading) return false; - if (userModifiedServerId) return false; - if (initialServerId !== undefined) return false; - if (currentServerId) return false; - return true; -} - -function resolutionIntentKeyPart(value: string | null | undefined): string { - if (value === undefined) return "undefined"; - if (value === null) return "null"; - return value; -} - -function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string { - if (!initialValues) return "none"; - // workingDir seeds the open request, but locked cwd updates must not re-run - // provider/model/mode resolution after the form has settled. - return [ - resolutionIntentKeyPart(initialValues.serverId), - resolutionIntentKeyPart(initialValues.provider), - resolutionIntentKeyPart(initialValues.modeId), - resolutionIntentKeyPart(initialValues.model), - resolutionIntentKeyPart(initialValues.thinkingOptionId), - ].join("\n"); -} - -function hasSnapshotDataForResolution(input: { - serverId: string | null; - snapshotEntries: ProviderSnapshotEntry[] | undefined; -}): boolean { - if (!input.serverId) { - return false; - } - return input.snapshotEntries !== undefined; -} - function resolveSelectedProviderModes(input: { selectedEntry: ProviderSnapshotEntry | null; provider: AgentProvider | null; @@ -170,7 +110,10 @@ function buildProviderModelsByProvider( ): ProviderModelsByProvider { const map: ProviderModelsByProvider = new Map(); for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, filterSelectableModels(entry.models ?? null)); + map.set( + entry.provider, + entry.status === "ready" ? filterSelectableModels(entry.models ?? null) : null, + ); } return map; } @@ -201,15 +144,8 @@ async function persistProviderPreferences(input: { ); } -export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { - const { - initialServerId = null, - initialValues, - isVisible = true, - isCreateFlow = true, - isTargetDaemonReady: _isTargetDaemonReady = true, - onlineServerIds = [], - } = options; +export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFormStateResult { + const { serverId, initialValues, workingDir, isVisible = true, isCreateFlow = true } = options; const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences(); const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences)); @@ -235,32 +171,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [updatePreferences], ); - const daemons = useHosts(); - - const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); - - const [{ form: formState, userModified, resolution }, dispatch] = useReducer( - resolveAgentForm, - initialServerId, - (serverId) => ({ - form: { - serverId, - provider: null, - modeId: "", - model: "", - thinkingOptionId: "", - workingDir: "", - }, - userModified: INITIAL_USER_MODIFIED, - resolution: INITIAL_AGENT_FORM_RESOLUTION, - }), - ); - - useEffect(() => { - if (!isVisible) { - dispatch({ type: "RESET" }); - } - }, [isVisible]); + const [{ form: formState, userModified, resolution }, dispatch] = useReducer(resolveAgentForm, { + form: { provider: null, modeId: "", model: "", thinkingOptionId: "" }, + userModified: INITIAL_USER_MODIFIED, + resolution: INITIAL_AGENT_FORM_RESOLUTION, + }); const { entries: snapshotEntries, @@ -269,7 +184,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg error: snapshotError, refresh: refreshSnapshot, refetchIfStale: refetchSnapshotIfStale, - } = useProvidersSnapshot(formState.serverId, { cwd: formState.workingDir }); + } = useProvidersSnapshot(serverId, { cwd: workingDir }); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( @@ -335,91 +250,31 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading; const isAllModelsLoading = isModelSelectionLoading; - const combinedInitialValues = useMemo( - () => combineInitialValues(initialValues, initialServerId), - [initialValues, initialServerId], - ); - const resolutionIntentKey = useMemo( - () => buildResolutionIntentKey(combinedInitialValues), - [combinedInitialValues], - ); - useEffect(() => { - if (!isVisible || !isCreateFlow) { - return; - } - - dispatch({ type: "REQUEST_RESOLUTION" }); - }, [isVisible, isCreateFlow, resolutionIntentKey]); - - useEffect(() => { - if (!isVisible || !isCreateFlow || resolution.status !== "pending") { - return; - } - if (isPreferencesLoading) { - return; - } - if ( - !hasSnapshotDataForResolution({ - serverId: formState.serverId, - snapshotEntries, - }) - ) { - return; - } - dispatch({ - type: "COMPLETE_RESOLUTION", - initialValues: combinedInitialValues, + type: "INPUTS_CHANGED", + serverId, + isVisible, + isCreateFlow, + isPreferencesLoading, + hasSnapshot: snapshotEntries !== undefined, + initialValues, preferences, providerModelsByProvider: snapshotProviderModelsByProvider, allowedProviderMap: snapshotResolvableProviderDefinitionMap, }); }, [ - combinedInitialValues, - formState.serverId, + serverId, + isVisible, isCreateFlow, isPreferencesLoading, - isVisible, - preferences, - resolution.status, snapshotEntries, + initialValues, + preferences, snapshotProviderModelsByProvider, snapshotResolvableProviderDefinitionMap, ]); - const onlineServerIdsKey = onlineServerIds.join("|"); - useEffect(() => { - const canAutoSelectServerId = shouldAutoSelectServerId({ - isVisible, - isCreateFlow, - isPreferencesLoading, - userModifiedServerId: userModified.serverId, - initialServerId: combinedInitialValues?.serverId, - currentServerId: formState.serverId, - }); - if (!canAutoSelectServerId) return; - - const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null; - if (!candidate) return; - - dispatch({ type: "AUTO_SELECT_SERVER", candidateServerId: candidate }); - }, [ - combinedInitialValues?.serverId, - isCreateFlow, - isPreferencesLoading, - isVisible, - onlineServerIds, - onlineServerIdsKey, - formState.serverId, - userModified.serverId, - validServerIds, - ]); - - const setSelectedServerIdFromUser = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID_FROM_USER", value }); - }, []); - const setProviderAndModelFromUser = useCallback( (provider: AgentProvider, modelId: string) => { if (!selectableProviderDefinitionMap.has(provider)) { @@ -579,18 +434,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [formState, updateCurrentPreferences], ); - const setWorkingDir = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR", value }); - }, []); - - const setWorkingDirFromUser = useCallback((value: string) => { - dispatch({ type: "SET_WORKING_DIR_FROM_USER", value }); - }, []); - - const setSelectedServerId = useCallback((value: string | null) => { - dispatch({ type: "SET_SERVER_ID", value }); - }, []); - const refreshProviderModels = useCallback( (provider?: AgentProvider) => { void refreshSnapshot(provider ? [provider] : undefined); @@ -626,13 +469,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const isModelLoading = isModelSelectionLoading; const modelError = snapshotError; - const workingDirIsEmpty = !formState.workingDir.trim(); + const workingDirIsEmpty = !workingDir.trim(); return useMemo( () => ({ - selectedServerId: formState.serverId, - setSelectedServerId, - setSelectedServerIdFromUser, + selectedServerId: serverId, selectedProvider: formState.provider, selectedMode: formState.modeId, setModeFromUser, @@ -640,9 +481,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setModelFromUser, selectedThinkingOptionId: formState.thinkingOptionId, setThinkingOptionFromUser, - workingDir: formState.workingDir, - setWorkingDir, - setWorkingDirFromUser, + workingDir, providerDefinitions, providerDefinitionMap, agentDefinition, @@ -665,19 +504,15 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg persistFormPreferences, }), [ - formState.serverId, + serverId, formState.provider, formState.modeId, formState.model, formState.thinkingOptionId, - formState.workingDir, - setSelectedServerId, - setSelectedServerIdFromUser, + workingDir, setModeFromUser, setModelFromUser, setThinkingOptionFromUser, - setWorkingDir, - setWorkingDirFromUser, providerDefinitions, providerDefinitionMap, agentDefinition, diff --git a/packages/app/src/hooks/use-agent-history.test.ts b/packages/app/src/hooks/use-agent-history.test.ts index 3fcdd3df5c..d4da1f01bb 100644 --- a/packages/app/src/hooks/use-agent-history.test.ts +++ b/packages/app/src/hooks/use-agent-history.test.ts @@ -311,10 +311,10 @@ describe("fetchAgentHistoryPage", () => { }); expect(client.calls[0]?.search).toBe("stripe"); - expect(page.searchScoreByAgentKey).toEqual({ "server-1:match": 1000 }); + expect(page.agents.map((agent) => agent.id)).toEqual(["match"]); }); - it("keeps per-host scores apart when two hosts issue the same agent id", async () => { + it("keeps sessions with the same id on different hosts and sorts by recency", async () => { const sharedId = "collision"; const serverAClient = createClient([ historyPayload({ @@ -352,13 +352,9 @@ describe("fetchAgentHistoryPage", () => { search: "match", }); - expect(page.searchScoreByAgentKey).toEqual({ - "server-a:collision": 4000, - "server-b:collision": 1000, - }); expect(page.agents.map((agent) => agent.title)).toEqual([ - "Strong match on B", "Weak match on A", + "Strong match on B", ]); }); @@ -395,9 +391,8 @@ describe("fetchAgentHistoryPage", () => { }); }); - it("reports truncation when two complete host pages overflow the merge", async () => { - // Neither host is locally truncated; together they exceed what the merged - // list can show, and the footer has to say so. + it("preserves every matching row when merging complete host pages", async () => { + // Each host owns its cursor; merging must not discard rows already consumed. const buildHost = (serverId: string, count: number) => createClient([ historyPayload({ @@ -422,8 +417,8 @@ describe("fetchAgentHistoryPage", () => { search: "match", }); - expect(page.isSearchTruncated).toBe(true); - expect(page.agents).toHaveLength(200); + expect(page.isSearchTruncated).toBe(false); + expect(page.agents).toHaveLength(300); }); it("names the host that failed instead of quietly shortening the list", async () => { @@ -515,7 +510,7 @@ describe("fetchAgentHistoryPage", () => { }), ).toEqual([{ serverId: "server-b", serverName: "Linux box" }]); }); - it("orders a searched all-host page by relevance instead of recency", async () => { + it("orders searched history chronologically regardless of match strength", async () => { const serverAClient = createClient([ historyPayload({ entries: [ @@ -553,8 +548,8 @@ describe("fetchAgentHistoryPage", () => { }); expect(page.agents.map((agent) => agent.id)).toEqual([ - "older-strong-match", "newer-weak-match", + "older-strong-match", ]); }); @@ -588,6 +583,7 @@ describe("fetchAgentHistoryPage", () => { { serverId: "server-b", serverLabel: "Linux box", client: serverBClient }, ] satisfies AgentHistoryHost[], cursorByServerId: { "server-b": "cursor-b" }, + search: "match", }); expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]); @@ -596,6 +592,7 @@ describe("fetchAgentHistoryPage", () => { { sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200, cursor: "cursor-b" }, + search: "match", } satisfies FetchAgentHistoryOptions, ]); }); diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index b24c5f8ee7..96b0395e76 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -3,7 +3,6 @@ import type { FetchAgentHistoryOptions, FetchAgentHistoryPageInfo, } from "@getpaseo/client/internal/daemon-client"; -import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; @@ -31,8 +30,6 @@ export interface AgentHistoryResult { isSearchSupported: boolean; /** More sessions matched than the ranked page holds. Narrow the query. */ isSearchTruncated: boolean; - /** Where the query matched each row, keyed by `serverId:agentId`. */ - searchMatchesByAgentKey: Record; /** Hosts that failed while others succeeded. The list still renders. */ hostErrors: AgentHistoryHostError[]; refreshAll: () => Promise; @@ -41,26 +38,11 @@ export interface AgentHistoryResult { export interface AgentHistoryPage { agents: AggregatedAgent[]; - /** - * Relevance of each agent to the request's search, keyed by `serverId:agentId` - * because that pair — not the bare agent id — is what identifies a row across - * hosts. Empty without a query. Each host ranks only its own sessions, so - * merging several of them into one list needs the scores rather than each - * host's row order. - */ - searchScoreByAgentKey: Record; - /** Where the query matched in each row, keyed like `searchScoreByAgentKey`. */ - searchMatchesByAgentKey: Record; pageInfo: FetchAgentHistoryPageInfo; /** More matched this host's query than its page could hold. */ isSearchTruncated: boolean; } -/** Identity of a history row. A bare agent id belongs to exactly one host. */ -export function agentHistoryRowKey(agent: { serverId: string; id: string }): string { - return `${agent.serverId}:${agent.id}`; -} - export type AgentHistoryClient = Pick; /** @@ -81,17 +63,12 @@ export interface AgentHistoryHost { interface AgentHistoryBatchPage { agents: AggregatedAgent[]; - searchScoreByAgentKey: Record; - searchMatchesByAgentKey: Record; pageInfoByServerId: Record; hostErrors: AgentHistoryHostError[]; /** Set only under a query: more sessions matched than this page can hold. */ isSearchTruncated?: boolean; } -/** Sorts unmatched rows last so a page missing scores keeps its own order. */ -const UNRANKED = Number.POSITIVE_INFINITY; - type AgentHistoryCursorByServerId = Record; export async function fetchAgentHistoryPage(input: { @@ -112,21 +89,7 @@ export async function fetchAgentHistoryPage(input: { serverId: input.serverId, entries: payload.entries, }); - const searchScoreByAgentKey: Record = {}; - const searchMatchesByAgentKey: Record = {}; - for (const entry of payload.entries) { - const key = agentHistoryRowKey({ serverId: input.serverId, id: entry.agent.id }); - if (entry.searchScore !== undefined) { - searchScoreByAgentKey[key] = entry.searchScore; - } - if (entry.searchMatches && entry.searchMatches.length > 0) { - searchMatchesByAgentKey[key] = entry.searchMatches; - } - } - return { - searchScoreByAgentKey, - searchMatchesByAgentKey, isSearchTruncated: payload.searchTruncated === true, agents: Array.from(agents.values(), (agent) => ({ id: agent.id, @@ -134,6 +97,7 @@ export async function fetchAgentHistoryPage(input: { serverLabel: input.serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -155,30 +119,6 @@ function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] { return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()); } -/** - * Relevance first, recency to break ties. A search result list ordered by time - * would bury the row the user described in favour of the row they last touched. - * - * Truncating the merge back to one page's worth is what makes the merged list - * globally correct: the best N matches overall are always contained in the - * union of each host's own best N, so keeping more than N here would be showing - * rows that a further host could still outrank. - */ -function mergeByRelevance( - agents: AggregatedAgent[], - searchScoreByAgentKey: Record, - limit: number, -): AggregatedAgent[] { - return [...agents] - .sort((a, b) => { - const scoreA = searchScoreByAgentKey[agentHistoryRowKey(a)] ?? UNRANKED; - const scoreB = searchScoreByAgentKey[agentHistoryRowKey(b)] ?? UNRANKED; - if (scoreA !== scoreB) return scoreA - scoreB; - return b.lastActivityAt.getTime() - a.lastActivityAt.getTime(); - }) - .slice(0, limit); -} - /** * Which hosts are missing from the list the user is looking at. * @@ -264,30 +204,13 @@ export async function fetchAgentHistoryBatch(input: { const pageInfoByServerId = Object.fromEntries( pages.map(({ host, page }) => [host.serverId, page.pageInfo]), ); - const searchScoreByAgentKey = Object.assign( - {}, - ...pages.map(({ page }) => page.searchScoreByAgentKey), - ) as Record; - const searchMatchesByAgentKey = Object.assign( - {}, - ...pages.map(({ page }) => page.searchMatchesByAgentKey), - ) as Record; - // Truncation has two independent sources: one host overflowed its own page, - // or several hosts each fit but their union does not. Two hosts returning 150 - // matches apiece are both complete and still add up to more than the merge - // can show. - const truncated = - pages.some(({ page }) => page.isSearchTruncated) || agents.length > AGENT_HISTORY_PAGE_LIMIT; - return { - agents: input.search - ? mergeByRelevance(agents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT) - : sortByLatestActivity(agents), - searchScoreByAgentKey, - searchMatchesByAgentKey, + agents: sortByLatestActivity(agents), pageInfoByServerId, hostErrors, - ...(input.search ? { isSearchTruncated: truncated } : {}), + ...(input.search + ? { isSearchTruncated: pages.some(({ page }) => page.isSearchTruncated) } + : {}), }; } @@ -419,28 +342,11 @@ export function useAgentHistory(options: { serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel, }), ); - if (!search) { - return sortByLatestActivity(labelledAgents); - } - // A searched query never has a second page, so this is re-merging one page - // after relabelling rather than stitching a stream back together. - const searchScoreByAgentKey = Object.assign( - {}, - ...pages.map((page) => page.searchScoreByAgentKey), - ) as Record; - return mergeByRelevance(labelledAgents, searchScoreByAgentKey, AGENT_HISTORY_PAGE_LIMIT); - }, [data?.pages, search, serverLabelById]); + return sortByLatestActivity(labelledAgents); + }, [data?.pages, serverLabelById]); const isInitialLoad = isLoading && agents.length === 0; const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0; const isSearchTruncated = Boolean(search && data?.pages.some((page) => page.isSearchTruncated)); - const searchMatchesByAgentKey = useMemo( - () => - Object.assign( - {}, - ...(data?.pages ?? []).map((page) => page.searchMatchesByAgentKey), - ) as Record, - [data?.pages], - ); const hostErrors = useMemo( () => collectAgentHistoryHostErrors({ pages: data?.pages ?? [], unreachableHosts }), [data?.pages, unreachableHosts], @@ -452,13 +358,10 @@ export function useAgentHistory(options: { isInitialLoad, isRevalidating, isError, - // Under a query the daemon returns the best matches and no cursor, so the - // list is complete as far as it goes and "Load more" would be a lie. - hasMore: search ? false : hasNextPage, + hasMore: hasNextPage, isLoadingMore: isFetchingNextPage, isSearchSupported, isSearchTruncated, - searchMatchesByAgentKey, hostErrors, refreshAll, loadMore, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index a7ed05e906..19caef4f8b 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,7 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", - activeTurn: { turnId: "turn-1", startedAt: now }, + turn: { phase: "open", turnId: "turn-1", startedAt: now, cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -175,7 +175,7 @@ describe("deriveAgentScreenViewState", () => { expect(sync.ui).toBe("overlay"); }); - it("uses silent catching-up state for already-hydrated agents", () => { + it("shows updating status for already-hydrated agents", () => { const memory = createBaseMemory({ hasRenderedReady: true, lastReadyAgent: createAgent("agent-1"), @@ -190,7 +190,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps hydrated history visible while reconnect revalidation and visibility catch-up overlap", () => { @@ -209,7 +209,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps already-hydrated history visible while a newly visible agent catches up", () => { @@ -228,7 +228,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); }); it("keeps hydrated history readable after a visibility catch-up error", () => { @@ -607,7 +607,7 @@ describe("deriveAgentScreenViewState", () => { const ready = expectReadyState(result.state); const sync = expectCatchingUpSync(ready); - expect(sync.ui).toBe("silent"); + expect(sync.ui).toBe("status"); expect(result.memory.hadInitialSyncFailure).toBe(false); }); }); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index df128e4f2f..591fd1abb1 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -82,7 +82,7 @@ export type AgentScreenReadySyncState = | { status: "reconnecting" } | { status: "catching_up"; - ui: "overlay" | "silent"; + ui: "overlay" | "status" | "silent"; } | { status: "sync_error"; isRetrying: boolean }; @@ -162,9 +162,9 @@ function resolveCatchingUpUi(args: { isVisibilityCatchUpPending: boolean; hasHydratedHistoryBefore: boolean; hadInitialSyncFailure: boolean; -}): "overlay" | "silent" { +}): "overlay" | "status" | "silent" { if (args.hasOptimisticCreateContinuity) return "silent"; - if (args.hasHydratedHistoryBefore) return "silent"; + if (args.hasHydratedHistoryBefore) return "status"; if (args.isVisibilityCatchUpPending) return "overlay"; if (args.hadInitialSyncFailure) return "silent"; return "overlay"; diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 1a016e9b13..78cca7fbb3 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -108,8 +109,8 @@ export function useAggregatedAgents(options?: { // Sort by: running agents first, then by most recent activity allAgents.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; + const leftRunning = left.turn.phase === "open"; + const rightRunning = right.turn.phase === "open"; if (leftRunning && !rightRunning) { return -1; } diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index cd4d433f74..df14f2df38 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,6 +1,7 @@ +import { seedSessionHosts } from "@/test/seed-session"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -41,13 +42,15 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } describe("useArchiveAgent", () => { + afterEach(() => seedSessionHosts([])); beforeEach(() => { useSessionStore.setState((state) => ({ ...state, sessions: {} })); + seedSessionHosts(["server-a"]); }); it("tracks pending archive state in shared react-query cache", () => { diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 437ed6df49..92d8e4d2d6 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useMemo } from "react"; import { useMutation, @@ -247,27 +248,7 @@ function getStoredAgentSnapshot(input: ArchiveAgentInput) { function restoreAgentSnapshot( input: ArchiveAgentInput & { agent: ReturnType }, ): void { - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const hasAgent = prev.has(input.agentId); - if (!input.agent) { - if (!hasAgent) { - return prev; - } - const next = new Map(prev); - next.delete(input.agentId); - return next; - } - - const current = prev.get(input.agentId); - if (current === input.agent) { - return prev; - } - - const next = new Map(prev); - next.set(input.agentId, input.agent); - return next; - }); + getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent); } function getArchivedAgentListCacheSnapshot( @@ -324,22 +305,11 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin return; } - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const existing = prev.get(input.agentId); - if (!existing) { - return prev; - } - if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) { - return prev; - } - const next = new Map(prev); - next.set(input.agentId, { - ...existing, - archivedAt, - }); - return next; - }); + getHostRuntimeStore().archiveAgentSnapshot( + input.serverId, + input.agentId, + archivedAt.toISOString(), + ); } interface ApplyArchivedAgentCloseResultsInput { diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts deleted file mode 100644 index 0ba74c97e6..0000000000 --- a/packages/app/src/hooks/use-clear-workspace-attention.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { i18n } from "@/i18n/i18next"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; - -export interface ClearWorkspaceAttentionController { - hasClearableAttention: boolean; - clearAttention: () => Promise; -} - -export function useClearWorkspaceAttention({ - serverId, - workspaceId, -}: { - serverId: string; - workspaceId: string; -}): ClearWorkspaceAttentionController { - const hasClearableAttention = useSessionStore((state) => { - const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); - return workspace?.status === "attention" || workspace?.status === "failed"; - }); - - const clearAttention = useCallback(async () => { - if (!hasClearableAttention) { - return; - } - const client = getHostRuntimeStore().getClient(serverId); - if (!client) { - throw new Error(i18n.t("workspace.terminal.hostDisconnected")); - } - await client.clearWorkspaceAttention(workspaceId); - }, [hasClearableAttention, serverId, workspaceId]); - - return useMemo( - () => ({ hasClearableAttention, clearAttention }), - [clearAttention, hasClearableAttention], - ); -} diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index aa50a8eead..293412fe1a 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import { Buffer } from "buffer"; import { useState } from "react"; -import { createAudioEngine } from "@/voice/audio-engine"; +import { createAudioEngine } from "@/audio"; import type { DictationAudioSource, diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index afb4b44499..c482bece18 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -1,3 +1,4 @@ +import { resampleToPcm16 } from "@/audio/pcm"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isElectronRuntime } from "@/desktop/host"; @@ -17,41 +18,6 @@ const getAudioContextCtor = (): typeof AudioContext | null => { return ctor ?? null; }; -const floatToInt16 = (sample: number): number => { - const clamped = Math.max(-1, Math.min(1, sample)); - return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); -}; - -const resampleToPcm16 = ( - input: Float32Array, - inputRate: number, - outputRate: number, -): Int16Array => { - if (input.length === 0) { - return new Int16Array(0); - } - if (inputRate === outputRate) { - const out = new Int16Array(input.length); - for (let i = 0; i < input.length; i++) { - out[i] = floatToInt16(input[i]); - } - return out; - } - - const ratio = inputRate / outputRate; - const outputLength = Math.max(1, Math.round(input.length / ratio)); - const out = new Int16Array(outputLength); - for (let i = 0; i < outputLength; i++) { - const sourceIndex = i * ratio; - const i0 = Math.floor(sourceIndex); - const i1 = Math.min(input.length - 1, i0 + 1); - const frac = sourceIndex - i0; - const sample = input[i0] * (1 - frac) + input[i1] * frac; - out[i] = floatToInt16(sample); - } - return out; -}; - const concatInt16 = (a: Int16Array, b: Int16Array): Int16Array => { if (a.length === 0) { return b; diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index f75e6eb857..44f7c6b0db 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -4,9 +4,9 @@ import { File } from "expo-file-system"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { isWeb } from "@/constants/platform"; import { getMimeTypeFromPath } from "@/attachments/file-types"; -import { readDesktopFileBytes, type PickedFile } from "@/attachments/picked-file"; +import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; -async function pickFilesWithDesktopDialog(): Promise { +async function pickFilesWithDesktopDialog(): Promise { const dialog = getDesktopHost()?.dialog; const dialogOpen = dialog?.open; if (typeof dialogOpen !== "function") { @@ -27,20 +27,18 @@ async function pickFilesWithDesktopDialog(): Promise { return null; } - const result: PickedFile[] = []; + const result: SelectedFile[] = []; for (const filePath of paths) { const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const mimeType = getMimeTypeFromPath(filePath); - const bytes = await readDesktopFileBytes(filePath); - - result.push({ fileName, mimeType, bytes }); + result.push({ fileName, mimeType, readBytes: () => readDesktopFileBytes(filePath) }); } return result; } -function pickFilesWithWebInput(): Promise { +function pickFilesWithWebInput(): Promise { return new Promise((resolve) => { const input = document.createElement("input"); input.type = "file"; @@ -54,13 +52,12 @@ function pickFilesWithWebInput(): Promise { return; } - const result: PickedFile[] = []; + const result: SelectedFile[] = []; for (const file of files) { - const bytes = new Uint8Array(await file.arrayBuffer()); result.push({ fileName: file.name, mimeType: file.type || getMimeTypeFromPath(file.name), - bytes, + readBytes: async () => new Uint8Array(await file.arrayBuffer()), }); } resolve(result); @@ -80,7 +77,7 @@ function pickFilesWithWebInput(): Promise { }); } -async function pickFilesWithDocumentPicker(): Promise { +async function pickFilesWithDocumentPicker(): Promise { const result = await DocumentPicker.getDocumentAsync({ multiple: true, copyToCacheDirectory: true, @@ -90,19 +87,17 @@ async function pickFilesWithDocumentPicker(): Promise { return null; } - return await Promise.all( - result.assets.map(async (asset) => ({ - fileName: asset.name, - mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name), - bytes: await new File(asset.uri).bytes(), - })), - ); + return result.assets.map((asset) => ({ + fileName: asset.name, + mimeType: asset.mimeType ?? getMimeTypeFromPath(asset.name), + readBytes: () => new File(asset.uri).bytes(), + })); } export function useFilePicker() { const isPickingRef = useRef(false); - const pickFiles = useCallback(async (): Promise => { + const pickFiles = useCallback(async (): Promise => { if (isPickingRef.current) { return null; } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 057efef369..d723b5be04 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -27,6 +27,7 @@ import { } from "@/keyboard/route-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; import { keyboardShortcutsAvailable } from "@/keyboard/availability"; @@ -75,7 +76,6 @@ export function useKeyboardShortcuts({ const openProjectPickerAction = useOpenAddProject(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const keyboardWorkspaceSelectionRef = useRef(null); - const badgeModifierKeyRef = useRef(undefined); const publishBrowserShortcutPolicy = useCallback( (chordState?: ChordState) => { @@ -107,271 +107,285 @@ export function useKeyboardShortcuts({ publishBrowserShortcutPolicy(); }, [isDesktopApp, publishBrowserShortcutPolicy]); + // Only the modifier that actually performs the workspace-index jump on this + // runtime should reveal the sidebar number badges (Alt on web, Cmd on + // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown + // to drive badge visibility, so we set the flag matching this runtime. + // Derived from the effective bindings: `null` when the user unassigned or + // rebound the jump shortcut, and no `event.key` ever equals null, so the + // badges simply never appear. + const badgeModifierKey = getWorkspaceIndexJumpModifierKey( + { isMac, isDesktop: isDesktopApp }, + bindings, + ); + + // The keyup listener matches the released key against the current modifier, + // so a modifier held while the jump binding changes could never be released + // and the badges would stay up until a blur. Clear them when it changes. + useEffect(() => { + resetModifiers(); + }, [badgeModifierKey, resetModifiers]); + + const setBadgeModifierDown = (down: boolean) => { + const state = useKeyboardShortcutsStore.getState(); + if (isDesktopApp) { + state.setCmdOrCtrlDown(down); + } else { + state.setAltDown(down); + } + }; + + const shouldHandle = () => { + if (typeof document === "undefined") return false; + if (document.visibilityState !== "visible") return false; + return true; + }; + + const captureCommandCenterFocusRestore = (event: KeyboardEvent) => { + const target = event.target instanceof Element ? event.target : null; + const targetEl = + target?.closest?.("textarea, input, [contenteditable='true']") ?? + (target instanceof HTMLElement ? target : null); + const active = document.activeElement; + const activeEl = active instanceof HTMLElement ? active : null; + setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); + }; + + const callbacksByName: Record void) | undefined> = { + "toggle-agent-list": toggleAgentList, + "toggle-both-sidebars": toggleBothSidebars, + "cycle-theme": cycleTheme, + }; + + const performShortcutAction = ( + action: ShortcutAction, + event: KeyboardEvent | null, + browserFocusRestoreElement: HTMLElement | null = null, + ): boolean => { + switch (action.kind) { + case "none": + return false; + case "dispatch": + return keyboardActionDispatcher.dispatch(action.action); + case "navigate-workspace": + keyboardWorkspaceSelectionRef.current = { + serverId: action.serverId, + workspaceId: action.workspaceId, + }; + navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); + return true; + case "navigate-last-workspace": + if (navigateToLastWorkspace()) { + return true; + } + router.replace(buildOpenProjectRoute()); + return true; + case "router-replace": + router.replace(action.route as Parameters[0]); + return true; + case "router-back": + router.back(); + return true; + case "router-push": + router.push(action.route as Parameters[0]); + return true; + case "open-project-picker": + void openProjectPickerAction(); + return true; + case "callback": + callbacksByName[action.name]?.(); + return true; + case "command-center-toggle": { + if (action.nextOpen) { + if (event) { + captureCommandCenterFocusRestore(event); + } else { + setCommandCenterFocusRestoreElement(browserFocusRestoreElement); + } + } + useKeyboardShortcutsStore + .getState() + .setCommandCenterOpen(action.nextOpen, action.scope ?? null); + return true; + } + case "shortcuts-dialog-toggle": + useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); + return true; + } + }; + + const routeAndPerformShortcut = (input: { + action: string; + payload: KeyboardShortcutPayload; + domEvent: KeyboardEvent | null; + browserFocusRestoreElement?: HTMLElement | null; + }): boolean => { + const store = useKeyboardShortcutsStore.getState(); + const shortcutAction = routeKeyboardShortcut( + { action: input.action, payload: input.payload }, + { + pathname, + isMobile, + sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, + navigationActiveWorkspace: + keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, + commandCenterOpen: store.commandCenterOpen, + shortcutsDialogOpen: store.shortcutsDialogOpen, + }, + ); + const handled = performShortcutAction( + shortcutAction, + input.domEvent, + input.browserFocusRestoreElement, + ); + if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { + exitFocusMode(); + } + return handled; + }; + + const resolveAndPerformShortcut = (input: { + event: KeyboardShortcutInput; + focusScope: KeyboardFocusScope; + domEvent: KeyboardEvent | null; + browserFocusRestoreElement?: HTMLElement | null; + }) => { + const store = useKeyboardShortcutsStore.getState(); + const previousChordState = chordStateRef.current; + const result = resolveKeyboardShortcut({ + event: input.event, + context: { + isMac, + isDesktop: isDesktopApp, + focusScope: input.focusScope, + commandCenterOpen: store.commandCenterOpen, + }, + chordState: chordStateRef.current, + onChordReset: () => { + chordStateRef.current = { + candidateIndices: [], + step: 0, + timeoutId: null, + }; + publishBrowserShortcutPolicy(); + }, + bindings, + }); + chordStateRef.current = result.nextChordState; + if ( + shouldPublishBrowserShortcutPolicy({ + isBrowserInput: "browserId" in input.event, + previousChordState, + nextChordState: result.nextChordState, + }) + ) { + publishBrowserShortcutPolicy(result.nextChordState); + } + + if (result.preventDefault && input.domEvent) { + input.domEvent.preventDefault(); + input.domEvent.stopPropagation(); + } + + if (!result.match) { + return; + } + + const handled = routeAndPerformShortcut({ + action: result.match.action, + payload: result.match.payload, + domEvent: input.domEvent, + browserFocusRestoreElement: input.browserFocusRestoreElement, + }); + if (!handled || !input.domEvent) { + return; + } + + if (result.match.preventDefault) { + input.domEvent.preventDefault(); + } + if (result.match.stopPropagation) { + input.domEvent.stopPropagation(); + } + }; + + // The window listeners must outlive ordinary re-renders: removing them resets + // any in-progress chord. Stable events let them read the latest render instead + // of being re-registered whenever a route, callback, or selection changes. + const handleKeyDown = useStableEvent((event: KeyboardEvent) => { + if (!shouldHandle()) { + return; + } + + if (dispatchTopWebOverlayKeyDown(event)) { + return; + } + + // During IME composition, Enter confirms the candidate selection and must + // not route through global shortcuts like message send. + if (isImeComposingKeyboardEvent(event)) { + return; + } + + const store = useKeyboardShortcutsStore.getState(); + if (store.capturingShortcut) { + return; + } + + const key = event.key ?? ""; + if ( + key === "Escape" && + pathname.startsWith("/settings") && + !isMobile && + hasActiveWebOverlay() + ) { + return; + } + if (key === badgeModifierKey && !event.shiftKey) { + setBadgeModifierDown(true); + } + if (key === "Shift") { + const state = useKeyboardShortcutsStore.getState(); + if (state.altDown || state.cmdOrCtrlDown) { + state.resetModifiers(); + } + } + + const focusScope = resolveKeyboardFocusScope({ + target: event.target, + commandCenterOpen: store.commandCenterOpen, + }); + resolveAndPerformShortcut({ + event, + focusScope, + domEvent: event, + }); + }); + + const handleKeyUp = useStableEvent((event: KeyboardEvent) => { + const key = event.key ?? ""; + if (key === badgeModifierKey) { + setBadgeModifierDown(false); + } + }); + + const handleBrowserShortcutInput = useStableEvent((payload: unknown) => { + const input = parseBrowserShortcutInput(payload); + if (!input) { + return; + } + resolveAndPerformShortcut({ + event: input, + focusScope: "browser", + domEvent: null, + browserFocusRestoreElement: getResidentBrowserWebview(input.browserId), + }); + }); + useEffect(() => { if (!enabled) return; if (!shortcutsAvailable) return; - // Only the modifier that actually performs the workspace-index jump on this - // runtime should reveal the sidebar number badges (Alt on web, Cmd on - // desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown - // to drive badge visibility, so we set the flag matching this runtime. - // Derived from the effective bindings: `null` when the user unassigned or - // rebound the jump shortcut, and no `event.key` ever equals null, so the - // badges simply never appear. - const badgeModifierKey = getWorkspaceIndexJumpModifierKey( - { isMac, isDesktop: isDesktopApp }, - bindings, - ); - const setBadgeModifierDown = (down: boolean) => { - const state = useKeyboardShortcutsStore.getState(); - if (isDesktopApp) { - state.setCmdOrCtrlDown(down); - } else { - state.setAltDown(down); - } - }; - - // The keyup listener matches the released key against the modifier derived - // when the effect last ran, so a modifier held while the jump binding - // changes can never be released -- the badges would stay up until a blur. - // Clear on change only: this effect also re-runs on every navigation, and - // clearing unconditionally would drop the badges mid Cmd+1, Cmd+2. - if (badgeModifierKeyRef.current !== badgeModifierKey) { - badgeModifierKeyRef.current = badgeModifierKey; - resetModifiers(); - } - - const shouldHandle = () => { - if (typeof document === "undefined") return false; - if (document.visibilityState !== "visible") return false; - return true; - }; - - const captureCommandCenterFocusRestore = (event: KeyboardEvent) => { - const target = event.target instanceof Element ? event.target : null; - const targetEl = - target?.closest?.("textarea, input, [contenteditable='true']") ?? - (target instanceof HTMLElement ? target : null); - const active = document.activeElement; - const activeEl = active instanceof HTMLElement ? active : null; - setCommandCenterFocusRestoreElement((targetEl as HTMLElement | null) ?? activeEl ?? null); - }; - - const callbacksByName: Record void) | undefined> = { - "toggle-agent-list": toggleAgentList, - "toggle-both-sidebars": toggleBothSidebars, - "cycle-theme": cycleTheme, - }; - - const performShortcutAction = ( - action: ShortcutAction, - event: KeyboardEvent | null, - browserFocusRestoreElement: HTMLElement | null = null, - ): boolean => { - switch (action.kind) { - case "none": - return false; - case "dispatch": - return keyboardActionDispatcher.dispatch(action.action); - case "navigate-workspace": - keyboardWorkspaceSelectionRef.current = { - serverId: action.serverId, - workspaceId: action.workspaceId, - }; - navigateToWorkspace({ serverId: action.serverId, workspaceId: action.workspaceId }); - return true; - case "navigate-last-workspace": - if (navigateToLastWorkspace()) { - return true; - } - router.replace(buildOpenProjectRoute()); - return true; - case "router-replace": - router.replace(action.route as Parameters[0]); - return true; - case "router-back": - router.back(); - return true; - case "router-push": - router.push(action.route as Parameters[0]); - return true; - case "open-project-picker": - void openProjectPickerAction(); - return true; - case "callback": - callbacksByName[action.name]?.(); - return true; - case "command-center-toggle": { - if (action.nextOpen) { - if (event) { - captureCommandCenterFocusRestore(event); - } else { - setCommandCenterFocusRestoreElement(browserFocusRestoreElement); - } - } - useKeyboardShortcutsStore - .getState() - .setCommandCenterOpen(action.nextOpen, action.scope ?? null); - return true; - } - case "shortcuts-dialog-toggle": - useKeyboardShortcutsStore.getState().setShortcutsDialogOpen(action.nextOpen); - return true; - } - }; - - const routeAndPerformShortcut = (input: { - action: string; - payload: KeyboardShortcutPayload; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }): boolean => { - const store = useKeyboardShortcutsStore.getState(); - const shortcutAction = routeKeyboardShortcut( - { action: input.action, payload: input.payload }, - { - pathname, - isMobile, - sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets, - navigationActiveWorkspace: - keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection, - commandCenterOpen: store.commandCenterOpen, - shortcutsDialogOpen: store.shortcutsDialogOpen, - }, - ); - const handled = performShortcutAction( - shortcutAction, - input.domEvent, - input.browserFocusRestoreElement, - ); - if (handled && isWorkspaceFocusModeEnabled && input.action.startsWith("sidebar.")) { - exitFocusMode(); - } - return handled; - }; - - const resolveAndPerformShortcut = (input: { - event: KeyboardShortcutInput; - focusScope: KeyboardFocusScope; - domEvent: KeyboardEvent | null; - browserFocusRestoreElement?: HTMLElement | null; - }) => { - const store = useKeyboardShortcutsStore.getState(); - const previousChordState = chordStateRef.current; - const result = resolveKeyboardShortcut({ - event: input.event, - context: { - isMac, - isDesktop: isDesktopApp, - focusScope: input.focusScope, - commandCenterOpen: store.commandCenterOpen, - }, - chordState: chordStateRef.current, - onChordReset: () => { - chordStateRef.current = { - candidateIndices: [], - step: 0, - timeoutId: null, - }; - publishBrowserShortcutPolicy(); - }, - bindings, - }); - chordStateRef.current = result.nextChordState; - if ( - shouldPublishBrowserShortcutPolicy({ - isBrowserInput: "browserId" in input.event, - previousChordState, - nextChordState: result.nextChordState, - }) - ) { - publishBrowserShortcutPolicy(result.nextChordState); - } - - if (result.preventDefault && input.domEvent) { - input.domEvent.preventDefault(); - input.domEvent.stopPropagation(); - } - - if (!result.match) { - return; - } - - const handled = routeAndPerformShortcut({ - action: result.match.action, - payload: result.match.payload, - domEvent: input.domEvent, - browserFocusRestoreElement: input.browserFocusRestoreElement, - }); - if (!handled || !input.domEvent) { - return; - } - - if (result.match.preventDefault) { - input.domEvent.preventDefault(); - } - if (result.match.stopPropagation) { - input.domEvent.stopPropagation(); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!shouldHandle()) { - return; - } - - if (dispatchTopWebOverlayKeyDown(event)) { - return; - } - - // During IME composition, Enter confirms the candidate selection and must - // not route through global shortcuts like message send. - if (isImeComposingKeyboardEvent(event)) { - return; - } - - const store = useKeyboardShortcutsStore.getState(); - if (store.capturingShortcut) { - return; - } - - const key = event.key ?? ""; - if ( - key === "Escape" && - pathname.startsWith("/settings") && - !isMobile && - hasActiveWebOverlay() - ) { - return; - } - if (key === badgeModifierKey && !event.shiftKey) { - setBadgeModifierDown(true); - } - if (key === "Shift") { - const state = useKeyboardShortcutsStore.getState(); - if (state.altDown || state.cmdOrCtrlDown) { - state.resetModifiers(); - } - } - - const focusScope = resolveKeyboardFocusScope({ - target: event.target, - commandCenterOpen: store.commandCenterOpen, - }); - resolveAndPerformShortcut({ - event, - focusScope, - domEvent: event, - }); - }; - - const handleKeyUp = (event: KeyboardEvent) => { - const key = event.key ?? ""; - if (key === badgeModifierKey) { - setBadgeModifierDown(false); - } - }; - const handleBlurOrHide = () => { resetModifiers(); }; @@ -382,18 +396,7 @@ export function useKeyboardShortcuts({ document.addEventListener("visibilitychange", handleBlurOrHide); const browserShortcutSubscription = isElectronRuntime() - ? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => { - const input = parseBrowserShortcutInput(payload); - if (!input) { - return; - } - resolveAndPerformShortcut({ - event: input, - focusScope: "browser", - domEvent: null, - browserFocusRestoreElement: getResidentBrowserWebview(input.browserId), - }); - }) + ? getDesktopHost()?.events?.on?.("browser-shortcut-input", handleBrowserShortcutInput) : null; return () => { if (chordStateRef.current.timeoutId !== null) { @@ -415,23 +418,11 @@ export function useKeyboardShortcuts({ } }; }, [ - bindings, - cycleTheme, enabled, - exitFocusMode, - activeWorkspaceSelection, - isDesktopApp, - isMac, - isMobile, - isWorkspaceFocusModeEnabled, - keyboardActionDispatcher, - openProjectPickerAction, - pathname, - publishBrowserShortcutPolicy, + handleBrowserShortcutInput, + handleKeyDown, + handleKeyUp, resetModifiers, - router, shortcutsAvailable, - toggleAgentList, - toggleBothSidebars, ]); } diff --git a/packages/app/src/hooks/use-keyboard-visibility.native.ts b/packages/app/src/hooks/use-keyboard-visibility.native.ts index cb222d9e0c..f15b0fe7c7 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.native.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.native.ts @@ -1,5 +1,21 @@ -import { useKeyboardState } from "react-native-keyboard-controller"; +import { useSyncExternalStore } from "react"; +import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller"; -export function useKeyboardVisibility(): boolean { - return useKeyboardState((state) => state.isVisible); +const subscribe = (onStoreChange: () => void) => { + const subscriptions = (["keyboardWillShow", "keyboardDidHide"] as const).map((event) => + KeyboardEvents.addListener(event, onStoreChange), + ); + return () => { + for (const subscription of subscriptions) { + subscription.remove(); + } + }; +}; + +export function useKeyboardVisibility(enabled = true): boolean { + return useSyncExternalStore( + subscribe, + () => (enabled ? KeyboardController.isVisible() : false), + () => false, + ); } diff --git a/packages/app/src/hooks/use-keyboard-visibility.ts b/packages/app/src/hooks/use-keyboard-visibility.ts index 793a521b6f..375734124d 100644 --- a/packages/app/src/hooks/use-keyboard-visibility.ts +++ b/packages/app/src/hooks/use-keyboard-visibility.ts @@ -1,3 +1,3 @@ -export function useKeyboardVisibility(): boolean { +export function useKeyboardVisibility(_enabled = true): boolean { return false; } diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 71a0a78da2..18dc5064f3 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; @@ -20,7 +21,15 @@ export function useOpenProject( state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( @@ -57,7 +66,15 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const upsertProject = useSessionStore((state) => state.upsertProject); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( diff --git a/packages/app/src/hooks/use-providers-snapshot.test.ts b/packages/app/src/hooks/use-providers-snapshot.test.ts index 620668e1fb..2f3c90b340 100644 --- a/packages/app/src/hooks/use-providers-snapshot.test.ts +++ b/packages/app/src/hooks/use-providers-snapshot.test.ts @@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; +import { + compactProviderSnapshot, + expandProviderSnapshot, +} from "@getpaseo/protocol/provider-snapshot-codec"; import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache"; import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query"; +import { providerSnapshotIcons } from "@/data/provider-icons"; import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router"; import { fetchProvidersSnapshot, @@ -81,12 +85,28 @@ function codexEntry( const readyCodexModel = { provider: "codex", id: "gpt-5.4", label: "GPT-5.4" } as const; const serverId = "server-1"; -function createCache(initial: CachedProviderSnapshot | null = null): ProviderSnapshotCache & { +function createCache( + initial: CachedProviderSnapshot | null = null, + bodyResident = true, +): ProviderSnapshotCache & { writes: Parameters[0][]; } { const writes: Parameters[0][] = []; return { writes, + async readHash(_serverId, hash) { + return bodyResident && initial?.hash === hash ? initial : null; + }, + async materialize(_serverId, snapshot) { + if (initial && initial.hash === snapshot.snapshotHash) + return { ...snapshot, entries: initial.entries }; + return { + ...snapshot, + entries: snapshot.compactSnapshot + ? expandProviderSnapshot(snapshot.compactSnapshot) + : snapshot.entries, + }; + }, async read() { return initial; }, @@ -97,7 +117,7 @@ function createCache(initial: CachedProviderSnapshot | null = null): ProviderSna } describe("providersSnapshotQueryKey", () => { - it("uses separate keys for home and workspace scopes", () => { + it("uses separate keys for home and workspace scopes", async () => { expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, "home"]); expect(providersSnapshotQueryKey(serverId, "/repo-a")).toEqual([ "providersSnapshot", @@ -109,6 +129,23 @@ describe("providersSnapshotQueryKey", () => { }); describe("fetchProvidersSnapshot", () => { + it("registers provider icons from the applied snapshot", async () => { + const svg = ''; + const client = createClient({ + snapshots: [ + providersSnapshot([ + { provider: "snapshot-icon-provider", status: "ready", enabled: true, iconSvg: svg }, + ]), + ], + }); + + await fetchProvidersSnapshot({ client, serverId, cwd: null, cache: createCache() }); + + expect(providerSnapshotIcons.getState().get(serverId)?.get("snapshot-icon-provider")).toEqual( + svg, + ); + }); + it("sends no cwd for the home scope", async () => { const client = createClient({ snapshots: [providersSnapshot([])] }); @@ -130,16 +167,19 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a" }]); }); - it("reuses a cached snapshot when the daemon reports its hash unchanged", async () => { + it("reuses a not-modified response even if its body was evicted during the request", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); - const cache = createCache({ - version: 1, - hash: "snapshot-hash", - generatedAt: "2026-01-01T00:00:00.000Z", - compactSnapshot, - entries, - }); + const cache = createCache( + { + version: 2, + hash: "snapshot-hash", + generatedAt: "2026-01-01T00:00:00.000Z", + compactSnapshot, + entries, + }, + false, + ); const client = createClient({ snapshots: [ { @@ -161,7 +201,7 @@ describe("fetchProvidersSnapshot", () => { expect(client.getCalls).toEqual([{ cwd: "/repo-a", ifNoneMatch: "snapshot-hash" }]); expect(snapshot.entries).toBe(entries); - expect(cache.writes).toEqual([]); + expect(cache.writes).toHaveLength(1); }); it("persists a changed compact snapshot for the next launch", async () => { @@ -182,7 +222,7 @@ describe("fetchProvidersSnapshot", () => { await fetchProvidersSnapshot({ client, serverId, cwd: "/repo-a", cache }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -323,8 +363,9 @@ describe("applyProvidersSnapshotUpdate", () => { }; } - it("routes updates to the home query cache when the message carries no cwd", () => { - applyProvidersSnapshotUpdate({ + it("routes updates to the home query cache when the message carries no cwd", async () => { + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])]), @@ -337,16 +378,17 @@ describe("applyProvidersSnapshotUpdate", () => { }); }); - it("routes workspace updates to the matching scope without touching siblings", () => { + it("routes workspace updates to the matching scope without touching siblings", async () => { queryClient.setQueryData(providersSnapshotQueryKey(serverId, "/repo-b"), providersSnapshot([])); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toEqual({ + expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, "/repo-a"))).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", @@ -356,7 +398,7 @@ describe("applyProvidersSnapshotUpdate", () => { ); }); - it("persists compact push updates", () => { + it("persists compact push updates", async () => { const entries = [codexEntry("ready", [readyCodexModel])]; const compactSnapshot = compactProviderSnapshot(entries); const cache = createCache(); @@ -364,9 +406,15 @@ describe("applyProvidersSnapshotUpdate", () => { message.payload.compactSnapshot = compactSnapshot; message.payload.snapshotHash = "push-hash"; - applyProvidersSnapshotUpdate({ serverId, queryClient, message, cache }); + await applyProvidersSnapshotUpdate({ + client: createClient(), + serverId, + queryClient, + message, + cache, + }); - expect(cache.writes).toEqual([ + expect(cache.writes.map(({ signal: _signal, ...write }) => write)).toEqual([ { serverId, cwd: "/repo-a", @@ -377,7 +425,7 @@ describe("applyProvidersSnapshotUpdate", () => { ]); }); - it("applies Windows daemon updates to app-normalized workspace paths", () => { + it("applies Windows daemon updates to app-normalized workspace paths", async () => { const workspaceCwd = "C:/Users/Ezekiel Bulver/project"; const daemonCwd = "C:\\Users\\Ezekiel Bulver\\project"; queryClient.setQueryData( @@ -385,27 +433,31 @@ describe("applyProvidersSnapshotUpdate", () => { providersSnapshot([codexEntry("loading")]), ); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], daemonCwd), }); - expect(queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd))).toEqual({ + expect( + queryClient.getQueryData(providersSnapshotQueryKey(serverId, workspaceCwd)), + ).toMatchObject({ entries: [codexEntry("ready", [readyCodexModel])], generatedAt: "2026-01-01T00:00:01.000Z", requestId: "providers_snapshot_update", }); }); - it("invalidates cached agent commands when a provider snapshot update arrives", () => { + it("invalidates cached agent commands when a provider snapshot update arrives", async () => { const commandsKey = draftAgentCommandsQueryKey({ serverId, draftConfig: { provider: "codex", cwd: "/repo-a" }, }); queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]); - applyProvidersSnapshotUpdate({ + await applyProvidersSnapshotUpdate({ + client: createClient(), serverId, queryClient, message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"), @@ -416,7 +468,7 @@ describe("applyProvidersSnapshotUpdate", () => { }); describe("selectorOpenRefetchDecision", () => { - it("refetches stale entries when no provider is selected", () => { + it("refetches stale entries when no provider is selected", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], @@ -425,13 +477,13 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("forces a refetch when the selected provider has no entry", () => { + it("forces a refetch when the selected provider has no entry", async () => { expect(selectorOpenRefetchDecision({ entries: [], selectedProvider: "codex" })).toBe( "refetch-always", ); }); - it("forces a refetch when the selected provider is still loading", () => { + it("forces a refetch when the selected provider is still loading", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("loading")], @@ -440,7 +492,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-always"); }); - it("keeps a stale-only refetch when the selected provider is ready with no models", () => { + it("keeps a stale-only refetch when the selected provider is ready with no models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [])], @@ -449,7 +501,7 @@ describe("selectorOpenRefetchDecision", () => { ).toBe("refetch-stale"); }); - it("keeps a stale-only refetch when the selected provider is ready with models", () => { + it("keeps a stale-only refetch when the selected provider is ready with models", async () => { expect( selectorOpenRefetchDecision({ entries: [codexEntry("ready", [readyCodexModel])], diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 9dfdae6f37..dc90c18f06 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -9,89 +9,25 @@ import { useSessionStore } from "@/stores/session-store"; import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { - providerSnapshotCache, - ProviderSnapshotCacheMissError, - type ProviderSnapshotCache, -} from "@/data/provider-snapshot-cache"; -import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query"; -import { - isProvidersSnapshotHomeScope, normalizeProvidersSnapshotCwd, providersSnapshotQueryKey, providersSnapshotQueryRoot, - providersSnapshotRequestOptions, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, } from "@/data/providers-snapshot"; -type GetProvidersSnapshotResult = Awaited>; -type RefreshProvidersSnapshotResult = Awaited>; - -export { providersSnapshotQueryKey, providersSnapshotQueryRoot }; +export { + providersSnapshotQueryKey, + providersSnapshotQueryRoot, + fetchProvidersSnapshot, + refreshAndApplyProvidersSnapshot, +}; export type ProvidersSnapshotClient = Pick< DaemonClient, "getProvidersSnapshot" | "refreshProvidersSnapshot" >; -export async function fetchProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - serverId: string; - cwd: string | null; - cache?: ProviderSnapshotCache; -}): Promise { - const cache = input.cache ?? providerSnapshotCache; - const cached = await cache.read(input.serverId, input.cwd); - const snapshot = await input.client.getProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, ifNoneMatch: cached?.hash }), - ); - if (snapshot.notModified) { - if (!cached) { - throw new ProviderSnapshotCacheMissError(); - } - return { ...snapshot, entries: cached.entries }; - } - if (snapshot.compactSnapshot && snapshot.snapshotHash) { - await cache.write({ - serverId: input.serverId, - cwd: input.cwd, - hash: snapshot.snapshotHash, - generatedAt: snapshot.generatedAt, - compactSnapshot: snapshot.compactSnapshot, - }); - } - return snapshot; -} - -export async function refreshAndApplyProvidersSnapshot(input: { - client: ProvidersSnapshotClient; - queryClient: QueryClient; - serverId: string; - cwd: string | null; - providers?: AgentProvider[]; - cache?: ProviderSnapshotCache; -}): Promise { - const refreshResult = await input.client.refreshProvidersSnapshot( - providersSnapshotRequestOptions({ cwd: input.cwd, providers: input.providers }), - ); - const snapshot = await fetchProvidersSnapshot({ - client: input.client, - serverId: input.serverId, - cwd: input.cwd, - cache: input.cache, - }); - input.queryClient.setQueryData(providersSnapshotQueryKey(input.serverId, input.cwd), snapshot); - void input.queryClient.invalidateQueries({ - queryKey: agentCommandsQueryRoot(input.serverId), - exact: false, - }); - if (isProvidersSnapshotHomeScope(input.cwd)) { - void input.queryClient.invalidateQueries({ - queryKey: providersSnapshotQueryRoot(input.serverId), - exact: false, - }); - } - return refreshResult; -} - export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always"; export function selectorOpenRefetchDecision(input: { @@ -143,13 +79,15 @@ export function useProvidersSnapshot( const snapshotQuery = useReplicaQuery({ queryKey, + // The content-hash cache already owns catalog and model identity. + structuralSharing: false, enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected), pushEvent: "providers_snapshot_update", - queryFn: async () => { + queryFn: async ({ signal }) => { if (!client || !serverId) { throw new Error(t("workspace.terminal.hostDisconnected")); } - return fetchProvidersSnapshot({ client, serverId, cwd }); + return fetchProvidersSnapshot({ client, serverId, cwd, queryClient, signal }); }, }); @@ -213,6 +151,8 @@ export function prefetchProvidersSnapshot( void singletonQueryClient.prefetchQuery({ queryKey, staleTime: Infinity, - queryFn: () => fetchProvidersSnapshot({ client, serverId, cwd }), + structuralSharing: false, + queryFn: ({ signal }) => + fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }), }); } diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index 704af34976..66a8e767b1 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -17,22 +17,27 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, DEFAULT_CONTENT_FONT_SIZE, + DEFAULT_CONTENT_MAX_WIDTH, DEFAULT_TERMINAL_SCROLLBACK_LINES, DEFAULT_THEME_PREFERENCE, DEFAULT_UI_BASE_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, + MAX_CONTENT_MAX_WIDTH, MAX_TERMINAL_SCROLLBACK_LINES, MAX_UI_BASE_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_CONTENT_FONT_SIZE, + MIN_CONTENT_MAX_WIDTH, MIN_TERMINAL_SCROLLBACK_LINES, MIN_UI_BASE_FONT_SIZE, loadAppSettingsFromStorage as loadAppSettingsFromStoragePure, loadSettingsFromStorage as loadSettingsFromStoragePure, normalizeAppSettings, parseClampedFontSize, + parseContentMaxWidth, parseTerminalScrollbackLines, + resolveContentMaxWidth, sanitizeFontFamily, saveAppSettings as saveAppSettingsPure, type AppSettings, @@ -56,19 +61,24 @@ export { DEFAULT_CLIENT_SETTINGS, DEFAULT_CODE_FONT_SIZE, DEFAULT_CONTENT_FONT_SIZE, + DEFAULT_CONTENT_MAX_WIDTH, DEFAULT_TERMINAL_SCROLLBACK_LINES, DEFAULT_THEME_PREFERENCE, DEFAULT_UI_BASE_FONT_SIZE, MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, + MAX_CONTENT_MAX_WIDTH, MAX_TERMINAL_SCROLLBACK_LINES, MAX_UI_BASE_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_CONTENT_FONT_SIZE, + MIN_CONTENT_MAX_WIDTH, MIN_TERMINAL_SCROLLBACK_LINES, MIN_UI_BASE_FONT_SIZE, parseClampedFontSize, + parseContentMaxWidth, parseTerminalScrollbackLines, + resolveContentMaxWidth, sanitizeFontFamily, }; export type { diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 51e2831338..d396f26d9a 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -14,6 +14,7 @@ import { loadSettingsFromStorage, parseClampedFontSize, parseTerminalScrollbackLines, + resolveContentMaxWidth, saveAppSettings, type SettingsDeps, } from "./storage"; @@ -22,7 +23,7 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, SIDEBAR_ROW_ITEMS, } from "@/components/sidebar/display-preferences/row-items"; -import { THEME_OPTIONS } from "@/styles/theme"; +import { DEFAULT_CONTENT_MAX_WIDTH, THEME_OPTIONS } from "@/styles/theme"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -237,6 +238,98 @@ describe("loadAppSettingsFromStorage", () => { expect(result.sidebarNavItems).toEqual([]); }); + it("loads stored sidebar footer items in order and defaults them to empty", async () => { + expect((await loadAppSettingsFromStorage(makeDeps())).sidebarFooterItems).toEqual([]); + + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + sidebarFooterItems: [ + // A footer button that was configurable once; the sidebar model skips it. + { key: "help", visible: false }, + { key: "plugin:sync:status", visible: true }, + { key: "usage", visible: false }, + ], + }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.sidebarFooterItems).toEqual([ + { key: "help", visible: false }, + { key: "plugin:sync:status", visible: true }, + { key: "usage", visible: false }, + ]); + }); + + it("loads legacy usage pins in order and defaults a fresh device to source defaults", async () => { + expect((await loadAppSettingsFromStorage(makeDeps())).usage).toEqual({ + displayAs: "used", + pins: null, + serverId: null, + }); + + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { + displayAs: "remaining", + pinned: [ + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ], + serverId: "server", + }, + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "remaining", + pins: [ + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ], + serverId: "server", + }); + }); + + it("loads an explicitly empty usage selection without restoring defaults", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { displayAs: "remaining", pins: [], serverId: "server" }, + }), + }), + }); + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "remaining", + pins: [], + serverId: "server", + }); + }); + + it("keeps valid usage preferences when one field is malformed", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ + usage: { + displayAs: "percent", + pinned: [{ sourceId: "claude", windowId: "weekly" }], + serverId: 42, + }, + }), + }), + }); + + expect((await loadAppSettingsFromStorage(deps)).usage).toEqual({ + displayAs: "used", + pins: [{ sourceId: "claude", windowId: "weekly" }], + serverId: null, + }); + }); + it("collapses legacy diff destinations into the former Explorer choice", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -955,3 +1048,49 @@ describe("parseClampedFontSize", () => { expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull(); }); }); + +describe("content max width", () => { + it("follows the current default until the user picks a width", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + + await saveAppSettings({ queryClient, updates: { codeFontSize: 14 }, deps }); + + const stored = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null"); + expect(stored.contentMaxWidth).toBeNull(); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( + DEFAULT_CONTENT_MAX_WIDTH, + ); + }); + + it("keeps a picked width and returns to the default when reset", async () => { + const deps = makeDeps(); + const queryClient = new QueryClient(); + + await saveAppSettings({ queryClient, updates: { contentMaxWidth: 1600 }, deps }); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe(1600); + + await saveAppSettings({ queryClient, updates: { contentMaxWidth: null }, deps }); + expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null").contentMaxWidth).toBe( + null, + ); + expect(resolveContentMaxWidth(await loadAppSettingsFromStorage(deps))).toBe( + DEFAULT_CONTENT_MAX_WIDTH, + ); + }); + + it("clamps stored widths and drops malformed ones to the default", async () => { + const load = (contentMaxWidth: unknown) => + loadAppSettingsFromStorage( + makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ contentMaxWidth }), + }), + }), + ); + + expect((await load(100_000)).contentMaxWidth).toBe(4000); + expect((await load(10)).contentMaxWidth).toBe(600); + expect((await load("wide")).contentMaxWidth).toBeNull(); + }); +}); diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 7a47bc7e6c..6c44546668 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -4,6 +4,11 @@ import type { QueryClient } from "@tanstack/react-query"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; import type { AppLanguage } from "@/i18n/locales"; import type { SidebarNavPreference } from "@/sidebar-nav/model"; +import { + DEFAULT_USAGE_PREFERENCES, + UsagePreferencesSchema, + type UsagePreferences, +} from "@/usage/preferences"; import { DEFAULT_SIDEBAR_CHECKS_DISPLAY, type SidebarChecksDisplay, @@ -15,6 +20,7 @@ import { } from "@/components/sidebar/display-preferences/row-items"; import { isNative } from "@/constants/platform"; import { + DEFAULT_CONTENT_MAX_WIDTH, FONT_SIZE, PLUGIN_THEME_PREFERENCE, THEME_OPTIONS, @@ -63,6 +69,9 @@ export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code export const MIN_CODE_FONT_SIZE = 9; export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe export const MAX_FONT_FAMILY_LENGTH = 200; +export { DEFAULT_CONTENT_MAX_WIDTH }; +export const MIN_CONTENT_MAX_WIDTH = 600; +export const MAX_CONTENT_MAX_WIDTH = 4000; export interface AppSettings { theme: ThemePreference; @@ -78,6 +87,8 @@ export interface AppSettings { uiBaseFontSize: number; // clamped px, platform default 14 or 15 contentFontSize: number; // clamped px, platform default 15 or 16 codeFontSize: number; // clamped px, default 12 + /** Max width of chat and markdown content in px; null follows the current default. */ + contentMaxWidth: number | null; syntaxTheme: SyntaxThemeId; // default "one" workspaceTitleSource: WorkspaceTitleSource; sidebarWorkspaceTrailing: SidebarWorkspaceTrailing; @@ -85,6 +96,10 @@ export interface AppSettings { sidebarChecksDisplay: SidebarChecksDisplay; /** Top-level sidebar rows in display order; empty means the default order, all visible. */ sidebarNavItems: SidebarNavPreference[]; + /** Sidebar footer items in display order; empty means the default order, all visible. */ + sidebarFooterItems: SidebarNavPreference[]; + /** How usage reads and which windows the sidebar summary shows. */ + usage: UsagePreferences; autoExpandReasoning: boolean; toolCallDetailLevel: ToolCallDetailLevel; chatOutlineEnabled: boolean; @@ -132,12 +147,15 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE, contentFontSize: DEFAULT_CONTENT_FONT_SIZE, codeFontSize: DEFAULT_CODE_FONT_SIZE, + contentMaxWidth: null, syntaxTheme: "one", workspaceTitleSource: "title", - sidebarWorkspaceTrailing: "diff", + sidebarWorkspaceTrailing: "timestamp", sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS, sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY, sidebarNavItems: [], + sidebarFooterItems: [], + usage: DEFAULT_USAGE_PREFERENCES, autoExpandReasoning: false, toolCallDetailLevel: "detailed", chatOutlineEnabled: true, @@ -218,15 +236,21 @@ const StoredAppSettingsSchema = z codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch( DEFAULT_CODE_FONT_SIZE, ), + contentMaxWidth: z + .null() + .or(clampedNumber(MIN_CONTENT_MAX_WIDTH, MAX_CONTENT_MAX_WIDTH)) + .catch(null), syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"), workspaceTitleSource: z.enum(["title", "branch"]).catch("title"), - sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"), + sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("timestamp"), sidebarRowItems: SidebarRowItemsSchema, sidebarChecksDisplay: z .enum(["iconAndText", "icon", "none"]) .optional() .catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY), sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), + sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]), + usage: UsagePreferencesSchema, autoExpandReasoning: z.boolean().catch(false), toolCallDetailLevel: z .enum(["overview", "detailed"]) @@ -457,6 +481,14 @@ export function parseTerminalScrollbackLines(value: unknown): number | null { ); } +export function parseContentMaxWidth(value: unknown): number | null { + return parseClampedFontSize(value, { min: MIN_CONTENT_MAX_WIDTH, max: MAX_CONTENT_MAX_WIDTH }); +} + +export function resolveContentMaxWidth(settings: Pick): number { + return settings.contentMaxWidth ?? DEFAULT_CONTENT_MAX_WIDTH; +} + export function parseClampedFontSize( value: unknown, bounds: { min: number; max: number }, diff --git a/packages/app/src/hooks/use-compact-time-ago.ts b/packages/app/src/hooks/use-time-ago.ts similarity index 75% rename from packages/app/src/hooks/use-compact-time-ago.ts rename to packages/app/src/hooks/use-time-ago.ts index de6bbc5214..e5d32a4ad1 100644 --- a/packages/app/src/hooks/use-compact-time-ago.ts +++ b/packages/app/src/hooks/use-time-ago.ts @@ -1,6 +1,11 @@ import { useEffect, useState } from "react"; import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker"; -import { describeCompactTimeAgo } from "@/utils/time"; +import { describeCompactTimeAgo, describeTimeAgo, type RelativeTimeLabel } from "@/utils/time"; + +/** A prose relative timestamp ("5m ago") that keeps itself current. See `useCompactTimeAgo`. */ +export function useTimeAgo(date: Date | null): string { + return useRelativeTimeLabel(date, describeTimeAgo); +} /** * A compact relative timestamp that keeps itself current. @@ -19,7 +24,14 @@ import { describeCompactTimeAgo } from "@/utils/time"; * never changes again. */ export function useCompactTimeAgo(date: Date | null): string { - const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : "")); + return useRelativeTimeLabel(date, describeCompactTimeAgo); +} + +function useRelativeTimeLabel( + date: Date | null, + describe: (date: Date) => RelativeTimeLabel, +): string { + const [label, setLabel] = useState(() => (date ? describe(date).label : "")); // Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so // depending on identity would tear down and rebuild the subscription for an unchanged time. @@ -32,13 +44,13 @@ export function useCompactTimeAgo(date: Date | null): string { } const source = new Date(time); - let current = describeCompactTimeAgo(source); + let current = describe(source); setLabel(current.label); let unsubscribe: (() => void) | null = null; const handleTick = () => { - const next = describeCompactTimeAgo(source); + const next = describe(source); if (next.label !== current.label) { setLabel(next.label); } @@ -62,7 +74,7 @@ export function useCompactTimeAgo(date: Date | null): string { return () => { unsubscribe?.(); }; - }, [time]); + }, [time, describe]); return label; } diff --git a/packages/app/src/hooks/use-workspace-read-state.ts b/packages/app/src/hooks/use-workspace-read-state.ts new file mode 100644 index 0000000000..2e7b092956 --- /dev/null +++ b/packages/app/src/hooks/use-workspace-read-state.ts @@ -0,0 +1,49 @@ +import { useCallback, useMemo } from "react"; +import { i18n } from "@/i18n/i18next"; +import { useHostFeature } from "@/runtime/host-features"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { markWorkspaceUnread } from "@/workspace/mark-unread"; + +export interface WorkspaceReadStateController { + hasClearableAttention: boolean; + canMarkUnread: boolean; + clearAttention: () => Promise; + markUnread: () => Promise; +} + +export function useWorkspaceReadState({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}): WorkspaceReadStateController { + const status = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.status); + const supportsMarkUnread = useHostFeature(serverId, "workspaceMarkUnread"); + const hasClearableAttention = status === "attention" || status === "failed"; + const canMarkUnread = supportsMarkUnread && status === "done"; + + const clearAttention = useCallback(async () => { + if (!hasClearableAttention) { + return; + } + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + await client.clearWorkspaceAttention(workspaceId); + }, [hasClearableAttention, serverId, workspaceId]); + + const markUnread = useCallback(async () => { + if (!canMarkUnread) { + return; + } + await markWorkspaceUnread(serverId, workspaceId); + }, [canMarkUnread, serverId, workspaceId]); + + return useMemo( + () => ({ hasClearableAttention, canMarkUnread, clearAttention, markUnread }), + [canMarkUnread, clearAttention, hasClearableAttention, markUnread], + ); +} diff --git a/packages/app/src/hosts/host-badge.tsx b/packages/app/src/hosts/host-badge.tsx index b316e4a7b7..d35a4324f5 100644 --- a/packages/app/src/hosts/host-badge.tsx +++ b/packages/app/src/hosts/host-badge.tsx @@ -47,7 +47,11 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) { testID={`host-badge-${badge.serverId}`} accessibilityLabel={badge.label} > - + {badge.showLabel ? ( {badge.label} @@ -92,13 +96,16 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: 3, - minWidth: 0, + minWidth: HOST_BADGE_ICON_SIZE, // Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully // squeezed before that sibling gives up its first pixel. An equal factor would split the // loss in proportion to length, which hands the most space to the longest hostname — // exactly backwards. The icon has a fixed width, so the badge never disappears outright. flexShrink: 100, }, + icon: { + flexShrink: 0, + }, label: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/hosts/host-confirmation-sheet.tsx b/packages/app/src/hosts/host-confirmation-sheet.tsx new file mode 100644 index 0000000000..c83be51a30 --- /dev/null +++ b/packages/app/src/hosts/host-confirmation-sheet.tsx @@ -0,0 +1,109 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { useHostConfirmation } from "@/runtime/host-runtime"; + +/** Shows the host confirmation the host runtime is waiting on. Mount once at the app root. */ +export function HostConfirmationSheet() { + const { t } = useTranslation(); + const { pending, answer } = useHostConfirmation(); + + const cancel = useCallback(() => answer(false), [answer]); + const connect = useCallback(() => answer(true), [answer]); + const header = useMemo(() => ({ title: t("pairing.hostConfirmation.title") }), [t]); + + if (!pending) return null; + + return ( + + + {pending.kind === "changedConnection" + ? t("pairing.hostConfirmation.descriptionChanged") + : t("pairing.hostConfirmation.description")} + + + + + + + + + + + + ); +} + +function DetailRow({ + label, + value, + showBorder = false, + testID, +}: { + label: string; + value: string; + showBorder?: boolean; + testID?: string; +}) { + return ( + + + {label} + + {value} + + + + ); +} + +// Same shape as the remove-host and remove-connection confirm sheets in +// screens/settings/host-page.tsx. +const styles = StyleSheet.create((theme) => ({ + confirmText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + confirmActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[4], + }, +})); + +const FLEX_1_STYLE = { flex: 1 }; diff --git a/packages/app/src/hosts/pair-scan-model.test.ts b/packages/app/src/hosts/pair-scan-model.test.ts new file mode 100644 index 0000000000..0ca69b9d24 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ConnectionLinkImport, LinkPairing } from "@/runtime/host-runtime"; +import { openPairScan, type PairScanModel } from "./pair-scan-model"; + +const LINK = "https://app.paseo.sh/#offer=abc"; +const PAIRING: LinkPairing = { submit: async () => ({ status: "cancelled" }) }; + +/** The host runtime's side of a scan: each import resolves with the next queued outcome. */ +function createScanner(outcomes: Array) { + const imports: string[] = []; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: async (link) => { + imports.push(link); + const outcome = outcomes.shift(); + if (!outcome) throw new Error("No outcome queued for this import"); + if (outcome instanceof Error) throw outcome; + return outcome; + }, + onConnected: (serverId) => connected.push(serverId), + }); + return { scan, imports, connected }; +} + +async function settled(scan: PairScanModel): Promise { + await vi.waitFor(() => expect(scan.getState().status).not.toBe("pairing")); +} + +describe("openPairScan", () => { + it("stops scanning after Cancel until the user scans again", async () => { + const { scan, imports } = createScanner([{ status: "cancelled" }, { status: "cancelled" }]); + + scan.scan(LINK); + await settled(scan); + + // The code is still in front of the camera. + scan.scan(LINK); + scan.scan(LINK); + await Promise.resolve(); + expect(imports).toEqual([LINK]); + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + + scan.scanAgain(); + scan.scan(LINK); + await settled(scan); + expect(imports).toEqual([LINK, LINK]); + }); + + it("starts one attempt per code while pairing, and ignores codes that are not pairing links", async () => { + const { scan, imports, connected } = createScanner([ + { status: "connected", serverId: "srv_scan" }, + ]); + + scan.scan("https://example.com/not-a-pairing-link"); + expect(scan.getState()).toEqual({ status: "scanning" }); + + scan.scan(LINK); + scan.scan(LINK); + await settled(scan); + + expect(imports).toEqual([LINK]); + expect(connected).toEqual(["srv_scan"]); + scan.scan(LINK); + expect(imports).toEqual([LINK]); + }); + + it("ends the attempt when the password prompt closes", async () => { + const { scan, imports } = createScanner([ + { status: "password_required", link: LINK, pairing: PAIRING }, + ]); + + scan.scan(LINK); + await settled(scan); + expect(scan.getState()).toEqual({ + status: "passwordRequired", + passwordRequired: { link: LINK, pairing: PAIRING }, + }); + scan.scan(LINK); + + scan.closePassword(); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: null }); + expect(imports).toEqual([LINK]); + }); + + it("stops with the error when an attempt fails", async () => { + const { scan, imports } = createScanner([new Error("Server disconnected")]); + + scan.scan(LINK); + await settled(scan); + scan.scan(LINK); + + expect(scan.getState()).toEqual({ status: "stopped", error: "Server disconnected" }); + expect(imports).toEqual([LINK]); + }); + + it("does not navigate for an attempt that finishes after the screen closed", async () => { + let finish: (outcome: ConnectionLinkImport) => void = () => {}; + const connected: string[] = []; + const scan = openPairScan({ + importConnectionLink: () => + new Promise((resolve) => { + finish = resolve; + }), + onConnected: (serverId) => connected.push(serverId), + }); + + scan.scan(LINK); + scan.close(); + finish({ status: "connected", serverId: "srv_scan" }); + await Promise.resolve(); + + expect(connected).toEqual([]); + }); +}); diff --git a/packages/app/src/hosts/pair-scan-model.ts b/packages/app/src/hosts/pair-scan-model.ts new file mode 100644 index 0000000000..4dca380543 --- /dev/null +++ b/packages/app/src/hosts/pair-scan-model.ts @@ -0,0 +1,103 @@ +import type { ConnectionLinkImport, PasswordRequiredPairing } from "@/runtime/host-runtime"; + +/** + * One visit to the QR scan screen. The camera reports every frame that shows + * a code, so an attempt that ends (Cancel on the host confirmation, a closed + * password prompt, or an error) stops scanning until the user taps scan again. + * Without that, the code still in front of the camera starts a new attempt on + * the next frame. + */ +export type PairScanState = + | { status: "scanning" } + | { status: "pairing" } + | { status: "passwordRequired"; passwordRequired: PasswordRequiredPairing } + | { status: "stopped"; error: string | null } + | { status: "connected" }; + +export interface PairScanModel { + getState: () => PairScanState; + subscribe: (listener: () => void) => () => void; + /** Data from a QR code the camera saw. Only a scanning session starts an attempt. */ + scan: (data: string) => void; + /** The user asked to scan again after an attempt ended. */ + scanAgain: () => void; + /** The user closed the password prompt, which ends the attempt. */ + closePassword: () => void; + close: () => void; +} + +export interface PairScanDependencies { + importConnectionLink: (link: string) => Promise; + onConnected: (serverId: string) => void; +} + +export function openPairScan(deps: PairScanDependencies): PairScanModel { + let state: PairScanState = { status: "scanning" }; + let closed = false; + const listeners = new Set<() => void>(); + + function publish(next: PairScanState): void { + if (closed) return; + state = next; + for (const listener of listeners) { + listener(); + } + } + + async function pair(link: string): Promise { + publish({ status: "pairing" }); + try { + const outcome = await deps.importConnectionLink(link); + if (closed) return; + if (outcome.status === "connected") { + publish({ status: "connected" }); + deps.onConnected(outcome.serverId); + return; + } + if (outcome.status === "password_required") { + const { link: passwordLink, pairing } = outcome; + publish({ status: "passwordRequired", passwordRequired: { link: passwordLink, pairing } }); + return; + } + publish({ status: "stopped", error: null }); + } catch (error) { + publish({ status: "stopped", error: error instanceof Error ? error.message : String(error) }); + } + } + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + scan(data) { + if (state.status !== "scanning") return; + const link = pairingLinkFromScan(data); + if (!link) return; + void pair(link); + }, + scanAgain() { + if (state.status !== "stopped") return; + publish({ status: "scanning" }); + }, + closePassword() { + if (state.status !== "passwordRequired") return; + publish({ status: "stopped", error: null }); + }, + close() { + closed = true; + listeners.clear(); + }, + }; +} + +function pairingLinkFromScan(data: string): string | null { + const raw = data.trim(); + if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://")) { + return raw; + } + return null; +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 46b3428026..bf42a3b61a 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [ "Unable to save desktop settings.", ] as const; +const pullRequestPanelSources = [ + "git/pull-request-panel/pane.tsx", + "git/pull-request-panel/data.ts", + "git/pull-request-panel/checks-section.tsx", + "components/sidebar/sidebar-status-list.tsx", +] as const; +const untranslatedPullRequestPanelLabels = [ + "Activity", + "No activity yet", + "Add all to chat", + "Add to chat", + "Adding...", + "Comment actions", + "Thread actions", + "Commented", + "Approved", + "Requested changes", + "Reviewed", + "Draft", + "Merged", + "Closed", + "Open", + "Resolved", + "Outdated", +] as const; + +function findUntranslatedPullRequestPanelLabels(): string[] { + return pullRequestPanelSources.flatMap((source) => { + const contents = readFileSync(join(appSourceRoot, source), "utf8"); + const matches: string[] = untranslatedPullRequestPanelLabels.filter( + (text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents), + ); + if (contents.includes("} group`")) { + matches.push("group"); + } + return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`]; + }); +} + function collectSourceFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name); @@ -183,10 +222,53 @@ describe("translation resources", () => { expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님"); }); + it("uses the French navigation and Git meanings for reported labels", () => { + expect(fr.common.back).toBe("Retour"); + expect(fr.common.actions.back).toBe("Retour"); + expect(fr.common.actions.copy).toBe("Copier"); + expect(fr.common.states.starting).toBe("Démarrage…"); + expect(fr.common.connectionStatus.connecting).toBe("Connexion…"); + expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications"); + expect(fr.sidebar.display.show.checks).toBe("Vérifications"); + }); + + it("separates French interpolation placeholders from neighboring words", () => { + const glued = Object.entries(flattenStrings(fr)).filter(([, value]) => + /\p{L}\{\{|\}\}\p{L}{2,}/u.test(value), + ); + expect(glued).toEqual([]); + }); + it("labels the immediate add-to-chat action without an ellipsis", () => { expect(en.workspace.fileActions.addToChat).toBe("Add to chat"); }); + it("keeps pull request panel and sidebar status group labels translated", () => { + expect(findUntranslatedPullRequestPanelLabels()).toEqual([]); + for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) { + const pr = resource.workspace.git.pr; + const englishPr = en.workspace.git.pr; + expect(pr.sections.activity).not.toBe(englishPr.sections.activity); + expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity); + expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat); + expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat); + expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat); + expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions); + expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions); + for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) { + expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]); + } + for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) { + expect(pr.states[state]).not.toBe(englishPr.states[state]); + } + expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved); + expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated); + expect(resource.sidebar.statusGroupAccessibility).not.toBe( + en.sidebar.statusGroupAccessibility, + ); + } + }); + it("keeps local connection fallback errors translated", () => { expect(findUntranslatedConnectionErrors()).toEqual([]); }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e09ef311de..ca402264f2 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2,16 +2,42 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { + paneFind: { + connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", + historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.", + revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.", + searching: "جارٍ البحث…", + loading: "جارٍ التحميل…", + failed: "فشل", + retry: "إعادة المحاولة", + + title: "بحث", + placeholder: "بحث في اللوحة", + close: "إغلاق البحث", + matches: "نتائج البحث", + previous: "التطابق السابق", + next: "التطابق التالي", + toggleReplace: "إظهار الاستبدال", + replaceWith: "استبدال بـ", + replace: "استبدال", + replaceAll: "استبدال الكل", + noMatches: "لا توجد تطابقات", + position: "{{current}} من {{total}}", + total: "{{total}} تطابقات", + }, common: { + bottomSheetBackdrop: "خلفية اللوحة السفلية", back: "خلف", loading: "تحميل...", actions: { + save: "حفظ", back: "خلف", cancel: "يلغي", close: "يغلق", copy: "ينسخ", copyLine: "نسخ السطر", dismiss: "رفض", + reload: "إعادة التحميل", retry: "أعد المحاولة", search: "يبحث", select: "يختار", @@ -98,6 +124,8 @@ export const ar: TranslationResources = { input: { accessibilityLabel: "وكيل الرسائل...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "ملء الشاشة", + exitFullscreen: "الخروج من ملء الشاشة", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", @@ -153,7 +181,7 @@ export const ar: TranslationResources = { initialPromptRequired: "مطلوب موجه الأولي", alreadyLoading: "جارٍ التحميل بالفعل", uploadFailed: "Failed to upload file", - noClipboardImage: "لا توجد صورة في الحافظة", + noClipboardImage: "لا توجد صورة في الحافظة الحالية. جرّب اللصق من لوحة المفاتيح.", pasteImageFailed: "تعذر لصق الصورة", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -208,6 +236,10 @@ export const ar: TranslationResources = { historyLoadFailed: "تعذر تحميل سجل الوكيل", messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { + rejectedPlan: "خطة مرفوضة", + approvedPlan: "خطة معتمدة", + canceledPlan: "خطة ملغاة", + plan: "يخطط", required: "الإذن مطلوب", deny: "ينكر", @@ -221,7 +253,8 @@ export const ar: TranslationResources = { states: { notFound: "لم يتم العثور على Agent", failedToLoad: "فشل تحميل الوكيل", - reconnecting: "جارٍ إعادة الاتصال", + reconnecting: "جارٍ إعادة الاتصال بالمضيف", + updating: "جارٍ تحديث الرسائل", timelineSyncFailed: "تعذر تحديث سجل الوكيل.", timelineSyncRetrying: "جارٍ إعادة المحاولة…", archivingTitle: "وكيل الارشيف...", @@ -352,6 +385,9 @@ export const ar: TranslationResources = { completed: "اكتملت", }, }, + turnFooter: { + workedFor: "عمل لمدة {{duration}}", + }, compaction: { loading: "الضغط...", auto: "يتم ضغط السياق تلقائيًا", @@ -410,7 +446,9 @@ export const ar: TranslationResources = { recovery: { archivedTitle: "مساحة العمل مؤرشفة", restoreDescription: - "تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.", + "استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.", + restoreWithoutBranchDescription: + "استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.", unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.", restoreAction: "استعادة", unarchiveAction: "إلغاء الأرشفة", @@ -879,6 +917,9 @@ export const ar: TranslationResources = { openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", + jumpToFile: { + title: "الانتقال إلى ملف", + }, binaryFile: "ملف ثنائي", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته", @@ -901,11 +942,8 @@ export const ar: TranslationResources = { refresh: "ينعش", refreshState: "تحديث حالة git و{{brand}}", failedRefresh: "فشل تحديث حالة git.", - emptyHiddenWhitespace: "لا توجد تغييرات مرئية بعد إخفاء المسافة البيضاء", - emptyUncommitted: "لا توجد تغييرات غير ملتزم بها", seeUncommittedChanges: "عرض التغييرات غير الملتزم بها", seeCommittedChanges: "عرض التغييرات الملتزم بها", - emptyAgainstBase: "لا توجد تغييرات مقابل{{baseRef}}", checkingRepository: "فحص المستودع...", notRepository: "ليس مستودع جيت", diffMode: "وضع الفرق", @@ -938,6 +976,47 @@ export const ar: TranslationResources = { actions: { viewPullRequest: "عرض", openOn: "فتح على {{brand}}", + addToChat: "إضافة إلى الدردشة", + addAllToChat: "إضافة الكل إلى الدردشة", + addingToChat: "جارٍ الإضافة...", + }, + checksOverview: { + headline: { + actionRequired: "بعض الفحوصات تحتاج إلى انتباهك", + failure: "بعض الفحوصات لم تنجح", + pending: "بعض الفحوصات لم تكتمل بعد", + success: "نجحت جميع الفحوصات", + none: "لا توجد فحوصات", + }, + count: { + actionRequired: "{{count}} تحتاج إجراءً", + warning: "{{count}} مع تحذير", + failure: "{{count}} فاشلة", + pending: "{{count}} قيد التشغيل", + manual: "{{count}} يدوية", + success: "{{count}} ناجحة", + ignored: "{{count}} متخطاة", + }, + detailOne: "الفحص: {{parts}}", + detailMany: "الفحوصات: {{parts}}", + groupOne: { + actionRequired: "{{count}} فحص يحتاج إجراءً", + warning: "{{count}} فحص مع تحذير", + failure: "{{count}} فحص فاشل", + pending: "{{count}} فحص قيد التشغيل", + manual: "{{count}} فحص يدوي", + success: "{{count}} فحص ناجح", + ignored: "{{count}} فحص متخطى", + }, + groupMany: { + actionRequired: "{{count}} فحوصات تحتاج إجراءً", + warning: "{{count}} فحوصات مع تحذير", + failure: "{{count}} فحوصات فاشلة", + pending: "{{count}} فحوصات قيد التشغيل", + manual: "{{count}} فحوصات يدوية", + success: "{{count}} فحوصات ناجحة", + ignored: "{{count}} فحوصات متخطاة", + }, }, checksSummary: { passedLabel: "نجح", @@ -951,17 +1030,21 @@ export const ar: TranslationResources = { checks: "الشيكات", pipeline: "خط المعالجة", reviews: "التعليقات", + activity: "النشاط", }, empty: { noJobs: "لا توجد مهام", loadingPipeline: "جارٍ تحميل خط المعالجة...", pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة", allowedToFail: "مسموح بالفشل", + noActivity: "لا يوجد نشاط بعد", }, approvals: "{{given}} من {{required}} موافقات", accessibility: { pullRequest: "سحب الطلب #{{number}}", pullRequest_mr: "طلب دمج !{{number}}", + commentActions: "إجراءات التعليق", + threadActions: "إجراءات سلسلة النقاش", checkStatus: { passed: "ناجح", failed: "فاشل", @@ -990,6 +1073,8 @@ export const ar: TranslationResources = { }, thread: { discussion: "سلسلة المناقشة", + resolved: "تم الحل", + outdated: "قديم", }, errors: { statusLoadFailed: "غير قادر على تحميل حالة طلب السحب", @@ -1045,8 +1130,27 @@ export const ar: TranslationResources = { updateHost: "حدّث هذا المضيف لإدارة التسميات.", }, }, + changelog: { + title: "ما الجديد", + installed: "مثبّت", + showMore: "عرض المزيد", + openWebsite: "سجل التغييرات الكامل", + error: { + title: "تعذّر تحميل سجل التغييرات", + description: "تحقق من اتصالك وحاول مرة أخرى.", + }, + }, sidebar: { + statusGroupAccessibility: "مجموعة {{label}}", + statusBucket: { + needsInput: "تحتاج إدخالاً", + failed: "فشل", + readyToReview: "جاهزة للمراجعة", + working: "قيد العمل", + done: "تم", + }, display: { + showBackground: "إظهار مساحات العمل في الخلفية", trigger: "تفضيلات العرض", heading: "العرض", grouping: { @@ -1106,6 +1210,9 @@ export const ar: TranslationResources = { settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", }, + footer: { + usage: "الاستخدام", + }, help: { trigger: "المساعدة والدعم", sectionHelp: "المساعدة", @@ -1266,6 +1373,7 @@ export const ar: TranslationResources = { detail: "إيقاف البرنامج الخفي المحلي.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "حالة", @@ -1482,6 +1590,8 @@ export const ar: TranslationResources = { noFiles: "لم يتم العثور على ملفات أو أدلة", noCommands: "لم يتم العثور على أي أوامر", failedToLoad: "فشل التحميل", + chooseProjectForCommands: "اختر مشروعًا لعرض الأوامر", + chooseModelForCommands: "اختر نموذجًا لعرض الأوامر", }, loadOlderHistory: { failed: "تعذر تحميل السجل الأقدم", @@ -1573,6 +1683,21 @@ export const ar: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "كلمة المرور لـ {{host}}", + label: "كلمة مرور المضيف", + }, + hostConfirmation: { + title: "الاتصال بهذا المضيف؟", + description: + "سيتمكن هذا المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.", + descriptionChanged: + "يغيّر هذا الرابط طريقة اتصالك بهذا المضيف. سيتمكن المضيف من تشغيل تعليمات برمجية في هذا التطبيق والوصول إلى مضيفيك الآخرين المتصلين. اتصل فقط إذا كنت تعرفه.", + hostLabel: "المضيف", + fingerprintLabel: "بصمة المفتاح", + relayLabel: "المُرحّل", + connect: "اتصال", + }, connectionMethods: { title: "إضافة اتصال", direct: { @@ -1640,6 +1765,12 @@ export const ar: TranslationResources = { helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.", fields: { target: "مضيف SSH", + password: "كلمة مرور الدايمون", + optional: "خياري", + }, + passwordVisibility: { + show: "إظهار كلمة المرور", + hide: "إخفاء كلمة المرور", }, actions: { cancel: "إلغاء", @@ -1868,6 +1999,8 @@ export const ar: TranslationResources = { dismiss: "رفض", }, contextWindow: { + noData: "لا توجد بيانات للسياق", + accessibilityNoData: "نافذة السياق: لا توجد بيانات للسياق", title: "نافذة السياق", used: "تم استخدام{{percentage}}%", tokens: "رموز{{used}}/{{max}}", @@ -1875,6 +2008,17 @@ export const ar: TranslationResources = { accessibility: "تم استخدام نافذة السياق{{percentage}}%", }, review: { + feedback: { + send: "إرسال الملاحظات ({{count}})", + sending: "جارٍ إرسال الملاحظات ({{count}})", + chooseAgent: "اختر وكيلاً", + sent: "تم إرسال الملاحظات إلى {{recipient}}", + "no-agents": "افتح علامة تبويب وكيل في مساحة العمل هذه لإرسال الملاحظات.", + disconnected: "اتصل بالمضيف لإرسال الملاحظات.", + "no-context": "لم تعد التعليقات المحفوظة تتطابق مع هذا الفرق.", + failed: "تعذر إرسال الملاحظات. حاول مرة أخرى.", + prompt: "يرجى معالجة مراجعة الكود هذه.", + }, comment: { add: "إضافة تعليق المراجعة", edit: "تحرير تعليق المراجعة", @@ -1909,8 +2053,11 @@ export const ar: TranslationResources = { groupInfo: "حول{{title}}", sections: { general: "عام", + chat: "الدردشة", appearance: "مظهر", - layout: en.settings.sections.layout, + sidebar: "الشريط الجانبي", + terminal: "الطرفية", + browser: "المتصفح", editor: "المحرر", shortcuts: "الاختصارات", integrations: "التكامل", @@ -1969,6 +2116,7 @@ export const ar: TranslationResources = { }, general: { title: "عام", + sending: "الإرسال", browserData: { title: "بيانات المتصفح", siteData: "ملفات تعريف الارتباط وبيانات المواقع", @@ -1996,8 +2144,6 @@ export const ar: TranslationResources = { }, }, serviceUrls: { - label: "عناوين URL للخدمة", - description: "مكان فتح عناوين URL من تشغيل البرامج النصية", options: { ask: "بسأل", inApp: "في Paseo", @@ -2016,7 +2162,6 @@ export const ar: TranslationResources = { toolCallDetail: { label: "عرض استدعاءات الأدوات", description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني", - accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})", options: { overview: "ملخص", detailed: "التفاصيل الكاملة", @@ -2070,6 +2215,7 @@ export const ar: TranslationResources = { about: { title: "عن", appVersion: "نسخة التطبيق", + whatsNewHint: "ملاحظات الإصدار لكل نسخة", thisDevice: "هذا الجهاز", connectedHosts: "المضيفين المتصلين", offline: "غير متصل", @@ -2118,8 +2264,15 @@ export const ar: TranslationResources = { description: "عرض مخطط للتنقل بين المطالبات", }, sidebar: { - title: "الشريط الجانبي", - description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", + header: { + title: "الرأس", + description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها", + }, + footer: { + title: "التذييل", + description: + "اختر الصفوف التي تظهر أسفل الشريط الجانبي وترتيبها. يظهر «إضافة مشروع» وصف الأيقونات دائمًا", + }, moveUp: "نقل لأعلى", moveDown: "نقل لأسفل", }, @@ -2143,6 +2296,14 @@ export const ar: TranslationResources = { codeSizeHint: "يُستخدم للكود والفروقات ومخرجات الطرفية", codeSizeAccessibility: "حجم خط الكود", }, + layout: { + title: "التخطيط", + contentWidth: "عرض المحتوى", + contentWidthHint: "أقصى عرض للمحادثة وملفات Markdown على الشاشات العريضة", + contentWidthAccessibility: "عرض المحتوى بالبكسل", + reset: "إعادة تعيين", + resetAccessibility: "إعادة عرض المحتوى إلى الافتراضي", + }, syntax: { title: "بناء الجملة", highlightTheme: "تسليط الضوء على الموضوع", @@ -2253,6 +2414,9 @@ export const ar: TranslationResources = { }, }, host: { + password: { + guidance: "أزل هذا المضيف ثم أضفه مرة أخرى بكلمة المرور التي يطلبها هذا الخادم.", + }, appearance: { title: "المظهر", name: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 2516a3f064..92dbb3f78c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,14 +1,39 @@ export const en = { + paneFind: { + connectionFailure: "Could not search this chat. Check the host connection and retry.", + historyChangedFailure: "The chat changed while searching. Search again.", + revealFailure: "Could not show this match. Retry.", + searching: "Searching…", + loading: "Loading…", + failed: "Failed", + retry: "Retry", + title: "Find", + placeholder: "Find in pane", + close: "Close Find", + matches: "Find matches", + previous: "Previous match", + next: "Next match", + toggleReplace: "Toggle replace", + replaceWith: "Replace with", + replace: "Replace", + replaceAll: "Replace all", + noMatches: "No matches", + position: "{{current}} of {{total}}", + total: "{{total}} matches", + }, common: { + bottomSheetBackdrop: "Bottom sheet backdrop", back: "Back", loading: "Loading...", actions: { + save: "Save", back: "Back", cancel: "Cancel", close: "Close", copy: "Copy", copyLine: "Copy line", dismiss: "Dismiss", + reload: "Reload", retry: "Retry", search: "Search", select: "Select", @@ -95,6 +120,8 @@ export const en = { input: { accessibilityLabel: "Message agent...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", @@ -150,7 +177,7 @@ export const en = { initialPromptRequired: "Initial prompt is required", alreadyLoading: "Already loading", uploadFailed: "Failed to upload file", - noClipboardImage: "No image in clipboard", + noClipboardImage: "No image in the current clipboard. Try pasting from your keyboard.", pasteImageFailed: "Failed to paste image", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -205,6 +232,10 @@ export const en = { historyLoadFailed: "Couldn't load agent history", messageCapped: "This message was capped ({{bytes}} bytes).", permission: { + rejectedPlan: "Rejected plan", + approvedPlan: "Approved plan", + canceledPlan: "Canceled plan", + plan: "Plan", required: "Permission Required", deny: "Deny", @@ -218,7 +249,8 @@ export const en = { states: { notFound: "Agent not found", failedToLoad: "Failed to load agent", - reconnecting: "Reconnecting", + reconnecting: "Reconnecting to host", + updating: "Updating messages", timelineSyncFailed: "Couldn't refresh agent history.", timelineSyncRetrying: "Retrying…", archivingTitle: "Archiving agent...", @@ -350,6 +382,9 @@ export const en = { completed: "Completed", }, }, + turnFooter: { + workedFor: "Worked for {{duration}}", + }, compaction: { loading: "Compacting...", auto: "Context automatically compacted", @@ -408,7 +443,9 @@ export const en = { recovery: { archivedTitle: "Workspace archived", restoreDescription: - "{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.", + "Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.", + restoreWithoutBranchDescription: + "Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.", unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.", restoreAction: "Restore", unarchiveAction: "Unarchive", @@ -888,6 +925,9 @@ export const en = { openChangesTab: "Open Changes tab", openDiffTab: "Open Diff tab", closeChangesTab: "Close Changes tab", + jumpToFile: { + title: "Jump to file", + }, binaryFile: "Binary file", tooLarge: "Diff too large to display", previewTooLargeTitle: "This diff is too large to preview", @@ -910,11 +950,8 @@ export const en = { refresh: "Refresh", refreshState: "Refresh git and {{brand}} state", failedRefresh: "Failed to refresh git state.", - emptyHiddenWhitespace: "No visible changes after hiding whitespace", - emptyUncommitted: "No uncommitted changes", seeUncommittedChanges: "See uncommitted changes", seeCommittedChanges: "See committed changes", - emptyAgainstBase: "No changes vs {{baseRef}}", checkingRepository: "Checking repository...", notRepository: "Not a git repository", diffMode: "Diff mode", @@ -947,6 +984,47 @@ export const en = { actions: { viewPullRequest: "View", openOn: "Open on {{brand}}", + addToChat: "Add to chat", + addAllToChat: "Add all to chat", + addingToChat: "Adding...", + }, + checksOverview: { + headline: { + actionRequired: "Some checks need your attention", + failure: "Some checks were not successful", + pending: "Some checks haven't completed yet", + success: "All checks have passed", + none: "No checks", + }, + count: { + actionRequired: "{{count}} needs action", + warning: "{{count}} warning", + failure: "{{count}} failing", + pending: "{{count}} in progress", + manual: "{{count}} manual", + success: "{{count}} successful", + ignored: "{{count}} skipped", + }, + detailOne: "{{parts}} check", + detailMany: "{{parts}} checks", + groupOne: { + actionRequired: "{{count}} needs action check", + warning: "{{count}} warning check", + failure: "{{count}} failing check", + pending: "{{count}} in progress check", + manual: "{{count}} manual check", + success: "{{count}} successful check", + ignored: "{{count}} skipped check", + }, + groupMany: { + actionRequired: "{{count}} needs action checks", + warning: "{{count}} warning checks", + failure: "{{count}} failing checks", + pending: "{{count}} in progress checks", + manual: "{{count}} manual checks", + success: "{{count}} successful checks", + ignored: "{{count}} skipped checks", + }, }, checksSummary: { passedLabel: "passed", @@ -960,17 +1038,21 @@ export const en = { checks: "Checks", pipeline: "Pipeline", reviews: "Reviews", + activity: "Activity", }, empty: { noJobs: "No jobs", loadingPipeline: "Loading pipeline…", pipelineJobsLoadFailed: "Could not load pipeline jobs", allowedToFail: "allowed to fail", + noActivity: "No activity yet", }, approvals: "{{given}} of {{required}} approvals", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Comment actions", + threadActions: "Thread actions", checkStatus: { passed: "Passed", failed: "Failed", @@ -999,6 +1081,8 @@ export const en = { }, thread: { discussion: "Discussion thread", + resolved: "Resolved", + outdated: "Outdated", }, errors: { statusLoadFailed: "Unable to load pull request status", @@ -1054,8 +1138,27 @@ export const en = { updateHost: "Update this host to manage labels.", }, }, + changelog: { + title: "What's new", + installed: "Installed", + showMore: "Show more", + openWebsite: "Full changelog", + error: { + title: "Unable to load the changelog", + description: "Check your connection and try again.", + }, + }, sidebar: { + statusGroupAccessibility: "{{label}} group", + statusBucket: { + needsInput: "Needs input", + failed: "Failed", + readyToReview: "Ready to review", + working: "Working", + done: "Done", + }, display: { + showBackground: "Show background", trigger: "Display preferences", heading: "Display", grouping: { @@ -1115,6 +1218,9 @@ export const en = { settings: "Settings", closeSidebar: "Close sidebar", }, + footer: { + usage: "Usage", + }, help: { trigger: "Help and support", sectionHelp: "Help", @@ -1276,6 +1382,23 @@ export const en = { }, daemon: { title: "Daemon", + lifecycle: { + owned: "Launched by this Desktop session", + attached: "Attached to an existing daemon", + ownedMessage: "This daemon was launched by this Desktop session.", + attachedMessage: "This daemon was not launched by this Desktop session.", + stopTitle: "Stop local daemon?", + stopMessage: + "{{ownership}}\nHome: {{home}}\nSupervisor PID: {{pid}}\nRunning agent work will be interrupted.", + stop: "Stop daemon", + stopping: "Stopping…", + stopFailed: "Unable to stop daemon", + pauseAttached: "Pause automatic daemon management? The attached daemon will keep running.", + pause: "Pause management", + workerUpdated: "Worker updated to {{version}}", + supervisorRefresh: + "The running supervisor retains its original code. Its launcher must stop and start it to refresh the supervisor.", + }, status: { title: "Status", builtInOnly: "Only the built-in desktop daemon is shown here", @@ -1492,6 +1615,8 @@ export const en = { noFiles: "No files or directories found", noCommands: "No commands found", failedToLoad: "Failed to load", + chooseProjectForCommands: "Choose a project to see commands", + chooseModelForCommands: "Select a model to see commands", }, loadOlderHistory: { failed: "Couldn't load older history", @@ -1583,6 +1708,21 @@ export const en = { }, }, pairing: { + hostPassword: { + title: "Password for {{host}}", + label: "Host password", + }, + hostConfirmation: { + title: "Connect to this host?", + description: + "This host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.", + descriptionChanged: + "This link changes how you connect to this host. The host will be able to run code in this app and reach your other connected hosts. Only connect if you recognize it.", + hostLabel: "Host", + fingerprintLabel: "Key fingerprint", + relayLabel: "Relay", + connect: "Connect", + }, connectionMethods: { title: "Add connection", direct: { @@ -1649,6 +1789,12 @@ export const en = { helper: "Connect to a Paseo daemon running on the remote host.", fields: { target: "SSH host", + password: "Daemon password", + optional: "Optional", + }, + passwordVisibility: { + show: "Show password", + hide: "Hide password", }, actions: { cancel: "Cancel", @@ -1878,6 +2024,8 @@ export const en = { dismiss: "Dismiss", }, contextWindow: { + noData: "No context data", + accessibilityNoData: "Context window: No context data", title: "Context window", used: "{{percentage}}% used", tokens: "{{used}} / {{max}} tokens", @@ -1885,6 +2033,17 @@ export const en = { accessibility: "Context window {{percentage}}% used", }, review: { + feedback: { + send: "Send feedback ({{count}})", + sending: "Sending feedback ({{count}})", + chooseAgent: "Choose an agent", + sent: "Feedback sent to {{recipient}}", + "no-agents": "Open an agent tab in this workspace to send feedback.", + disconnected: "Connect to the host to send feedback.", + "no-context": "Saved comments no longer match this diff.", + failed: "Failed to send feedback. Try again.", + prompt: "Please address this code review.", + }, comment: { add: "Add review comment", edit: "Edit review comment", @@ -1919,9 +2078,12 @@ export const en = { groupInfo: "About {{title}}", sections: { general: "General", + chat: "Chat", appearance: "Appearance", - layout: "Layout", + sidebar: "Sidebar", editor: "Editor", + terminal: "Terminal", + browser: "Browser", shortcuts: "Shortcuts", integrations: "Integrations", notifications: "Notifications", @@ -1939,29 +2101,32 @@ export const en = { }, sources: { explorerFiles: { - label: "Selecting a file in Explorer", + label: "Clicking a file in the Explorer sidebar", description: "Open files selected in the Explorer sidebar beside your work", }, diffs: { - label: "Opening a diff", + label: "Clicking a change in the Explorer sidebar or a chat", description: "Open diffs from Explorer and agent conversations beside your work", }, chatFiles: { - label: "Opening a file from an agent chat", + label: "Clicking a file in an agent chat", description: "Open file links and tool-call files beside the conversation", }, diffFiles: { - label: "Opening a file from Changes", + label: "Clicking a file in a diff", description: "Open source files selected from a diff beside it", }, subagents: { - label: "Opening a subagent", + label: "Clicking a subagent in an agent chat", description: "Open subagents beside their parent agent", }, pullRequests: { - label: "Opening a pull request from Changes", + label: "Clicking a pull request in the Explorer sidebar", description: "Open pull request details beside Changes", }, + serviceUrls: { + label: "Clicking a script's service URL", + }, }, }, }, @@ -1999,17 +2164,22 @@ export const en = { }, plugins: { title: "Plugins", + screens: { + open: "Open", + offline: "Connect to this host to open plugin settings.", + update: "Update this host to use plugin settings.", + unavailable: "This plugin settings screen is unavailable.", + backToPlugins: "Back to plugins", + }, trustedTitle: "Plugins are trusted code", trustedDescription: "They run on this host and inside the app without sandboxing. Install only code you trust.", globalTitle: "Enable plugins", globalHint: "Global switch for every configured plugin", - directoryLabel: "Plugin directory", - directoryPlaceholder: "/absolute/path/on/host", - idLabel: "Plugin installation ID", - idHint: "Leave blank to use paseo-plugin.json", - idPlaceholder: "Manifest default", - install: "Install directory", + sourceLabel: "Plugin source", + sourcePlaceholder: "owner/slug, directory, Git URL, or npm package", + docs: "Docs", + install: "Install plugin", installing: "Installing…", logs: { action: "Logs", @@ -2022,6 +2192,7 @@ export const en = { }, status: { running: "running", disabled: "disabled", failed: "failed" }, actions: { + menu: "Actions for {{id}}", reload: "Reload", reloading: "Reloading…", enable: "Enable", @@ -2047,6 +2218,7 @@ export const en = { offlineTitle: "Plugin host is offline", offlineDescription: "Reconnect to this host to manage its plugins.", updateTitle: "Update this host to manage plugins", + sourceUpdateTitle: "Update this host to install plugins", loading: "Loading plugins…", errorTitle: "Unable to load plugins", retry: "Retry", @@ -2069,6 +2241,7 @@ export const en = { }, general: { title: "General", + sending: "Sending", browserData: { title: "Browser data", siteData: "Cookies and site data", @@ -2095,8 +2268,6 @@ export const en = { }, }, serviceUrls: { - label: "Service URLs", - description: "Where to open URLs from running scripts", options: { ask: "Ask", inApp: "In Paseo", @@ -2115,7 +2286,6 @@ export const en = { toolCallDetail: { label: "Tool call display", description: "How tool calls appear in the timeline", - accessibilityLabel: "Select tool call display ({{value}})", options: { overview: "Summary", detailed: "Full detail", @@ -2169,6 +2339,7 @@ export const en = { about: { title: "About", appVersion: "App version", + whatsNewHint: "Release notes for every version", thisDevice: "This device", connectedHosts: "Connected hosts", offline: "Offline", @@ -2217,8 +2388,15 @@ export const en = { description: "Show an outline for jumping between prompts", }, sidebar: { - title: "Sidebar", - description: "Choose which items appear at the top of the sidebar and in what order", + header: { + title: "Header", + description: "Choose which items appear at the top of the sidebar and in what order", + }, + footer: { + title: "Footer", + description: + "Choose which rows appear at the bottom of the sidebar and in what order. Add project and the icon row always show", + }, moveUp: "Move up", moveDown: "Move down", }, @@ -2242,6 +2420,14 @@ export const en = { codeSizeHint: "Used for code, diffs, and terminal output", codeSizeAccessibility: "Code font size", }, + layout: { + title: "Layout", + contentWidth: "Content width", + contentWidthHint: "Max width of chat and Markdown files on wide screens", + contentWidthAccessibility: "Content width in pixels", + reset: "Reset", + resetAccessibility: "Reset content width to default", + }, syntax: { title: "Syntax", highlightTheme: "Highlight theme", @@ -2351,6 +2537,9 @@ export const en = { }, }, host: { + password: { + guidance: "Remove this host and add it again with the password this daemon asks for.", + }, appearance: { title: "Appearance", name: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1b7ee5118a..bc53c8b2aa 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2,16 +2,43 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { + paneFind: { + connectionFailure: + "No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.", + historyChangedFailure: "El chat cambió durante la búsqueda. Vuelve a buscar.", + revealFailure: "No se pudo mostrar esta coincidencia. Vuelve a intentarlo.", + searching: "Buscando…", + loading: "Cargando…", + failed: "Error", + retry: "Reintentar", + + title: "Buscar", + placeholder: "Buscar en el panel", + close: "Cerrar búsqueda", + matches: "Coincidencias", + previous: "Coincidencia anterior", + next: "Siguiente coincidencia", + toggleReplace: "Mostrar reemplazo", + replaceWith: "Reemplazar con", + replace: "Reemplazar", + replaceAll: "Reemplazar todo", + noMatches: "Sin coincidencias", + position: "{{current}} de {{total}}", + total: "{{total}} coincidencias", + }, common: { + bottomSheetBackdrop: "Fondo del panel inferior", back: "Atrás", loading: "Cargando...", actions: { + save: "Guardar", back: "Atrás", cancel: "Cancelar", close: "Cerrar", copy: "Copiar", copyLine: "Copiar línea", dismiss: "Despedir", + reload: "Volver a cargar", retry: "Rever", search: "Buscar", select: "Seleccionar", @@ -98,6 +125,8 @@ export const es: TranslationResources = { input: { accessibilityLabel: "Agente de mensajes...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Pantalla completa", + exitFullscreen: "Salir de pantalla completa", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", @@ -153,7 +182,8 @@ export const es: TranslationResources = { initialPromptRequired: "Se requiere aviso inicial", alreadyLoading: "Ya cargando", uploadFailed: "Failed to upload file", - noClipboardImage: "No hay ninguna imagen en el portapapeles", + noClipboardImage: + "No hay ninguna imagen en el portapapeles actual. Prueba a pegar desde el teclado.", pasteImageFailed: "No se pudo pegar la imagen", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -208,6 +238,10 @@ export const es: TranslationResources = { historyLoadFailed: "No se pudo cargar el historial del agente", messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).", permission: { + rejectedPlan: "Plan rechazado", + approvedPlan: "Plan aprobado", + canceledPlan: "Plan cancelado", + plan: "Plan", required: "Permiso requerido", deny: "Denegar", @@ -221,7 +255,8 @@ export const es: TranslationResources = { states: { notFound: "Agentno encontrado", failedToLoad: "No se pudo cargar el agente", - reconnecting: "Reconectando", + reconnecting: "Reconectando al host", + updating: "Actualizando mensajes", timelineSyncFailed: "No se pudo actualizar el historial del agente.", timelineSyncRetrying: "Reintentando…", archivingTitle: "Agente de archivo...", @@ -355,6 +390,9 @@ export const es: TranslationResources = { completed: "Completada", }, }, + turnFooter: { + workedFor: "Trabajó durante {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automáticamente", @@ -413,7 +451,9 @@ export const es: TranslationResources = { recovery: { archivedTitle: "Espacio de trabajo archivado", restoreDescription: - "{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.", + "Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.", + restoreWithoutBranchDescription: + "Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.", unarchiveDescription: "{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.", restoreAction: "Restaurar", @@ -910,6 +950,9 @@ export const es: TranslationResources = { openChangesTab: "Abrir la pestaña Cambios", openDiffTab: "Abrir la pestaña Diferencia", closeChangesTab: "Cerrar la pestaña Cambios", + jumpToFile: { + title: "Ir al archivo", + }, binaryFile: "archivo binario", tooLarge: "La diferencia es demasiado grande para mostrarse", previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo", @@ -932,11 +975,8 @@ export const es: TranslationResources = { refresh: "Refrescar", refreshState: "Actualizar el estado de git y {{brand}}", failedRefresh: "No se pudo actualizar el estado de git.", - emptyHiddenWhitespace: "No hay cambios visibles después de ocultar espacios en blanco", - emptyUncommitted: "Sin cambios no confirmados", seeUncommittedChanges: "Ver cambios no confirmados", seeCommittedChanges: "Ver cambios confirmados", - emptyAgainstBase: "Sin cambios frente a{{baseRef}}", checkingRepository: "Comprobando repositorio...", notRepository: "No es un repositorio de git", diffMode: "modo diferencial", @@ -969,6 +1009,47 @@ export const es: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir en {{brand}}", + addToChat: "Añadir al chat", + addAllToChat: "Añadir todo al chat", + addingToChat: "Añadiendo...", + }, + checksOverview: { + headline: { + actionRequired: "Algunas comprobaciones requieren tu atención", + failure: "Algunas comprobaciones no se superaron", + pending: "Algunas comprobaciones aún no han terminado", + success: "Todas las comprobaciones se superaron", + none: "Sin comprobaciones", + }, + count: { + actionRequired: "{{count}} con acción pendiente", + warning: "{{count}} con advertencia", + failure: "{{count}} con error", + pending: "{{count}} en curso", + manual: "{{count}} manual(es)", + success: "{{count}} superada(s)", + ignored: "{{count}} omitida(s)", + }, + detailOne: "Comprobación: {{parts}}", + detailMany: "Comprobaciones: {{parts}}", + groupOne: { + actionRequired: "{{count}} comprobación con acción pendiente", + warning: "{{count}} comprobación con advertencia", + failure: "{{count}} comprobación con error", + pending: "{{count}} comprobación en curso", + manual: "{{count}} comprobación manual", + success: "{{count}} comprobación superada", + ignored: "{{count}} comprobación omitida", + }, + groupMany: { + actionRequired: "{{count}} comprobaciones con acción pendiente", + warning: "{{count}} comprobaciones con advertencia", + failure: "{{count}} comprobaciones con error", + pending: "{{count}} comprobaciones en curso", + manual: "{{count}} comprobaciones manuales", + success: "{{count}} comprobaciones superadas", + ignored: "{{count}} comprobaciones omitidas", + }, }, checksSummary: { passedLabel: "pasó", @@ -982,17 +1063,21 @@ export const es: TranslationResources = { checks: "cheques", pipeline: "Pipeline", reviews: "Reseñas", + activity: "Actividad", }, empty: { noJobs: "Sin trabajos", loadingPipeline: "Cargando pipeline...", pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline", allowedToFail: "permitido fallar", + noActivity: "Aún no hay actividad", }, approvals: "{{given}} de {{required}} aprobaciones", accessibility: { pullRequest: "Solicitud de extracción n.°{{number}}", pullRequest_mr: "Solicitud de fusión !{{number}}", + commentActions: "Acciones del comentario", + threadActions: "Acciones del hilo", checkStatus: { passed: "Superado", failed: "Fallido", @@ -1011,16 +1096,18 @@ export const es: TranslationResources = { open: "Abierto", }, activity: { - commented: "Comentado", - approved: "Aprobado", - requestedChanges: "Cambios solicitados", - reviewed: "Revisado", + commented: "Comentó", + approved: "Aprobó", + requestedChanges: "Solicitó cambios", + reviewed: "Revisó", }, time: { justNow: "En este momento", }, thread: { discussion: "Hilo de discusión", + resolved: "Resuelto", + outdated: "Desactualizado", }, errors: { statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción", @@ -1079,8 +1166,27 @@ export const es: TranslationResources = { updateHost: "Actualiza este host para gestionar etiquetas.", }, }, + changelog: { + title: "Novedades", + installed: "Instalada", + showMore: "Mostrar más", + openWebsite: "Registro de cambios completo", + error: { + title: "No se pudo cargar el registro de cambios", + description: "Comprueba tu conexión e inténtalo de nuevo.", + }, + }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Necesita datos", + failed: "Con error", + readyToReview: "Para revisar", + working: "En ejecución", + done: "Terminado", + }, display: { + showBackground: "Mostrar espacios en segundo plano", trigger: "Preferencias de visualización", heading: "Visualización", grouping: { @@ -1140,6 +1246,9 @@ export const es: TranslationResources = { settings: "Ajustes", closeSidebar: "Cerrar barra lateral", }, + footer: { + usage: "Uso", + }, help: { trigger: "Ayuda y soporte", sectionHelp: "Ayuda", @@ -1300,6 +1409,7 @@ export const es: TranslationResources = { detail: "Deteniendo el demonio local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Estado", @@ -1526,6 +1636,8 @@ export const es: TranslationResources = { noFiles: "No se encontraron archivos ni directorios", noCommands: "No se encontraron comandos", failedToLoad: "No se pudo cargar", + chooseProjectForCommands: "Elige un proyecto para ver los comandos", + chooseModelForCommands: "Selecciona un modelo para ver los comandos", }, loadOlderHistory: { failed: "No se pudo cargar el historial anterior", @@ -1617,6 +1729,21 @@ export const es: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "Contraseña de {{host}}", + label: "Contraseña del host", + }, + hostConfirmation: { + title: "¿Conectar con este host?", + description: + "Este host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.", + descriptionChanged: + "Este enlace cambia cómo te conectas a este host. El host podrá ejecutar código en esta app y acceder a tus otros hosts conectados. Conéctate solo si lo reconoces.", + hostLabel: "Host", + fingerprintLabel: "Huella de la clave", + relayLabel: "Relay", + connect: "Conectar", + }, connectionMethods: { title: "Agregar conexión", direct: { @@ -1684,6 +1811,12 @@ export const es: TranslationResources = { helper: "Conéctate a un daemon de Paseo en el host remoto.", fields: { target: "Host SSH", + password: "Contraseña del daemon", + optional: "Opcional", + }, + passwordVisibility: { + show: "Mostrar contraseña", + hide: "Ocultar contraseña", }, actions: { cancel: "Cancelar", @@ -1914,6 +2047,8 @@ export const es: TranslationResources = { dismiss: "Despedir", }, contextWindow: { + noData: "No hay datos de contexto", + accessibilityNoData: "Ventana de contexto: No hay datos de contexto", title: "ventana contextual", used: "{{percentage}}% utilizado", tokens: "Fichas{{used}}/{{max}}", @@ -1921,6 +2056,17 @@ export const es: TranslationResources = { accessibility: "Ventana de contexto{{percentage}}% utilizada", }, review: { + feedback: { + send: "Enviar comentarios ({{count}})", + sending: "Enviando comentarios ({{count}})", + chooseAgent: "Elegir un agente", + sent: "Comentarios enviados a {{recipient}}", + "no-agents": "Abre una pestaña de agente en este espacio de trabajo para enviar comentarios.", + disconnected: "Conéctate al host para enviar comentarios.", + "no-context": "Los comentarios guardados ya no coinciden con este diff.", + failed: "No se pudieron enviar los comentarios. Inténtalo de nuevo.", + prompt: "Por favor, atiende esta revisión de código.", + }, comment: { add: "Agregar comentario de revisión", edit: "Editar comentario de revisión", @@ -1955,8 +2101,11 @@ export const es: TranslationResources = { groupInfo: "Acerca de{{title}}", sections: { general: "General", + chat: "Chat", appearance: "Apariencia", - layout: en.settings.sections.layout, + sidebar: "Barra lateral", + terminal: "Terminal", + browser: "Navegador", editor: "Editor", shortcuts: "Atajos", integrations: "Integraciones", @@ -2015,6 +2164,7 @@ export const es: TranslationResources = { }, general: { title: "General", + sending: "Envío", browserData: { title: "Datos del navegador", siteData: "Cookies y datos de sitios", @@ -2045,8 +2195,6 @@ export const es: TranslationResources = { }, }, serviceUrls: { - label: "URL de servicio", - description: "Dónde abrir URL desde scripts en ejecución", options: { ask: "Preguntar", inApp: "EnPaseo", @@ -2066,7 +2214,6 @@ export const es: TranslationResources = { toolCallDetail: { label: "Visualización de llamadas a herramientas", description: "Cómo aparecen las llamadas a herramientas en la cronología", - accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})", options: { overview: "Resumen", detailed: "Detalle completo", @@ -2120,6 +2267,7 @@ export const es: TranslationResources = { about: { title: "Acerca de", appVersion: "Versión de la aplicación", + whatsNewHint: "Notas de versión de cada release", thisDevice: "este dispositivo", connectedHosts: "Anfitriones conectados", offline: "Desconectado", @@ -2168,9 +2316,16 @@ export const es: TranslationResources = { description: "Muestra un esquema para saltar entre instrucciones", }, sidebar: { - title: "Barra lateral", - description: - "Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden", + header: { + title: "Encabezado", + description: + "Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden", + }, + footer: { + title: "Pie", + description: + "Elige qué filas aparecen en la parte inferior de la barra lateral y en qué orden. Añadir proyecto y la fila de iconos siempre se muestran", + }, moveUp: "Mover hacia arriba", moveDown: "Mover hacia abajo", }, @@ -2195,6 +2350,14 @@ export const es: TranslationResources = { codeSizeHint: "Se usa en código, diferencias y la salida del terminal", codeSizeAccessibility: "Tamaño de fuente del código", }, + layout: { + title: "Diseño", + contentWidth: "Ancho del contenido", + contentWidthHint: "Ancho máximo del chat y de los archivos Markdown en pantallas anchas", + contentWidthAccessibility: "Ancho del contenido en píxeles", + reset: "Restablecer", + resetAccessibility: "Restablecer el ancho del contenido", + }, syntax: { title: "Sintaxis", highlightTheme: "Tema destacado", @@ -2305,6 +2468,9 @@ export const es: TranslationResources = { }, }, host: { + password: { + guidance: "Elimina este host y vuelve a añadirlo con la contraseña que pide este daemon.", + }, appearance: { title: "Apariencia", name: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 5807c7e86f..78a9b2e82b 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2,51 +2,77 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { + paneFind: { + connectionFailure: + "Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.", + historyChangedFailure: "La conversation a changé pendant la recherche. Relancez la recherche.", + revealFailure: "Impossible d’afficher ce résultat. Réessayez.", + searching: "Recherche…", + loading: "Chargement…", + failed: "Échec", + retry: "Réessayer", + + title: "Rechercher", + placeholder: "Rechercher dans le panneau", + close: "Fermer la recherche", + matches: "Résultats de recherche", + previous: "Résultat précédent", + next: "Résultat suivant", + toggleReplace: "Afficher/masquer le remplacement", + replaceWith: "Remplacer par", + replace: "Remplacer", + replaceAll: "Tout remplacer", + noMatches: "Aucun résultat", + position: "{{current}} sur {{total}}", + total: "{{total}} résultats", + }, common: { - back: "Dos", - loading: "Chargement...", + bottomSheetBackdrop: "Arrière-plan du panneau inférieur", + back: "Retour", + loading: "Chargement…", actions: { - back: "Dos", + save: "Enregistrer", + back: "Retour", cancel: "Annuler", close: "Fermer", - copy: "Copie", + copy: "Copier", copyLine: "Copier la ligne", - dismiss: "Rejeter", + dismiss: "Ignorer", + reload: "Recharger", retry: "Réessayer", - search: "Recherche", + search: "Rechercher", select: "Sélectionner", selectAll: "Tout sélectionner", }, placeholders: { - search: "Recherche...", + search: "Rechercher…", }, empty: { - noResults: "Aucun résultat trouvé", + noResults: "Aucun résultat", noOptionsMatchSearch: "Aucune option ne correspond à votre recherche.", }, states: { - loading: "Chargement...", - starting: "Départ...", + loading: "Chargement…", + starting: "Démarrage…", copied: "Copié", - copiedLabel: "{{label}}copié", + copiedLabel: "Copié : {{label}}", downloadComplete: "Téléchargement terminé", - downloadFailed: "Le téléchargement a échoué", + downloadFailed: "Échec du téléchargement", }, errors: { error: "Erreur", - unableToSave: "Impossible d'enregistrer", + unableToSave: "Impossible d’enregistrer", unableToCopy: "Impossible de copier", nameRequired: "Le nom est requis", - daemonUnavailable: "Daemonindisponible", - daemonClientUnavailable: "ClientDaemonindisponible", - daemonClientDisconnected: "Le clientDaemonest déconnecté", - noFileFound: "Aucun fichier trouvé pour{{token}}", - unexpectedDictationError: - "Une erreur inattendue s'est produite lors du traitement de la dictée.", + daemonUnavailable: "Daemon indisponible", + daemonClientUnavailable: "Client du daemon indisponible", + daemonClientDisconnected: "Le client du daemon est déconnecté", + noFileFound: "Aucun fichier trouvé pour {{token}}", + unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.", }, connectionStatus: { online: "En ligne", - connecting: "De liaison", + connecting: "Connexion…", offline: "Hors ligne", error: "Erreur", idle: "Inactif", @@ -54,193 +80,201 @@ export const fr: TranslationResources = { }, shell: { menu: { - toggleSidebar: "Basculer la barre latérale", + toggleSidebar: "Afficher/masquer la barre latérale", open: "Ouvrir le menu", close: "Fermer le menu", }, commandCenter: { - placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents...", - filePlaceholder: "Rechercher des fichiers...", - searchingFiles: "Recherche de fichiers...", - noMatches: "Aucune correspondance", - actions: "Actes", + placeholder: "Rechercher des commandes, fichiers, espaces de travail et agents…", + filePlaceholder: "Rechercher des fichiers…", + searchingFiles: "Recherche de fichiers…", + noMatches: "Aucun résultat", + actions: "Actions", files: "Fichiers", workspaces: "Espaces de travail", agents: "Agents", newAgent: "Nouvel agent", open: "Ouvrir {{name}}", openInSidePane: "Ouvrir {{name}} dans le panneau latéral", - openInFocusedPane: "Ouvrir {{name}} dans le volet actif", + openInFocusedPane: "Ouvrir {{name}} dans le panneau actif", addProject: "Ajouter un projet", - home: "Maison", + home: "Accueil", groupByProject: "Grouper par projet", groupByStatus: "Grouper par statut", modelGroupLabel: "Modèle", modelSearchKeywords: - "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", - thinkingGroupLabel: "Raisonnement", - thinkingSearchKeywords: "raisonnement effort réflexion", + "changer de modèle modifier le modèle définir le modèle sélectionner le modèle model", + thinkingGroupLabel: "Réflexion", + thinkingSearchKeywords: "réflexion raisonnement effort thinking", modeGroupLabel: "Mode", - modeSearchKeywords: "accès permission approbation mode", + modeSearchKeywords: "accès autorisation approbation mode permission", planModeGroupLabel: "Mode plan", planModeSearchKeywords: "plan planification mode plan", fastModeGroupLabel: "Rapide", - fastModeSearchKeywords: "rapide vitesse faible latence", + fastModeSearchKeywords: "rapide vitesse faible latence fast", settingOn: "Activé", settingOff: "Désactivé", }, }, composer: { placeholders: { - desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills", - mobile: "Message,@files,/commands", - fallback: "Message...", + desktop: "Écrivez à l’agent, mentionnez des @fichiers ou utilisez les /commandes et /skills", + mobile: "Message, @fichiers, /commandes", + fallback: "Message…", terminal: "Prompt", }, input: { - accessibilityLabel: "Agent de messagerie...", - terminalAccessibilityLabel: "Terminal prompt", - focusHint: "{{shortcut}}pour se concentrer", + accessibilityLabel: "Écrire à l’agent…", + terminalAccessibilityLabel: "Prompt du terminal", + fullscreen: "Plein écran", + exitFullscreen: "Quitter le plein écran", + focusHint: "{{shortcut}} pour saisir", addAttachment: "Ajouter une pièce jointe", - interruptAgent: "Agent d'interruption", - queueMessage: "Message de file d'attente", + interruptAgent: "Interrompre l’agent", + queueMessage: "Mettre le message en file d’attente", sendAndInterrupt: "Envoyer et interrompre", - sendAndSteer: "Envoyer et guider", - sendMessage: "Envoyer un message", - queue: "File d'attente", + sendAndSteer: "Envoyer et réorienter", + sendMessage: "Envoyer le message", + queue: "Mettre en file d’attente", send: "Envoyer", }, cancel: { - cancelingAgent: "Agent d'annulation", - stopAgent: "Agent d'arrêt", + cancelingAgent: "Annulation de l’agent…", + stopAgent: "Arrêter l’agent", interrupt: "Interrompre", }, voice: { enableVoiceMode: "Activer le mode vocal", voiceMode: "Mode vocal", - unmuteVoiceMode: "Activer le mode vocal", - muteVoiceMode: "Mode voix muette", + unmuteVoiceMode: "Réactiver le micro du mode vocal", + muteVoiceMode: "Couper le micro du mode vocal", stopDictation: "Arrêter la dictée", startDictation: "Démarrer la dictée", - unmuteVoice: "Réactiver la voix", - muteVoice: "Voix muette", + unmuteVoice: "Réactiver le micro", + muteVoice: "Couper le micro", dictation: "Dictée", - interruptBeforeVoice: "Interrompre l'agent avant de démarrer le mode vocal", + interruptBeforeVoice: "Interrompez l’agent avant de démarrer le mode vocal", }, attachments: { addImage: "Ajouter une image", pasteImage: "Coller une image", - addFile: "Upload file", - addIssueOrPr: "Ajouter un problème ouPR", - addIssueOrPr_mr: "Ajouter un problème ou MR", + addFile: "Importer un fichier", + addIssueOrPr: "Ajouter un ticket ou une PR", + addIssueOrPr_mr: "Ajouter un ticket ou une MR", dropImagesHere: "Déposez des images ici", - dropFilesHere: "Drop files here", - editQueuedMessage: "Modifier le message en file d'attente", - sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant", - openImage: "Ouvrir la pièce jointe de l'image", - removeImage: "Supprimer l'image jointe", - removeFile: "Remove file attachment", + dropFilesHere: "Déposez des fichiers ici", + editQueuedMessage: "Modifier le message en file d’attente", + sendQueuedMessageNow: "Envoyer maintenant le message en file d’attente", + openImage: "Ouvrir l’image jointe", + removeImage: "Retirer l’image jointe", + removeFile: "Retirer le fichier joint", openGithub: "Ouvrir {{kind}} {{number}}", - removeGithub: "Supprimer {{kind}} {{number}}", + removeGithub: "Retirer {{kind}} {{number}}", element: "Élément", - openBrowserElement: "Ouvrir la pièce jointe de l'élément de navigateur", - removeBrowserElement: "Supprimer la pièce jointe d'un élément de navigateur", - openReview: "Ouvrir la pièce jointe de l'avis", - removeReview: "Supprimer la pièce jointe de l'avis", + openBrowserElement: "Ouvrir l’élément de navigateur joint", + removeBrowserElement: "Retirer l’élément de navigateur joint", + openReview: "Ouvrir la revue jointe", + removeReview: "Retirer la revue jointe", }, errors: { - failedToSend: "Échec de l'envoi du message", - failedToCreateAgent: "Échec de la création de l'agent", + failedToSend: "Impossible d’envoyer le message", + failedToCreateAgent: "Impossible de créer l’agent", noHostSelected: "Aucun hôte sélectionné", - initialPromptRequired: "Une invite initiale est requise", - alreadyLoading: "Déjà en cours de chargement", - uploadFailed: "Failed to upload file", - noClipboardImage: "Aucune image dans le presse-papiers", + initialPromptRequired: "Un prompt initial est requis", + alreadyLoading: "Chargement déjà en cours", + uploadFailed: "Impossible d’importer le fichier", + noClipboardImage: + "Aucune image dans le presse-papiers. Essayez de coller depuis votre clavier.", pasteImageFailed: "Impossible de coller l’image", - fileTooLarge: "{{fileName}} is too large (max {{size}})", + fileTooLarge: "{{fileName}} est trop volumineux (maximum {{size}})", }, clientCommands: { - archiveAgent: "Archiver l'agent actuel", - freshDraft: "Archivez cet agent et démarrez un nouveau brouillon", + archiveAgent: "Archiver l’agent actuel", + freshDraft: "Archiver cet agent et démarrer un nouveau brouillon", }, github: { - searching: "Recherche...", - noResults: "Aucun résultat trouvé.", - searchPlaceholder: "Problèmes de recherche et PR...", - searchPlaceholder_mr: "Problèmes de recherche et MR...", - title: "Joindre le problème ouPR", - title_mr: "Joindre le problème ou MR", + searching: "Recherche…", + noResults: "Aucun résultat.", + searchPlaceholder: "Rechercher des tickets et des PR…", + searchPlaceholder_mr: "Rechercher des tickets et des MR…", + title: "Joindre un ticket ou une PR", + title_mr: "Joindre un ticket ou une MR", }, }, agentControls: { provider: { fallback: "Fournisseur", - select: "Sélectionnez le fournisseur d'agent", + select: "Choisir le fournisseur d’agent", }, thinking: { - title: "Pensée", + title: "Réflexion", unknown: "Inconnu", - extraHigh: "Très haut", - select: "Sélectionnez l'option de réflexion", - selectWithValue: "Sélectionnez l'option de réflexion ({{value}})", + extraHigh: "Très élevé", + select: "Choisir le niveau de réflexion", + selectWithValue: "Choisir le niveau de réflexion ({{value}})", }, model: { unknown: "Modèle inconnu", }, features: { - title: "Caractéristiques", - open: "Fonctionnalités de l'agent ouvert", - on: "Sur", + title: "Fonctionnalités", + open: "Ouvrir les fonctionnalités de l’agent", + on: "Activé", off: "Désactivé", }, mode: { title: "Mode", - searchPlaceholder: "Modes de recherche...", - selectWithValue: "Sélectionnez le mode agent ({{value}})", + searchPlaceholder: "Rechercher un mode…", + selectWithValue: "Choisir le mode de l’agent ({{value}})", }, hints: { - thinking: "Mode réflexion", + thinking: "Mode de réflexion", model: "Changer de modèle", mode: "Changer de mode", }, }, agentStream: { - empty: "Commencez à discuter avec cet agent...", + empty: "Commencez à discuter avec cet agent…", scrollToBottom: "Faire défiler vers le bas", historyLoadFailed: "Impossible de charger l’historique de l’agent", messageCapped: "Ce message a été tronqué ({{bytes}} octets).", permission: { + rejectedPlan: "Plan refusé", + approvedPlan: "Plan approuvé", + canceledPlan: "Plan annulé", + plan: "Plan", required: "Autorisation requise", deny: "Refuser", accept: "Accepter", - implement: "Mettre en œuvre", - question: "Comment souhaitez-vous procéder?", + implement: "Implémenter", + question: "Comment souhaitez-vous procéder ?", proposedPlan: "Plan proposé", }, }, agentPanel: { states: { - notFound: "Agentintrouvable", - failedToLoad: "Échec du chargement de l'agent", - reconnecting: "Reconnexion", + notFound: "Agent introuvable", + failedToLoad: "Impossible de charger l’agent", + reconnecting: "Reconnexion à l’hôte…", + updating: "Mise à jour des messages…", timelineSyncFailed: "Impossible d’actualiser l’historique de l’agent.", timelineSyncRetrying: "Nouvelle tentative…", - archivingTitle: "Agent d'archivage...", - archivingSubtitle: "Veuillez patienter pendant que nous archivons cet agent.", + archivingTitle: "Archivage de l’agent…", + archivingSubtitle: "Veuillez patienter pendant l’archivage de cet agent.", }, unavailable: { selectedHost: "Hôte sélectionné", unknownHost: - "Impossible d'ouvrir cet agent car{{serverLabel}}n'est pas configuré sur ce périphérique.", + "Impossible d’ouvrir cet agent, car {{serverLabel}} n’est pas configuré sur cet appareil.", addHost: - "Ajoutez l'hôte dans Paramètres ou ouvrez un agent sur un serveur configuré pour continuer.", - preparingSession: "Préparation de la séance{{serverLabel}}...", - connecting: "Connexion à{{serverLabel}}...", - showSoon: "Nous montrerons cet agent dans un instant.", - showWhenOnline: "Nous afficherons cet agent une fois que l'hôte sera en ligne.", - reconnectingTo: "Reconnexion à{{serverLabel}}...", - showAgainWhenReachable: "Nous afficherons à nouveau cet agent dès que l'hôte sera joignable.", + "Pour continuer, ajoutez l’hôte dans Paramètres ou ouvrez un agent sur un serveur configuré.", + preparingSession: "Préparation de la session {{serverLabel}}…", + connecting: "Connexion à {{serverLabel}}…", + showSoon: "Cet agent va s’afficher dans un instant.", + showWhenOnline: "Cet agent s’affichera dès que l’hôte sera en ligne.", + reconnectingTo: "Reconnexion à {{serverLabel}}…", + showAgainWhenReachable: "Cet agent s’affichera de nouveau dès que l’hôte sera joignable.", }, archived: { callout: "Cet agent est archivé", @@ -249,42 +283,42 @@ export const fr: TranslationResources = { }, sessions: { title: "Historique", - empty: "Aucune séance pour l'instant", - noMatches: "Aucune séance correspondante", + empty: "Aucune session pour l’instant", + noMatches: "Aucune session correspondante", tooManyMatches: "Trop de résultats — affinez la recherche", - hostLoadFailed: "{{host}} : Impossible de charger l'historique", - searchPlaceholder: "Rechercher dans l'historique", + hostLoadFailed: "{{host}} : impossible de charger l’historique", + searchPlaceholder: "Rechercher dans l’historique", actions: { loadMore: "Charger plus", clearSearch: "Effacer la recherche", }, }, agentList: { - fallbackTitle: "Nouvelle séance", + fallbackTitle: "Nouvelle session", dateSections: { - recent: "Récent", - today: "Aujourd'hui", + recent: "Récents", + today: "Aujourd’hui", yesterday: "Hier", thisWeek: "Cette semaine", thisMonth: "Ce mois-ci", - older: "Plus vieux", + older: "Plus anciens", }, status: { - initializing: "Départ", + initializing: "Démarrage…", idle: "Inactif", - running: "En cours d'exécution", + running: "En cours", error: "Erreur", closed: "Fermé", }, badges: { archived: "Archivé", - pending: "{{count}}en attente", + pending: "{{count}} en attente", attention: "Attention", }, archiveSheet: { - hostOffline: "Hosthors ligne", - runningAgent: "Cet agent est toujours en cours d'exécution. L’archiver arrêtera l’agent.", - archive: "Archive", + hostOffline: "Hôte hors ligne", + runningAgent: "Cet agent est toujours en cours d’exécution. L’archiver l’arrêtera.", + archive: "Archiver", }, }, message: { @@ -299,33 +333,33 @@ export const fr: TranslationResources = { }, actions: { copyCode: "Copier le code", - copyTurn: "Copier le tour", + copyTurn: "Copier l’échange", copyMessage: "Copier le message", - forkMenu: "Dupliquer le message", - forkInNewTab: "Dupliquer dans un nouvel onglet", - forkInNewWorkspace: "Dupliquer dans un nouvel espace de travail", - forkUnavailable: "Mettez l'hôte à jour pour utiliser ceci.", - forkMissingWorkspace: "Cet agent n'est pas dans un espace de travail.", - forkFailed: "Impossible de dupliquer le chat", + forkMenu: "Forker la conversation à partir d’ici", + forkInNewTab: "Forker dans un nouvel onglet", + forkInNewWorkspace: "Forker dans un nouvel espace de travail", + forkUnavailable: "Mettez à jour l’hôte pour utiliser cette fonction.", + forkMissingWorkspace: "Cet agent n’est dans aucun espace de travail.", + forkFailed: "Impossible de forker la conversation", openFile: "Ouvrir le fichier", copied: "Copié", }, attachments: { - dismissImage: "Ignorer l'image", - closeImage: "Fermer l'image", - imageLoadFailed: "Impossible de charger l'image", + dismissImage: "Ignorer l’image", + closeImage: "Fermer l’image", + imageLoadFailed: "Impossible de charger l’image", imageUnavailable: "Image indisponible", - imagePreviewUnavailable: "Aperçu de l'image indisponible.", - imagePreviewLoadFailed: "Impossible de charger l'aperçu de l'image.", + imagePreviewUnavailable: "Aperçu de l’image indisponible.", + imagePreviewLoadFailed: "Impossible de charger l’aperçu de l’image.", review: "Revue", commentsOne: "1 commentaire", commentsMany: "{{count}} commentaires", - textAttachment: "Texte en pièce jointe", + textAttachment: "Pièce jointe texte", text: "Texte", file: "Fichier", }, speak: { - header: "Rayon", + header: "Prononcé", }, activity: { details: "Détails", @@ -333,21 +367,21 @@ export const fr: TranslationResources = { dictation: { start: "Démarrer la dictée vocale", cancel: "Annuler la dictée", - retry: "Réessayer la dictée", + retry: "Relancer la dictée", insert: "Insérer la transcription", insertAndSend: "Insérer la transcription et envoyer", - failed: "Échec de la dictée:{{error}}", - failedRetry: "La dictée a échoué. Appuyez sur réessayer.", + failed: "Échec de la dictée : {{error}}", + failedRetry: "La dictée a échoué. Appuyez sur Réessayer.", }, question: { - submit: "Soumettre", + submit: "Envoyer", next: "Suivant", - answerPlaceholder: "Tapez votre réponse...", - otherPlaceholder: "Autre...", + answerPlaceholder: "Saisissez votre réponse…", + otherPlaceholder: "Autre…", }, todo: { title: "Tâches", - empty: "Aucune tâche pour l'instant.", + empty: "Aucune tâche pour l’instant.", tasksProgress: "{{completed}}/{{total}} tâches", activity: { created: "{{count}} tâches créées", @@ -356,18 +390,21 @@ export const fr: TranslationResources = { completed: "Terminée", }, }, + turnFooter: { + workedFor: "A travaillé pendant {{duration}}", + }, compaction: { - loading: "Compactage...", - auto: "Contexte automatiquement compacté", + loading: "Compaction…", + auto: "Contexte compacté automatiquement", manual: "Contexte compacté manuellement", - withTokens: "Contexte compacté (jetons{{tokens}}K)", + withTokens: "Contexte compacté ({{tokens}}K tokens)", completed: "Contexte compacté", }, }, importSession: { chooseHostTitle: en.importSession.chooseHostTitle, - title: "Session d'importation", - searchPlaceholder: "Rechercher des sessions...", + title: "Importer une session", + searchPlaceholder: "Rechercher des sessions…", scope: { host: "Sessions sur {{host}}", workspace: "Cet espace de travail", @@ -377,55 +414,57 @@ export const fr: TranslationResources = { }, status: { connectHost: "Connectez-vous à un hôte pour importer des sessions", - updateHost: "Mettez à jour l'hôte pour importer des sessions.", - noProviders: "Aucun fournisseur importable n'est activé.", - loading: "Chargement des sessions récentes...", + updateHost: "Mettez à jour l’hôte pour importer des sessions.", + noProviders: "Aucun fournisseur importable n’est activé.", + loading: "Chargement des sessions récentes…", failedProvider: "Impossible de charger les sessions {{provider}}", - failedImport: "Impossible d'importer la session sélectionnée.", + failedImport: "Impossible d’importer la session sélectionnée.", }, actions: { - refresh: "Sessions de rafraîchissement", + refresh: "Actualiser les sessions", showAll: "Tout afficher", loadMore: "Charger plus", }, preview: { - untitledSession: "Séance sans titre", - noPrompt: "Aucun aperçu rapide", + untitledSession: "Session sans titre", + noPrompt: "Aucun aperçu du prompt", }, empty: { noRecent: "Aucune session récente à importer.", noMatches: "Aucune session ne correspond à votre recherche.", alreadyImported: "Toutes les sessions récentes sont déjà importées.", - noProviderSessions: "Aucune session{{provider}}trouvée.", + noProviderSessions: "Aucune session {{provider}} trouvée.", }, row: { - importing: "Importation...", + importing: "Importation…", }, }, workspace: { route: { - loading: "Chargement de l'espace de travail", - connecting: "De liaison", - hostOffline: "{{hostName}}est hors ligne", - cannotReachHost: "Impossible d'atteindre{{hostName}}", - hostStatus: "StatutHost:{{status}}", + loading: "Chargement de l’espace de travail…", + connecting: "Connexion…", + hostOffline: "{{hostName}} est hors ligne", + cannotReachHost: "Impossible de joindre {{hostName}}", + hostStatus: "Statut de l’hôte : {{status}}", needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", - manageHost: "Gérer l'hôte", + manageHost: "Gérer l’hôte", recovery: { archivedTitle: "Espace de travail archivé", restoreDescription: - "{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.", + "Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.", + restoreWithoutBranchDescription: + "Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.", unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", restoreAction: "Restaurer", unarchiveAction: "Désarchiver", - restoringTitle: "Restauration de l'espace de travail", - restoringAction: "Restauration...", + restoringTitle: "Restauration de l’espace de travail", + restoringAction: "Restauration…", unavailableTitle: "Espace de travail indisponible", - checkFailedTitle: "Impossible de vérifier l'espace de travail", + checkFailedTitle: "Impossible de vérifier l’espace de travail", }, }, hoverCard: { - scriptsAccessibility: "ScriptsWorkspace", + scriptsAccessibility: "Scripts de l’espace de travail", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", copied: "Copié", @@ -438,8 +477,8 @@ export const fr: TranslationResources = { copyRelativePath: "Copier le chemin relatif", revealIn: "Afficher dans {{target}}", download: "Télécharger", - addToChat: "Ajouter au chat", - moreActions: "Plus de propositions", + addToChat: "Ajouter à la conversation", + moreActions: "Plus d’actions", newFile: "Nouveau fichier", newFolder: "Nouveau dossier", collapseFolder: "Replier le dossier", @@ -448,24 +487,24 @@ export const fr: TranslationResources = { revert: "Abandonner les modifications", delete: "Supprimer", confirmDelete: { - fileTitle: "Supprimer le fichier ?", - folderTitle: "Supprimer le dossier ?", - message: "« {{name}} » sera définitivement supprimé.", + fileTitle: "Supprimer le fichier ?", + folderTitle: "Supprimer le dossier ?", + message: "« {{name}} » sera définitivement supprimé.", confirm: "Supprimer", cancel: "Annuler", }, confirmRevert: { - title: "Abandonner les modifications ?", - message: "Les modifications de « {{name}} » seront définitivement abandonnées.", + title: "Abandonner les modifications ?", + message: "Les modifications de « {{name}} » seront définitivement abandonnées.", confirm: "Abandonner", cancel: "Annuler", - failed: "Échec de l'abandon des modifications", + failed: "Impossible d’abandonner les modifications", }, }, fileExplorer: { sort: { name: "Nom", - modified: "Modifié", + modified: "Date de modification", size: "Taille", }, context: { @@ -473,10 +512,10 @@ export const fr: TranslationResources = { modified: "Modifié", }, actions: { - back: "Dos", + back: "Retour", retry: "Réessayer", refresh: "Actualiser les fichiers", - refreshing: "Actualisation des fichiers", + refreshing: "Actualisation des fichiers…", hideHiddenFiles: "Masquer les fichiers cachés", showHiddenFiles: "Afficher les fichiers cachés", }, @@ -485,16 +524,16 @@ export const fr: TranslationResources = { noVisibleFiles: "Aucun fichier visible", }, states: { - unavailable: "Workspacen'est pas disponible", - loading: "Chargement des fichiers...", + unavailable: "L’espace de travail est indisponible", + loading: "Chargement des fichiers…", }, errors: { - failedToListDirectory: "Échec de la liste du répertoire", - createFailed: "Échec de la création de l'entrée", - renameFailed: "Échec du renommage de l'entrée", - duplicateFailed: "Échec de la duplication de l'entrée", - revealFailed: "Échec de l'affichage de l'entrée", - deleteFailed: "Échec de la suppression de l'entrée", + failedToListDirectory: "Impossible de lister le répertoire", + createFailed: "Impossible de créer l’élément", + renameFailed: "Impossible de renommer l’élément", + duplicateFailed: "Impossible de dupliquer l’élément", + revealFailed: "Impossible d’afficher l’élément", + deleteFailed: "Impossible de supprimer l’élément", }, draft: { filePlaceholder: "Nom du fichier", @@ -503,33 +542,33 @@ export const fr: TranslationResources = { }, setup: { descriptor: { - label: "Installation", + label: "Configuration", completed: "Configuration terminée", - failed: "Échec de l'installation", - workspace: "ConfigurationWorkspace", + failed: "Échec de la configuration", + workspace: "Configuration de l’espace de travail", blocked: "Configuration bloquée", }, status: { - running: "En cours d'exécution", - completed: "Complété", - failed: "Échoué", - waiting: "En attente de la sortie de configuration", + running: "En cours", + completed: "Terminé", + failed: "Échec", + waiting: "En attente de la sortie de la configuration", blocked: "Bloqué", }, - waiting: "Configuration de l'espace de travail...", + waiting: "Configuration de l’espace de travail…", blocked: { - title: "La configuration n’a pas été exécutée", + title: "La configuration n’a pas été lancée", description: - "Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.", - run: "Exécuter la configuration", - runFailed: "Échec de la configuration de l’espace de travail", + "Cette PR provient de {{repository}}, un autre dépôt. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas relu.", + run: "Lancer la configuration", + runFailed: "Impossible de lancer la configuration de l’espace de travail", }, empty: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail.", }, accessibility: { - noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail", - log: "Journal de configurationWorkspace", + noCommands: "Aucune commande de configuration n’a été exécutée pour cet espace de travail", + log: "Journal de configuration de l’espace de travail", }, log: { noOutput: "Aucune sortie", @@ -537,177 +576,177 @@ export const fr: TranslationResources = { }, browser: { unavailable: { - title: "Le navigateur est réservé au bureau", + title: "Le navigateur est réservé à l’app de bureau", subtitle: "Ouvrez cet espace de travail dans Electron pour utiliser le navigateur intégré.", }, - session: "Session de navigateur{{browserId}}", + session: "Session de navigateur {{browserId}}", controls: { - back: "Dos", - forward: "Avant", + back: "Retour", + forward: "Suivant", stopLoading: "Arrêter le chargement", - refresh: "Rafraîchir", - browserUrl: "NavigateurURL", - enterUrl: "EntrezURL", - openDevTools: "Outils de développement du navigateur ouvert", - cancelSelector: "Annuler le sélecteur d'élément", - annotateElement: "Annoter l'élément", - screenshotElement: "Capturer l'élément", - screenshotCopied: "Capture d'écran copiée dans le presse-papiers", + refresh: "Actualiser", + browserUrl: "URL du navigateur", + enterUrl: "Saisir une URL", + openDevTools: "Ouvrir les outils de développement du navigateur", + cancelSelector: "Annuler la sélection d’élément", + annotateElement: "Annoter l’élément", + screenshotElement: "Capturer l’élément", + screenshotCopied: "Capture d’écran copiée dans le presse-papiers", elementCopied: "Élément copié dans le presse-papiers", - screenshotFailed: "Impossible de copier la capture", + screenshotFailed: "Impossible de copier la capture d’écran", selectorLoading: "Attendez la fin du chargement de la page", - selectorFailed: "Impossible de démarrer le sélecteur d’élément", + selectorFailed: "Impossible de lancer le sélecteur d’élément", }, annotate: { - title: "Annoter l'élément", - placeholder: "Message à l'agent concernant cet élément…", + title: "Annoter l’élément", + placeholder: "Message à l’agent à propos de cet élément…", submit: "Joindre", cancel: "Annuler", }, devices: { - label: "Taille de l'appareil", + label: "Taille de l’appareil", responsive: "Adaptatif", }, errors: { - failedToLoad: "Échec du chargement de la page", - invalidUrl: "NavigateurURLinvalide", - unsupportedProtocol: "Navigateur non pris en charge bloquéURL:{{protocol}}", + failedToLoad: "Impossible de charger la page", + invalidUrl: "URL du navigateur invalide", + unsupportedProtocol: "URL bloquée, protocole non pris en charge : {{protocol}}", }, }, terminal: { - hostDisconnected: "Hostn'est pas connecté", - updateHost: "Mettez à jour l’hôte pour utiliser le terminal natif.", - unableToSubscribe: "Impossible de s'abonner au terminal", + hostDisconnected: "L’hôte n’est pas connecté", + updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.", + unableToSubscribe: "Impossible de s’abonner au terminal", }, tabs: { - loading: "Chargement...", + loading: "Chargement…", modified: "Modifications non enregistrées", - loadingAgentTitle: "Titre d'agent de chargement", + loadingAgentTitle: "Chargement du titre de l’agent…", fallback: { newAgent: "Nouvel agent", - setup: "Installation", - workspaceSetup: "ConfigurationWorkspace", + setup: "Configuration", + workspaceSetup: "Configuration de l’espace de travail", terminal: "Terminal", browser: "Navigateur", agent: "Agent", - workspace: "Workspace", + workspace: "Espace de travail", }, switcher: { - trigger: "Changer d'onglet ({{count}}ouvert)", - title: "Changer d'onglet", - searchPlaceholder: "Onglets de recherche", + trigger: "Changer d’onglet ({{count}} ouverts)", + title: "Changer d’onglet", + searchPlaceholder: "Rechercher des onglets", }, menu: { - openFor: "Ouvrir le menu pour{{label}}", + openFor: "Ouvrir le menu de {{label}}", copyResumeCommand: "Copier la commande de reprise", - copyAgentId: "Copier l'identifiant de l'agent", - copyTerminalId: "Copier l'identifiant du terminal", - copyFilePath: "Copy file path", - rename: "Rebaptiser", - closeAbove: "Fermer les onglets ci-dessus", - closeBelow: "Fermer les onglets ci-dessous", - closeLeft: "Près de la gauche", - closeRight: "Près de la droite", + copyAgentId: "Copier l’ID de l’agent", + copyTerminalId: "Copier l’ID du terminal", + copyFilePath: "Copier le chemin du fichier", + rename: "Renommer", + closeAbove: "Fermer les onglets au-dessus", + closeBelow: "Fermer les onglets en dessous", + closeLeft: "Fermer les onglets à gauche", + closeRight: "Fermer les onglets à droite", closeOthers: "Fermer les autres onglets", moveToMain: "Déplacer vers le panneau principal", - reloadAgent: "Agent de rechargement", + reloadAgent: "Recharger l’agent", reloadAgentTooltip: - "Rechargez l'agent pour mettre à jour les compétences, les MCP ou le statut de connexion.", + "Rechargez l’agent pour mettre à jour les skills, les MCP ou l’état de connexion.", close: "Fermer", renameTerminal: "Renommer le terminal", - renameAgent: "Renommer l'agent", + renameAgent: "Renommer l’agent", }, actions: { newTab: "Nouvel onglet", newAgent: "Nouvel agent", newTerminal: "Nouveau terminal", - preparingTerminal: "Préparation de l'onglet du terminal", - preparingTerminalTooltip: "Préparation du terminal...", + preparingTerminal: "Préparation de l’onglet de terminal…", + preparingTerminalTooltip: "Préparation du terminal…", newBrowser: "Nouveau navigateur", - maximizePane: "Agrandir le volet", - restorePane: "Restaurer le volet", - closePane: "Fermer le volet", + maximizePane: "Agrandir le panneau", + restorePane: "Restaurer le panneau", + closePane: "Fermer le panneau", exitFocusMode: "Quitter le mode concentration", - splitRight: "Volet divisé à droite", - splitDown: "Diviser le volet vers le bas", + splitRight: "Diviser le panneau à droite", + splitDown: "Diviser le panneau vers le bas", changes: "Modifications", files: "Fichiers", - pullRequest: "Demande de fusion", - terminalProfilesMenu: "Terminal profiles", - editTerminalProfiles: "Edit profiles", + pullRequest: "Pull request", + terminalProfilesMenu: "Profils de terminal", + editTerminalProfiles: "Modifier les profils", }, explorerSidebar: { - open: "Ouvrir le panneau latéral", - close: "Fermer le panneau latéral", - toggle: "Basculer le panneau latéral", - changes: "Changements", + open: "Ouvrir la barre latérale de l’explorateur", + close: "Fermer la barre latérale de l’explorateur", + toggle: "Afficher/masquer la barre latérale de l’explorateur", + changes: "Modifications", files: "Fichiers", }, toasts: { - copyFailed: "Échec de la copie", - agentIdCopiedLabel: "AgentID", - terminalIdCopiedLabel: "Identifiant du terminal", - resumeCommandCopiedLabel: "reprendre la commande", - filePathCopiedLabel: "File path", - resumeIdUnavailable: "ReprendreIDnon disponible", - resumeCommandUnavailable: "Commande de reprise non disponible", - reloadingAgent: "Agent de rechargement...", + copyFailed: "Impossible de copier", + agentIdCopiedLabel: "ID de l’agent", + terminalIdCopiedLabel: "ID du terminal", + resumeCommandCopiedLabel: "Commande de reprise", + filePathCopiedLabel: "Chemin du fichier", + resumeIdUnavailable: "ID de reprise indisponible", + resumeCommandUnavailable: "Commande de reprise indisponible", + reloadingAgent: "Rechargement de l’agent…", reloadedAgent: "Agent rechargé", - failedToReloadAgent: "Échec du rechargement de l'agent", - failedToCloseAgent: "Échec de la fermeture de l'agent", + failedToReloadAgent: "Impossible de recharger l’agent", + failedToCloseAgent: "Impossible de fermer l’agent", }, confirmations: { unsavedTitle: "Modifications non enregistrées", unsavedMessage: "Cet onglet contient des modifications non enregistrées. Le fermer supprimera le brouillon.", closeWithoutSaving: "Fermer sans enregistrer", - closePaneTitle: "Fermer le volet?", + closePaneTitle: "Fermer le panneau ?", bulkUnsaved: "{{count}} onglet(s) contiennent des modifications non enregistrées. Les fermer supprimera ces brouillons.", close: "Fermer", cancel: "Annuler", - archive: "Archive", - closeTerminalTitle: "Fermer le terminal?", - closeTerminalMessage: - "Tout processus en cours d’exécution dans ce terminal sera immédiatement arrêté.", - archiveRunningAgentTitle: "Archiver l'agent en cours d'exécution?", + archive: "Archiver", + closeTerminalTitle: "Fermer le terminal ?", + closeTerminalMessage: "Tout processus en cours dans ce terminal sera arrêté immédiatement.", + archiveRunningAgentTitle: "Archiver l’agent en cours ?", archiveRunningAgentMessage: - "Cet agent est toujours en cours d'exécution. L'archiver arrêtera l'agent et fermera l'onglet.", - closeTabsLeftTitle: "Fermer les onglets à gauche?", - closeTabsRightTitle: "Fermer les onglets à droite?", - closeOtherTabsTitle: "Fermer les autres onglets?", + "Cet agent est toujours en cours d’exécution. L’archiver arrêtera l’agent et fermera l’onglet.", + closeTabsLeftTitle: "Fermer les onglets à gauche ?", + closeTabsRightTitle: "Fermer les onglets à droite ?", + closeOtherTabsTitle: "Fermer les autres onglets ?", bulk: { - all: "Cela archivera les agents{{agents}}, fermera les terminaux{{terminals}}et fermera les onglets{{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + all: "Cette action archivera {{agents}} agent(s), fermera {{terminals}} terminal(s) et fermera {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", agentsAndTerminals: - "Cela archivera les agents{{agents}}et fermera les terminaux{{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", + "Cette action archivera {{agents}} agent(s) et fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", terminalsAndTabs: - "Cela fermera le(s) terminal(s){{terminals}}et fermera le(s) onglet(s){{tabs}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - agentsAndTabs: "Cela archivera les agents{{agents}}et fermera les onglets{{tabs}}.", + "Cette action fermera {{terminals}} terminal(s) et {{tabs}} onglet(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + agentsAndTabs: + "Cette action archivera {{agents}} agent(s) et fermera {{tabs}} onglet(s).", terminals: - "Cela fermera le(s) terminal(s){{terminals}}. Tout processus en cours d’exécution dans un terminal fermé sera immédiatement arrêté.", - tabs: "Cela fermera les onglets{{tabs}}.", - agents: "Cela archivera les agents{{agents}}.", + "Cette action fermera {{terminals}} terminal(s). Tout processus en cours dans un terminal fermé sera arrêté immédiatement.", + tabs: "Cette action fermera {{tabs}} onglet(s).", + agents: "Cette action archivera {{agents}} agent(s).", }, }, }, header: { actions: { - workspaceActions: "ActionsWorkspace", + workspaceActions: "Actions de l’espace de travail", newAgent: "Nouvel agent", - newTerminal: "Nouvelle borne", - newBrowser: "Nouvel onglet du navigateur", - importSession: "Session d'importation", - copyPath: "Copier le chemin de l'espace de travail", + newTerminal: "Nouveau terminal", + newBrowser: "Nouvel onglet de navigateur", + importSession: "Importer une session", + copyPath: "Copier le chemin de l’espace de travail", copyBranchName: "Copier le nom de la branche", showSetup: "Afficher la configuration", }, toasts: { - workspacePathUnavailable: "Le cheminWorkspacen'est pas encore disponible", - branchNameUnavailable: "Nom de la succursale non disponible", + workspacePathUnavailable: "Le chemin de l’espace de travail n’est pas encore disponible", + branchNameUnavailable: "Nom de la branche indisponible", terminalQueued: - "Préparation de l'espace de travail, ouverture du terminal lorsque vous êtes prêt...", - workspacePathCopiedLabel: "CheminWorkspace", - branchNameCopiedLabel: "Nom de la succursale", + "Préparation de l’espace de travail, le terminal s’ouvrira dès qu’il sera prêt…", + workspacePathCopiedLabel: "Chemin de l’espace de travail", + branchNameCopiedLabel: "Nom de la branche", }, }, scripts: { @@ -717,231 +756,224 @@ export const fr: TranslationResources = { copyUrl: "Copier l’URL", openService: "Voir le service", restart: "Redémarrer", - run: "Courir", + run: "Lancer", stop: "Arrêter", view: "Voir le terminal", }, accessibility: { - trigger: "ScriptsWorkspace", + trigger: "Scripts de l’espace de travail", openService: "Voir le service {{scriptName}}", - viewTerminal: "Voir le terminal{{scriptName}}", - runScript: "Exécuter le script{{scriptName}}", - stopScript: "Arrêter{{scriptName}}", - restartScript: "Redémarrer{{scriptName}}", - copyUrl: "Copier l'URL de{{scriptName}}", - chooseUrl: "Choisir l’URL pour {{scriptName}}", - script: "Script{{scriptName}}", + viewTerminal: "Voir le terminal de {{scriptName}}", + runScript: "Lancer le script {{scriptName}}", + stopScript: "Arrêter {{scriptName}}", + restartScript: "Redémarrer {{scriptName}}", + copyUrl: "Copier l’URL de {{scriptName}}", + chooseUrl: "Choisir l’URL de {{scriptName}}", + script: "Script {{scriptName}}", }, routes: { public: "Proxy inverse", - paseo: "Memorable", + paseo: "Mémorable", direct: "Directe", }, states: { - exitCode: "quitter{{code}}", - startFailed: "Échec du démarrage de{{scriptName}}", - stopFailed: "Échec de l'arrêt de{{scriptName}}", + exitCode: "sortie {{code}}", + startFailed: "Impossible de démarrer {{scriptName}}", + stopFailed: "Impossible d’arrêter {{scriptName}}", }, }, tree: { - showFolderTree: "Afficher l'arborescence des dossiers", - hideFolderTree: "Masquer l'arborescence des dossiers", + showFolderTree: "Afficher l’arborescence des dossiers", + hideFolderTree: "Masquer l’arborescence des dossiers", }, git: { actions: { - moreOptions: "Plus d'options", - moreActions: "Plus de propositions", + moreOptions: "Plus d’options", + moreActions: "Plus d’actions", commit: { - label: "Commettre", - pending: "S'engager...", - success: "Engagé", + label: "Commiter", + pending: "Commit en cours…", + success: "Commité", }, pull: { - label: "Tirer", - pending: "Tirer...", - success: "Tiré", + label: "Pull", + pending: "Pull en cours…", + success: "Pull effectué", }, push: { - label: "Pousser", - pending: "Pousser...", - success: "Poussé", + label: "Push", + pending: "Push en cours…", + success: "Push effectué", }, pullAndPush: { - label: "Tirez et poussez", - pending: "Tirer et pousser...", - success: "Tiré et poussé", + label: "Pull et push", + pending: "Pull et push en cours…", + success: "Pull et push effectués", }, - viewPr: "VoirPR", + viewPr: "Voir la PR", createPr: { - label: "CréerPR", - pending: "Création dePR...", - success: "PRcréé", - label_mr: "Créer MR", - pending_mr: "Création de MR...", - success_mr: "MR créé", + label: "Créer une PR", + pending: "Création de la PR…", + success: "PR créée", + label_mr: "Créer une MR", + pending_mr: "Création de la MR…", + success_mr: "MR créée", }, mergeBranch: { - label: "Fusionner localement", - pending: "Fusion...", + label: "Fusionner en local", + pending: "Fusion…", success: "Fusionné", }, mergeFromBase: { - label: "Mise à jour de{{baseRef}}", - pending: "Mise à jour...", + label: "Mettre à jour depuis {{baseRef}}", + pending: "Mise à jour…", success: "Mis à jour", }, archive: { label: "Archiver l’espace de travail", - pending: "Archivage...", + pending: "Archivage…", success: "Archivé", }, mergePr: { - squash: "Fusionner PR (squash)", - merge: "Fusionner PR (merge)", - rebase: "Fusionner PR (rebase)", - pending: "Fusion de PR...", - success: "PR fusionné", - squash_mr: "Fusionner MR (squash)", - merge_mr: "Fusionner MR (merge)", - rebase_mr: "Fusionner MR (rebase)", - pending_mr: "Fusion de MR...", - success_mr: "MR fusionné", + squash: "Fusionner la PR (squash)", + merge: "Fusionner la PR (merge)", + rebase: "Fusionner la PR (rebase)", + pending: "Fusion de la PR…", + success: "PR fusionnée", + squash_mr: "Fusionner la MR (squash)", + merge_mr: "Fusionner la MR (merge)", + rebase_mr: "Fusionner la MR (rebase)", + pending_mr: "Fusion de la MR…", + success_mr: "MR fusionnée", }, - viewPr_mr: "Voir MR", + viewPr_mr: "Voir la MR", autoMerge: { enableSquash: "Fusion automatique (squash)", enableMerge: "Fusion automatique (merge)", enableRebase: "Fusion automatique (rebase)", enabled: "Fusion automatique activée", - enabling: "Activation de la fusion automatique...", - disabling: "Désactivation de la fusion automatique...", + enabling: "Activation de la fusion automatique…", + disabling: "Désactivation de la fusion automatique…", disabled: "Fusion automatique désactivée", }, unavailable: { viewPrNoForge: - "Voir {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de voir la {{noun}} pour l’instant : {{brand}} n’est pas connecté", pullNoRemote: - "Pull n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Pull indisponible : cette branche n’est pas encore liée à un dépôt distant", pullDirty: - "Pull n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", - pullUpToDate: "Pull n'est pas disponible car cette branche est déjà à jour", + "Pull indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", + pullUpToDate: "Pull indisponible : cette branche est déjà à jour", pushNoRemote: - "Push n'est pas disponible ici car cette branche n'est pas encore connectée à une télécommande", + "Push indisponible : cette branche n’est pas encore liée à un dépôt distant", pushBehind: - "Push n'est pas encore disponible, car de nouvelles modifications doivent être apportées en premier.", - pushNothing: "Push n'est pas disponible car il n'y a rien de nouveau à envoyer", + "Push indisponible pour l’instant : des modifications plus récentes doivent d’abord être récupérées", + pushNothing: "Push indisponible : il n’y a rien de nouveau à pousser", pullAndPushNoRemote: - "Le pull et le push ne sont pas disponibles ici car cette branche n'est pas encore connectée à une télécommande", + "Pull et push indisponibles : cette branche n’est pas encore liée à un dépôt distant", pullAndPushDirty: - "Pull et push ne sont pas disponibles tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Pull et push indisponibles tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", pullAndPushNoIncoming: - "Pull et push ne sont pas disponibles car il n'y a aucun changement entrant à récupérer d'abord", - pullAndPushInSync: - "Les fonctions Pull et Push ne sont pas disponibles car cette branche est déjà synchronisée", + "Pull et push indisponibles : aucune modification entrante à récupérer", + pullAndPushInSync: "Pull et push indisponibles : cette branche est déjà synchronisée", pullAndPushNothingToPush: - "Pull et push ne sont pas disponibles car il n'y a rien de nouveau à envoyer après le pull", + "Pull et push indisponibles : il n’y aurait rien de nouveau à pousser après le pull", createPrNoForge: - "Créer {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", + "Impossible de créer une {{noun}} pour l’instant : {{brand}} n’est pas connecté", createPrNoCommits: - "CréerPRn'est pas disponible car cette branche n'a pas encore de nouveaux commits", - mergeNoBase: - "La fusion n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Impossible de créer une PR : cette branche n’a pas encore de nouveau commit", + mergeNoBase: "Fusion indisponible : impossible de déterminer la branche de base", mergeDirty: - "La fusion n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Fusion indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", mergeNothing: - "La fusion n'est pas disponible car cette branche n'a encore rien de nouveau à fusionner", - updateNoBase: - "La mise à jour n'est pas disponible car nous n'avons pas pu déterminer la branche de base", + "Fusion indisponible : cette branche n’a encore rien de nouveau à fusionner", + updateNoBase: "Mise à jour indisponible : impossible de déterminer la branche de base", updateDirty: - "La mise à jour n'est pas disponible tant que vous avez des modifications locales, alors validez-les ou cachez-les d'abord", + "Mise à jour indisponible tant que vous avez des modifications locales : commitez-les ou remisez-les d’abord", updateCurrent: - "La mise à jour n'est pas disponible car cette branche est déjà à jour avec{{baseRef}}", + "Mise à jour indisponible : cette branche est déjà à jour avec {{baseRef}}", mergePrNoGithub: - "La fusionPRn'est pas disponible pour le moment carGitHubn'est pas connecté", + "Impossible de fusionner la PR pour l’instant : GitHub n’est pas connecté", archiveNotWorktree: - "L'archive n'est pas disponible ici car cet espace de travail n'a pas été créé en tant qu'arbre de travailPaseo", + "Archivage indisponible : cet espace de travail n’a pas été créé comme worktree Paseo", mergePrNoForge: - "La fusion {{noun}} n'est pas disponible pour le moment car {{brand}} n'est pas connecté", - mergePrMissing: - "La fusionPRn'est pas disponible car il n'y a pas encore de demande d'extraction", - mergePrDraft: - "La fusionPRn'est pas disponible car la demande d'extraction est encore un brouillon", - mergePrMerged: - "La fusionPRn'est pas disponible car la demande d'extraction est déjà fusionnée", - mergePrClosed: "La fusionPRn'est pas disponible car la demande d'extraction est fermée", - mergePrConflicts: - "La fusionPRn'est pas disponible car la demande d'extraction présente des conflits", + "Impossible de fusionner la {{noun}} pour l’instant : {{brand}} n’est pas connecté", + mergePrMissing: "Impossible de fusionner la PR : il n’y a pas encore de pull request", + mergePrDraft: "Impossible de fusionner la PR : la pull request est encore un brouillon", + mergePrMerged: "Impossible de fusionner la PR : la pull request est déjà fusionnée", + mergePrClosed: "Impossible de fusionner la PR : la pull request est fermée", + mergePrConflicts: "Impossible de fusionner la PR : la pull request a des conflits", mergePrQueue: - "MergePRn'est pas disponible ici car ce référentiel utilise une file d'attente de fusion", + "Impossible de fusionner la PR ici : ce dépôt utilise une file d’attente de fusion", mergePrNotReady: - "La fusion {{noun}} n'est pas disponible jusqu'à ce que {{brand}} signale que {{noun}} est prête à fusionner", + "Impossible de fusionner la {{noun}} tant que {{brand}} n’indique pas que la {{noun}} est prête à être fusionnée", autoMergeCannotDisable: "La fusion automatique est activée, mais ce compte ne peut pas la désactiver", }, toasts: { - failedCommit: "Échec de la validation", - failedPull: "Échec de l'extraction", - failedPush: "Échec de la poussée", - failedPullAndPush: "Impossible de tirer et de pousser", - failedCreatePr: "Échec de la création dePR", - failedMergePr: "Échec de la fusion dePR", - failedEnableAutoMerge: "Échec de l'activation de la fusion automatique", - failedDisableAutoMerge: "Échec de la désactivation de la fusion automatique", - baseRefUnavailable: "Réf de base indisponible", + failedCommit: "Impossible de commiter", + failedPull: "Échec du pull", + failedPush: "Échec du push", + failedPullAndPush: "Échec du pull et du push", + failedCreatePr: "Impossible de créer la PR", + failedMergePr: "Impossible de fusionner la PR", + failedEnableAutoMerge: "Impossible d’activer la fusion automatique", + failedDisableAutoMerge: "Impossible de désactiver la fusion automatique", + baseRefUnavailable: "Référence de base indisponible", failedMerge: "Échec de la fusion", - failedMergeFromBase: "Échec de la fusion à partir de la base", + failedMergeFromBase: "Échec de la fusion depuis la base", }, archiveWarning: { - title: "Archiver «{{workspaceName}}»?", - confirm: "Archive", + title: "Archiver « {{workspaceName}} » ?", + confirm: "Archiver", cancel: "Annuler", - uncommittedChanges: "Modifications non validées", - uncommittedChangesWithDiff: "Modifications non validées ({{diffStat}})", - addedLine: "Ligne ajoutée{{count}}", - addedLines: "{{count}}lignes ajoutées", - deletedLine: "Ligne supprimée{{count}}", - deletedLines: "{{count}}lignes supprimées", - unpushedCommit: "Validation non poussée{{count}}", - unpushedCommits: "Validations non poussées{{count}}", + uncommittedChanges: "Modifications non commitées", + uncommittedChangesWithDiff: "Modifications non commitées ({{diffStat}})", + addedLine: "{{count}} ligne ajoutée", + addedLines: "{{count}} lignes ajoutées", + deletedLine: "{{count}} ligne supprimée", + deletedLines: "{{count}} lignes supprimées", + unpushedCommit: "{{count}} commit non poussé", + unpushedCommits: "{{count}} commits non poussés", }, }, diff: { - openChangesTab: "Ouvrir l'onglet Modifications", - openDiffTab: "Ouvrir l'onglet Diff", - closeChangesTab: "Fermer l'onglet Modifications", + openChangesTab: "Ouvrir l’onglet Modifications", + openDiffTab: "Ouvrir l’onglet Diff", + closeChangesTab: "Fermer l’onglet Modifications", + jumpToFile: { + title: "Aller au fichier", + }, binaryFile: "Fichier binaire", - tooLarge: "Diff trop grand pour être affiché", + tooLarge: "Diff trop volumineux pour être affiché", previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé", - previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici", - unified: "Différentiel unifié", - split: "Différent côte à côte", + previewTooLargeDescription: "Réduisez la comparaison pour le prévisualiser ici", + unified: "Diff unifié", + split: "Diff côte à côte", switchToUnified: "Passer au diff unifié", switchToSplit: "Passer au diff côte à côte", options: "Options du diff", - inlineDiff: "Diff intégré", + inlineDiff: "Diff en ligne", hideWhitespace: "Masquer les espaces", showWhitespace: "Afficher les espaces", scrollLongLines: "Faire défiler les longues lignes", - wrapLongLines: "Enroulez les longues lignes", - collapseAllFolders: "Réduire tous les dossiers", - expandAllFolders: "Développer tous les dossiers", - collapseAllFiles: "Réduire tous les fichiers", - expandAllFiles: "Développer tous les fichiers", - refreshing: "Rafraîchissant", - refresh: "Rafraîchir", - refreshState: "Actualiser l'état de git et de {{brand}}", - failedRefresh: "Échec de l'actualisation de l'état git.", - emptyHiddenWhitespace: "Aucun changement visible après avoir masqué les espaces", - emptyUncommitted: "Aucune modification non validée", - seeUncommittedChanges: "Voir les modifications non validées", - seeCommittedChanges: "Voir les modifications validées", - emptyAgainstBase: "Aucun changement par rapport à{{baseRef}}", - checkingRepository: "Vérification du référentiel...", - notRepository: "Pas un dépôt git", - diffMode: "Mode différentiel", - uncommitted: "Non engagé", - committed: "Engagé", - branchUnknown: "Inconnu", + wrapLongLines: "Renvoyer les longues lignes à la ligne", + collapseAllFolders: "Replier tous les dossiers", + expandAllFolders: "Déplier tous les dossiers", + collapseAllFiles: "Replier tous les fichiers", + expandAllFiles: "Déplier tous les fichiers", + refreshing: "Actualisation…", + refresh: "Actualiser", + refreshState: "Actualiser l’état de git et de {{brand}}", + failedRefresh: "Impossible d’actualiser l’état git.", + seeUncommittedChanges: "Voir les modifications non commitées", + seeCommittedChanges: "Voir les modifications commitées", + checkingRepository: "Vérification du dépôt…", + notRepository: "Ce n’est pas un dépôt git", + diffMode: "Mode de diff", + uncommitted: "Non commité", + committed: "Commité", + branchUnknown: "Inconnue", base: "base", newFile: "Nouveau", deletedFile: "Supprimé", @@ -949,81 +981,128 @@ export const fr: TranslationResources = { commits: { title: "Commits", countLabel: "{{count}} commits de l’espace de travail", - noneAhead: "Aucun commit en avance sur {{baseRef}} pour le moment", + noneAhead: "Aucun commit en avance sur {{baseRef}} pour l’instant", fileDiffEmpty: "Aucune modification à afficher", - fileDiffError: "Échec du chargement du diff du fichier", + fileDiffError: "Impossible de charger le diff du fichier", loading: "Chargement des commits…", - loadError: "Échec du chargement des commits", - empty: "Aucun commit pour le moment", + loadError: "Impossible de charger les commits", + empty: "Aucun commit pour l’instant", }, }, openInEditor: { open: "Ouvrir", - chooseEditor: "Choisir l'éditeur", - openIn: "Espace de travail ouvert dans{{target}}", - openFileIn: "Open {{fileName}} in {{target}}", - failedOpen: "Échec de l'ouverture de l'espace de travail", + chooseEditor: "Choisir l’éditeur", + openIn: "Ouvrir l’espace de travail dans {{target}}", + openFileIn: "Ouvrir {{fileName}} dans {{target}}", + failedOpen: "Impossible d’ouvrir l’espace de travail", }, pr: { actions: { viewPullRequest: "Voir", openOn: "Ouvrir sur {{brand}}", + addToChat: "Ajouter à la conversation", + addAllToChat: "Tout ajouter à la conversation", + addingToChat: "Ajout…", + }, + checksOverview: { + headline: { + actionRequired: "Certaines vérifications demandent votre attention", + failure: "Certaines vérifications ont échoué", + pending: "Certaines vérifications ne sont pas terminées", + success: "Toutes les vérifications ont réussi", + none: "Aucune vérification", + }, + count: { + actionRequired: "{{count}} à traiter", + warning: "{{count}} avec avertissement", + failure: "{{count}} en échec", + pending: "{{count}} en cours", + manual: "{{count}} manuelle(s)", + success: "{{count}} réussie(s)", + ignored: "{{count}} ignorée(s)", + }, + detailOne: "Vérification : {{parts}}", + detailMany: "Vérifications : {{parts}}", + groupOne: { + actionRequired: "{{count}} vérification à traiter", + warning: "{{count}} vérification avec avertissement", + failure: "{{count}} vérification en échec", + pending: "{{count}} vérification en cours", + manual: "{{count}} vérification manuelle", + success: "{{count}} vérification réussie", + ignored: "{{count}} vérification ignorée", + }, + groupMany: { + actionRequired: "{{count}} vérifications à traiter", + warning: "{{count}} vérifications avec avertissement", + failure: "{{count}} vérifications en échec", + pending: "{{count}} vérifications en cours", + manual: "{{count}} vérifications manuelles", + success: "{{count}} vérifications réussies", + ignored: "{{count}} vérifications ignorées", + }, }, checksSummary: { - passedLabel: "succès", - failedLabel: "échec", + passedLabel: "réussies", + failedLabel: "en échec", runningLabel: "en cours", passedAccessible: "Vérifications réussies", failedAccessible: "Vérifications en échec", runningAccessible: "Vérifications en cours", }, sections: { - checks: "Chèques", + checks: "Vérifications", pipeline: "Pipeline", - reviews: "Avis", + reviews: "Revues", + activity: "Activité", }, empty: { - noJobs: "Aucune tâche", - loadingPipeline: "Chargement du pipeline...", - pipelineJobsLoadFailed: "Impossible de charger les tâches du pipeline", - allowedToFail: "autorisé à échouer", + noJobs: "Aucun job", + loadingPipeline: "Chargement du pipeline…", + pipelineJobsLoadFailed: "Impossible de charger les jobs du pipeline", + allowedToFail: "échec autorisé", + noActivity: "Aucune activité pour le moment", }, approvals: "{{given}} sur {{required}} approbations", accessibility: { - pullRequest: "Demande de tirage #{{number}}", - pullRequest_mr: "Demande de fusion !{{number}}", + pullRequest: "Pull request #{{number}}", + pullRequest_mr: "Merge request !{{number}}", + commentActions: "Actions du commentaire", + threadActions: "Actions du fil", checkStatus: { - passed: "Réussi", + passed: "Réussie", failed: "Échec", warning: "Avertissement", actionRequired: "Action requise", - manual: "Manuel", + manual: "Manuelle", pending: "En attente", - skipped: "Ignoré", - cancelled: "Annulé", + skipped: "Ignorée", + cancelled: "Annulée", }, }, states: { draft: "Brouillon", - merged: "Fusionné", - closed: "Fermé", - open: "Ouvrir", + merged: "Fusionnée", + closed: "Fermée", + open: "Ouverte", }, activity: { - commented: "Commenté", - approved: "Approuvé", - requestedChanges: "Modifications demandées", - reviewed: "Révisé", + commented: "A commenté", + approved: "A approuvé", + requestedChanges: "A demandé des modifications", + reviewed: "A relu", }, time: { - justNow: "tout à l' heure", + justNow: "à l’instant", }, thread: { discussion: "Fil de discussion", + resolved: "Résolu", + outdated: "Obsolète", }, errors: { - statusLoadFailed: "Impossible de charger le statut de la demande d'extraction", - activityLoadFailed: "Impossible de charger l'activité de demande d'extraction", + statusLoadFailed: "Impossible de charger le statut de la pull request", + activityLoadFailed: "Impossible de charger l’activité de la pull request", }, }, forgeSetup: { @@ -1052,7 +1131,7 @@ export const fr: TranslationResources = { orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -1071,16 +1150,35 @@ export const fr: TranslationResources = { color: "Couleur", save: "Enregistrer", delete: "Supprimer", - deleteTitle: "Supprimer {{name}} ?", + deleteTitle: "Supprimer {{name}} ?", deleteMessage_one: "Cela retire l’étiquette de {{count}} espace de travail sur cet hôte.", deleteMessage_other: "Cela retire l’étiquette de {{count}} espaces de travail sur cet hôte.", offline: "Cet hôte est hors ligne.", updateHost: "Mettez à jour cet hôte pour gérer les étiquettes.", }, }, + changelog: { + title: "Nouveautés", + installed: "Installée", + showMore: "Afficher plus", + openWebsite: "Journal des modifications complet", + error: { + title: "Impossible de charger le journal des modifications", + description: "Vérifiez votre connexion et réessayez.", + }, + }, sidebar: { + statusGroupAccessibility: "Groupe {{label}}", + statusBucket: { + needsInput: "Attend une réponse", + failed: "Échec", + readyToReview: "À relire", + working: "En cours", + done: "Terminé", + }, display: { - trigger: "Préférences d'affichage", + showBackground: "Afficher les espaces en arrière-plan", + trigger: "Préférences d’affichage", heading: "Affichage", grouping: { label: "Regroupement", @@ -1102,7 +1200,7 @@ export const fr: TranslationResources = { checks: "Vérifications", services: "Services", labels: "Libellés", - diff: "Statistiques de diff", + diff: "Statistiques du diff", timestamp: "Dernière activité", }, checks: { @@ -1130,8 +1228,8 @@ export const fr: TranslationResources = { }, host: { noHost: "Aucun hôte", - switchTitle: "Changer d'hôte", - searchPlaceholder: "Rechercher des hôtes...", + switchTitle: "Changer d’hôte", + searchPlaceholder: "Rechercher des hôtes…", }, actions: { addProject: "Ajouter un projet", @@ -1140,6 +1238,9 @@ export const fr: TranslationResources = { settings: "Paramètres", closeSidebar: "Fermer la barre latérale", }, + footer: { + usage: "Utilisation", + }, help: { trigger: "Aide et assistance", sectionHelp: "Aide", @@ -1153,93 +1254,93 @@ export const fr: TranslationResources = { }, sections: { sessions: "Historique", - search: "Rechercher", + search: "Recherche", schedules: "Planifications", }, worktreeSetup: { - title: "Configurer les scripts d'arbre de travail", + title: "Configurer les scripts de worktree", description: - "Ajoutez des commandes de configuration pour que les nouveaux arbres de travail puissent installer des dépendances et se préparer automatiquement.", + "Ajoutez des commandes de configuration pour que les nouveaux worktrees installent leurs dépendances et se préparent automatiquement.", openProjectSettings: "Ouvrir les paramètres du projet", }, project: { actions: { menu: "Actions du projet", openSettings: "Ouvrir les paramètres du projet", - openNewWindow: "Open in new window", - openNewWindowFailed: "Couldn't open a new window", - openFolder: "Open in file manager", - openFolderFailed: "Couldn't open folder", - remove: "Supprimer le projet", - removing: "Suppression...", + openNewWindow: "Ouvrir dans une nouvelle fenêtre", + openNewWindowFailed: "Impossible d’ouvrir une nouvelle fenêtre", + openFolder: "Ouvrir dans le gestionnaire de fichiers", + openFolderFailed: "Impossible d’ouvrir le dossier", + remove: "Retirer le projet", + removing: "Retrait…", }, confirmations: { - removeTitle: "Supprimer le projet?", + removeTitle: "Retirer le projet ?", removeMessage: - "Supprimer «{{projectName}}» de la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", + "Retirer « {{projectName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", removeConfirm: "Retirer", cancel: "Annuler", }, toasts: { - hostDisconnected: "Hostn'est pas connecté", - removeFailed: "Échec de la suppression de certains espaces de travail", - updateHostToRemove: "Mettez à jour le host pour supprimer des projets.", + hostDisconnected: "L’hôte n’est pas connecté", + removeFailed: "Impossible de retirer certains espaces de travail", + updateHostToRemove: "Mettez à jour l’hôte pour retirer des projets.", }, empty: { - title: "Aucun projet pour l'instant", + title: "Aucun projet pour l’instant", description: "Ajoutez un projet pour commencer", }, }, workspace: { status: { - serviceRunning: "Service {{name}} en cours", + serviceRunning: "Service {{name}} en cours d’exécution", serviceUnhealthy: "Service {{name}} en échec", - creating: "Création...", + creating: "Création…", }, checks: { - passed: "Réussis : {{count}}", - failed: "Échecs : {{count}}", - warning: "Avertissements : {{count}}", - actionRequired: "Action requise : {{count}}", - manual: "Manuels : {{count}}", - pending: "En attente : {{count}}", + passed: "Réussies : {{count}}", + failed: "Échouées : {{count}}", + warning: "Avertissements : {{count}}", + actionRequired: "Action requise : {{count}}", + manual: "Manuelles : {{count}}", + pending: "En attente : {{count}}", }, actions: { - menu: "ActionsWorkspace", + menu: "Actions de l’espace de travail", newWorkspace: "Nouvel espace de travail", showMore: "Afficher plus", showLess: "Afficher moins", - createWorkspaceFor: "Créer un nouvel espace de travail pour{{projectName}}", + createWorkspaceFor: "Créer un espace de travail pour {{projectName}}", copyPath: "Copier le chemin", copyBranchName: "Copier le nom de la branche", - rename: "Renommer l'espace de travail", + rename: "Renommer l’espace de travail", pin: "Épingler en haut", unpin: "Désépingler", - archive: "Archive", + archive: "Archiver", archiveWorkspace: "Archiver l’espace de travail", hideFromSidebar: "Masquer de la barre latérale", - archiving: "Archivage...", - hiding: "Dissimulation...", + archiving: "Archivage…", + hiding: "Masquage…", }, confirmations: { - hideTitle: "Masquer l'espace de travail?", + hideTitle: "Masquer l’espace de travail ?", hideMessage: - "Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.", - hideConfirm: "Cacher", + "Masquer « {{workspaceName}} » de la barre latérale ?\n\nLes fichiers sur le disque ne seront pas modifiés.", + hideConfirm: "Masquer", cancel: "Annuler", }, rename: { - title: "Renommer l'espace de travail", - submit: "Rebaptiser", - invalidBranchName: "Nom de succursale invalide", + title: "Renommer l’espace de travail", + submit: "Renommer", + invalidBranchName: "Nom de branche invalide", }, toasts: { - workspacePathUnavailable: "CheminWorkspacenon disponible", + workspacePathUnavailable: "Chemin de l’espace de travail indisponible", pathCopied: "Chemin copié", - branchNameCopied: "Nom de la succursale copié", - hostDisconnected: "Hostn'est pas connecté", - hideFailed: "Échec du masquage de l'espace de travail", - archiveFailed: "Échec de l'archivage de l'espace de travail", + branchNameCopied: "Nom de branche copié", + hostDisconnected: "L’hôte n’est pas connecté", + hideFailed: "Impossible de masquer l’espace de travail", + archiveFailed: "Impossible d’archiver l’espace de travail", }, }, }, @@ -1258,34 +1359,34 @@ export const fr: TranslationResources = { }, titlePlaceholder: "Titre (facultatif)", errors: { - hostDisconnected: "Hostn'est pas connecté", - createWorktreeFailed: "Échec de la création de l'arbre de travail", - composerStateRequired: "L'état du compositeur est requis", + hostDisconnected: "L’hôte n’est pas connecté", + createWorktreeFailed: "Impossible de créer le worktree", + composerStateRequired: "L’état de la zone de saisie est requis", selectModel: "Sélectionnez un modèle", }, tooltips: { - project: "Choose the project", - host: "Choose the host", - isolation: "Choose the isolation level", - startingRef: "Choisissez par où commencer", - launch: "Choose what to launch", + project: "Choisir le projet", + host: "Choisir l’hôte", + isolation: "Choisir le niveau d’isolation", + startingRef: "Choisir le point de départ", + launch: "Choisir quoi lancer", }, refPicker: { - startingRef: "Réf de départ", - intoBase: "dans {{baseRef}}", - searching: "Recherche...", + startingRef: "Réf. de départ", + intoBase: "vers {{baseRef}}", + searching: "Recherche…", noMatchingRefs: "Aucune référence correspondante.", - searchPlaceholder: "Rechercher des succursales et des PR", - title: "Commencer à partir de", + searchPlaceholder: "Rechercher des branches et des PR", + title: "Partir de", }, launch: { - title: "What to launch", + title: "Que lancer ?", chat: "Chat", terminal: "Terminal", - manageProfiles: "Manage profiles", - submit: "Launch", - promptPlaceholder: "Prompt {{name}}", - commandPlaceholder: "Run a command, or leave empty for a blank terminal", + manageProfiles: "Gérer les profils", + submit: "Lancer", + promptPlaceholder: "Prompt pour {{name}}", + commandPlaceholder: "Saisissez une commande, ou laissez vide pour un terminal vierge", }, }, desktop: { @@ -1296,180 +1397,180 @@ export const fr: TranslationResources = { close: "Fermer la fenêtre", }, quitting: { - title: "QuitterPaseo...", - detail: "Arrêt du démon local.", + title: "Fermeture de Paseo…", + detail: "Arrêt du daemon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Statut", - builtInOnly: "Seul le démon de bureau intégré est affiché ici", - running: "en cours d'exécution", - notRunning: "ne fonctionne pas", - pid: "PID{{pid}}", + builtInOnly: "Seul le daemon intégré à l’application de bureau est affiché ici", + running: "en cours d’exécution", + notRunning: "arrêté", + pid: "PID {{pid}}", }, management: { - title: "Gérer le démon intégré", - hint: "LaissezPaseodémarrer et arrêter le démon intégré", - pauseTitle: "Suspendre le démon intégré", + title: "Gérer le daemon intégré", + hint: "Laisser Paseo démarrer et arrêter le daemon intégré", + pauseTitle: "Suspendre le daemon intégré", pauseMessage: - "Cela arrêtera immédiatement le démon intégré. Les agents en cours d'exécution et les terminaux connectés au démon intégré seront arrêtés.", - pauseAndStop: "Pause et arrêt", + "Le daemon intégré sera arrêté immédiatement. Les agents en cours et les terminaux connectés au daemon intégré seront arrêtés.", + pauseAndStop: "Suspendre et arrêter", registrationFailed: - "Built-in daemon started, but Paseo could not save the localhost connection. Toggle daemon management off and on again, or add localhost manually.", + "Le daemon intégré a démarré, mais Paseo n’a pas pu enregistrer la connexion localhost. Désactivez puis réactivez la gestion du daemon, ou ajoutez localhost manuellement.", pausedStopFailed: - "La gestion du démon intégré a été suspendue, maisPaseon'a ​​pas pu arrêter le démon.", - updateFailed: "Impossible de mettre à jour la gestion des démons intégrés.", + "La gestion du daemon intégré a été suspendue, mais Paseo n’a pas pu arrêter le daemon.", + updateFailed: "Impossible de mettre à jour la gestion du daemon intégré.", }, keepRunning: { - title: "Laisser le démon fonctionner après avoir quitté", - hint: "Daemoncontinue de fonctionner lorsque vous quittezPaseo", + title: "Laisser le daemon tourner après la fermeture", + hint: "Le daemon continue de tourner quand vous quittez Paseo", }, logs: { title: "Fichier journal", - modalTitle: "JournauxDaemon", + modalTitle: "Journaux du daemon", unavailable: "Chemin du journal indisponible", empty: "(le fichier journal est vide)", copied: "Chemin du journal copié.", copyFailed: "Impossible de copier le chemin du journal.", - open: "Journaux ouverts", + open: "Ouvrir les journaux", copyPath: "Copier le chemin", }, fullStatus: { title: "Statut complet", - modalTitle: "StatutDaemon", - hint: "Exécute`paseo daemon status`et affiche la sortie", - view: "Afficher l'état", + modalTitle: "Statut du daemon", + hint: "Exécute `paseo daemon status` et affiche la sortie", + view: "Afficher le statut", copied: "Statut copié dans le presse-papiers.", - fetchFailed: "Échec de la récupération de l'état du démon:{{message}}", + fetchFailed: "Impossible de récupérer le statut du daemon : {{message}}", }, advancedSettings: "Paramètres avancés", - openAdvancedSettings: "Ouvrir les paramètres avancés du démon", + openAdvancedSettings: "Ouvrir les paramètres avancés du daemon", versionMismatch: - "Les versions de l'application et du démon ne correspondent pas. Mettez à jour les deux vers la même version pour une meilleure expérience.", - loadFailed: "Impossible de charger l'état du démon de bureau.", + "Les versions de l’application et du daemon ne correspondent pas. Mettez-les à jour vers la même version pour une meilleure expérience.", + loadFailed: "Impossible de charger le statut du daemon de l’application de bureau.", }, updates: { status: { - checking: "Vérification des mises à jour de l'application...", - installing: "Installation de la mise à jour de l'application...", - upToDate: "L'application est à jour.", - upToDateWithLastChecked: "Up to date. Last checked at {{time}}.", - pending: "Nous vous informerons lorsque la mise à jour sera prête.", + checking: "Recherche de mises à jour de l’application…", + installing: "Installation de la mise à jour de l’application…", + upToDate: "L’application est à jour.", + upToDateWithLastChecked: "À jour. Dernière vérification à {{time}}.", + pending: "Nous vous préviendrons quand la mise à jour sera prête.", pendingWithLastChecked: - "Nous vous informerons lorsque la mise à jour sera prête. Dernière vérification à {{time}}.", - pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement...", + "Nous vous préviendrons quand la mise à jour sera prête. Dernière vérification à {{time}}.", + pendingWithVersion: "Mise à jour trouvée : {{version}}. Téléchargement…", pendingWithVersionAndLastChecked: - "Mise à jour trouvée : {{version}}. Téléchargement... Dernière vérification à {{time}}.", - availableWithVersion: "Mise à jour prête:{{version}}", + "Mise à jour trouvée : {{version}}. Téléchargement… Dernière vérification à {{time}}.", + availableWithVersion: "Mise à jour prête : {{version}}", availableWithVersionAndLastChecked: - "Mise à jour prête:{{version}}. Dernière vérification à {{time}}.", - available: "Une mise à jour de l'application est prête à être installée.", + "Mise à jour prête : {{version}}. Dernière vérification à {{time}}.", + available: "Une mise à jour de l’application est prête à être installée.", availableWithLastChecked: - "Une mise à jour de l'application est prête à être installée. Dernière vérification à {{time}}.", - installed: "Mise à jour de l'application installée. Redémarrage requis.", - failed: "Échec de la mise à jour de l'application.", - idle: "L'état de la mise à jour n'a pas encore été vérifié.", + "Une mise à jour de l’application est prête à être installée. Dernière vérification à {{time}}.", + installed: "Mise à jour de l’application installée. Redémarrage requis.", + failed: "Échec de la mise à jour de l’application.", + idle: "Les mises à jour n’ont pas encore été vérifiées.", }, - installError: "Impossible d'installer la mise à jour de l'application de bureau.", + installError: "Impossible d’installer la mise à jour de l’application de bureau.", callout: { installingTitle: "Installation de la mise à jour", - failedTitle: "La mise à jour a échoué", + failedTitle: "Échec de la mise à jour", availableTitle: "Mise à jour disponible", - genericError: "Quelque chose s'est mal passé.", - whatsNew: "Quoi de neuf", - installingAction: "Installation...", + genericError: "Une erreur s’est produite.", + whatsNew: "Nouveautés", + installingAction: "Installation…", installAndRestart: "Installer et redémarrer", - installingDescription: "Installation et redémarrage...", - versionReady: "{{version}}est prêt à être installé.", + installingDescription: "Installation et redémarrage…", + versionReady: "La version {{version}} est prête à être installée.", newVersionReady: "Une nouvelle version est prête à être installée.", restartWarning: - "La mise à niveau de l'application arrêtera l'exécution des agents et fermera les sessions de terminal.", + "La mise à jour de l’application arrêtera les agents en cours et fermera les sessions de terminal.", }, }, settings: { - loadFailed: "Impossible de charger les paramètres du bureau.", - saveFailed: "Impossible d'enregistrer les paramètres du bureau.", + loadFailed: "Impossible de charger les paramètres de l’application de bureau.", + saveFailed: "Impossible d’enregistrer les paramètres de l’application de bureau.", }, rosetta: { - title: "Téléchargez la versionApple Silicon", - runningIntel: "Vous exécutez la versionInteldePaseosousRosettasurApple Silicon.", + title: "Téléchargez la version Apple Silicon", + runningIntel: "Vous utilisez la version Intel de Paseo via Rosetta sur Apple Silicon.", highCpu: - "Cela entraîne une utilisation élevée du processeur. Téléchargez la versionApple Siliconpour le réparer.", + "Cela entraîne une forte utilisation du processeur. Téléchargez la version Apple Silicon pour corriger le problème.", download: "Télécharger", }, permissions: { notifications: { - allowed: "Les notifications sont autorisées par le système d'exploitation.", + allowed: "Les notifications sont autorisées par le système.", denied: "Les notifications sont refusées dans les paramètres système.", - notGranted: "Les notifications n'ont pas encore été accordées.", + notGranted: "Les notifications n’ont pas encore été autorisées.", webOnly: - "L’état des notifications sur le bureau est uniquement disponible sur l’exécution Web.", + "L’état des notifications de bureau n’est disponible que dans l’environnement web.", supported: "Les notifications de bureau sont prises en charge.", unsupported: "Les notifications de bureau ne sont pas prises en charge sur cette plateforme.", - apiUnavailable: "L'API de notification Web n'est pas disponible dans cet environnement.", + apiUnavailable: "L’API Web Notification n’est pas disponible dans cet environnement.", requestsWebOnly: - "Les demandes de notification sur le bureau sont uniquement disponibles sur l'exécution Web.", - requestUnavailable: "L'API de notification Web requestPermission() n'est pas disponible.", - requestFailed: "Échec de la demande d'autorisation de notification:{{message}}", - unexpectedState: "État d'autorisation de notification inattendu:{{state}}", + "Les demandes de notification de bureau ne sont disponibles que dans l’environnement web.", + requestUnavailable: + "La méthode requestPermission() de l’API Web Notification n’est pas disponible.", + requestFailed: "Échec de la demande d’autorisation de notification : {{message}}", + unexpectedState: "État d’autorisation de notification inattendu : {{state}}", }, microphone: { - webOnly: - "L’état du microphone de bureau est uniquement disponible dans l’environnement d’exécution Web.", - navigatorUnavailable: "Le navigateur n'est pas disponible dans cet environnement.", - granted: "L’accès au microphone est accordé.", - denied: "L'accès au microphone est refusé dans les paramètres système.", - notGranted: "L'autorisation du microphone n'a pas encore été accordée.", - unexpectedState: "État d'autorisation inattendu du microphone:{{state}}", + webOnly: "L’état du microphone n’est disponible que dans l’environnement web.", + navigatorUnavailable: "L’objet navigator n’est pas disponible dans cet environnement.", + granted: "L’accès au microphone est autorisé.", + denied: "L’accès au microphone est refusé dans les paramètres système.", + notGranted: "L’accès au microphone n’a pas encore été autorisé.", + unexpectedState: "État d’autorisation du microphone inattendu : {{state}}", statusApiUnavailable: - "L’API d’état du microphone n’est pas disponible dans ce runtime. Utilisez Request pour vérifier l’accès.", - queryFailed: "Échec de l'interrogation de l'état du microphone:{{message}}", - captureUnavailable: "La capture du microphone n'est pas disponible dans cet environnement.", + "L’API d’état du microphone n’est pas disponible dans cet environnement. Utilisez « Demander » pour vérifier l’accès.", + queryFailed: "Impossible de consulter l’état du microphone : {{message}}", + captureUnavailable: "La capture du microphone n’est pas disponible dans cet environnement.", permissionApiUnavailable: - "L'API d'état des autorisations n'est pas disponible. Utilisez Request pour vérifier l’accès.", + "L’API d’état des autorisations n’est pas disponible. Utilisez « Demander » pour vérifier l’accès.", requestsWebOnly: - "Les demandes de microphone de bureau sont uniquement disponibles sur le runtime Web.", + "Les demandes d’accès au microphone ne sont disponibles que dans l’environnement web.", captureApiUnavailable: - "L'API de capture de microphone n'est pas disponible dans cet environnement.", - requestDenied: - "L'autorisation du microphone a été refusée par l'utilisateur ou le système.", - noDevice: "Aucun microphone n'a été trouvé.", - requestFailed: "Échec de la demande d'autorisation du microphone:{{message}}", + "L’API de capture du microphone n’est pas disponible dans cet environnement.", + requestDenied: "L’accès au microphone a été refusé par l’utilisateur ou le système.", + noDevice: "Aucun microphone trouvé.", + requestFailed: "Échec de la demande d’accès au microphone : {{message}}", }, empty: { - notifications: "L'état de la notification n'a pas encore été vérifié.", - microphone: "L'état du microphone n'a pas encore été vérifié.", + notifications: "L’état des notifications n’a pas encore été vérifié.", + microphone: "L’état du microphone n’a pas encore été vérifié.", }, testNotification: { - title: "Test de notificationPaseo", - body: "Si vous pouvez voir cela, les notifications sur le bureau fonctionnent.", + title: "Test de notification Paseo", + body: "Si vous voyez ceci, les notifications de bureau fonctionnent.", notDelivered: - "La notification n'a pas été délivrée. Vérifiez Paramètres système > Notifications.", - failed: "Échec de l'envoi de la notification.", + "La notification n’a pas été délivrée. Vérifiez Réglages système > Notifications.", + failed: "Échec de l’envoi de la notification.", }, }, integrations: { cli: { - statusFailed: "Impossible de vérifier l'état de l'installation deCLI.", - installFailed: "Impossible d'installer lePaseoCLI.", + statusFailed: "Impossible de vérifier l’installation de la CLI.", + installFailed: "Impossible d’installer la CLI Paseo.", }, }, }, rootError: { title: "Paseo a rencontré un problème.", - body: "Réessayez pour recharger l'application. Si cela continue, joignez les détails ci-dessous au signalement.", + body: "Réessayez pour recharger l’application. Si le problème persiste, joignez les détails ci-dessous à votre signalement.", details: "Détails", }, startup: { - errorTitle: "Quelque chose s'est mal passé", + errorTitle: "Une erreur s’est produite", errorDescription: - "Le serveur local n'a pas pu démarrer. Si cela continue, veuillez signaler le problème surGitHubet inclure les journaux ci-dessous.", + "Le serveur local n’a pas pu démarrer. Si le problème persiste, signalez-le sur GitHub en joignant les journaux ci-dessous.", logs: { - loading: "Chargement des journaux du démon...", - unavailable: "Aucun journal de démon disponible.", - loadFailed: "Impossible de charger les journaux du démon:{{message}}", + loading: "Chargement des journaux du daemon…", + unavailable: "Aucun journal du daemon disponible.", + loadFailed: "Impossible de charger les journaux du daemon : {{message}}", }, }, openProject: { @@ -1479,24 +1580,24 @@ export const fr: TranslationResources = { description: "Ouvrez un dossier sur votre machine", }, importSession: { - title: "Session d'importation", - description: "Apportez des sessionsCLIexternes récentes", + title: "Importer une session", + description: "Ouvrez une session Claude Code, Codex ou autre lancée dans un terminal", }, setupProviders: { - title: "Fournisseurs d'installation", - description: "ConfigurezClaude Code,Codexet plus", + title: "Configurer les fournisseurs", + description: "Configurez Claude Code, Codex et plus encore", }, pairDevice: { title: "Associer un appareil", - description: "Connectez votre téléphone à ce démon", + description: "Connectez votre téléphone à ce daemon", }, }, }, projectPicker: { - placeholder: "Tapez pour rechercher...", + placeholder: "Tapez pour rechercher…", browse: "Parcourir…", - opening: "Projet d'ouverture...", - searching: "Recherche en cours...", + opening: "Ouverture du projet…", + searching: "Recherche…", empty: "Commencez à taper un chemin", errors: { directory_not_found: "Répertoire introuvable.", @@ -1506,74 +1607,77 @@ export const fr: TranslationResources = { }, branchSwitcher: { triggerTooltip: "Changer la branche de l’espace de travail", - currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.", - placeholder: "Changer de branche...", - searchPlaceholder: "Filtrer les branches...", + currentBranch: "Branche actuelle : {{branchName}}. Appuyez pour changer de branche.", + placeholder: "Changer de branche…", + searchPlaceholder: "Filtrer les branches…", empty: "Aucune branche trouvée.", title: "Changer de branche", - uncommittedTitle: "Modifications non validées", + uncommittedTitle: "Modifications non commitées", uncommittedMessage: - "Vous avez des modifications non validées. Les ranger avant de changer de branche?", - stashAndSwitch: "Cachette et changement", - failedToStash: "Échec de la sauvegarde des modifications", - failedToSwitch: "Échec du changement de branche", - restoreStashTitle: "Restaurer les modifications cachées?", + "Vous avez des modifications non commitées. Les remiser avant de changer de branche ?", + stashAndSwitch: "Remiser et changer", + failedToStash: "Impossible de remiser les modifications", + failedToSwitch: "Impossible de changer de branche", + restoreStashTitle: "Restaurer les modifications remisées ?", restoreStashMessage: - "Cette branche a caché les modifications d'une session précédente. Souhaitez-vous les restaurer?", + "Cette branche contient des modifications remisées lors d’une session précédente. Voulez-vous les restaurer ?", restore: "Restaurer", later: "Plus tard", - stashRestored: "Modifications cachées restaurées", + stashRestored: "Modifications remisées restaurées", }, agentAutocomplete: { - searchingWorkspace: "Recherche dans l'espace de travail...", - loadingCommands: "Chargement des commandes...", + searchingWorkspace: "Recherche dans l’espace de travail…", + loadingCommands: "Chargement des commandes…", noFiles: "Aucun fichier ou répertoire trouvé", noCommands: "Aucune commande trouvée", failedToLoad: "Échec du chargement", + chooseProjectForCommands: "Choisissez un projet pour afficher les commandes", + chooseModelForCommands: "Sélectionnez un modèle pour afficher les commandes", }, loadOlderHistory: { - failed: "Impossible de charger l'ancien historique", + failed: "Impossible de charger l’historique plus ancien", }, imageAttachmentPicker: { permissionTitle: "Autorisation requise", - permissionMessage: "Veuillez autoriser l'accès à votre photothèque pour joindre des images.", + permissionMessage: "Autorisez l’accès à votre photothèque pour joindre des images.", errorTitle: "Erreur", - failedToSelect: "Échec de la sélection de l'image", + failedToSelect: "Impossible de sélectionner l’image", dialogTitle: "Joindre des images", dialogFilterName: "Images", }, workspaceSetup: { title: "Créer un espace de travail", errors: { - failedCreateWorktree: "Échec de la création de l'arbre de travail", - failedOpenProject: "Échec de l'ouverture du projet", + failedCreateWorktree: "Impossible de créer le worktree", + failedOpenProject: "Impossible d’ouvrir le projet", selectModel: "Sélectionnez un modèle", - hostDisconnected: "Hostn'est pas connecté", - pendingRequired: "Aucune configuration d'espace de travail n'est en attente", - composerStateRequired: "L'état du compositeur de configurationWorkspaceest requis", + hostDisconnected: "L’hôte n’est pas connecté", + pendingRequired: "Aucune configuration d’espace de travail en attente", + composerStateRequired: + "L’état de la zone de saisie de configuration de l’espace de travail est requis", }, }, onboarding: { - title: "Bienvenue surPaseo", + title: "Bienvenue dans Paseo", subtitle: "Connectez votre ordinateur pour commencer", actions: { settings: "Paramètres", }, }, modelSelector: { - title: "Sélectionnez le fournisseur", - selectModel: "Sélectionnez le modèle", - selectedModel: "Sélectionnez le modèle ({{model}})", - loading: "Chargement...", + title: "Sélectionner le fournisseur", + selectModel: "Sélectionner le modèle", + selectedModel: "Sélectionner le modèle ({{model}})", + loading: "Chargement…", loadingShort: "Chargement", - loadingSelector: "Chargement du sélecteur de modèle...", + loadingSelector: "Chargement du sélecteur de modèle…", error: "Erreur", - defaultModel: "Défaut", + defaultModel: "Par défaut", profiles: "Profils", providers: "Fournisseurs", model: "Modèle", editProfiles: "Modifier", - editProfilesLabel: "Modifier les profils d'agent", + editProfilesLabel: "Modifier les profils d’agent", createProfile: "Créer un profil", createProfileFromModel: "Créer un profil à partir de ce modèle", editProfileLabel: "Modifier {{name}}", @@ -1581,46 +1685,61 @@ export const fr: TranslationResources = { modelCount: "{{count}} modèle", modelCountPlural: "{{count}} modèles", retry: "Réessayer", - retrying: "Nouvelle tentative...", + retrying: "Nouvelle tentative…", noMatches: "Aucun modèle ne correspond à votre recherche", - noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", - searchAllPlaceholder: "Rechercher dans tous les modèles...", - searchPlaceholder: "Rechercher des modèles...", - openProviderSettings: "Ouvrir les paramètres{{provider}}", + noMatchesForQuery: "Aucun modèle ne correspond à « {{query}} »", + searchAllPlaceholder: "Rechercher dans tous les modèles…", + searchPlaceholder: "Rechercher des modèles…", + openProviderSettings: "Ouvrir les paramètres de {{provider}}", }, providerCatalog: { title: "Ajouter un fournisseur", - search: "Fournisseurs de recherche", + search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", actions: { add: "Ajouter", - adding: "Ajout", + adding: "Ajout…", installed: "Installé", cancel: "Annuler", - installInstructions: "Instructions d'installation", - installInstructionsFor: "Instructions d'installation{{provider}}", + installInstructions: "Instructions d’installation", + installInstructionsFor: "Instructions d’installation de {{provider}}", }, errors: { - unableToInstall: "Impossible d'installer le fournisseur", + unableToInstall: "Impossible d’installer le fournisseur", }, }, providerSelection: { - defaultModel: "Défaut", - selectModel: "Sélectionnez le modèle", - loading: "Chargement...", + defaultModel: "Par défaut", + selectModel: "Sélectionner le modèle", + loading: "Chargement…", error: "Erreur", unavailable: "Indisponible", unknownError: "Erreur inconnue", readiness: { - initialPromptRequired: "Une invite initiale est requise", - noProviders: "Aucun fournisseur disponible sur l'hébergeur sélectionné", - modelDefaultsLoading: "Les valeurs par défaut du modèle sont toujours en cours de chargement", - noModelAvailable: "Aucun modèle n'est disponible pour le fournisseur sélectionné", - workspaceDirectoryNotFound: "RépertoireWorkspaceintrouvable", - hostDisconnected: "Hostn'est pas connecté", + initialPromptRequired: "Un prompt initial est requis", + noProviders: "Aucun fournisseur disponible sur l’hôte sélectionné", + modelDefaultsLoading: "Les valeurs par défaut du modèle sont en cours de chargement", + noModelAvailable: "Aucun modèle disponible pour le fournisseur sélectionné", + workspaceDirectoryNotFound: "Répertoire de l’espace de travail introuvable", + hostDisconnected: "L’hôte n’est pas connecté", }, }, pairing: { + hostPassword: { + title: "Mot de passe pour {{host}}", + label: "Mot de passe de l’hôte", + }, + hostConfirmation: { + title: "Se connecter à cet hôte ?", + description: + "Cet hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", + descriptionChanged: + "Ce lien modifie la façon dont vous vous connectez à cet hôte. L’hôte pourra exécuter du code dans cette app et accéder à vos autres hôtes connectés. Ne vous connectez que si vous le reconnaissez.", + hostLabel: "Hôte", + fingerprintLabel: "Empreinte de la clé", + relayLabel: "Relais", + connect: "Se connecter", + }, connectionMethods: { title: "Ajouter une connexion", direct: { @@ -1629,22 +1748,22 @@ export const fr: TranslationResources = { }, remoteSsh: { title: "SSH distant", - description: "Connectez-vous avec le client SSH de bureau.", + description: "Connexion via le client SSH de bureau.", }, scanQr: { - title: "Scanner le codeQR", - description: "Connexion relais cryptée.", + title: "Scanner le code QR", + description: "Connexion chiffrée via le relais.", }, pasteLink: { - title: "Coller le lien d'association", - description: "Connexion relais cryptée.", + title: "Coller le lien d’association", + description: "Connexion chiffrée via le relais.", }, }, direct: { title: "Connexion directe", - helper: "Saisissez l'adresse d'un serveurPaseo.", + helper: "Saisissez l’adresse d’un serveur Paseo.", fields: { - host: "Host", + host: "Hôte", port: "Port", password: "Mot de passe", optional: "Facultatif", @@ -1652,9 +1771,9 @@ export const fr: TranslationResources = { connectionUri: "URI de connexion", }, advanced: { - label: "Avancé", - show: "Afficher avancé", - hide: "Masquer avancé", + label: "Options avancées", + show: "Afficher les options avancées", + hide: "Masquer les options avancées", }, passwordVisibility: { show: "Afficher le mot de passe", @@ -1662,101 +1781,107 @@ export const fr: TranslationResources = { }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "De liaison...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { - hostRequired: "Hostest requis", + hostRequired: "L’hôte est requis", invalidPort: "Le port doit être compris entre 1 et 65535", invalidConnection: "Connexion invalide", - failedTitle: "La connexion a échoué", - failedToConnect: "Nous n'avons pas réussi à nous connecter à{{endpoint}}.", - noAdditionalDetails: "{{detail}}(aucun détail supplémentaire fourni)", - timedOut: "La connexion a expiré. Vérifiez l'hôte/portet votre réseau.", - refused: "Connexion rejetée. Le serveur fonctionne-t-il à cette adresse?", - hostNotFound: "Hostintrouvable. Vérifiez le nom d'hôte et réessayez.", - hostUnreachable: "Hostest inaccessible. Vérifiez votre réseau et votre pare-feu.", + failedTitle: "Échec de la connexion", + failedToConnect: "Impossible de se connecter à {{endpoint}}.", + noAdditionalDetails: "{{detail}} (aucun détail supplémentaire)", + timedOut: "Délai de connexion dépassé. Vérifiez l’hôte, le port et votre réseau.", + refused: "Connexion refusée. Le serveur est-il bien lancé à cette adresse ?", + hostNotFound: "Hôte introuvable. Vérifiez le nom d’hôte et réessayez.", + hostUnreachable: "Hôte inaccessible. Vérifiez votre réseau et votre pare-feu.", tlsError: - "Erreur TLS. Les connexions directes utilisent SSL uniquement lorsqu'un terminateur TLS se trouve devant le démon.", + "Erreur TLS. Les connexions directes n’utilisent SSL que si un terminateur TLS est placé devant le daemon.", unableToConnect: - "Impossible de se connecter. Vérifiez l'hôte/portet que le démon est accessible.", - details: "Détails:{{detail}}", + "Impossible de se connecter. Vérifiez l’hôte, le port et que le daemon est joignable.", + details: "Détails : {{detail}}", }, }, remoteSsh: { title: "SSH distant", - helper: "Connectez-vous à un daemon Paseo sur l’hôte distant.", + helper: "Connectez-vous à un daemon Paseo qui tourne sur l’hôte distant.", fields: { target: "Hôte SSH", + password: "Mot de passe du daemon", + optional: "Facultatif", + }, + passwordVisibility: { + show: "Afficher le mot de passe", + hide: "Masquer le mot de passe", }, actions: { cancel: "Annuler", - connect: "Connecter", - connecting: "Connexion...", + connect: "Se connecter", + connecting: "Connexion…", }, errors: { targetRequired: "L’hôte SSH est requis", invalidTarget: "Saisissez un hôte ssh:// valide", - failedToConnect: "Connexion SSH impossible. {{detail}}", + failedToConnect: "Impossible de se connecter en SSH. {{detail}}", }, }, link: { - title: "Coller le lien d'association", - helper: "Collez le lien d'appairage depuis votre serveur.", - label: "Lien d'appariement", + title: "Coller le lien d’association", + helper: "Collez le lien d’association fourni par votre serveur.", + label: "Lien d’association", errors: { - required: "Collez un lien d'appairage (.../#offer=...)", - missingOffer: "Le lien doit inclure#offer=...", - emptyOffer: "La charge utile de l'offre est vide", - invalid: "Lien d'association invalide", - unableToPair: "Impossible de coupler l'hôte", + required: "Collez un lien d’association (.../#offer=...)", + missingOffer: "Le lien doit contenir #offer=...", + emptyOffer: "Le contenu de l’offre est vide", + invalid: "Lien d’association invalide", + unableToPair: "Impossible d’associer l’hôte", }, alert: { - failedTitle: "Échec du couplage", + failedTitle: "Échec de l’association", }, actions: { cancel: "Annuler", - pair: "Paire", - pairing: "L'appariement...", + pair: "Associer", + pairing: "Association…", }, }, scan: { - title: "ScannerQR", - webUnavailableTitle: "Non disponible sur le Web", + title: "Scanner le code QR", + webUnavailableTitle: "Indisponible sur le web", webUnavailableBody: - "L'analyseQRn'est pas prise en charge dans la version Web. Utilisez plutôt \"Coller le lien\".", + "Le scan de code QR n’est pas pris en charge dans la version web. Utilisez plutôt « Coller le lien ».", backToSettings: "Retour aux paramètres", cameraPermissionTitle: "Autorisation de la caméra", cameraPermissionBody: - "Autorisez l'accès à la caméra pour scanner le code d'appairageQRà partir de votre démon.", - grantPermission: "Accorder l'autorisation", - pairing: "L'appariement...", - unableToPair: "Impossible de coupler l'hôte", + "Autorisez l’accès à la caméra pour scanner le code QR d’association affiché par votre daemon.", + grantPermission: "Accorder l’autorisation", + pairing: "Association…", + unableToPair: "Impossible d’associer l’hôte", errorTitle: "Erreur", }, device: { - loadingOffer: "Chargement de l'offre d'association...", - failedToLoadOffer: "Échec du chargement de l'offre d'association.", - relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.", - enableTitle: "Activer le relais ?", + loadingOffer: "Chargement de l’offre d’association…", + failedToLoadOffer: "Impossible de charger l’offre d’association.", + relayDisabled: "Le relais n’est pas activé. Activez-le pour associer un appareil.", + enableTitle: "Activer le relais ?", enableDescription: - "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.", + "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic d’association est chiffré de bout en bout.", relayDocs: "Fonctionnement du relais", - relayDocsAccessibility: "Lire comment fonctionne le relais Paseo", + relayDocsAccessibility: "Découvrir le fonctionnement du relais Paseo", enableRelay: "Activer le relais", - enablingRelay: "Activation...", + enablingRelay: "Activation…", notNow: "Pas maintenant", directConnectionHint: "Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.", updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", - unavailable: "Offre de jumelage indisponible.", - hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", + unavailable: "Offre d’association indisponible.", + hint: "Scannez ce code QR avec Paseo sur votre téléphone, ou copiez le lien ci-dessous.", securityWarning: "Traitez ce lien d’association comme un mot de passe. Toute personne qui le possède peut accéder à ce daemon.", - qrUnavailable: "CodeQRindisponible.", - qrAccessibility: "Code QR de couplage", + qrUnavailable: "Code QR indisponible.", + qrAccessibility: "Code QR d’association", retry: "Réessayer", - copy: "Copie", + copy: "Copier", copied: "Copié", }, }, @@ -1764,41 +1889,41 @@ export const fr: TranslationResources = { actions: { mute: "Couper la voix en temps réel", unmute: "Réactiver la voix en temps réel", - stop: "Arrêtez la voix en temps réel et interrompez le tour", + stop: "Arrêter la voix en temps réel et interrompre le tour", }, }, rewind: { - tooltip: "Revenez à ce message", - warning: "Cette action ne peut pas être annulée", + tooltip: "Revenir à ce message", + warning: "Cette action est irréversible", actions: { - conversation: "Rembobiner la conversation", - files: "Rembobiner les fichiers", - both: "Rembobiner la conversation et les fichiers", + conversation: "Revenir en arrière sur la conversation", + files: "Revenir en arrière sur les fichiers", + both: "Revenir en arrière sur la conversation et les fichiers", }, errors: { - failed: "Échec du rembobinage de l'agent", + failed: "Impossible de faire revenir l’agent en arrière", }, }, diffViewer: { - empty: "Aucun changement à afficher", + empty: "Aucune modification à afficher", }, serviceUrl: { - title: "Service ouvertURL", - message: "Ouvrir{{url}}?", - inPaseo: "DansPaseo", + title: "Ouvrir l’URL du service", + message: "Ouvrir {{url}} ?", + inPaseo: "Dans Paseo", externalBrowser: "Navigateur externe", - dontAskAgain: "Ne demande plus", + dontAskAgain: "Ne plus demander", }, downloads: { - requestTokenFailed: "Échec de la demande du jeton de téléchargement.", - hostUnavailable: "L'hôte de téléchargement n'est pas disponible.", - cancelled: "Le téléchargement a été annulé.", - failed: "Échec du téléchargement du fichier.", - shareFile: "Partager un fichier", - shareFileNamed: "Partager{{fileName}}", + requestTokenFailed: "Impossible d’obtenir le token de téléchargement.", + hostUnavailable: "L’hôte de téléchargement est indisponible.", + cancelled: "Téléchargement annulé.", + failed: "Impossible de télécharger le fichier.", + shareFile: "Partager le fichier", + shareFileNamed: "Partager {{fileName}}", }, menu: { - backdrop: "Toile de fond du menu", + backdrop: "Arrière-plan du menu", }, subagents: { title: "Sous-agents", @@ -1809,32 +1934,32 @@ export const fr: TranslationResources = { pillLabelNeedsInputOne: "1 attend une réponse", pillLabelNeedsInputMany: "{{count}} attendent une réponse", pillLabelReadyToReview: "{{count}} à relire", - detachAction: "Detacher {{label}}", - detachTooltip: "Detacher le sous-agent", - archiveAction: "Archiver{{label}}", - archiveTooltip: "Sous-agent d'archivage", + detachAction: "Détacher {{label}}", + detachTooltip: "Détacher le sous-agent", + archiveAction: "Archiver {{label}}", + archiveTooltip: "Archiver le sous-agent", archiveFinishedAction: "Archiver les sous-agents terminés", archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})", }, panels: { draft: { newAgent: "Nouvel agent", - creatingAgent: "Agent créateur", + creatingAgent: "Création de l’agent", }, file: { - directoryMissing: "Répertoire Workspace introuvable.", - loading: "Chargement du fichier...", + directoryMissing: "Répertoire de l’espace de travail introuvable.", + loading: "Chargement du fichier…", noPreview: "Aucun aperçu disponible", - binaryPreviewUnavailable: "Aperçu binaire indisponible", + binaryPreviewUnavailable: "Aperçu indisponible pour un fichier binaire", tooLargeToDisplay: "Ce fichier est trop volumineux pour être affiché", - failedToLoad: "Échec du chargement du fichier", - failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", + failedToLoad: "Impossible de charger le fichier", + failedToLoadPreview: "Impossible de charger l’aperçu du fichier", editor: { - fileSize: "Taille {{size}}", + fileSize: "Taille du fichier : {{size}}", lines: "{{count}} lignes", - editorStatus: "État de l’éditeur : {{status}}", + editorStatus: "État de l’éditeur : {{status}}", unsavedChanges: "Modifications non enregistrées", - saving: "Enregistrement...", + saving: "Enregistrement…", saveFailed: "Échec de l’enregistrement", changedOnDisk: "Modifié sur le disque", vimMode: "Mode Vim {{mode}}", @@ -1847,7 +1972,7 @@ export const fr: TranslationResources = { conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", overwrite: "Écraser", reload: "Recharger", - reloadTitle: "Recharger depuis le disque ?", + reloadTitle: "Recharger depuis le disque ?", reloadMessage: "Vos modifications locales seront perdues.", }, }, @@ -1858,110 +1983,127 @@ export const fr: TranslationResources = { chooseFile: "Choisissez un fichier", }, pullRequest: { - label: "Demande de fusion", - subtitle: "Détails de la demande de fusion", - emptyTitle: "Aucune demande de fusion pour le moment", + label: "Pull request", + subtitle: "Détails de la pull request", + emptyTitle: "Aucune pull request pour l’instant", emptyDescription: - "Créez une demande de fusion pour cette copie de travail afin d’afficher ses détails ici.", + "Créez une pull request pour cette copie de travail afin d’en voir les détails ici.", }, diff: { changesLabel: "Modifications", diffLabel: "Diff", - changesSubtitle: "Différences de l'arbre de travail", - commitSubtitle: "Différences du commit", - uncommittedSubtitle: "Modifications non validées", + changesSubtitle: "Diff de l’arbre de travail", + commitSubtitle: "Diff du commit", + uncommittedSubtitle: "Modifications non commitées", baseSubtitle: "Comparé à {{baseRef}}", - directoryMissing: "Répertoire Workspace introuvable.", + directoryMissing: "Répertoire de l’espace de travail introuvable.", empty: "Aucune modification", - loadError: "Échec du chargement des différences", - capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.", + loadError: "Impossible de charger le diff", + capabilityMissing: "Mettez à jour l’hôte pour afficher le diff des commits.", }, }, toolCallDetails: { error: "Erreur", empty: "Aucun détail supplémentaire disponible", subAgentActivity: "Activité du sous-agent", - input: "Saisir", - output: "Sortir", + input: "Entrée", + output: "Sortie", }, toolCallGroup: { editedFiles: { - one: "a modifié {{count}} fichier", - other: "a modifié {{count}} fichiers", + one: "{{count}} fichier modifié", + other: "{{count}} fichiers modifiés", }, commands: { - one: "a exécuté {{count}} commande", - other: "a exécuté {{count}} commandes", + one: "{{count}} commande exécutée", + other: "{{count}} commandes exécutées", }, readFiles: { - one: "a lu {{count}} fichier", - other: "a lu {{count}} fichiers", + one: "{{count}} fichier lu", + other: "{{count}} fichiers lus", }, searches: { - one: "a effectué {{count}} recherche", - other: "a effectué {{count}} recherches", + one: "{{count}} recherche effectuée", + other: "{{count}} recherches effectuées", }, otherTools: { - one: "a utilisé {{count}} autre outil", - other: "a utilisé {{count}} autres outils", + one: "{{count}} autre outil utilisé", + other: "{{count}} autres outils utilisés", }, paseoCalls: { - one: "a appelé Paseo {{count}} fois", - other: "a appelé Paseo {{count}} fois", + one: "{{count}} appel à Paseo", + other: "{{count}} appels à Paseo", }, and: "et", }, renameModal: { - rename: "Rebaptiser", - saving: "Sauvegarde...", + rename: "Renommer", + saving: "Enregistrement…", }, sidebarCallout: { - dismiss: "Rejeter", + dismiss: "Ignorer", }, contextWindow: { - title: "Fenêtre contextuelle", - used: "{{percentage}}% utilisé", - tokens: "Jetons{{used}}/{{max}}", - sessionCost: "Coût de la séance{{cost}}", - accessibility: "Fenêtre contextuelle{{percentage}}% utilisé", + noData: "Aucune donnée de contexte", + accessibilityNoData: "Fenêtre de contexte : aucune donnée de contexte", + title: "Fenêtre de contexte", + used: "{{percentage}} % utilisés", + tokens: "{{used}} / {{max}} tokens", + sessionCost: "Coût de la session : {{cost}}", + accessibility: "Fenêtre de contexte : {{percentage}} % utilisés", }, review: { + feedback: { + send: "Envoyer les commentaires ({{count}})", + sending: "Envoi des commentaires ({{count}})", + chooseAgent: "Choisir un agent", + sent: "Commentaires envoyés à {{recipient}}", + "no-agents": + "Ouvrez un onglet d’agent dans cet espace de travail pour envoyer les commentaires.", + disconnected: "Connectez-vous à l’hôte pour envoyer les commentaires.", + "no-context": "Les commentaires enregistrés ne correspondent plus à ce diff.", + failed: "Échec de l’envoi des commentaires. Réessayez.", + prompt: "Veuillez traiter cette revue de code.", + }, comment: { - add: "Ajouter un commentaire", - edit: "Modifier le commentaire de l'avis", - delete: "Supprimer le commentaire de l'avis", - label: "Revoir le commentaire", + add: "Ajouter un commentaire de revue", + edit: "Modifier le commentaire de revue", + delete: "Supprimer le commentaire de revue", + label: "Commentaire de revue", placeholder: "Laisser un commentaire", cancel: "Annuler", - cancelAccessibility: "Annuler le commentaire de révision", - save: "Commentaire", - saveAccessibility: "Enregistrer le commentaire de l'avis", + cancelAccessibility: "Annuler le commentaire de revue", + save: "Commenter", + saveAccessibility: "Enregistrer le commentaire de revue", }, }, settings: { title: "Paramètres", - loading: "Chargement des paramètres...", + loading: "Chargement des paramètres…", groups: { app: "Application", - host: "Host", + host: "Hôte", }, hostPicker: { - switchHost: "Changer d'hôte", - local: "Locale", + switchHost: "Changer d’hôte", + local: "Local", }, - backToWorkspace: "Dos", + backToWorkspace: "Retour", addHost: "Ajouter un hôte", - enableBuiltInDaemon: "Activer le démon intégré", + enableBuiltInDaemon: "Activer le daemon intégré", projects: "Projets", projectList: { - hostLoadFailed: "Impossible de charger les projets depuis l'hôte{{hostName}}:{{message}}", - editProject: "Modifier{{projectName}}", + hostLoadFailed: "Impossible de charger les projets depuis l’hôte {{hostName}} : {{message}}", + editProject: "Modifier {{projectName}}", }, - groupInfo: "À propos de{{title}}", + groupInfo: "À propos de {{title}}", sections: { general: "Général", + chat: "Discussion", appearance: "Apparence", - layout: en.settings.sections.layout, + sidebar: "Barre latérale", + terminal: "Terminal", + browser: "Navigateur", editor: "Éditeur", shortcuts: "Raccourcis", integrations: "Intégrations", @@ -1974,32 +2116,32 @@ export const fr: TranslationResources = { editor: { title: "Éditeur", vimKeybindings: "Raccourcis Vim", - vimHint: "S’applique aux fichiers source sur le web et le bureau.", + vimHint: "S’applique aux fichiers source dans l’app web et l’app de bureau.", }, notifications: { title: "Notifications", permission: "Autorisation des notifications", refreshAccessibility: "Actualiser l’autorisation des notifications", playSound: "Émettre un son", - playSoundHint: "Émet un son lorsqu’une notification de bureau arrive", + playSoundHint: "Émet un son à l’arrivée d’une notification de bureau", test: "Tester la notification", testHint: "Envoie une notification avec ces réglages", - permissionRequired: "Autorisez l’accès aux notifications avant le test", + permissionRequired: "Autorisez les notifications avant de tester", send: "Envoyer", - sending: "Envoi...", + sending: "Envoi…", sentTitle: "Notification de test envoyée", sentDescription: "Paseo a transmis la notification au système d’exploitation.", sendFailedTitle: "Impossible d’envoyer la notification de test", }, hostSections: { projects: "Projets", - connections: "Relations", + connections: "Connexions", agents: "Agents", metadata: "Métadonnées", - workspaces: "Workspaces", + workspaces: "Espaces de travail", providers: "Fournisseurs", usage: "Utilisation", - terminals: "Terminals", + terminals: "Terminaux", plugins: "Plugins", host: "Aperçu", }, @@ -2020,56 +2162,54 @@ export const fr: TranslationResources = { }, general: { title: "Général", + sending: "Envoi", browserData: { title: "Données du navigateur", siteData: "Cookies et données des sites", description: "Les onglets du navigateur partagent les connexions et les données des sites dans Paseo.", clear: "Effacer les données du navigateur", - clearing: "Effacement...", - confirmTitle: "Effacer les données du navigateur ?", + clearing: "Effacement…", + confirmTitle: "Effacer les données du navigateur ?", confirmMessage: "Vous serez déconnecté des sites et les onglets ouverts seront rechargés.", success: "Données du navigateur effacées.", - error: "Impossible d'effacer les données du navigateur.", + error: "Impossible d’effacer les données du navigateur.", }, defaultSend: { label: "Envoi par défaut", descriptions: { interrupt: - "Lorsque l'agent est en cours d'exécution, Entrée interrompt. Commande/Ctrl+Entrée met en file d'attente.", + "Quand l’agent est en cours d’exécution, Entrée l’interrompt. Cmd/Ctrl+Entrée met en file d’attente.", steer: - "Lorsque l’agent est en cours d’exécution, Entrée oriente le tour actif. Commande/Ctrl+Entrée met en file d’attente.", + "Quand l’agent est en cours d’exécution, Entrée réoriente le tour en cours. Cmd/Ctrl+Entrée met en file d’attente.", queue: - "Lorsque l'agent est en cours d'exécution, Entrée met en file d'attente. Commande/Ctrl+Entrée envoie.", + "Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.", }, options: { interrupt: "Interrompre", - steer: "Orienter", - queue: "File d'attente", + steer: "Réorienter", + queue: "File d’attente", }, }, serviceUrls: { - label: "URL de services", - description: "Où ouvrir les URL à partir de scripts en cours d'exécution", options: { ask: "Demander", - inApp: "DansPaseo", + inApp: "Dans Paseo", external: "Navigateur externe", }, }, terminalScrollback: { - label: "DéfilementTerminal", + label: "Historique du terminal", description: "Lignes conservées dans le tampon du terminal intégré", - accessibilityLabel: "Lignes de défilementTerminal", + accessibilityLabel: "Lignes d’historique du terminal", }, autoExpandReasoning: { label: "Toujours afficher le raisonnement", - description: "Afficher le raisonnement de l'agent entièrement développé par défaut", + description: "Afficher par défaut le raisonnement de l’agent entièrement déplié", }, toolCallDetail: { label: "Affichage des appels d’outils", description: "Comment les appels d’outils apparaissent dans la chronologie", - accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})", options: { overview: "Résumé", detailed: "Détails complets", @@ -2077,7 +2217,7 @@ export const fr: TranslationResources = { }, language: { label: "Langue", - description: "Langue de l'application", + description: "Langue de l’application", options: { system: "Système", ar: "العربية", @@ -2100,66 +2240,68 @@ export const fr: TranslationResources = { accessibilityLabel: "Utiliser l’ancien rendu du terminal", }, testAudio: "Tester le son", - playTest: "Jouer à l'essai", - playing: "Jouant...", - playbackFailed: "Échec de la lecture:{{message}}", + playTest: "Lancer le test", + playing: "Lecture…", + playbackFailed: "Échec de la lecture : {{message}}", app: { - title: "App diagnostic", - rowTitle: "App diagnostic", - rowHint: "Collect connection, daemon, provider, desktop, and log details", - run: "Run", - running: "Running diagnostic...", + title: "Diagnostic de l’app", + rowTitle: "Diagnostic de l’app", + rowHint: + "Collecter les informations sur la connexion, le daemon, les fournisseurs, l’app de bureau et les journaux", + run: "Lancer", + running: "Diagnostic en cours…", copyLabel: "diagnostic", - copyAccessibility: "Copy diagnostic", - copyFailed: "Failed to copy diagnostic", - refreshAccessibility: "Refresh diagnostic", - refreshingAccessibility: "Refreshing diagnostic", + copyAccessibility: "Copier le diagnostic", + copyFailed: "Impossible de copier le diagnostic", + refreshAccessibility: "Actualiser le diagnostic", + refreshingAccessibility: "Actualisation du diagnostic", progress: { client: "Client", - desktop: "Desktop", + desktop: "App de bureau", }, }, }, about: { title: "À propos", - appVersion: "Version de l'application", + appVersion: "Version de l’application", + whatsNewHint: "Les nouveautés de chaque version", thisDevice: "Cet appareil", connectedHosts: "Hôtes connectés", offline: "Hors ligne", - versionDiffers: "La version diffère de cet appareil", + versionDiffers: "Version différente de celle de cet appareil", releaseChannel: { - label: "Canal de sortie", + label: "Canal de publication", description: - "Passez àBetapour obtenir des mises à jour plus tôt et contribuer à les façonner", + "Passez à la Bêta pour recevoir les mises à jour plus tôt et contribuer à les façonner", stable: "Stable", - beta: "Beta", + beta: "Bêta", }, updates: { - label: "Mises à jour de l'application", - readyToInstall: "Prêt à installer:{{version}}", - installTitle: "Installer la mise à jour du bureau", - installMessage: "Cela met à jourPaseosur cet ordinateur", + label: "Mises à jour de l’application", + readyToInstall: "Prête à installer : {{version}}", + installTitle: "Installer la mise à jour de l’app de bureau", + installMessage: "Paseo sera mis à jour sur cet ordinateur", installConfirm: "Installer la mise à jour", - update: "Mise à jour", - updateTo: "Mise à jour vers{{version}}", - installing: "Installation...", + update: "Mettre à jour", + updateTo: "Mettre à jour vers {{version}}", + installing: "Installation…", check: "Vérifier", - checking: "Vérification...", + checking: "Vérification…", alertTitle: "Erreur", - alertMessage: "Impossible d'ouvrir la boîte de dialogue de confirmation de mise à jour.", + alertMessage: "Impossible d’ouvrir la boîte de dialogue de confirmation de mise à jour.", }, }, appearance: { theme: { title: "Thème", - accessibilityLabel: "Thème:{{value}}", + accessibilityLabel: "Thème : {{value}}", options: { - light: "Lumière", + light: "Clair", dark: "Sombre", zinc: "Zinc", midnight: "Minuit", claude: "Claude", - ghostty: "Fantôme", + ghostty: "Ghostty", pureBlack: "Noir pur", auto: "Système", }, @@ -2169,57 +2311,74 @@ export const fr: TranslationResources = { }, chatOutline: { title: "Plan de la discussion", - description: "Afficher un plan pour passer d’une requête à l’autre", + description: "Afficher un plan pour naviguer entre les prompts", }, sidebar: { - title: "Barre latérale", - description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + header: { + title: "En-tête", + description: + "Choisissez les éléments affichés en haut de la barre latérale et leur ordre", + }, + footer: { + title: "Pied de page", + description: + "Choisissez les lignes affichées en bas de la barre latérale et leur ordre. « Ajouter un projet » et la rangée d’icônes restent toujours visibles", + }, + moveUp: "Monter", + moveDown: "Descendre", }, fonts: { title: "Polices", - systemDefault: "Valeur par défaut du système", - interfaceFont: "Police d'interface", + systemDefault: "Police par défaut du système", + interfaceFont: "Police de l’interface", interfaceFontHint: - "Utilisé dans toute l'application. Laisser vide pour la valeur par défaut du système", - interfaceFontAccessibility: "Famille de polices d'interface", - interfaceSize: "Taille de l'interface", + "Utilisée dans toute l’application. Laissez vide pour utiliser la police par défaut du système", + interfaceFontAccessibility: "Famille de police de l’interface", + interfaceSize: "Taille de l’interface", interfaceSizeHint: "Utilisée pour la navigation, les contrôles et les libellés", - interfaceSizeAccessibility: "Taille de police de l'interface", + interfaceSizeAccessibility: "Taille de police de l’interface", contentSize: "Taille du contenu", - contentSizeHint: "Utilisée pour le texte du chat et le Markdown rendu", + contentSizeHint: "Utilisée pour le texte de la discussion et le Markdown rendu", contentSizeAccessibility: "Taille de police du contenu", - codeFont: "Police de code", + codeFont: "Police du code", codeFontHint: - "Utilisé dans le code, les différences et la sortie du terminal. Laisser vide pour la valeur par défaut du système", - codeFontAccessibility: "Famille de polices de code", + "Utilisée pour le code, les diffs et la sortie du terminal. Laissez vide pour utiliser la police par défaut du système", + codeFontAccessibility: "Famille de police du code", codeSize: "Taille du code", codeSizeHint: "Utilisée pour le code, les diffs et la sortie du terminal", - codeSizeAccessibility: "Taille de la police du code", + codeSizeAccessibility: "Taille de police du code", + }, + layout: { + title: "Mise en page", + contentWidth: "Largeur du contenu", + contentWidthHint: + "Largeur maximale de la discussion et des fichiers Markdown sur les grands écrans", + contentWidthAccessibility: "Largeur du contenu en pixels", + reset: "Réinitialiser", + resetAccessibility: "Réinitialiser la largeur du contenu", }, syntax: { title: "Syntaxe", - highlightTheme: "Thème de surbrillance", - highlightThemeHint: "Couleurs du code, indépendamment du thème de l'application", - highlightThemeAccessibility: "Thème phare:{{value}}", + highlightTheme: "Thème de coloration", + highlightThemeHint: "Couleurs du code, indépendantes du thème de l’application", + highlightThemeAccessibility: "Thème de coloration : {{value}}", previewAccessibility: - "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police de code", + "Aperçu en direct de la typographie du contenu, du thème de syntaxe et de la police du code", previewContent: "Aperçu du contenu et du code", }, }, shortcuts: { dialogTitle: "Raccourcis", searchPlaceholder: "Rechercher des raccourcis", - unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur le bureau", - capturePrompt: "Appuyez sur le raccourci...", - unassigned: "Non défini", + unavailableOnMobile: "Les raccourcis clavier ne sont disponibles que sur l’app de bureau", + capturePrompt: "Appuyez sur le raccourci…", + unassigned: "Non attribué", actions: { menu: "Actions pour {{name}}", - done: "Fait", + done: "Terminé", cancel: "Annuler", bind: "Attribuer", - rebind: "Relier", + rebind: "Réattribuer", clear: "Effacer", reset: "Rétablir la valeur par défaut", resetAll: "Tout réinitialiser", @@ -2227,88 +2386,92 @@ export const fr: TranslationResources = { sections: { general: "Général", workspaces: "Projets et espaces de travail", - tabsPanes: "Onglets et volets", + tabsPanes: "Onglets et panneaux", layout: "Disposition", - agentInput: "EntréeAgent", + agentInput: "Zone de saisie", }, help: { - openProject: "Projet ouvert", + openProject: "Ouvrir un projet", newWorkspace: "Nouvel espace de travail", - newWorktree: "Nouvel arbre de travail", + newWorktree: "Nouveau worktree", archiveWorkspace: "Archiver l’espace de travail", newTab: "Nouvel onglet", - closeCurrentTab: "Fermer l'onglet actuel", - jumpToWorkspace: "Accéder à l'espace de travail", - jumpToTab: "Aller à l'onglet", + closeCurrentTab: "Fermer l’onglet actuel", + jumpToWorkspace: "Aller à l’espace de travail", + jumpToTab: "Aller à l’onglet", previousWorkspace: "Espace de travail précédent", nextWorkspace: "Espace de travail suivant", previousTab: "Onglet précédent", nextTab: "Onglet suivant", - splitPaneRight: "Volet divisé à droite", - splitPaneDown: "Diviser le volet vers le bas", - focusPaneLeft: "Volet de mise au point à gauche", - focusPaneRight: "Volet de mise au point à droite", - focusPaneUp: "Volet de mise au point vers le haut", - focusPaneDown: "Volet de mise au point vers le bas", - moveTabLeft: "Déplacer l'onglet vers la gauche", - moveTabRight: "Déplacer l'onglet vers la droite", - moveTabUp: "Déplacer l'onglet vers le haut", - moveTabDown: "Déplacer l'onglet vers le bas", - closePane: "Fermer le volet", - newTerminal: "Nouvelle borne", + splitPaneRight: "Diviser le panneau vers la droite", + splitPaneDown: "Diviser le panneau vers le bas", + focusPaneLeft: "Activer le panneau de gauche", + focusPaneRight: "Activer le panneau de droite", + focusPaneUp: "Activer le panneau du haut", + focusPaneDown: "Activer le panneau du bas", + moveTabLeft: "Déplacer l’onglet vers la gauche", + moveTabRight: "Déplacer l’onglet vers la droite", + moveTabUp: "Déplacer l’onglet vers le haut", + moveTabDown: "Déplacer l’onglet vers le bas", + closePane: "Fermer le panneau", + newTerminal: "Nouveau terminal", searchFiles: "Rechercher des fichiers", - toggleCommandCenter: "Basculer le centre de commande", + toggleCommandCenter: "Afficher/masquer la palette de commandes", showKeyboardShortcuts: "Afficher les raccourcis clavier", - toggleLeftSidebar: "Basculer la barre latérale gauche", - toggleRightSidebar: "Basculer le panneau latéral", - toggleBothSidebars: "Basculer les deux barres latérales", - toggleSettings: "Basculer les paramètres", - toggleFocusMode: "Basculer le mode de mise au point", - cycleTheme: "Thème du cycle", - focusMessageInput: "Saisie du message de focus", - cycleAgentMode: "Parcourir les modes de l'agent", - toggleVoiceMode: "Changer le mode vocal", - startStopDictation: "Démarrer la dictée/stop", - interruptAgent: "Agent d'interruption", - sendMessage: "Envoyer un message", - queueMessage: "Message de file d'attente", - muteUnmuteVoiceMode: "Mode vocal/unmutemuet", + toggleLeftSidebar: "Afficher/masquer la barre latérale gauche", + toggleRightSidebar: "Afficher/masquer la barre latérale de l’explorateur", + toggleBothSidebars: "Afficher/masquer les deux barres latérales", + toggleSettings: "Afficher/masquer les paramètres", + toggleFocusMode: "Activer/désactiver le mode focus", + cycleTheme: "Parcourir les thèmes", + focusMessageInput: "Aller à la zone de saisie", + cycleAgentMode: "Parcourir les modes de l’agent", + toggleVoiceMode: "Activer/désactiver le mode vocal", + startStopDictation: "Démarrer/arrêter la dictée", + interruptAgent: "Interrompre l’agent", + sendMessage: "Envoyer le message", + queueMessage: "Mettre le message en file d’attente", + muteUnmuteVoiceMode: "Couper/réactiver le micro en mode vocal", switchProject: "Changer de projet", }, helpNotes: { showKeyboardShortcuts: - "Disponible lorsque le focus n’est pas dans un champ de texte ou un terminal.", + "Disponible quand le focus n’est ni dans un champ de texte ni dans un terminal.", }, }, integrations: { title: "Intégrations", docs: { - cli: "DocumentsCLI", - openCli: "Ouvrir la documentationCLI", + cli: "Documentation CLI", + openCli: "Ouvrir la documentation CLI", }, commandLine: { title: "Ligne de commande", - description: "Agents de contrôle et de script depuis votre terminal", + description: "Pilotez et scriptez vos agents depuis votre terminal", }, actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", }, }, permissions: { title: "Autorisations", microphone: "Microphone", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + refresh: "Actualiser", + refreshing: "Actualisation…", refreshAccessibility: "Actualiser les autorisations du bureau", actions: { - granted: "Accordé", - request: "Demande", - requesting: "Demander...", + granted: "Accordée", + request: "Demander", + requesting: "Demande…", }, }, host: { + password: { + guidance: + "Supprimez cet hôte puis ajoutez-le à nouveau avec le mot de passe demandé par ce daemon.", + }, appearance: { title: "Apparence", name: { @@ -2320,12 +2483,12 @@ export const fr: TranslationResources = { options: { none: "Par défaut", violet: "Violet", - sky: "Ciel", + sky: "Bleu ciel", emerald: "Émeraude", orange: "Orange", pink: "Rose", indigo: "Indigo", - teal: "Sarcelle", + teal: "Bleu canard", red: "Rouge", amber: "Ambre", blue: "Bleu", @@ -2344,250 +2507,252 @@ export const fr: TranslationResources = { workspaceName: "my-workspace", }, }, - notFound: "Hostintrouvable", + notFound: "Hôte introuvable", badges: { relay: "Relais", - local: "Locale", + local: "Local", remoteSsh: "SSH distant", }, connections: { - title: "Relations", + title: "Connexions", removeTitle: "Supprimer la connexion", - removeMessage: "Supprimer{{name}}? Cela ne peut pas être annulé.", - removeAction: "Retirer", + removeMessage: "Supprimer {{name}} ? Cette action est irréversible.", + removeAction: "Supprimer", removeErrorTitle: "Erreur", removeErrorMessage: "Impossible de supprimer la connexion", - timeout: "Temps mort", + timeout: "Délai dépassé", }, pairDevices: { title: "Associer des appareils", rowTitle: "Associer un appareil", - rowHint: "Scannez un codeQRou copiez un lien pour connecter votre téléphone à cet hôte", + rowHint: "Scannez un QR code ou copiez un lien pour connecter votre téléphone à cet hôte", }, skills: { ...en.settings.host.skills, - sectionTitle: "Compétences en orchestration", - title: "Compétences en orchestration", - description: "Apprenez à vos agents à orchestrer via leCLI", + sectionTitle: "Skills d’orchestration", + title: "Skills d’orchestration", + description: "Apprenez à vos agents à orchestrer via la CLI", updateAvailable: "Mise à jour disponible", - updateTitle: "Mettre à jour les compétencesPaseo?", - updateFallback: "Synchronisez les compétences regroupées sur votre machine.", - uninstallTitle: "Désinstaller les compétencesPaseo?", + updateTitle: "Mettre à jour les skills Paseo ?", + updateFallback: "Synchronisez les skills fournis sur cet hôte.", + uninstallTitle: "Désinstaller les skills Paseo ?", uninstallMessage: - "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", - choose: "Choisir les compétences", - chooseAll: "Toutes les compétences", + "Supprime tous les skills d’orchestration Paseo de ~/.agents, ~/.claude et ~/.codex sur cet hôte.", + choose: "Choisir les skills", + chooseAll: "Tous les skills", chooseAllHint: - "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", - chooseList: "Compétences fournies", - chooseEmpty: "Cette version ne fournit aucune compétence.", - removeTitle: "Supprimer les compétences décochées ?", + "Garder installés tous les skills fournis, y compris ceux ajoutés plus tard.", + chooseList: "Skills fournis", + chooseEmpty: "Cet hôte ne fournit aucun skill.", + removeTitle: "Supprimer les skills décochés ?", removeMessage: - "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", - saveFailed: "Impossible d'enregistrer votre sélection de compétences.", - statusFailed: "Impossible de vérifier l'état des compétences d'orchestration.", - updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", - uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", - saveSelectionFailed: - "Impossible d'enregistrer la sélection des compétences d'orchestration.", + "{{skills}} seront supprimés de ~/.agents, ~/.claude et ~/.codex sur cet hôte. Tout ce que vous avez ajouté dans ces dossiers de skills sera également supprimé.", + saveFailed: "Impossible d’enregistrer votre sélection de skills.", + statusFailed: "Impossible de vérifier l’état des skills d’orchestration.", + updateFailed: "Impossible de mettre à jour les skills d’orchestration.", + uninstallFailed: "Impossible de désinstaller les skills d’orchestration.", + saveSelectionFailed: "Impossible d’enregistrer la sélection des skills d’orchestration.", actions: { install: "Installer", - installing: "Installation...", + installing: "Installation…", installed: "Installé", - update: "Mise à jour", - working: "Fonctionnement...", + update: "Mettre à jour", + working: "En cours…", remove: "Supprimer", uninstall: "Désinstaller", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", cancel: "Annuler", }, operations: { - add: "Ajouter une compétence", - update: "Mettre à jour la compétence", - delete: "Supprimer la compétence", + add: "Ajouter un skill", + update: "Mettre à jour le skill", + delete: "Supprimer le skill", }, }, orchestration: { title: "Orchestration", - unavailable: "Connectez-vous à cet hôte pour gérer l'orchestration", + unavailable: "Connectez-vous à cet hôte pour gérer l’orchestration", enableTools: { - title: "Activer les outilsPaseo", - hint: "Les agents pourront gérer les arbres de travail, les agents et les horaires", - accessibilityLabel: "Injecter les outilsPaseo", + title: "Activer les outils Paseo", + hint: "Les agents pourront gérer les worktrees, les agents et les planifications", + accessibilityLabel: "Injecter les outils Paseo", }, systemPrompt: { - title: "Invite système", - hint: "Ajoute une invite système à tous les agents", - sheetTitle: "Ajouter une invite système", - accessibilityLabel: "Ajouter une invite système", - placeholder: "Gardez toujours des réponses concises.", + title: "Prompt système", + hint: "Ajoute un prompt système à tous les agents", + sheetTitle: "Ajouter un prompt système", + accessibilityLabel: "Ajouter un prompt système", + placeholder: "Toujours répondre de façon concise.", edit: "Modifier", reset: "Réinitialiser", - save: "Sauvegarder", - saving: "Sauvegarde...", + save: "Enregistrer", + saving: "Enregistrement…", }, }, agents: { - unavailable: "Connect to this host to manage agents", + unavailable: "Connectez-vous à cet hôte pour gérer les agents", }, workspaces: { - unavailable: "Connect to this host to manage workspaces", + unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail", }, terminalProfiles: { - unavailable: "Connect to this host to manage terminal profiles", - sectionTitle: "Terminal profiles", - editProfile: "Edit profile", - addProfileTitle: "Add terminal profile", - editProfileTitle: "Edit terminal profile", + unavailable: "Connectez-vous à cet hôte pour gérer les profils de terminal", + sectionTitle: "Profils de terminal", + editProfile: "Modifier le profil", + addProfileTitle: "Ajouter un profil de terminal", + editProfileTitle: "Modifier le profil de terminal", namePlaceholder: "Claude Code", commandPlaceholder: "claude", argsPlaceholder: "--dangerously-skip-permissions", - nameLabel: "Name", - commandLabel: "Command", + nameLabel: "Nom", + commandLabel: "Commande", argsLabel: "Arguments", - nameRequired: "Name is required", - commandRequired: "Command is required", - argsHint: "Space-separated arguments passed to the command", - saving: "Saving...", - remove: "Remove", - removeConfirmTitle: "Remove profile?", - removeConfirmMessage: 'Remove "{{name}}"?', - moveUp: "Move up", - moveDown: "Move down", - save: "Save", - emptyState: "No profiles yet. Add one to launch terminals with a specific command.", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + argsHint: "Arguments séparés par des espaces, transmis à la commande", + saving: "Enregistrement…", + remove: "Supprimer", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", + save: "Enregistrer", + emptyState: + "Aucun profil pour le moment. Ajoutez-en un pour lancer des terminaux avec une commande précise.", }, agentProfiles: { - sectionTitle: "Profils d'agent", - unavailable: "Connectez-vous à cet hôte pour gérer les profils d'agent", - unsupported: - "Cet hôte exécute un daemon qui ne prend pas encore en charge les profils d'agent", + sectionTitle: "Profils d’agent", + unavailable: "Connectez-vous à cet hôte pour gérer les profils d’agent", + unsupported: "Le daemon de cet hôte ne prend pas encore en charge les profils d’agent", emptyState: - "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents à partir d'un fournisseur et d'un modèle enregistrés.", - addProfileTitle: "Ajouter un profil d'agent", + "Aucun profil pour le moment. Ajoutez-en un pour démarrer des agents avec un fournisseur et un modèle enregistrés.", + addProfileTitle: "Ajouter un profil d’agent", newProfile: "Nouveau profil", editProfile: "Modifier le profil", - editProfileTitle: "Modifier le profil d'agent", + editProfileTitle: "Modifier le profil d’agent", nameLabel: "Nom", - namePlaceholder: "Travail sur l'UI", + namePlaceholder: "Travail sur l’UI", iconLabel: "Icône", noIcon: "Aucune", providerLabel: "Fournisseur", providerPlaceholder: "Sélectionner un fournisseur", - noProviders: "Aucun fournisseur n'est disponible sur cet hôte", + noProviders: "Aucun fournisseur disponible sur cet hôte", modelLabel: "Modèle", - noModels: "Ce fournisseur n'a aucun modèle à choisir", + noModels: "Ce fournisseur ne propose aucun modèle", modeLabel: "Mode", - noModes: "Ce fournisseur n'a aucun mode à choisir", - thinkingLabel: "Pensée", - noThinkingOptions: "Ce modèle n'a aucun niveau de pensée", - featuresLabel: "Caractéristiques", - featureCount: "{{count}} caractéristiques", - featureCountOne: "{{count}} caractéristique", + noModes: "Ce fournisseur ne propose aucun mode", + thinkingLabel: "Réflexion", + noThinkingOptions: "Ce modèle n’a aucun niveau de réflexion", + featuresLabel: "Fonctionnalités", + featureCount: "{{count}} fonctionnalités", + featureCountOne: "{{count}} fonctionnalité", notesLabel: "Quand l’utiliser", notesPlaceholder: - "À utiliser pour le travail sur l'UI — composants, mise en page et tokens de design.", + "À utiliser pour le travail sur l’UI : composants, mise en page et design tokens.", notesHint: "Aide les agents à choisir ce profil lorsqu’ils démarrent un autre agent.", save: "Enregistrer", - saving: "Enregistrement...", + saving: "Enregistrement…", remove: "Supprimer", - removeConfirmTitle: "Supprimer le profil ?", - removeConfirmMessage: "Supprimer « {{name}} » ?", - moveUp: "Déplacer vers le haut", - moveDown: "Déplacer vers le bas", + removeConfirmTitle: "Supprimer le profil ?", + removeConfirmMessage: "Supprimer « {{name}} » ?", + moveUp: "Monter", + moveDown: "Descendre", }, daemon: { rename: { - editLabel: "Modifier l'étiquette", - title: "Renommer l'hôte", - placeholder: "MonHost", + editLabel: "Modifier le libellé", + title: "Renommer l’hôte", + placeholder: "Mon hôte", submit: "Renommer", }, restart: { - title: "Redémarrer le démon", - hint: "Redémarre le processus démon. L'application se reconnectera automatiquement", + title: "Redémarrer le daemon", + hint: "Redémarre le processus du daemon. L’application se reconnectera automatiquement", confirm: "Redémarrer", - confirmTitle: "Redémarrer{{name}}", + confirmTitle: "Redémarrer {{name}}", confirmMessage: - "Cela redémarrera le démon. Les agents qui s'y exécutent continueront à fonctionner; l'application se reconnectera automatiquement.", - restarting: "Redémarrage...", + "Le daemon va redémarrer. Les agents qui y tournent continueront de s’exécuter et l’application se reconnectera automatiquement.", + restarting: "Redémarrage…", unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}}n'est pas revenu en ligne. Veuillez vérifier qu'il a redémarré.", - unavailableTitle: "Hostindisponible", + "{{name}} n’est pas revenu en ligne. Vérifiez qu’il a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "Cet hôte n'est pas connecté. Attendez qu'il soit en ligne avant de redémarrer.", - offlineTitle: "Hosthors ligne", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de redémarrer.", + offlineTitle: "Hôte hors ligne", offlineMessage: - "Cet hôte est hors ligne.Paseose reconnecte automatiquement: attendez qu'il soit de nouveau en ligne avant de redémarrer.", + "Cet hôte est hors ligne. Paseo se reconnecte automatiquement : attendez qu’il soit de nouveau en ligne avant de redémarrer.", requestFailedTitle: "Erreur", requestFailedMessage: - "Échec de l'envoi de la demande de redémarrage.Paseose reconnecte automatiquement - réessayez une fois que l'hôte apparaît comme en ligne.", + "Impossible d’envoyer la demande de redémarrage. Paseo se reconnecte automatiquement : réessayez quand l’hôte apparaît en ligne.", dialogFailedMessage: - "Impossible d'ouvrir la boîte de dialogue de confirmation de redémarrage.", + "Impossible d’ouvrir la boîte de dialogue de confirmation du redémarrage.", }, update: { desktopManagedHint: "Ce daemon est géré par Paseo Desktop. Mettez à jour Paseo Desktop sur l’hôte.", - title: "Update daemon", - hint: "Update the daemon to the latest version and restart it", - confirm: "Update", - confirmTitle: "Update {{name}}", + title: "Mettre à jour le daemon", + hint: "Met à jour le daemon vers la dernière version puis le redémarre", + confirm: "Mettre à jour", + confirmTitle: "Mettre à jour {{name}}", confirmMessage: - "This will update the daemon to the latest version and restart it. Running agents will be briefly interrupted.", - updating: "Updating...", - phaseStarting: "Preparing update...", - phaseDownloading: "Downloading packages...", - phaseInstalling: "Installing...", - phaseComplete: "Update complete, restarting...", - unableToReconnectTitle: "Unable to reconnect", + "Le daemon va être mis à jour vers la dernière version puis redémarré. Les agents en cours seront brièvement interrompus.", + updating: "Mise à jour…", + phaseStarting: "Préparation de la mise à jour…", + phaseDownloading: "Téléchargement des paquets…", + phaseInstalling: "Installation…", + phaseComplete: "Mise à jour terminée, redémarrage…", + unableToReconnectTitle: "Impossible de se reconnecter", unableToReconnectMessage: - "{{name}} did not come back online after updating. Please verify the daemon restarted.", - unavailableTitle: "Host unavailable", + "{{name}} n’est pas revenu en ligne après la mise à jour. Vérifiez que le daemon a bien redémarré.", + unavailableTitle: "Hôte indisponible", unavailableMessage: - "This host is not connected. Wait for it to come online before updating.", - offlineTitle: "Host offline", - offlineMessage: "This host is offline. Wait until it is back online before updating.", - requestFailedTitle: "Update failed", - requestFailedMessage: "Failed to update the daemon: {{error}}", - dialogFailedMessage: "Unable to open the update confirmation dialog.", + "Cet hôte n’est pas connecté. Attendez qu’il soit en ligne avant de lancer la mise à jour.", + offlineTitle: "Hôte hors ligne", + offlineMessage: + "Cet hôte est hors ligne. Attendez qu’il soit de nouveau en ligne avant de lancer la mise à jour.", + requestFailedTitle: "Échec de la mise à jour", + requestFailedMessage: "Impossible de mettre à jour le daemon : {{error}}", + dialogFailedMessage: + "Impossible d’ouvrir la boîte de dialogue de confirmation de la mise à jour.", }, dangerZone: "Zone dangereuse", remove: { - title: "Supprimer l'hôte", - localTitle: "Remove localhost connection", + title: "Supprimer l’hôte", + localTitle: "Supprimer la connexion localhost", hint: "Supprime cet hôte et ses connexions enregistrées de cet appareil", - localHint: "Removes localhost from this device and stops the built-in daemon", - localConfirmTitle: "Remove localhost connection and stop daemon?", - confirmMessage: "Supprimer{{name}}? Cela supprimera ses connexions enregistrées.", + localHint: "Supprime localhost de cet appareil et arrête le daemon intégré", + localConfirmTitle: "Supprimer la connexion localhost et arrêter le daemon ?", + confirmMessage: "Supprimer {{name}} ? Ses connexions enregistrées seront supprimées.", localConfirmMessage: - "This will remove the localhost connection, turn off built-in daemon management, and stop the managed daemon. Remote hosts remain connected.", + "La connexion localhost sera supprimée, la gestion du daemon intégré désactivée et le daemon géré arrêté. Les hôtes distants restent connectés.", errorTitle: "Erreur", - errorMessage: "Impossible de supprimer l'hôte", - localErrorMessage: "Unable to remove localhost connection", + errorMessage: "Impossible de supprimer l’hôte", + localErrorMessage: "Impossible de supprimer la connexion localhost", }, }, }, providers: { title: "Fournisseurs", addProvider: "Ajouter un fournisseur", - providerDetails: "Détails du fournisseur{{name}}", - enableProvider: "Activer{{name}}", + providerDetails: "Détails du fournisseur {{name}}", + enableProvider: "Activer {{name}}", unavailable: "Connectez-vous à cet hôte pour voir les fournisseurs", - loading: "Chargement...", - addErrorTitle: "Unable to add provider", + loading: "Chargement…", + addErrorTitle: "Impossible d’ajouter le fournisseur", updateErrorTitle: "Impossible de mettre à jour le fournisseur", actions: { - menu: "{{name}} actions", - remove: "Remove provider", - removing: "Removing...", + menu: "Actions de {{name}}", + remove: "Supprimer le fournisseur", + removing: "Suppression…", }, remove: { - confirmTitle: "Remove {{name}}?", - confirmMessage: "This deletes the provider entry from config.json. It cannot be undone.", - confirm: "Remove", - errorTitle: "Unable to remove provider", + confirmTitle: "Supprimer {{name}} ?", + confirmMessage: + "L’entrée du fournisseur sera supprimée de config.json. Cette action est irréversible.", + confirm: "Supprimer", + errorTitle: "Impossible de supprimer le fournisseur", }, statuses: { disabled: "Désactivé", @@ -2601,40 +2766,40 @@ export const fr: TranslationResources = { many: "{{count}} modèles", addModel: "Ajouter un modèle", addCustomTitle: "Ajouter un modèle personnalisé", - modelId: "ModèleID", - modelIdPlaceholder: "par ex. ouvert/gpt-5", + modelId: "ID du modèle", + modelIdPlaceholder: "ex. openai/gpt-5", add: "Ajouter", - adding: "Ajout...", - failedToSave: "Échec de l'enregistrement du modèle", - removeModel: "Supprimer{{id}}", + adding: "Ajout…", + failedToSave: "Impossible d’enregistrer le modèle", + removeModel: "Supprimer {{id}}", searchPlaceholder: "Rechercher des modèles", - loading: "Chargement des modèles...", + loading: "Chargement des modèles…", retry: "Réessayer", - retrying: "Nouvelle tentative...", + retrying: "Nouvelle tentative…", noSearchMatches: "Aucun modèle ne correspond à votre recherche", noneDetected: "Aucun modèle détecté", - discovered: "Découvert", + discovered: "Détectés", custom: "Modèles personnalisés", - updated: "{{time}}mis à jour", + updated: "Mis à jour {{time}}", }, diagnostic: { - title: "Diagnostique", - button: "Diagnostique", - refresh: "Rafraîchir", - refreshing: "Rafraîchissant...", + title: "Diagnostic", + button: "Diagnostic", + refresh: "Actualiser", + refreshing: "Actualisation…", copyLabel: "diagnostic", copyAccessibility: "Copier le diagnostic", - copyFailed: "Échec de la copie du diagnostic", + copyFailed: "Impossible de copier le diagnostic", refreshAccessibility: "Actualiser le diagnostic", - refreshingAccessibility: "Diagnostic rafraîchissant", - running: "Exécution du diagnostic...", + refreshingAccessibility: "Actualisation du diagnostic", + running: "Exécution du diagnostic…", none: "Aucun diagnostic disponible", - failedToFetch: "Échec de la récupération du diagnostic", + failedToFetch: "Impossible de récupérer le diagnostic", unknownError: "Erreur inconnue", }, }, project: { - noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.", + noEditableTarget: "Ce projet n’est pas modifiable sur cet hôte.", backToProjects: "Retour aux projets", edit: { title: "Modifier le projet", @@ -2642,85 +2807,86 @@ export const fr: TranslationResources = { nameLabel: "Nom du projet", icon: "Icône", chooseImage: "Choisir une image", - useAutomatic: "Utiliser l'automatique", - imageUrl: "URL d'image ou de site", - save: "Enregistrer", + useAutomatic: "Icône automatique", + imageUrl: "URL d’une image ou d’un site", + save: "Enregistrer les modifications", savedToast: "Projet mis à jour", }, readFailures: { - invalidTitle: "paseo.json n'a pas pu être analysé", + invalidTitle: "Impossible d’analyser paseo.json", invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", - missingTitle: "Cet hôte n'a pas ce projet", - missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.", + missingTitle: "Ce projet n’existe pas sur cet hôte", + missingSingleHost: "L’hôte sélectionné n’a aucune trace de ce projet.", transportTitle: "Impossible de charger paseo.json", - transportFallback: "L'hôte n'a pas répondu.", + transportFallback: "L’hôte n’a pas répondu.", failedTitle: "Impossible de charger paseo.json", failedDescription: "Rechargez pour réessayer.", }, worktree: { - title: "Crochets de cycle de vie Worktree", - info: "Commandes exécutées lorsqu'un arbre de travail est créé ou supprimé pour ce projet", - docs: "Documents", + title: "Hooks de cycle de vie des worktrees", + info: "Commandes exécutées à la création ou à la suppression d’un worktree de ce projet", + docs: "Documentation", docsTooltip: - "Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes", - setup: "Installation", - setupAccessibility: "Commandes de configuration de Worktree", - uncommittedTitle: "Validez les modifications de paseo.json", + "Consultez la documentation pour plus de détails et la liste des variables d’environnement disponibles pour ces commandes", + setup: "Configuration", + setupAccessibility: "Commandes de configuration du worktree", + uncommittedTitle: "Commitez les modifications de paseo.json", uncommittedDescription: "Les nouveaux worktrees utilisent le script de configuration de la branche de base sélectionnée.", - teardown: "Démolir", - teardownAccessibility: "Commandes de démontage de Worktree", + teardown: "Démontage", + teardownAccessibility: "Commandes de démontage du worktree", }, scripts: { title: "Scripts", - info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer à partir de n'importe quel agent de ce projet", - empty: "Pas encore de scripts.", + info: "Services de longue durée et commandes ponctuelles que vous pouvez lancer depuis n’importe quel agent de ce projet", + empty: "Aucun script pour le moment.", untitled: "Script sans titre", - port: "port{{port}}", - menuAccessibility: "Ouvrir le menu des scripts", - removeTitle: "Supprimer le script?", - removeMessage: "Supprimer{{name}}?", - removeFallbackName: "ce scénario", + port: "port {{port}}", + menuAccessibility: "Ouvrir le menu du script", + removeTitle: "Supprimer le script ?", + removeMessage: "Supprimer {{name}} ?", + removeFallbackName: "ce script", name: "Nom", command: "Commande", nameAccessibility: "Nom du script", - commandAccessibility: "Commande de script", - nameRequired: "Le nom est requis", - commandRequired: "La commande est requise", - newScript: "Nouveau scénario", - editScript: "Modifier{{name}}", - runAsService: "Exécuter en tant que service", - serviceHint: "Paseosupervise le processus et attribue un port via $PASEO_PORT", + commandAccessibility: "Commande du script", + nameRequired: "Le nom est obligatoire", + commandRequired: "La commande est obligatoire", + newScript: "Nouveau script", + editScript: "Modifier {{name}}", + runAsService: "Exécuter comme service", + serviceHint: "Paseo supervise le processus et lui attribue un port via $PASEO_PORT", actions: { add: "Ajouter un script", edit: "Modifier", - remove: "Retirer", + remove: "Supprimer", }, }, metadata: { - title: "Génération de métadonnées", - info: "Instructions spécifiques au projet injectées dans les invites de l'IA quePaseoutilise pour générer des métadonnées: utilisez-les pour appliquer les conventions de votre équipe telles que la dénomination des branches, le style de validation ou le formatPR.", - branchName: "Noms des succursales", + title: "Génération des métadonnées", + info: "Instructions propres au projet, injectées dans les prompts IA que Paseo utilise pour générer les métadonnées : servez-vous-en pour imposer les conventions de votre équipe (nommage des branches, style de commit, format des PR).", + branchName: "Noms de branches", branchNamePlaceholder: - "Préfixez les branches avec feat/ ou fix/, mb/ pour les branches personnelles", - commitMessage: "Valider les messages", - commitMessagePlaceholder: "Utiliser des commits conventionnels avec une portée", - pullRequest: "Demandes de tirage", + "Préfixez les branches par feat/ ou fix/, et par mb/ pour les branches personnelles", + commitMessage: "Messages de commit", + commitMessagePlaceholder: "Utilisez les Conventional Commits avec un scope", + pullRequest: "Pull requests", pullRequestPlaceholder: - "Commencez avec un résumé d'un paragraphe, incluez une section sur le plan de test", + "Commencez par un résumé d’un paragraphe et incluez une section « Test plan »", }, writeFailures: { staleTitle: "Configuration modifiée sur le disque", - staleDescription: "Rechargez pour récupérer le dernier paseo.json avant de sauvegarder.", - failedTitle: "Impossible d'enregistrer paseo.json", - failedDescription: "Réessayez ou rechargez la dernière version à partir du disque.", + staleDescription: + "Rechargez pour récupérer la dernière version de paseo.json avant d’enregistrer.", + failedTitle: "Impossible d’enregistrer paseo.json", + failedDescription: "Réessayez ou rechargez la dernière version depuis le disque.", }, actions: { reload: "Recharger", - tryAgain: "Essayer à nouveau", - save: "Sauvegarder", + tryAgain: "Réessayer", + save: "Enregistrer", saved: "Projet enregistré", - saving: "Sauvegarde...", + saving: "Enregistrement…", cancel: "Annuler", }, }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1e2e95838d..9899d0ff2b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2,16 +2,43 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { + paneFind: { + connectionFailure: + "このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。", + historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。", + revealFailure: "この一致を表示できませんでした。再試行してください。", + searching: "検索中…", + loading: "読み込み中…", + failed: "失敗", + retry: "再試行", + + title: "検索", + placeholder: "ペイン内を検索", + close: "検索を閉じる", + matches: "検索結果", + previous: "前の一致", + next: "次の一致", + toggleReplace: "置換を切り替え", + replaceWith: "置換後の文字列", + replace: "置換", + replaceAll: "すべて置換", + noMatches: "一致なし", + position: "{{current}} / {{total}}", + total: "{{total}} 件の一致", + }, common: { + bottomSheetBackdrop: "ボトムシートの背景", back: "戻る", loading: "読み込み中...", actions: { + save: "保存", back: "戻る", cancel: "キャンセル", close: "閉じる", copy: "コピー", copyLine: "行をコピー", dismiss: "閉じる", + reload: "再読み込み", retry: "再試行", search: "検索", select: "選択", @@ -98,6 +125,8 @@ export const ja: TranslationResources = { input: { accessibilityLabel: "エージェントにメッセージ...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全画面表示", + exitFullscreen: "全画面表示を終了", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", @@ -153,7 +182,8 @@ export const ja: TranslationResources = { initialPromptRequired: "初期プロンプトが必要です", alreadyLoading: "すでに読み込み中です", uploadFailed: "ファイルのアップロードに失敗しました", - noClipboardImage: "クリップボードに画像がありません", + noClipboardImage: + "現在のクリップボードに画像がありません。キーボードから貼り付けてみてください。", pasteImageFailed: "画像を貼り付けられませんでした", fileTooLarge: "{{fileName}}が大きすぎます(最大{{size}})", }, @@ -208,6 +238,10 @@ export const ja: TranslationResources = { historyLoadFailed: "エージェントの履歴を読み込めませんでした", messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { + rejectedPlan: "却下されたプラン", + approvedPlan: "承認されたプラン", + canceledPlan: "キャンセルされたプラン", + plan: "プラン", required: "権限が必要です", deny: "拒否", @@ -221,7 +255,8 @@ export const ja: TranslationResources = { states: { notFound: "エージェントが見つかりません", failedToLoad: "エージェントの読み込みに失敗しました", - reconnecting: "再接続中", + reconnecting: "ホストに再接続中", + updating: "メッセージを更新中", timelineSyncFailed: "エージェントの履歴を更新できませんでした。", timelineSyncRetrying: "再試行しています…", archivingTitle: "エージェントをアーカイブ中...", @@ -355,6 +390,9 @@ export const ja: TranslationResources = { completed: "完了", }, }, + turnFooter: { + workedFor: "作業時間 {{duration}}", + }, compaction: { loading: "コンテキストを圧縮中...", auto: "コンテキストが自動的に圧縮されました", @@ -413,7 +451,9 @@ export const ja: TranslationResources = { recovery: { archivedTitle: "ワークスペースはアーカイブ済みです", restoreDescription: - "{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。", + "{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。", + restoreWithoutBranchDescription: + "{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。", unarchiveDescription: "{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。", restoreAction: "復元", @@ -890,6 +930,9 @@ export const ja: TranslationResources = { openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", + jumpToFile: { + title: "ファイルへ移動", + }, binaryFile: "バイナリファイル", tooLarge: "差分が大きすぎて表示できません", previewTooLargeTitle: "この差分は大きすぎるためプレビューできません", @@ -912,11 +955,8 @@ export const ja: TranslationResources = { refresh: "更新", refreshState: "gitと{{brand}}の状態を更新", failedRefresh: "gitの状態の更新に失敗しました。", - emptyHiddenWhitespace: "空白を非表示にすると変更は表示されません", - emptyUncommitted: "未コミットの変更なし", seeUncommittedChanges: "未コミットの変更を表示", seeCommittedChanges: "コミット済みの変更を表示", - emptyAgainstBase: "{{baseRef}}との差分なし", checkingRepository: "リポジトリを確認中...", notRepository: "gitリポジトリではありません", diffMode: "差分モード", @@ -949,6 +989,47 @@ export const ja: TranslationResources = { actions: { viewPullRequest: "表示", openOn: "{{brand}}で開く", + addToChat: "チャットに追加", + addAllToChat: "すべてチャットに追加", + addingToChat: "追加中...", + }, + checksOverview: { + headline: { + actionRequired: "対応が必要なチェックがあります", + failure: "失敗したチェックがあります", + pending: "完了していないチェックがあります", + success: "すべてのチェックに合格しました", + none: "チェックなし", + }, + count: { + actionRequired: "{{count}} 件要対応", + warning: "{{count}} 件警告", + failure: "{{count}} 件失敗", + pending: "{{count}} 件実行中", + manual: "{{count}} 件手動", + success: "{{count}} 件成功", + ignored: "{{count}} 件スキップ", + }, + detailOne: "チェック: {{parts}}", + detailMany: "チェック: {{parts}}", + groupOne: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, + groupMany: { + actionRequired: "要対応のチェック {{count}} 件", + warning: "警告のチェック {{count}} 件", + failure: "失敗したチェック {{count}} 件", + pending: "実行中のチェック {{count}} 件", + manual: "手動のチェック {{count}} 件", + success: "成功したチェック {{count}} 件", + ignored: "スキップされたチェック {{count}} 件", + }, }, checksSummary: { passedLabel: "成功", @@ -962,17 +1043,21 @@ export const ja: TranslationResources = { checks: "チェック", pipeline: "パイプライン", reviews: "レビュー", + activity: "アクティビティ", }, empty: { noJobs: "ジョブなし", loadingPipeline: "パイプラインを読み込み中...", pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした", allowedToFail: "失敗を許可", + noActivity: "まだアクティビティはありません", }, approvals: "{{given}} / {{required}} 承認", accessibility: { pullRequest: "プルリクエスト#{{number}}", pullRequest_mr: "マージリクエスト !{{number}}", + commentActions: "コメントの操作", + threadActions: "スレッドの操作", checkStatus: { passed: "成功", failed: "失敗", @@ -1001,6 +1086,8 @@ export const ja: TranslationResources = { }, thread: { discussion: "ディスカッションスレッド", + resolved: "解決済み", + outdated: "古い", }, errors: { statusLoadFailed: "プルリクエストのステータスを読み込めません", @@ -1056,8 +1143,27 @@ export const ja: TranslationResources = { updateHost: "ラベルを管理するにはホストを更新してください。", }, }, + changelog: { + title: "新着情報", + installed: "インストール済み", + showMore: "もっと見る", + openWebsite: "変更履歴をすべて表示", + error: { + title: "変更履歴を読み込めません", + description: "接続を確認してもう一度お試しください。", + }, + }, sidebar: { + statusGroupAccessibility: "{{label}} グループ", + statusBucket: { + needsInput: "入力待ち", + failed: "失敗", + readyToReview: "レビュー待ち", + working: "実行中", + done: "完了", + }, display: { + showBackground: "バックグラウンドを表示", trigger: "表示設定", heading: "表示", grouping: { @@ -1118,6 +1224,9 @@ export const ja: TranslationResources = { settings: "設定", closeSidebar: "サイドバーを閉じる", }, + footer: { + usage: "使用状況", + }, help: { trigger: "ヘルプとサポート", sectionHelp: "ヘルプ", @@ -1278,6 +1387,7 @@ export const ja: TranslationResources = { detail: "ローカルデーモンを停止中。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "デーモン", status: { title: "ステータス", @@ -1497,6 +1607,8 @@ export const ja: TranslationResources = { noFiles: "ファイルまたはディレクトリが見つかりません", noCommands: "コマンドが見つかりません", failedToLoad: "読み込みに失敗しました", + chooseProjectForCommands: "コマンドを表示するにはプロジェクトを選択してください", + chooseModelForCommands: "コマンドを表示するにはモデルを選択してください", }, loadOlderHistory: { failed: "古い履歴を読み込めませんでした", @@ -1588,6 +1700,21 @@ export const ja: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "{{host}} のパスワード", + label: "ホストのパスワード", + }, + hostConfirmation: { + title: "このホストに接続しますか?", + description: + "このホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。", + descriptionChanged: + "このリンクはこのホストへの接続方法を変更します。ホストはこのアプリ内でコードを実行し、接続中の他のホストにアクセスできるようになります。心当たりがある場合のみ接続してください。", + hostLabel: "ホスト", + fingerprintLabel: "鍵のフィンガープリント", + relayLabel: "リレー", + connect: "接続", + }, connectionMethods: { title: "接続を追加", direct: { @@ -1654,6 +1781,12 @@ export const ja: TranslationResources = { helper: "リモートホストで動作する Paseo デーモンに接続します。", fields: { target: "SSH ホスト", + password: "デーモンのパスワード", + optional: "任意", + }, + passwordVisibility: { + show: "パスワードを表示", + hide: "パスワードを非表示", }, actions: { cancel: "キャンセル", @@ -1886,6 +2019,8 @@ export const ja: TranslationResources = { dismiss: "閉じる", }, contextWindow: { + noData: "コンテキストデータがありません", + accessibilityNoData: "コンテキストウィンドウ:コンテキストデータがありません", title: "コンテキストウィンドウ", used: "{{percentage}}%使用", tokens: "{{used}} / {{max}}トークン", @@ -1893,6 +2028,18 @@ export const ja: TranslationResources = { accessibility: "コンテキストウィンドウ{{percentage}}%使用", }, review: { + feedback: { + send: "フィードバックを送信 ({{count}})", + sending: "フィードバックを送信中 ({{count}})", + chooseAgent: "エージェントを選択", + sent: "{{recipient}} にフィードバックを送信しました", + "no-agents": + "フィードバックを送信するには、このワークスペースでエージェントタブを開いてください。", + disconnected: "フィードバックを送信するにはホストに接続してください。", + "no-context": "保存したコメントはこの差分と一致しなくなりました。", + failed: "フィードバックを送信できませんでした。もう一度お試しください。", + prompt: "このコードレビューに対応してください。", + }, comment: { add: "レビューコメントを追加", edit: "レビューコメントを編集", @@ -1927,8 +2074,11 @@ export const ja: TranslationResources = { groupInfo: "{{title}}について", sections: { general: "一般", + chat: "チャット", appearance: "外観", - layout: en.settings.sections.layout, + sidebar: "サイドバー", + terminal: "ターミナル", + browser: "ブラウザ", editor: "エディター", shortcuts: "ショートカット", integrations: "連携", @@ -1987,6 +2137,7 @@ export const ja: TranslationResources = { }, general: { title: "一般", + sending: "送信", browserData: { title: "ブラウザーデータ", siteData: "Cookie とサイトデータ", @@ -2013,8 +2164,6 @@ export const ja: TranslationResources = { }, }, serviceUrls: { - label: "サービスURL", - description: "実行中のスクリプトからURLを開く場所", options: { ask: "確認する", inApp: "Paseoで", @@ -2033,7 +2182,6 @@ export const ja: TranslationResources = { toolCallDetail: { label: "ツール呼び出しの表示", description: "タイムラインでのツール呼び出しの表示方法", - accessibilityLabel: "ツール呼び出しの表示を選択({{value}})", options: { overview: "要約", detailed: "すべての詳細", @@ -2087,6 +2235,7 @@ export const ja: TranslationResources = { about: { title: "アプリ情報", appVersion: "アプリバージョン", + whatsNewHint: "各バージョンのリリースノート", thisDevice: "このデバイス", connectedHosts: "接続されているホスト", offline: "オフライン", @@ -2135,8 +2284,15 @@ export const ja: TranslationResources = { description: "プロンプト間を移動するためのアウトラインを表示します", }, sidebar: { - title: "サイドバー", - description: "サイドバー上部に表示する項目とその順序を選択します", + header: { + title: "ヘッダー", + description: "サイドバー上部に表示する項目とその順序を選択します", + }, + footer: { + title: "フッター", + description: + "サイドバー下部に表示する行とその順序を選択します。プロジェクトを追加とアイコンの行は常に表示されます", + }, moveUp: "上に移動", moveDown: "下に移動", }, @@ -2161,6 +2317,14 @@ export const ja: TranslationResources = { codeSizeHint: "コード、差分、ターミナル出力に使用されます", codeSizeAccessibility: "コードフォントサイズ", }, + layout: { + title: "レイアウト", + contentWidth: "コンテンツ幅", + contentWidthHint: "ワイド画面でのチャットと Markdown ファイルの最大幅", + contentWidthAccessibility: "コンテンツ幅 (ピクセル)", + reset: "リセット", + resetAccessibility: "コンテンツ幅をデフォルトに戻す", + }, syntax: { title: "構文ハイライト", highlightTheme: "ハイライトテーマ", @@ -2272,6 +2436,10 @@ export const ja: TranslationResources = { }, }, host: { + password: { + guidance: + "このホストを削除し、このデーモンが求めるパスワードを入力して追加し直してください。", + }, appearance: { title: "外観", name: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a37feac7a3..c6a8f17b3c 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2,16 +2,42 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { + paneFind: { + connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", + historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.", + revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.", + searching: "검색 중…", + loading: "불러오는 중…", + failed: "실패", + retry: "다시 시도", + + title: "찾기", + placeholder: "패널에서 찾기", + close: "찾기 닫기", + matches: "검색 결과", + previous: "이전 일치 항목", + next: "다음 일치 항목", + toggleReplace: "바꾸기 표시 전환", + replaceWith: "바꿀 내용", + replace: "바꾸기", + replaceAll: "모두 바꾸기", + noMatches: "일치 항목 없음", + position: "{{current}} / {{total}}", + total: "일치 항목 {{total}}개", + }, common: { + bottomSheetBackdrop: "하단 시트 배경", back: "뒤로", loading: "불러오는 중...", actions: { + save: "저장", back: "뒤로", cancel: "취소", close: "닫기", copy: "복사", copyLine: "줄 복사", dismiss: "닫기", + reload: "다시 로드", retry: "다시 시도", search: "검색", select: "선택", @@ -98,6 +124,8 @@ export const ko: TranslationResources = { input: { accessibilityLabel: "에이전트에게 메시지...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "전체 화면", + exitFullscreen: "전체 화면 종료", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", @@ -153,7 +181,7 @@ export const ko: TranslationResources = { initialPromptRequired: "초기 프롬프트가 필요합니다", alreadyLoading: "이미 불러오는 중입니다", uploadFailed: "파일을 업로드하지 못했습니다", - noClipboardImage: "클립보드에 이미지가 없습니다.", + noClipboardImage: "현재 클립보드에 이미지가 없습니다. 키보드에서 붙여넣기를 시도해 보세요.", pasteImageFailed: "이미지를 붙여넣지 못했습니다.", fileTooLarge: "{{fileName}}이(가) 너무 큽니다 (최대 {{size}})", }, @@ -208,6 +236,10 @@ export const ko: TranslationResources = { historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { + rejectedPlan: "거부된 계획", + approvedPlan: "승인된 계획", + canceledPlan: "취소된 계획", + plan: "계획", required: "권한 필요", deny: "거부", @@ -221,7 +253,8 @@ export const ko: TranslationResources = { states: { notFound: "에이전트를 찾을 수 없습니다", failedToLoad: "에이전트를 불러오지 못했습니다", - reconnecting: "다시 연결하는 중", + reconnecting: "호스트에 다시 연결하는 중", + updating: "메시지 업데이트 중", timelineSyncFailed: "에이전트 기록을 새로고침할 수 없습니다.", timelineSyncRetrying: "재시도 중…", archivingTitle: "에이전트 보관 중...", @@ -353,6 +386,9 @@ export const ko: TranslationResources = { completed: "완료됨", }, }, + turnFooter: { + workedFor: "작업 시간 {{duration}}", + }, compaction: { loading: "압축하는 중...", auto: "컨텍스트가 자동으로 압축되었습니다", @@ -411,7 +447,9 @@ export const ko: TranslationResources = { recovery: { archivedTitle: "워크스페이스가 보관되었습니다", restoreDescription: - "{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.", + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.", + restoreWithoutBranchDescription: + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.", unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.", restoreAction: "복원", unarchiveAction: "보관 취소", @@ -886,6 +924,9 @@ export const ko: TranslationResources = { openChangesTab: "변경사항 탭 열기", openDiffTab: "Diff 탭 열기", closeChangesTab: "변경사항 탭 닫기", + jumpToFile: { + title: "파일로 이동", + }, binaryFile: "바이너리 파일", tooLarge: "표시하기에 diff가 너무 큽니다", previewTooLargeTitle: "Diff가 너무 커서 미리 볼 수 없습니다", @@ -908,11 +949,8 @@ export const ko: TranslationResources = { refresh: "새로고침", refreshState: "Git 및 {{brand}} 상태 새로고침", failedRefresh: "Git 상태를 새로고침하지 못했습니다.", - emptyHiddenWhitespace: "공백을 숨긴 후 표시할 변경 사항이 없습니다", - emptyUncommitted: "커밋되지 않은 변경 사항이 없습니다", seeUncommittedChanges: "커밋되지 않은 변경 사항 보기", seeCommittedChanges: "커밋된 변경 사항 보기", - emptyAgainstBase: "{{baseRef}} 대비 변경 사항이 없습니다", checkingRepository: "저장소 확인 중...", notRepository: "Git 저장소가 아닙니다", diffMode: "Diff 모드", @@ -945,6 +983,47 @@ export const ko: TranslationResources = { actions: { viewPullRequest: "보기", openOn: "{{brand}}에서 열기", + addToChat: "채팅에 추가", + addAllToChat: "모두 채팅에 추가", + addingToChat: "추가 중...", + }, + checksOverview: { + headline: { + actionRequired: "확인이 필요한 검사가 있습니다", + failure: "실패한 검사가 있습니다", + pending: "아직 완료되지 않은 검사가 있습니다", + success: "모든 검사를 통과했습니다", + none: "검사 없음", + }, + count: { + actionRequired: "{{count}}개 조치 필요", + warning: "{{count}}개 경고", + failure: "{{count}}개 실패", + pending: "{{count}}개 진행 중", + manual: "{{count}}개 수동", + success: "{{count}}개 성공", + ignored: "{{count}}개 건너뜀", + }, + detailOne: "검사: {{parts}}", + detailMany: "검사: {{parts}}", + groupOne: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, + groupMany: { + actionRequired: "조치 필요 검사 {{count}}개", + warning: "경고 검사 {{count}}개", + failure: "실패한 검사 {{count}}개", + pending: "진행 중인 검사 {{count}}개", + manual: "수동 검사 {{count}}개", + success: "성공한 검사 {{count}}개", + ignored: "건너뛴 검사 {{count}}개", + }, }, checksSummary: { passedLabel: "통과", @@ -958,17 +1037,21 @@ export const ko: TranslationResources = { checks: "검사", pipeline: "파이프라인", reviews: "리뷰", + activity: "활동", }, empty: { noJobs: "작업 없음", loadingPipeline: "파이프라인 로드 중…", pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.", allowedToFail: "실패가 허용됨", + noActivity: "아직 활동이 없습니다", }, approvals: "{{required}} 중 {{given}} 승인", accessibility: { pullRequest: "풀 리퀘스트 #{{number}}", pullRequest_mr: "병합 요청 !{{number}}", + commentActions: "댓글 작업", + threadActions: "스레드 작업", checkStatus: { passed: "통과", failed: "실패", @@ -997,6 +1080,8 @@ export const ko: TranslationResources = { }, thread: { discussion: "토론 스레드", + resolved: "해결됨", + outdated: "오래됨", }, errors: { statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다", @@ -1052,8 +1137,27 @@ export const ko: TranslationResources = { updateHost: "레이블을 관리하려면 호스트를 업데이트하세요.", }, }, + changelog: { + title: "새로운 소식", + installed: "설치됨", + showMore: "더 보기", + openWebsite: "전체 변경 내역", + error: { + title: "변경 내역을 불러오지 못했습니다", + description: "연결을 확인한 후 다시 시도하세요.", + }, + }, sidebar: { + statusGroupAccessibility: "{{label}} 그룹", + statusBucket: { + needsInput: "입력 필요", + failed: "실패", + readyToReview: "검토 대기", + working: "실행 중", + done: "완료", + }, display: { + showBackground: "백그라운드 표시", trigger: "표시 설정", heading: "표시", grouping: { @@ -1113,6 +1217,9 @@ export const ko: TranslationResources = { settings: "설정", closeSidebar: "사이드바 닫기", }, + footer: { + usage: "사용량", + }, help: { trigger: "도움말 및 지원", sectionHelp: "도움말", @@ -1273,6 +1380,7 @@ export const ko: TranslationResources = { detail: "로컬 데몬을 중지하는 중입니다.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "데몬", status: { title: "상태", @@ -1492,6 +1600,8 @@ export const ko: TranslationResources = { noFiles: "파일 또는 디렉터리를 찾을 수 없습니다", noCommands: "명령을 찾을 수 없습니다", failedToLoad: "불러오지 못했습니다", + chooseProjectForCommands: "명령을 보려면 프로젝트를 선택하세요", + chooseModelForCommands: "명령을 보려면 모델을 선택하세요", }, loadOlderHistory: { failed: "이전 기록을 불러올 수 없습니다", @@ -1583,6 +1693,21 @@ export const ko: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "{{host}}의 비밀번호", + label: "호스트 비밀번호", + }, + hostConfirmation: { + title: "이 호스트에 연결할까요?", + description: + "이 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.", + descriptionChanged: + "이 링크는 이 호스트에 연결하는 방식을 변경합니다. 호스트는 이 앱에서 코드를 실행하고 연결된 다른 호스트에 접근할 수 있게 됩니다. 알고 있는 호스트인 경우에만 연결하세요.", + hostLabel: "호스트", + fingerprintLabel: "키 지문", + relayLabel: "릴레이", + connect: "연결", + }, connectionMethods: { title: "연결 추가", direct: { @@ -1648,6 +1773,12 @@ export const ko: TranslationResources = { helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.", fields: { target: "SSH 호스트", + password: "데몬 비밀번호", + optional: "선택 사항", + }, + passwordVisibility: { + show: "비밀번호 표시", + hide: "비밀번호 숨기기", }, actions: { cancel: "취소", @@ -1878,6 +2009,8 @@ export const ko: TranslationResources = { dismiss: "닫기", }, contextWindow: { + noData: "컨텍스트 데이터 없음", + accessibilityNoData: "컨텍스트 창: 컨텍스트 데이터 없음", title: "컨텍스트 윈도우", used: "{{percentage}}% 사용됨", tokens: "{{used}} / {{max}} 토큰", @@ -1885,6 +2018,17 @@ export const ko: TranslationResources = { accessibility: "컨텍스트 윈도우 {{percentage}}% 사용됨", }, review: { + feedback: { + send: "피드백 보내기 ({{count}})", + sending: "피드백 전송 중 ({{count}})", + chooseAgent: "에이전트 선택", + sent: "{{recipient}}에게 피드백을 보냈습니다", + "no-agents": "피드백을 보내려면 이 작업 공간에서 에이전트 탭을 여세요.", + disconnected: "피드백을 보내려면 호스트에 연결하세요.", + "no-context": "저장된 댓글이 더 이상 이 변경 사항과 일치하지 않습니다.", + failed: "피드백 전송에 실패했습니다. 다시 시도하세요.", + prompt: "이 코드 리뷰를 반영해 주세요.", + }, comment: { add: "리뷰 댓글 추가", edit: "리뷰 댓글 편집", @@ -1919,8 +2063,11 @@ export const ko: TranslationResources = { groupInfo: "{{title}} 정보", sections: { general: "일반", + chat: "채팅", appearance: "모양", - layout: en.settings.sections.layout, + sidebar: "사이드바", + terminal: "터미널", + browser: "브라우저", editor: "편집기", shortcuts: "단축키", integrations: "통합", @@ -1979,6 +2126,7 @@ export const ko: TranslationResources = { }, general: { title: "일반", + sending: "전송", browserData: { title: "브라우저 데이터", siteData: "쿠키 및 사이트 데이터", @@ -2007,8 +2155,6 @@ export const ko: TranslationResources = { }, }, serviceUrls: { - label: "서비스 URL", - description: "실행 중인 스크립트의 URL을 열 위치", options: { ask: "물어보기", inApp: "Paseo에서", @@ -2027,7 +2173,6 @@ export const ko: TranslationResources = { toolCallDetail: { label: "도구 호출 표시", description: "타임라인에 도구 호출이 표시되는 방식", - accessibilityLabel: "도구 호출 표시 선택({{value}})", options: { overview: "요약", detailed: "전체 세부정보", @@ -2081,6 +2226,7 @@ export const ko: TranslationResources = { about: { title: "정보", appVersion: "앱 버전", + whatsNewHint: "모든 버전의 릴리스 노트", thisDevice: "이 기기", connectedHosts: "연결된 호스트", offline: "오프라인", @@ -2129,8 +2275,15 @@ export const ko: TranslationResources = { description: "프롬프트 사이를 이동하기 위한 개요 표시", }, sidebar: { - title: "사이드바", - description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", + header: { + title: "헤더", + description: "사이드바 상단에 표시할 항목과 순서를 선택하세요", + }, + footer: { + title: "푸터", + description: + "사이드바 하단에 표시할 행과 순서를 선택하세요. 프로젝트 추가와 아이콘 행은 항상 표시됩니다", + }, moveUp: "위로 이동", moveDown: "아래로 이동", }, @@ -2154,6 +2307,14 @@ export const ko: TranslationResources = { codeSizeHint: "코드, diff 및 터미널 출력에 사용됩니다", codeSizeAccessibility: "코드 글꼴 크기", }, + layout: { + title: "레이아웃", + contentWidth: "콘텐츠 너비", + contentWidthHint: "와이드 화면에서 채팅과 Markdown 파일의 최대 너비", + contentWidthAccessibility: "콘텐츠 너비(픽셀)", + reset: "재설정", + resetAccessibility: "콘텐츠 너비를 기본값으로 재설정", + }, syntax: { title: "구문", highlightTheme: "강조 테마", @@ -2263,6 +2424,9 @@ export const ko: TranslationResources = { }, }, host: { + password: { + guidance: "이 호스트를 제거한 뒤 이 데몬이 요구하는 비밀번호로 다시 추가하세요.", + }, appearance: { title: "모양", name: { diff --git a/packages/app/src/i18n/resources/plugin-settings.ts b/packages/app/src/i18n/resources/plugin-settings.ts index 7390beac36..8146ad9747 100644 --- a/packages/app/src/i18n/resources/plugin-settings.ts +++ b/packages/app/src/i18n/resources/plugin-settings.ts @@ -1,5 +1,4 @@ const shared = { - directoryPlaceholder: "/absolute/path/on/host", logs: { action: "Logs", title: "Logs: {{id}}", @@ -14,20 +13,27 @@ const shared = { export const pluginSettings = { ar: { ...shared, + screens: { + open: "فتح", + offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.", + update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.", + unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.", + backToPlugins: "العودة إلى الإضافات", + }, title: "الإضافات", trustedTitle: "الإضافات تعليمات برمجية موثوقة", trustedDescription: "تعمل على هذا المضيف وداخل التطبيق دون عزل. ثبّت فقط التعليمات البرمجية التي تثق بها.", globalTitle: "تفعيل الإضافات", globalHint: "مفتاح عام لكل الإضافات المضبوطة", - directoryLabel: "مجلد الإضافة", - idLabel: "معرّف تثبيت الإضافة", - idHint: "اتركه فارغًا لاستخدام paseo-plugin.json", - idPlaceholder: "القيمة الافتراضية للبيان", - install: "تثبيت المجلد", + sourceLabel: "مصدر الإضافة", + sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm", + docs: "الوثائق", + install: "تثبيت الإضافة", installing: "جارٍ التثبيت…", status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" }, actions: { + menu: "إجراءات {{id}}", reload: "إعادة تحميل", reloading: "جارٍ إعادة التحميل…", enable: "تفعيل", @@ -53,6 +59,7 @@ export const pluginSettings = { offlineTitle: "مضيف الإضافات غير متصل", offlineDescription: "أعد الاتصال بهذا المضيف لإدارة إضافاته.", updateTitle: "حدّث هذا المضيف لإدارة الإضافات", + sourceUpdateTitle: "حدّث هذا المضيف لتثبيت الإضافات", loading: "جارٍ تحميل الإضافات…", errorTitle: "تعذّر تحميل الإضافات", retry: "إعادة المحاولة", @@ -61,20 +68,27 @@ export const pluginSettings = { }, es: { ...shared, + screens: { + open: "Abrir", + offline: "Conéctate a este host para abrir los ajustes del plugin.", + update: "Actualiza este host para usar los ajustes del plugin.", + unavailable: "Esta pantalla de ajustes del plugin no está disponible.", + backToPlugins: "Volver a plugins", + }, title: "Plugins", trustedTitle: "Los plugins son código de confianza", trustedDescription: "Se ejecutan en este host y dentro de la app sin aislamiento. Instala solo código de confianza.", globalTitle: "Activar plugins", globalHint: "Interruptor global para todos los plugins configurados", - directoryLabel: "Directorio del plugin", - idLabel: "ID de instalación del plugin", - idHint: "Déjalo vacío para usar paseo-plugin.json", - idPlaceholder: "Valor del manifiesto", - install: "Instalar directorio", + sourceLabel: "Fuente del plugin", + sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm", + docs: "Documentación", + install: "Instalar plugin", installing: "Instalando…", status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" }, actions: { + menu: "Acciones para {{id}}", reload: "Recargar", reloading: "Recargando…", enable: "Activar", @@ -100,6 +114,7 @@ export const pluginSettings = { offlineTitle: "El host de plugins está sin conexión", offlineDescription: "Vuelve a conectar este host para gestionar sus plugins.", updateTitle: "Actualiza este host para gestionar plugins", + sourceUpdateTitle: "Actualiza este host para instalar plugins", loading: "Cargando plugins…", errorTitle: "No se pudieron cargar los plugins", retry: "Reintentar", @@ -108,20 +123,27 @@ export const pluginSettings = { }, fr: { ...shared, + screens: { + open: "Ouvrir", + offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.", + update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.", + unavailable: "Cet écran de paramètres du plugin est indisponible.", + backToPlugins: "Retour aux plugins", + }, title: "Plugins", trustedTitle: "Les plugins sont du code de confiance", trustedDescription: "Ils s’exécutent sur cet hôte et dans l’app sans isolation. N’installez que du code fiable.", globalTitle: "Activer les plugins", globalHint: "Interrupteur global pour tous les plugins configurés", - directoryLabel: "Dossier du plugin", - idLabel: "ID d’installation du plugin", - idHint: "Laissez vide pour utiliser paseo-plugin.json", - idPlaceholder: "Valeur du manifeste", - install: "Installer le dossier", + sourceLabel: "Source du plugin", + sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm", + docs: "Documentation", + install: "Installer le plugin", installing: "Installation…", status: { running: "actif", disabled: "désactivé", failed: "échec" }, actions: { + menu: "Actions pour {{id}}", reload: "Recharger", reloading: "Rechargement…", enable: "Activer", @@ -141,12 +163,13 @@ export const pluginSettings = { globalEnabled: "Plugins activés", globalDisabled: "Plugins désactivés", }, - removeConfirmTitle: "Supprimer {{id}} ?", + removeConfirmTitle: "Supprimer {{id}} ?", removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.", states: { offlineTitle: "L’hôte des plugins est hors ligne", offlineDescription: "Reconnectez cet hôte pour gérer ses plugins.", updateTitle: "Mettez cet hôte à jour pour gérer les plugins", + sourceUpdateTitle: "Mettez cet hôte à jour pour installer des plugins", loading: "Chargement des plugins…", errorTitle: "Impossible de charger les plugins", retry: "Réessayer", @@ -155,20 +178,27 @@ export const pluginSettings = { }, ja: { ...shared, + screens: { + open: "開く", + offline: "このホストに接続してプラグイン設定を開いてください。", + update: "プラグイン設定を使用するには、このホストを更新してください。", + unavailable: "このプラグイン設定画面は利用できません。", + backToPlugins: "プラグインに戻る", + }, title: "プラグイン", trustedTitle: "プラグインは信頼済みコードです", trustedDescription: "ホストとアプリ内で分離されずに実行されます。信頼できるコードのみインストールしてください。", globalTitle: "プラグインを有効化", globalHint: "設定済みプラグインすべての共通スイッチ", - directoryLabel: "プラグインディレクトリ", - idLabel: "プラグインのインストール ID", - idHint: "paseo-plugin.json を使う場合は空欄にします", - idPlaceholder: "マニフェストの既定値", - install: "ディレクトリをインストール", + sourceLabel: "プラグインソース", + sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ", + docs: "ドキュメント", + install: "プラグインをインストール", installing: "インストール中…", status: { running: "実行中", disabled: "無効", failed: "失敗" }, actions: { + menu: "{{id}} のアクション", reload: "再読み込み", reloading: "再読み込み中…", enable: "有効化", @@ -194,6 +224,7 @@ export const pluginSettings = { offlineTitle: "プラグインホストはオフラインです", offlineDescription: "プラグインを管理するにはホストに再接続してください。", updateTitle: "プラグインを管理するにはホストを更新してください", + sourceUpdateTitle: "プラグインをインストールするにはこのホストを更新してください", loading: "プラグインを読み込み中…", errorTitle: "プラグインを読み込めません", retry: "再試行", @@ -202,19 +233,26 @@ export const pluginSettings = { }, ko: { ...shared, + screens: { + open: "열기", + offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.", + update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.", + unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.", + backToPlugins: "플러그인으로 돌아가기", + }, title: "플러그인", trustedTitle: "플러그인은 신뢰하는 코드입니다", trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.", globalTitle: "플러그인 활성화", globalHint: "설정된 모든 플러그인의 전역 스위치", - directoryLabel: "플러그인 디렉터리", - idLabel: "플러그인 설치 ID", - idHint: "paseo-plugin.json을 사용하려면 비워 두세요", - idPlaceholder: "매니페스트 기본값", - install: "디렉터리 설치", + sourceLabel: "플러그인 소스", + sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지", + docs: "문서", + install: "플러그인 설치", installing: "설치 중…", status: { running: "실행 중", disabled: "비활성화", failed: "실패" }, actions: { + menu: "{{id}} 작업", reload: "다시 로드", reloading: "다시 로드 중…", enable: "활성화", @@ -240,6 +278,7 @@ export const pluginSettings = { offlineTitle: "플러그인 호스트가 오프라인입니다", offlineDescription: "플러그인을 관리하려면 호스트에 다시 연결하세요.", updateTitle: "플러그인을 관리하려면 호스트를 업데이트하세요", + sourceUpdateTitle: "플러그인을 설치하려면 이 호스트를 업데이트하세요", loading: "플러그인 로드 중…", errorTitle: "플러그인을 로드할 수 없습니다", retry: "다시 시도", @@ -248,20 +287,27 @@ export const pluginSettings = { }, "pt-BR": { ...shared, + screens: { + open: "Abrir", + offline: "Conecte-se a este host para abrir as configurações do plugin.", + update: "Atualize este host para usar as configurações do plugin.", + unavailable: "Esta tela de configurações do plugin está indisponível.", + backToPlugins: "Voltar para plugins", + }, title: "Plugins", trustedTitle: "Plugins são código confiável", trustedDescription: "Eles rodam neste host e dentro do app sem isolamento. Instale apenas código confiável.", globalTitle: "Ativar plugins", globalHint: "Controle global para todos os plugins configurados", - directoryLabel: "Diretório do plugin", - idLabel: "ID de instalação do plugin", - idHint: "Deixe vazio para usar paseo-plugin.json", - idPlaceholder: "Padrão do manifesto", - install: "Instalar diretório", + sourceLabel: "Fonte do plugin", + sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm", + docs: "Documentação", + install: "Instalar plugin", installing: "Instalando…", status: { running: "em execução", disabled: "desativado", failed: "falhou" }, actions: { + menu: "Ações para {{id}}", reload: "Recarregar", reloading: "Recarregando…", enable: "Ativar", @@ -287,6 +333,7 @@ export const pluginSettings = { offlineTitle: "O host de plugins está offline", offlineDescription: "Reconecte este host para gerenciar seus plugins.", updateTitle: "Atualize este host para gerenciar plugins", + sourceUpdateTitle: "Atualize este host para instalar plugins", loading: "Carregando plugins…", errorTitle: "Não foi possível carregar os plugins", retry: "Tentar novamente", @@ -295,20 +342,27 @@ export const pluginSettings = { }, ru: { ...shared, + screens: { + open: "Открыть", + offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.", + update: "Обновите этот хост, чтобы использовать настройки плагина.", + unavailable: "Этот экран настроек плагина недоступен.", + backToPlugins: "Вернуться к плагинам", + }, title: "Плагины", trustedTitle: "Плагины — доверенный код", trustedDescription: "Они выполняются на хосте и в приложении без изоляции. Устанавливайте только доверенный код.", globalTitle: "Включить плагины", globalHint: "Общий переключатель для всех настроенных плагинов", - directoryLabel: "Каталог плагина", - idLabel: "ID установки плагина", - idHint: "Оставьте пустым, чтобы использовать paseo-plugin.json", - idPlaceholder: "Значение из манифеста", - install: "Установить каталог", + sourceLabel: "Источник плагина", + sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm", + docs: "Документация", + install: "Установить плагин", installing: "Установка…", status: { running: "работает", disabled: "отключён", failed: "ошибка" }, actions: { + menu: "Действия для {{id}}", reload: "Перезагрузить", reloading: "Перезагрузка…", enable: "Включить", @@ -334,6 +388,7 @@ export const pluginSettings = { offlineTitle: "Хост плагинов не в сети", offlineDescription: "Подключите хост снова, чтобы управлять плагинами.", updateTitle: "Обновите хост для управления плагинами", + sourceUpdateTitle: "Обновите этот хост для установки плагинов", loading: "Загрузка плагинов…", errorTitle: "Не удалось загрузить плагины", retry: "Повторить", @@ -342,19 +397,26 @@ export const pluginSettings = { }, "zh-CN": { ...shared, + screens: { + open: "打开", + offline: "连接到此主机以打开插件设置。", + update: "更新此主机以使用插件设置。", + unavailable: "此插件设置页面不可用。", + backToPlugins: "返回插件", + }, title: "插件", trustedTitle: "插件是受信任的代码", trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。", globalTitle: "启用插件", globalHint: "所有已配置插件的全局开关", - directoryLabel: "插件目录", - idLabel: "插件安装 ID", - idHint: "留空以使用 paseo-plugin.json", - idPlaceholder: "清单默认值", - install: "安装目录", + sourceLabel: "插件来源", + sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包", + docs: "文档", + install: "安装插件", installing: "正在安装…", status: { running: "运行中", disabled: "已禁用", failed: "失败" }, actions: { + menu: "{{id}} 的操作", reload: "重新加载", reloading: "正在重新加载…", enable: "启用", @@ -380,6 +442,7 @@ export const pluginSettings = { offlineTitle: "插件主机离线", offlineDescription: "重新连接此主机以管理其插件。", updateTitle: "更新此主机以管理插件", + sourceUpdateTitle: "更新此主机以安装插件", loading: "正在加载插件…", errorTitle: "无法加载插件", retry: "重试", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index f6ee9bdc29..66a25e9f03 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2,16 +2,43 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { + paneFind: { + connectionFailure: + "Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.", + historyChangedFailure: "A conversa mudou durante a busca. Busque novamente.", + revealFailure: "Não foi possível mostrar esta correspondência. Tente novamente.", + searching: "Buscando…", + loading: "Carregando…", + failed: "Falhou", + retry: "Tentar novamente", + + title: "Buscar", + placeholder: "Buscar no painel", + close: "Fechar busca", + matches: "Correspondências", + previous: "Correspondência anterior", + next: "Próxima correspondência", + toggleReplace: "Mostrar substituição", + replaceWith: "Substituir por", + replace: "Substituir", + replaceAll: "Substituir tudo", + noMatches: "Nenhuma correspondência", + position: "{{current}} de {{total}}", + total: "{{total}} correspondências", + }, common: { + bottomSheetBackdrop: "Fundo do painel inferior", back: "Voltar", loading: "Carregando...", actions: { + save: "Salvar", back: "Voltar", cancel: "Cancelar", close: "Fechar", copy: "Copiar", copyLine: "Copiar linha", dismiss: "Dispensar", + reload: "Recarregar", retry: "Tentar novamente", search: "Buscar", select: "Selecionar", @@ -98,6 +125,8 @@ export const ptBR: TranslationResources = { input: { accessibilityLabel: "Enviar mensagem ao agente...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Tela cheia", + exitFullscreen: "Sair da tela cheia", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", @@ -153,7 +182,7 @@ export const ptBR: TranslationResources = { initialPromptRequired: "O prompt inicial é obrigatório", alreadyLoading: "Já está carregando", uploadFailed: "Falha ao enviar arquivo", - noClipboardImage: "Não há imagem na área de transferência", + noClipboardImage: "Não há imagem na área de transferência atual. Tente colar pelo teclado.", pasteImageFailed: "Falha ao colar a imagem", fileTooLarge: "{{fileName}} é grande demais (máximo {{size}})", }, @@ -208,6 +237,10 @@ export const ptBR: TranslationResources = { historyLoadFailed: "Não foi possível carregar o histórico do agente", messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).", permission: { + rejectedPlan: "Plano rejeitado", + approvedPlan: "Plano aprovado", + canceledPlan: "Plano cancelado", + plan: "Plano", required: "Permissão necessária", deny: "Negar", @@ -221,7 +254,8 @@ export const ptBR: TranslationResources = { states: { notFound: "Agente não encontrado", failedToLoad: "Falha ao carregar agente", - reconnecting: "Reconectando", + reconnecting: "Reconectando ao host", + updating: "Atualizando mensagens", timelineSyncFailed: "Não foi possível atualizar o histórico do agente.", timelineSyncRetrying: "Tentando novamente…", archivingTitle: "Arquivando agente...", @@ -355,6 +389,9 @@ export const ptBR: TranslationResources = { completed: "Concluída", }, }, + turnFooter: { + workedFor: "Trabalhou por {{duration}}", + }, compaction: { loading: "Compactando...", auto: "Contexto compactado automaticamente", @@ -413,7 +450,9 @@ export const ptBR: TranslationResources = { recovery: { archivedTitle: "Workspace arquivado", restoreDescription: - "{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.", + "Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.", + restoreWithoutBranchDescription: + "Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.", unarchiveDescription: "{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.", restoreAction: "Restaurar", @@ -901,6 +940,9 @@ export const ptBR: TranslationResources = { openChangesTab: "Abrir a aba Alterações", openDiffTab: "Abrir a aba Diff", closeChangesTab: "Fechar a aba Alterações", + jumpToFile: { + title: "Ir para o arquivo", + }, binaryFile: "Arquivo binário", tooLarge: "Diff grande demais para exibir", previewTooLargeTitle: "Este diff é grande demais para visualizar", @@ -923,11 +965,8 @@ export const ptBR: TranslationResources = { refresh: "Atualizar", refreshState: "Atualizar estado do git e do {{brand}}", failedRefresh: "Falha ao atualizar estado do git.", - emptyHiddenWhitespace: "Nenhuma alteração visível após ocultar espaços em branco", - emptyUncommitted: "Nenhuma alteração sem commit", seeUncommittedChanges: "Ver alterações sem commit", seeCommittedChanges: "Ver alterações com commit", - emptyAgainstBase: "Nenhuma alteração vs {{baseRef}}", checkingRepository: "Verificando repositório...", notRepository: "Não é um repositório git", diffMode: "Modo de diff", @@ -960,6 +999,47 @@ export const ptBR: TranslationResources = { actions: { viewPullRequest: "Ver", openOn: "Abrir no {{brand}}", + addToChat: "Adicionar ao chat", + addAllToChat: "Adicionar tudo ao chat", + addingToChat: "Adicionando...", + }, + checksOverview: { + headline: { + actionRequired: "Algumas verificações precisam da sua atenção", + failure: "Algumas verificações não foram bem-sucedidas", + pending: "Algumas verificações ainda não terminaram", + success: "Todas as verificações foram aprovadas", + none: "Nenhuma verificação", + }, + count: { + actionRequired: "{{count}} com ação pendente", + warning: "{{count}} com aviso", + failure: "{{count}} com falha", + pending: "{{count}} em andamento", + manual: "{{count}} manual(is)", + success: "{{count}} aprovada(s)", + ignored: "{{count}} ignorada(s)", + }, + detailOne: "Verificação: {{parts}}", + detailMany: "Verificações: {{parts}}", + groupOne: { + actionRequired: "{{count}} verificação com ação pendente", + warning: "{{count}} verificação com aviso", + failure: "{{count}} verificação com falha", + pending: "{{count}} verificação em andamento", + manual: "{{count}} verificação manual", + success: "{{count}} verificação aprovada", + ignored: "{{count}} verificação ignorada", + }, + groupMany: { + actionRequired: "{{count}} verificações com ação pendente", + warning: "{{count}} verificações com aviso", + failure: "{{count}} verificações com falha", + pending: "{{count}} verificações em andamento", + manual: "{{count}} verificações manuais", + success: "{{count}} verificações aprovadas", + ignored: "{{count}} verificações ignoradas", + }, }, checksSummary: { passedLabel: "passou", @@ -973,17 +1053,21 @@ export const ptBR: TranslationResources = { checks: "Verificações", pipeline: "Pipeline", reviews: "Revisões", + activity: "Atividade", }, empty: { noJobs: "Sem jobs", loadingPipeline: "Carregando pipeline...", pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline", allowedToFail: "permitido falhar", + noActivity: "Nenhuma atividade ainda", }, approvals: "{{given}} de {{required}} aprovações", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "Merge request !{{number}}", + commentActions: "Ações do comentário", + threadActions: "Ações da conversa", checkStatus: { passed: "Aprovado", failed: "Falhou", @@ -1003,15 +1087,17 @@ export const ptBR: TranslationResources = { }, activity: { commented: "Comentou", - approved: "Aprovado", + approved: "Aprovou", requestedChanges: "Solicitou alterações", - reviewed: "Revisado", + reviewed: "Revisou", }, time: { justNow: "agora mesmo", }, thread: { discussion: "Tópico de discussão", + resolved: "Resolvido", + outdated: "Desatualizado", }, errors: { statusLoadFailed: "Não foi possível carregar o status da pull request", @@ -1070,8 +1156,27 @@ export const ptBR: TranslationResources = { updateHost: "Atualize este host para gerenciar etiquetas.", }, }, + changelog: { + title: "Novidades", + installed: "Instalada", + showMore: "Mostrar mais", + openWebsite: "Changelog completo", + error: { + title: "Não foi possível carregar o changelog", + description: "Verifique sua conexão e tente novamente.", + }, + }, sidebar: { + statusGroupAccessibility: "Grupo {{label}}", + statusBucket: { + needsInput: "Precisa de resposta", + failed: "Com falha", + readyToReview: "Para revisar", + working: "Em execução", + done: "Concluído", + }, display: { + showBackground: "Mostrar espaços em segundo plano", trigger: "Preferências de exibição", heading: "Exibição", grouping: { @@ -1131,6 +1236,9 @@ export const ptBR: TranslationResources = { settings: "Configurações", closeSidebar: "Fechar barra lateral", }, + footer: { + usage: "Uso", + }, help: { trigger: "Ajuda e suporte", sectionHelp: "Ajuda", @@ -1291,6 +1399,7 @@ export const ptBR: TranslationResources = { detail: "Parando o daemon local.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "Status", @@ -1512,6 +1621,8 @@ export const ptBR: TranslationResources = { noFiles: "Nenhum arquivo ou diretório encontrado", noCommands: "Nenhum comando encontrado", failedToLoad: "Falha ao carregar", + chooseProjectForCommands: "Escolha um projeto para ver os comandos", + chooseModelForCommands: "Selecione um modelo para ver os comandos", }, loadOlderHistory: { failed: "Não foi possível carregar o histórico mais antigo", @@ -1603,6 +1714,21 @@ export const ptBR: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "Senha de {{host}}", + label: "Senha do host", + }, + hostConfirmation: { + title: "Conectar a este host?", + description: + "Este host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.", + descriptionChanged: + "Este link muda como você se conecta a este host. O host poderá executar código neste app e acessar seus outros hosts conectados. Conecte apenas se você o reconhecer.", + hostLabel: "Host", + fingerprintLabel: "Impressão da chave", + relayLabel: "Relay", + connect: "Conectar", + }, connectionMethods: { title: "Adicionar conexão", direct: { @@ -1670,6 +1796,12 @@ export const ptBR: TranslationResources = { helper: "Conecte-se a um daemon Paseo no host remoto.", fields: { target: "Host SSH", + password: "Senha do daemon", + optional: "Opcional", + }, + passwordVisibility: { + show: "Mostrar senha", + hide: "Ocultar senha", }, actions: { cancel: "Cancelar", @@ -1900,6 +2032,8 @@ export const ptBR: TranslationResources = { dismiss: "Dispensar", }, contextWindow: { + noData: "Sem dados de contexto", + accessibilityNoData: "Janela de contexto: sem dados de contexto", title: "Janela de contexto", used: "{{percentage}}% usado", tokens: "{{used}} / {{max}} tokens", @@ -1907,6 +2041,17 @@ export const ptBR: TranslationResources = { accessibility: "Janela de contexto {{percentage}}% usada", }, review: { + feedback: { + send: "Enviar comentários ({{count}})", + sending: "Enviando comentários ({{count}})", + chooseAgent: "Escolher um agente", + sent: "Comentários enviados para {{recipient}}", + "no-agents": "Abra uma aba de agente neste espaço de trabalho para enviar comentários.", + disconnected: "Conecte-se ao host para enviar comentários.", + "no-context": "Os comentários salvos não correspondem mais a este diff.", + failed: "Falha ao enviar comentários. Tente novamente.", + prompt: "Por favor, atenda a esta revisão de código.", + }, comment: { add: "Adicionar comentário de revisão", edit: "Editar comentário de revisão", @@ -1941,8 +2086,11 @@ export const ptBR: TranslationResources = { groupInfo: "Sobre {{title}}", sections: { general: "Geral", + chat: "Chat", appearance: "Aparência", - layout: en.settings.sections.layout, + sidebar: "Barra lateral", + terminal: "Terminal", + browser: "Navegador", editor: "Editor", shortcuts: "Atalhos", integrations: "Integrações", @@ -2001,6 +2149,7 @@ export const ptBR: TranslationResources = { }, general: { title: "Geral", + sending: "Envio", browserData: { title: "Dados do navegador", siteData: "Cookies e dados de sites", @@ -2029,8 +2178,6 @@ export const ptBR: TranslationResources = { }, }, serviceUrls: { - label: "URLs de serviço", - description: "Onde abrir URLs de scripts em execução", options: { ask: "Perguntar", inApp: "No Paseo", @@ -2050,7 +2197,6 @@ export const ptBR: TranslationResources = { toolCallDetail: { label: "Exibição de chamadas de ferramentas", description: "Como as chamadas de ferramentas aparecem na linha do tempo", - accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})", options: { overview: "Resumo", detailed: "Detalhes completos", @@ -2104,6 +2250,7 @@ export const ptBR: TranslationResources = { about: { title: "Sobre", appVersion: "Versão do app", + whatsNewHint: "Notas de versão de cada release", thisDevice: "Este dispositivo", connectedHosts: "Hosts conectados", offline: "Offline", @@ -2152,8 +2299,15 @@ export const ptBR: TranslationResources = { description: "Mostrar uma estrutura para navegar entre prompts", }, sidebar: { - title: "Barra lateral", - description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", + header: { + title: "Cabeçalho", + description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem", + }, + footer: { + title: "Rodapé", + description: + "Escolha quais linhas aparecem na parte inferior da barra lateral e em que ordem. Adicionar projeto e a linha de ícones sempre aparecem", + }, moveUp: "Mover para cima", moveDown: "Mover para baixo", }, @@ -2177,6 +2331,14 @@ export const ptBR: TranslationResources = { codeSizeHint: "Usado em código, diffs e saída do terminal", codeSizeAccessibility: "Tamanho da fonte de código", }, + layout: { + title: "Layout", + contentWidth: "Largura do conteúdo", + contentWidthHint: "Largura máxima do chat e dos arquivos Markdown em telas largas", + contentWidthAccessibility: "Largura do conteúdo em pixels", + reset: "Redefinir", + resetAccessibility: "Redefinir a largura do conteúdo", + }, syntax: { title: "Sintaxe", highlightTheme: "Tema de destaque", @@ -2288,6 +2450,9 @@ export const ptBR: TranslationResources = { }, }, host: { + password: { + guidance: "Remova este host e adicione-o novamente com a senha que este daemon pede.", + }, appearance: { title: "Aparência", name: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index eefd8aa147..c06e53eda0 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2,16 +2,43 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { + paneFind: { + connectionFailure: + "Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.", + historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.", + revealFailure: "Не удалось показать это совпадение. Повторите попытку.", + searching: "Поиск…", + loading: "Загрузка…", + failed: "Ошибка", + retry: "Повторить", + + title: "Найти", + placeholder: "Найти в панели", + close: "Закрыть поиск", + matches: "Совпадения", + previous: "Предыдущее совпадение", + next: "Следующее совпадение", + toggleReplace: "Показать замену", + replaceWith: "Заменить на", + replace: "Заменить", + replaceAll: "Заменить всё", + noMatches: "Нет совпадений", + position: "{{current}} из {{total}}", + total: "Совпадений: {{total}}", + }, common: { + bottomSheetBackdrop: "Фон нижней панели", back: "Назад", loading: "Загрузка...", actions: { + save: "Сохранить", back: "Назад", cancel: "Отмена", close: "Закрыть", copy: "Копировать", copyLine: "Копировать строку", dismiss: "Отклонить", + reload: "Перезагрузить", retry: "Повторить", search: "Поиск", select: "Выбрать", @@ -98,6 +125,8 @@ export const ru: TranslationResources = { input: { accessibilityLabel: "Написать агенту...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "Полный экран", + exitFullscreen: "Выйти из полноэкранного режима", focusHint: "{{shortcut}}, для фокуса", addAttachment: "Добавить вложение", interruptAgent: "Прервать агента", @@ -153,7 +182,8 @@ export const ru: TranslationResources = { initialPromptRequired: "Необходим инициализирующий промпт", alreadyLoading: "Уже загружается", uploadFailed: "Ошибка загрузки файла", - noClipboardImage: "В буфере обмена нет изображения", + noClipboardImage: + "В текущем буфере обмена нет изображения. Попробуйте вставить его с клавиатуры.", pasteImageFailed: "Не удалось вставить изображение", fileTooLarge: "Файл {{fileName}} слишком большой (максимальный размер: {{size}})", }, @@ -208,6 +238,10 @@ export const ru: TranslationResources = { historyLoadFailed: "Не удалось загрузить историю агента", messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { + rejectedPlan: "Отклонённый план", + approvedPlan: "Одобренный план", + canceledPlan: "Отменённый план", + plan: "План", required: "Требуется разрешение", deny: "Отклонить", @@ -221,7 +255,8 @@ export const ru: TranslationResources = { states: { notFound: "Агент не найден", failedToLoad: "Не удалось загрузить агента", - reconnecting: "Повторное подключение", + reconnecting: "Повторное подключение к хосту", + updating: "Обновление сообщений", timelineSyncFailed: "Не удалось обновить историю агента.", timelineSyncRetrying: "Повторная попытка…", archivingTitle: "Агент перемещается в архив...", @@ -354,6 +389,9 @@ export const ru: TranslationResources = { completed: "Завершена", }, }, + turnFooter: { + workedFor: "Время работы: {{duration}}", + }, compaction: { loading: "Сжатие контекста...", auto: "Контекст сжат автоматически", @@ -412,7 +450,9 @@ export const ru: TranslationResources = { recovery: { archivedTitle: "Рабочая область в архиве", restoreDescription: - "Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.", + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.", + restoreWithoutBranchDescription: + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.", unarchiveDescription: "Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.", restoreAction: "Восстановить", @@ -894,6 +934,9 @@ export const ru: TranslationResources = { openChangesTab: "Открыть вкладку «Изменения»", openDiffTab: "Открыть вкладку «Дифф»", closeChangesTab: "Закрыть вкладку «Изменения»", + jumpToFile: { + title: "Перейти к файлу", + }, binaryFile: "Бинарный файл", tooLarge: "Дифф слишком велик для отображения", previewTooLargeTitle: "Этот дифф слишком велик для предварительного просмотра", @@ -916,11 +959,8 @@ export const ru: TranslationResources = { refresh: "Обновить", refreshState: "Обновить состояние Git и {{brand}}", failedRefresh: "Не удалось обновить состояние Git.", - emptyHiddenWhitespace: "После скрытия пробельных изменений видимых изменений нет", - emptyUncommitted: "Нет незафиксированных изменений", seeUncommittedChanges: "Показать незафиксированные изменения", seeCommittedChanges: "Показать зафиксированные изменения", - emptyAgainstBase: "Нет изменений относительно {{baseRef}}", checkingRepository: "Проверяем репозиторий...", notRepository: "Это не репозиторий Git", diffMode: "Режим диффа", @@ -953,6 +993,47 @@ export const ru: TranslationResources = { actions: { viewPullRequest: "Просмотреть", openOn: "Открыть на {{brand}}", + addToChat: "Добавить в чат", + addAllToChat: "Добавить всё в чат", + addingToChat: "Добавление...", + }, + checksOverview: { + headline: { + actionRequired: "Некоторые проверки требуют внимания", + failure: "Некоторые проверки не пройдены", + pending: "Некоторые проверки ещё не завершены", + success: "Все проверки пройдены", + none: "Нет проверок", + }, + count: { + actionRequired: "требует действий: {{count}}", + warning: "с предупреждением: {{count}}", + failure: "не пройдено: {{count}}", + pending: "выполняется: {{count}}", + manual: "вручную: {{count}}", + success: "пройдено: {{count}}", + ignored: "пропущено: {{count}}", + }, + detailOne: "Проверка ({{parts}})", + detailMany: "Проверки ({{parts}})", + groupOne: { + actionRequired: "Требует действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдена: {{count}}", + pending: "Выполняется: {{count}}", + manual: "Ручная: {{count}}", + success: "Пройдена: {{count}}", + ignored: "Пропущена: {{count}}", + }, + groupMany: { + actionRequired: "Требуют действий: {{count}}", + warning: "С предупреждением: {{count}}", + failure: "Не пройдены: {{count}}", + pending: "Выполняются: {{count}}", + manual: "Ручные: {{count}}", + success: "Пройдены: {{count}}", + ignored: "Пропущены: {{count}}", + }, }, checksSummary: { passedLabel: "успешно", @@ -966,17 +1047,21 @@ export const ru: TranslationResources = { checks: "Проверки", pipeline: "Пайплайн", reviews: "Ревью", + activity: "Активность", }, empty: { noJobs: "Нет заданий", loadingPipeline: "Загрузка пайплайна...", pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна", allowedToFail: "допускается сбой", + noActivity: "Активности пока нет", }, approvals: "Одобрено: {{given}} из {{required}}", accessibility: { pullRequest: "PR #{{number}}", pullRequest_mr: "MR !{{number}}", + commentActions: "Действия с комментарием", + threadActions: "Действия с обсуждением", checkStatus: { passed: "Успешно", failed: "Ошибка", @@ -1005,6 +1090,8 @@ export const ru: TranslationResources = { }, thread: { discussion: "Ветка обсуждения", + resolved: "Решено", + outdated: "Устарело", }, errors: { statusLoadFailed: "Не удалось загрузить статус PR", @@ -1060,8 +1147,27 @@ export const ru: TranslationResources = { updateHost: "Обновите этот хост для управления метками.", }, }, + changelog: { + title: "Что нового", + installed: "Установлена", + showMore: "Показать ещё", + openWebsite: "Полный список изменений", + error: { + title: "Не удалось загрузить список изменений", + description: "Проверьте подключение и попробуйте снова.", + }, + }, sidebar: { + statusGroupAccessibility: "Группа «{{label}}»", + statusBucket: { + needsInput: "Ожидает ввода", + failed: "Ошибка", + readyToReview: "На проверку", + working: "В работе", + done: "Готово", + }, display: { + showBackground: "Показать фоновые пространства", trigger: "Настройки отображения", heading: "Отображение", grouping: { @@ -1122,6 +1228,9 @@ export const ru: TranslationResources = { settings: "Настройки", closeSidebar: "Закрыть боковую панель", }, + footer: { + usage: "Использование", + }, help: { trigger: "Помощь и поддержка", sectionHelp: "Помощь", @@ -1283,6 +1392,7 @@ export const ru: TranslationResources = { detail: "Остановка локального демона.", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Демон", status: { title: "Статус", @@ -1509,6 +1619,8 @@ export const ru: TranslationResources = { noFiles: "Файлы и каталоги не найдены", noCommands: "Команды не найдены", failedToLoad: "Не удалось загрузить", + chooseProjectForCommands: "Выберите проект, чтобы увидеть команды", + chooseModelForCommands: "Выберите модель, чтобы увидеть команды", }, loadOlderHistory: { failed: "Не удалось загрузить старую историю.", @@ -1600,6 +1712,21 @@ export const ru: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "Пароль для {{host}}", + label: "Пароль хоста", + }, + hostConfirmation: { + title: "Подключиться к этому хосту?", + description: + "Этот хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.", + descriptionChanged: + "Эта ссылка меняет способ подключения к этому хосту. Хост сможет выполнять код в приложении и обращаться к другим вашим подключённым хостам. Подключайтесь, только если узнаёте его.", + hostLabel: "Хост", + fingerprintLabel: "Отпечаток ключа", + relayLabel: "Реле", + connect: "Подключить", + }, connectionMethods: { title: "Добавить подключение", direct: { @@ -1667,6 +1794,12 @@ export const ru: TranslationResources = { helper: "Подключитесь к демону Paseo на удалённом хосте.", fields: { target: "Хост SSH", + password: "Пароль демона", + optional: "Необязательно", + }, + passwordVisibility: { + show: "Показать пароль", + hide: "Скрыть пароль", }, actions: { cancel: "Отмена", @@ -1899,6 +2032,8 @@ export const ru: TranslationResources = { dismiss: "Закрыть", }, contextWindow: { + noData: "Нет данных о контексте", + accessibilityNoData: "Контекстное окно: нет данных о контексте", title: "Контекстное окно", used: "Использовано: {{percentage}}%", tokens: "Токены: {{used}} / {{max}}", @@ -1906,6 +2041,17 @@ export const ru: TranslationResources = { accessibility: "Использовано {{percentage}}% контекстного окна", }, review: { + feedback: { + send: "Отправить отзыв ({{count}})", + sending: "Отправка отзыва ({{count}})", + chooseAgent: "Выберите агента", + sent: "Отзыв отправлен: {{recipient}}", + "no-agents": "Откройте вкладку агента в этой рабочей области, чтобы отправить отзыв.", + disconnected: "Подключитесь к хосту, чтобы отправить отзыв.", + "no-context": "Сохранённые комментарии больше не соответствуют этому diff.", + failed: "Не удалось отправить отзыв. Повторите попытку.", + prompt: "Пожалуйста, учтите эту проверку кода.", + }, comment: { add: "Добавить комментарий к ревью", edit: "Изменить комментарий к ревью", @@ -1940,8 +2086,11 @@ export const ru: TranslationResources = { groupInfo: "О разделе «{{title}}»", sections: { general: "Основные", + chat: "Чат", appearance: "Оформление", - layout: en.settings.sections.layout, + sidebar: "Боковая панель", + terminal: "Терминал", + browser: "Браузер", editor: "Редактор", shortcuts: "Сочетания клавиш", integrations: "Интеграции", @@ -2000,6 +2149,7 @@ export const ru: TranslationResources = { }, general: { title: "Основные", + sending: "Отправка", browserData: { title: "Данные браузера", siteData: "Файлы cookie и данные сайтов", @@ -2029,8 +2179,6 @@ export const ru: TranslationResources = { }, }, serviceUrls: { - label: "URL-адреса сервисов", - description: "Где открывать URL-адреса запущенных скриптов", options: { ask: "Спрашивать", inApp: "В Paseo", @@ -2050,7 +2198,6 @@ export const ru: TranslationResources = { toolCallDetail: { label: "Отображение вызовов инструментов", description: "Как вызовы инструментов отображаются на временной шкале", - accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})", options: { overview: "Сводка", detailed: "Все подробности", @@ -2105,6 +2252,7 @@ export const ru: TranslationResources = { about: { title: "О приложении", appVersion: "Версия приложения", + whatsNewHint: "Заметки о выпуске для каждой версии", thisDevice: "Это устройство", connectedHosts: "Подключенные хосты", offline: "Оффлайн", @@ -2154,9 +2302,16 @@ export const ru: TranslationResources = { description: "Показывать оглавление для перехода между запросами", }, sidebar: { - title: "Боковая панель", - description: - "Выберите, какие элементы отображаются вверху боковой панели и в каком порядке", + header: { + title: "Верх", + description: + "Выберите, какие элементы отображаются вверху боковой панели и в каком порядке", + }, + footer: { + title: "Низ", + description: + "Выберите, какие строки отображаются внизу боковой панели и в каком порядке. «Добавить проект» и ряд значков видны всегда", + }, moveUp: "Переместить вверх", moveDown: "Переместить вниз", }, @@ -2181,6 +2336,14 @@ export const ru: TranslationResources = { codeSizeHint: "Используется для кода, диффов и вывода терминала", codeSizeAccessibility: "Размер шрифта кода", }, + layout: { + title: "Макет", + contentWidth: "Ширина содержимого", + contentWidthHint: "Максимальная ширина чата и файлов Markdown на широких экранах", + contentWidthAccessibility: "Ширина содержимого в пикселях", + reset: "Сбросить", + resetAccessibility: "Сбросить ширину содержимого", + }, syntax: { title: "Синтаксис", highlightTheme: "Тема подсветки", @@ -2291,6 +2454,10 @@ export const ru: TranslationResources = { }, }, host: { + password: { + guidance: + "Удалите этот хост и добавьте его снова с паролем, который запрашивает этот демон.", + }, appearance: { title: "Оформление", name: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 35e160de59..27a0d62ac1 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2,16 +2,42 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { + paneFind: { + connectionFailure: "无法搜索此聊天。请检查主机连接后重试。", + historyChangedFailure: "搜索期间聊天已更改。请重新搜索。", + revealFailure: "无法显示此匹配项。请重试。", + searching: "搜索中…", + loading: "加载中…", + failed: "失败", + retry: "重试", + + title: "查找", + placeholder: "在窗格中查找", + close: "关闭查找", + matches: "查找结果", + previous: "上一个匹配项", + next: "下一个匹配项", + toggleReplace: "切换替换", + replaceWith: "替换为", + replace: "替换", + replaceAll: "全部替换", + noMatches: "无匹配项", + position: "{{current}} / {{total}}", + total: "{{total}} 个匹配项", + }, common: { + bottomSheetBackdrop: "底部面板背景", back: "返回", loading: "加载中...", actions: { + save: "保存", back: "返回", cancel: "取消", close: "关闭", copy: "复制", copyLine: "复制行", dismiss: "关闭", + reload: "重新加载", retry: "重试", search: "搜索", select: "选择", @@ -98,6 +124,8 @@ export const zhCN: TranslationResources = { input: { accessibilityLabel: "给 Agent 发消息...", terminalAccessibilityLabel: "Terminal prompt", + fullscreen: "全屏", + exitFullscreen: "退出全屏", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", @@ -153,7 +181,7 @@ export const zhCN: TranslationResources = { initialPromptRequired: "初始 prompt 必填", alreadyLoading: "正在加载", uploadFailed: "Failed to upload file", - noClipboardImage: "剪贴板中没有图片", + noClipboardImage: "当前剪贴板中没有图片。请尝试通过键盘粘贴。", pasteImageFailed: "无法粘贴图片", fileTooLarge: "{{fileName}} is too large (max {{size}})", }, @@ -208,6 +236,10 @@ export const zhCN: TranslationResources = { historyLoadFailed: "无法加载智能体历史记录", messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { + rejectedPlan: "已拒绝的计划", + approvedPlan: "已批准的计划", + canceledPlan: "已取消的计划", + plan: "Plan", required: "需要权限", deny: "拒绝", @@ -221,7 +253,8 @@ export const zhCN: TranslationResources = { states: { notFound: "未找到 Agent", failedToLoad: "加载 Agent 失败", - reconnecting: "正在重连", + reconnecting: "正在重新连接主机", + updating: "正在更新消息", timelineSyncFailed: "无法刷新代理历史记录。", timelineSyncRetrying: "正在重试…", archivingTitle: "正在归档 Agent...", @@ -352,6 +385,9 @@ export const zhCN: TranslationResources = { completed: "已完成", }, }, + turnFooter: { + workedFor: "工作了 {{duration}}", + }, compaction: { loading: "正在压缩...", auto: "上下文已自动压缩", @@ -410,7 +446,9 @@ export const zhCN: TranslationResources = { recovery: { archivedTitle: "Workspace 已归档", restoreDescription: - "{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。", + "恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。", + restoreWithoutBranchDescription: + "恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。", unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。", restoreAction: "恢复", unarchiveAction: "取消归档", @@ -871,6 +909,9 @@ export const zhCN: TranslationResources = { openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", + jumpToFile: { + title: "跳转到文件", + }, binaryFile: "二进制文件", tooLarge: "Diff 过大,无法显示", previewTooLargeTitle: "此 diff 过大,无法预览", @@ -893,11 +934,8 @@ export const zhCN: TranslationResources = { refresh: "刷新", refreshState: "刷新 git 和 {{brand}} 状态", failedRefresh: "刷新 git 状态失败。", - emptyHiddenWhitespace: "隐藏空白差异后没有可见变更", - emptyUncommitted: "没有未 commit 的变更", seeUncommittedChanges: "查看未 commit 的变更", seeCommittedChanges: "查看已 commit 的变更", - emptyAgainstBase: "相对于 {{baseRef}} 没有变更", checkingRepository: "正在检查 repository...", notRepository: "不是 git repository", diffMode: "Diff 模式", @@ -930,6 +968,47 @@ export const zhCN: TranslationResources = { actions: { viewPullRequest: "查看", openOn: "在 {{brand}} 上打开", + addToChat: "添加到聊天", + addAllToChat: "全部添加到聊天", + addingToChat: "正在添加...", + }, + checksOverview: { + headline: { + actionRequired: "部分检查需要你处理", + failure: "部分检查未通过", + pending: "部分检查尚未完成", + success: "所有检查均已通过", + none: "没有检查", + }, + count: { + actionRequired: "{{count}} 个需处理", + warning: "{{count}} 个警告", + failure: "{{count}} 个失败", + pending: "{{count}} 个进行中", + manual: "{{count}} 个手动", + success: "{{count}} 个成功", + ignored: "{{count}} 个已跳过", + }, + detailOne: "检查:{{parts}}", + detailMany: "检查:{{parts}}", + groupOne: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, + groupMany: { + actionRequired: "{{count}} 个需处理的检查", + warning: "{{count}} 个有警告的检查", + failure: "{{count}} 个失败的检查", + pending: "{{count}} 个进行中的检查", + manual: "{{count}} 个手动检查", + success: "{{count}} 个成功的检查", + ignored: "{{count}} 个已跳过的检查", + }, }, checksSummary: { passedLabel: "通过", @@ -943,17 +1022,21 @@ export const zhCN: TranslationResources = { checks: "Checks", pipeline: "流水线", reviews: "Reviews", + activity: "动态", }, empty: { noJobs: "无作业", loadingPipeline: "正在加载流水线...", pipelineJobsLoadFailed: "无法加载流水线作业", allowedToFail: "允许失败", + noActivity: "暂无动态", }, approvals: "{{given}} / {{required}} 批准", accessibility: { pullRequest: "Pull request #{{number}}", pullRequest_mr: "合并请求 !{{number}}", + commentActions: "评论操作", + threadActions: "讨论串操作", checkStatus: { passed: "成功", failed: "失败", @@ -966,22 +1049,24 @@ export const zhCN: TranslationResources = { }, }, states: { - draft: "Draft", - merged: "已 merge", + draft: "草稿", + merged: "已合并", closed: "已关闭", - open: "Open", + open: "开放", }, activity: { commented: "已评论", approved: "已批准", requestedChanges: "请求修改", - reviewed: "已 review", + reviewed: "已审查", }, time: { justNow: "刚刚", }, thread: { discussion: "讨论主题", + resolved: "已解决", + outdated: "已过时", }, errors: { statusLoadFailed: "无法加载 Pull Request 状态", @@ -1037,8 +1122,27 @@ export const zhCN: TranslationResources = { updateHost: "请更新此主机以管理标签。", }, }, + changelog: { + title: "新功能", + installed: "已安装", + showMore: "显示更多", + openWebsite: "完整更新日志", + error: { + title: "无法加载更新日志", + description: "请检查网络连接后重试。", + }, + }, sidebar: { + statusGroupAccessibility: "{{label}} 分组", + statusBucket: { + needsInput: "需要输入", + failed: "失败", + readyToReview: "待查看", + working: "运行中", + done: "已完成", + }, display: { + showBackground: "显示后台工作区", trigger: "显示偏好", heading: "显示", grouping: { @@ -1098,6 +1202,9 @@ export const zhCN: TranslationResources = { settings: "设置", closeSidebar: "关闭侧边栏", }, + footer: { + usage: "使用情况", + }, help: { trigger: "帮助与支持", sectionHelp: "帮助", @@ -1255,6 +1362,7 @@ export const zhCN: TranslationResources = { detail: "正在停止本地 daemon。", }, daemon: { + lifecycle: en.desktop.daemon.lifecycle, title: "Daemon", status: { title: "状态", @@ -1465,6 +1573,8 @@ export const zhCN: TranslationResources = { noFiles: "没有找到文件或目录", noCommands: "没有找到 commands", failedToLoad: "加载失败", + chooseProjectForCommands: "选择项目以查看命令", + chooseModelForCommands: "选择模型以查看命令", }, loadOlderHistory: { failed: "无法加载更早历史", @@ -1556,6 +1666,20 @@ export const zhCN: TranslationResources = { }, }, pairing: { + hostPassword: { + title: "{{host}} 的密码", + label: "主机密码", + }, + hostConfirmation: { + title: "连接到此主机?", + description: "此主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。", + descriptionChanged: + "此链接会改变你连接此主机的方式。该主机将能在此应用中运行代码,并访问你其他已连接的主机。仅在你认识它时才连接。", + hostLabel: "主机", + fingerprintLabel: "密钥指纹", + relayLabel: "中继", + connect: "连接", + }, connectionMethods: { title: "添加连接", direct: { @@ -1621,6 +1745,12 @@ export const zhCN: TranslationResources = { helper: "连接到远程主机上运行的 Paseo 守护进程。", fields: { target: "SSH 主机", + password: "守护进程密码", + optional: "可选", + }, + passwordVisibility: { + show: "显示密码", + hide: "隐藏密码", }, actions: { cancel: "取消", @@ -1847,6 +1977,8 @@ export const zhCN: TranslationResources = { dismiss: "关闭", }, contextWindow: { + noData: "暂无上下文数据", + accessibilityNoData: "上下文窗口:暂无上下文数据", title: "上下文窗口", used: "已使用 {{percentage}}%", tokens: "{{used}} / {{max}} tokens", @@ -1854,6 +1986,17 @@ export const zhCN: TranslationResources = { accessibility: "上下文窗口已使用 {{percentage}}%", }, review: { + feedback: { + send: "发送反馈 ({{count}})", + sending: "正在发送反馈 ({{count}})", + chooseAgent: "选择智能体", + sent: "已向 {{recipient}} 发送反馈", + "no-agents": "请在此工作区打开智能体标签页以发送反馈。", + disconnected: "请连接到主机以发送反馈。", + "no-context": "保存的评论不再匹配此差异。", + failed: "发送反馈失败。请重试。", + prompt: "请处理此代码审查。", + }, comment: { add: "添加 review 评论", edit: "编辑 review 评论", @@ -1888,8 +2031,11 @@ export const zhCN: TranslationResources = { groupInfo: "关于 {{title}}", sections: { general: "通用", + chat: "聊天", appearance: "外观", - layout: en.settings.sections.layout, + sidebar: "侧边栏", + terminal: "终端", + browser: "浏览器", editor: "编辑器", shortcuts: "快捷键", integrations: "集成", @@ -1947,6 +2093,7 @@ export const zhCN: TranslationResources = { }, general: { title: "通用", + sending: "发送", browserData: { title: "浏览器数据", siteData: "Cookie 和网站数据", @@ -1972,8 +2119,6 @@ export const zhCN: TranslationResources = { }, }, serviceUrls: { - label: "服务 URL", - description: "运行脚本中的 URL 打开位置", options: { ask: "询问", inApp: "在 Paseo 中", @@ -1992,7 +2137,6 @@ export const zhCN: TranslationResources = { toolCallDetail: { label: "工具调用显示", description: "工具调用在时间线中的显示方式", - accessibilityLabel: "选择工具调用显示方式({{value}})", options: { overview: "摘要", detailed: "完整详情", @@ -2046,6 +2190,7 @@ export const zhCN: TranslationResources = { about: { title: "关于", appVersion: "应用版本", + whatsNewHint: "每个版本的发布说明", thisDevice: "此设备", connectedHosts: "已连接的 Host", offline: "离线", @@ -2094,8 +2239,14 @@ export const zhCN: TranslationResources = { description: "显示用于在提示词之间跳转的大纲", }, sidebar: { - title: "侧边栏", - description: "选择侧边栏顶部显示的项目及其顺序", + header: { + title: "顶部", + description: "选择侧边栏顶部显示的项目及其顺序", + }, + footer: { + title: "底部", + description: "选择侧边栏底部显示的行及其顺序。添加项目和图标行始终显示", + }, moveUp: "上移", moveDown: "下移", }, @@ -2118,6 +2269,14 @@ export const zhCN: TranslationResources = { codeSizeHint: "用于代码、差异和终端输出", codeSizeAccessibility: "代码字号", }, + layout: { + title: "布局", + contentWidth: "内容宽度", + contentWidthHint: "宽屏上聊天和 Markdown 文件的最大宽度", + contentWidthAccessibility: "内容宽度(像素)", + reset: "重置", + resetAccessibility: "将内容宽度重置为默认值", + }, syntax: { title: "语法", highlightTheme: "高亮主题", @@ -2227,6 +2386,9 @@ export const zhCN: TranslationResources = { }, }, host: { + password: { + guidance: "移除此主机,然后使用此守护进程要求的密码重新添加。", + }, appearance: { title: "外观", name: { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index 02e23ba614..2a7960c9b9 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { formatShortcut } from "@/utils/format-shortcut"; +import { keyboardEventToComboString } from "./shortcut-string"; import { buildKeyboardShortcutHelpSections, buildEffectiveBindings, @@ -550,6 +551,36 @@ describe("keyboard-shortcuts", () => { expectNoShortcutResolution({ event, context }); }); + // A rebound pane-focus shortcut has to fire wherever the user is typing. + // Its default combo carries `editable: false` so that Cmd+Shift+Arrow keeps + // selecting text (the two cases above), and that guard describes the default + // combo rather than the action, so it must not survive the rebind. + describe("a rebound pane-focus shortcut", () => { + const PANE_FOCUS_DOWN_BINDING = "workspace-pane-focus-down-cmd-shift-down"; + // macOS emits U+2206 for Option+J; the stored combo comes from the code. + const altJ = { key: "\u2206", code: "KeyJ", altKey: true }; + + it.each(["message-input", "editable"] as const)("fires with %s focused", (focusScope) => { + const result = resolveShortcut({ + event: altJ, + context: { isMac: true, focusScope }, + bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }), + }); + + expect(result.match?.action).toBe("workspace.pane.focus.down"); + }); + + it("still fires outside a text field", () => { + const result = resolveShortcut({ + event: altJ, + context: { isMac: true, focusScope: "other" }, + bindings: buildEffectiveBindings({ [PANE_FOCUS_DOWN_BINDING]: "Alt+J" }), + }); + + expect(result.match?.action).toBe("workspace.pane.focus.down"); + }); + }); + it("prefers advancing chord candidates over single-combo matches on the same prefix", () => { const bindings = buildEffectiveBindings({ "workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S", @@ -593,6 +624,42 @@ describe("keyboard-shortcuts", () => { }); }); + it("completes a chord whose second step carries a modifier", () => { + const bindings = buildEffectiveBindings({ + "command-center-toggle-ctrl-k-non-mac": "Ctrl+K Ctrl+J", + }); + + // The browser emits a keydown for the bare Control key before every combo, + // so the stream for Ctrl+K, release, Ctrl+J is four keydowns, not two. + const firstModifier = resolveShortcut({ + event: { key: "Control", code: "ControlLeft", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + bindings, + }); + const firstStep = resolveShortcut({ + event: { key: "k", code: "KeyK", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + chordState: firstModifier.nextChordState, + bindings, + }); + expect(firstStep.nextChordState.step).toBe(1); + + const secondModifier = resolveShortcut({ + event: { key: "Control", code: "ControlLeft", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + chordState: firstStep.nextChordState, + bindings, + }); + const secondStep = resolveShortcut({ + event: { key: "j", code: "KeyJ", ctrlKey: true }, + context: { isMac: false, isDesktop: true }, + chordState: secondModifier.nextChordState, + bindings, + }); + + expect(secondStep.match?.action).toBe("command-center.toggle"); + }); + it("schedules a chord reset timeout for advancing candidates", () => { vi.useFakeTimers(); @@ -960,6 +1027,25 @@ describe("unassigned shortcuts", () => { }); describe("matching", () => { + it("dispatches a captured Dvorak binding on the same logical key", () => { + const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true }); + const combo = keyboardEventToComboString(event as KeyboardEvent); + expect(combo).toBe("Cmd+B"); + const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! }); + + expect( + resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match + ?.action, + ).toBe("sidebar.toggle.left"); + expect( + resolveShortcut({ + event: { key: "x", code: "KeyB", metaKey: true }, + context: { isMac: true, isDesktop: true }, + bindings, + }).match, + ).toBeNull(); + }); + it("stops matching a shortcut the user unassigned", () => { const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO }); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index d18650a80a..4a843124f2 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -7,6 +7,7 @@ import type { } from "@/keyboard/actions"; import { chordStringToShortcutKeys, + isModifierKeyCode, type KeyCombo, parseChordString, } from "@/keyboard/shortcut-string"; @@ -1229,14 +1230,33 @@ export function buildEffectiveBindings(overrides: ShortcutOverrides): ParsedShor if (binding.repeat === false && lastCombo) { lastCombo.repeat = false; } + const when = withoutDefaultComboGuard(binding.when); if (!binding.help?.defaultDisplayKeys) { - return { ...binding, combo: override, parsedChord }; + return { ...binding, combo: override, parsedChord, when }; } const { defaultDisplayKeys: _defaultDisplayKeys, ...help } = binding.help; - return { ...binding, combo: override, parsedChord, help }; + return { ...binding, combo: override, parsedChord, when, help }; }); } +/** + * `editable: false` is a statement about a binding's *default* combo, not + * about its action: the pane-focus defaults carry it so that Cmd+Shift+Arrow + * keeps selecting text in a field instead of moving pane focus. An override + * replaces that combo, so the guard no longer describes anything and has to + * go, the same way `defaultDisplayKeys` does — otherwise the combo the user + * picked in Settings silently refuses to fire wherever they are typing. + * + * The other guards stay. Platform, command center, terminal and focus scope + * are properties of the action and of where it makes sense, and none of them + * change because the keys did. + */ +function withoutDefaultComboGuard(when: ShortcutWhen | undefined): ShortcutWhen | undefined { + if (when?.editable !== false) return when; + const { editable: _editable, ...rest } = when; + return rest; +} + // --- Matching engine --- function parseDigit(event: KeyboardShortcutInput): number | null { @@ -1517,6 +1537,13 @@ export function resolveKeyboardShortcut(input: { preventDefault: boolean; } { const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input; + // Pressing a modifier emits its own keydown before the combo that holds it, + // so a chord waiting on `Ctrl+J` sees a bare `Control` first. That keydown + // matches no combo, and resolving it would drop the chord back to its first + // step. It decides nothing: leave the chord where it is. + if (isModifierKeyCode(event.code)) { + return { match: null, nextChordState: chordState, preventDefault: false }; + } if (chordState.step === 0) { return resolveInitialChordStep({ event, context, chordState, onChordReset, bindings }); } diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts new file mode 100644 index 0000000000..3c56692919 --- /dev/null +++ b/packages/app/src/keyboard/shift/index.ts @@ -0,0 +1,5 @@ +export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; +export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; +export { KeyboardTranslateView } from "./internal/translate"; +export { shouldUseCompactExplorerKeyboardPadding } from "./internal/policy"; +export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/hooks/keyboard-shift-context.ts b/packages/app/src/keyboard/shift/internal/context.ts similarity index 53% rename from packages/app/src/hooks/keyboard-shift-context.ts rename to packages/app/src/keyboard/shift/internal/context.ts index a6eeb53729..dcb0b4cf64 100644 --- a/packages/app/src/hooks/keyboard-shift-context.ts +++ b/packages/app/src/keyboard/shift/internal/context.ts @@ -3,10 +3,14 @@ import type { SharedValue } from "react-native-reanimated"; export interface KeyboardShiftContextValue { shift: SharedValue; + /** Space reserved at keyboard boundaries, never updated by animation frames. */ + layoutShift: SharedValue; + isMoving: SharedValue; bottomInset: SharedValue; } export const KeyboardShiftContext = createContext(null); +export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ export function useKeyboardShift(): KeyboardShiftContextValue { @@ -16,3 +20,12 @@ export function useKeyboardShift(): KeyboardShiftContextValue { } return context; } + +/** Read the keyboard inset published after native keyboard motion has settled. */ +export function useSettledKeyboardShift(): number { + const context = useContext(SettledKeyboardShiftContext); + if (context === null) { + throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider"); + } + return context; +} diff --git a/packages/app/src/hooks/use-keyboard-shift-style.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts similarity index 51% rename from packages/app/src/hooks/use-keyboard-shift-style.test.ts rename to packages/app/src/keyboard/shift/internal/policy.test.ts index 96165d7f1f..330cb2cfab 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -1,9 +1,56 @@ import { describe, expect, it } from "vitest"; import { + resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldUseCompactExplorerKeyboardPadding, -} from "./keyboard-shift-policy"; +} from "./policy"; + +describe("keyboard layout reservation", () => { + it("reserves the opening destination before motion and releases it only after closing", () => { + const opened = reserveKeyboardLayoutShift({ current: 0, target: 308, phase: "start" }); + expect(opened).toBe(308); + const closing = reserveKeyboardLayoutShift({ current: opened, target: 0, phase: "start" }); + expect(closing).toBe(308); + expect(reserveKeyboardLayoutShift({ current: closing, target: 0, phase: "end" })).toBe(0); + }); + + it("keeps enough space when a resize or reversal interrupts keyboard motion", () => { + const resizing = reserveKeyboardLayoutShift({ current: 308, target: 250, phase: "start" }); + expect(resizing).toBe(308); + const reversing = reserveKeyboardLayoutShift({ + current: resizing, + target: 340, + phase: "start", + }); + expect(reversing).toBe(340); + expect(reserveKeyboardLayoutShift({ current: reversing, target: 340, phase: "end" })).toBe(340); + }); +}); + +describe("resolveStreamKeyboardInset", () => { + it("uses the native scroll inset on iOS without changing content size", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 311 }, + }); + }); + + it("keeps Android's exact content-container padding behavior", () => { + expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({ + contentContainerPaddingBottom: 311, + contentInset: undefined, + }); + }); + + it("does not expose a negative inset", () => { + expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({ + contentContainerPaddingBottom: 0, + contentInset: { bottom: 0 }, + }); + }); +}); describe("resolveKeyboardShift", () => { it("keeps the existing open-keyboard offset behavior", () => { diff --git a/packages/app/src/hooks/keyboard-shift-policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts similarity index 55% rename from packages/app/src/hooks/keyboard-shift-policy.ts rename to packages/app/src/keyboard/shift/internal/policy.ts index 68e013e1b1..97435027fa 100644 --- a/packages/app/src/hooks/keyboard-shift-policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -1,5 +1,35 @@ export const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; +/** Reserve both endpoints during motion so content cannot grow into a closing IME. */ +export function reserveKeyboardLayoutShift(input: { + current: number; + target: number; + phase: "start" | "end"; +}): number { + "worklet"; + return input.phase === "start" ? Math.max(input.current, input.target) : input.target; +} + +export function resolveStreamKeyboardInset(input: { + platform: "android" | "ios"; + settledShift: number; +}): { + contentContainerPaddingBottom: number; + contentInset: { bottom: number } | undefined; +} { + const settledShift = Math.max(0, input.settledShift); + if (input.platform === "ios") { + return { + contentContainerPaddingBottom: 0, + contentInset: { bottom: settledShift }, + }; + } + return { + contentContainerPaddingBottom: settledShift, + contentInset: undefined, + }; +} + export function shouldUseCompactExplorerKeyboardPadding(input: { isGit: boolean; explorerTab: "changes" | "files" | "pr"; diff --git a/packages/app/src/hooks/use-keyboard-shift-style.ts b/packages/app/src/keyboard/shift/internal/provider.ts similarity index 51% rename from packages/app/src/hooks/use-keyboard-shift-style.ts rename to packages/app/src/keyboard/shift/internal/provider.ts index 6365771573..7b2ff46eae 100644 --- a/packages/app/src/hooks/use-keyboard-shift-style.ts +++ b/packages/app/src/keyboard/shift/internal/provider.ts @@ -1,4 +1,4 @@ -import { createElement, useEffect, useMemo, type ReactNode } from "react"; +import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,16 +8,19 @@ import { } from "react-native-keyboard-controller"; import { useAnimatedStyle, + useAnimatedReaction, useDerivedValue, useSharedValue, type SharedValue, } from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, + reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, -} from "@/hooks/keyboard-shift-policy"; -import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context"; +} from "./policy"; +import { KeyboardShiftContext, SettledKeyboardShiftContext, useKeyboardShift } from "./context"; type KeyboardShiftMode = "translate" | "padding"; @@ -26,24 +29,17 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); const isIos = Platform.OS === "ios"; + const isMoving = useSharedValue(false); + const layoutShift = useSharedValue(0); + const [settledShift, setSettledShift] = useState(0); + const publishSettledShift = useCallback((nextShift: number) => { + setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); + }, []); useEffect(() => { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); - useGenericKeyboardHandler( - { - onEnd: (event) => { - "worklet"; - if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { - keyboardHeight.value = 0; - keyboardProgress.value = 0; - } - }, - }, - [isIos, keyboardHeight, keyboardProgress], - ); - const shift = useDerivedValue(() => { "worklet"; return resolveKeyboardShift({ @@ -55,15 +51,73 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { }); }); + useGenericKeyboardHandler( + { + onStart: (event) => { + "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.height > 0 ? 1 : 0, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "start", + }); + isMoving.value = true; + }, + onEnd: (event) => { + "worklet"; + const target = resolveKeyboardShift({ + rawKeyboardHeight: event.height, + keyboardProgress: event.progress, + bottomInset: bottomInset.value, + isIos, + iosMinHeight: DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, + }); + layoutShift.value = reserveKeyboardLayoutShift({ + current: layoutShift.value, + target, + phase: "end", + }); + if (isIos && shouldReconcileHiddenKeyboardEnd(event)) { + keyboardHeight.value = 0; + keyboardProgress.value = 0; + } + isMoving.value = false; + }, + }, + [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], + ); + + useAnimatedReaction( + () => ({ moving: isMoving.value, shift: shift.value }), + (current, previous) => { + if (!current.moving && (previous === null || previous.moving)) { + scheduleOnRN(publishSettledShift, current.shift); + } + }, + [isMoving, publishSettledShift, shift], + ); + const value = useMemo( () => ({ shift, + layoutShift, + isMoving, bottomInset, }), - [bottomInset, shift], + [bottomInset, isMoving, layoutShift, shift], ); - return createElement(KeyboardShiftContext.Provider, { value }, children); + return createElement( + KeyboardShiftContext.Provider, + { value }, + createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children), + ); } export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { diff --git a/packages/app/src/keyboard/shift/internal/stream.ts b/packages/app/src/keyboard/shift/internal/stream.ts new file mode 100644 index 0000000000..45fe167fe4 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/stream.ts @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import { Platform } from "react-native"; +import { useSettledKeyboardShift } from "./context"; +import { resolveStreamKeyboardInset } from "./policy"; + +/** Preserve the translated inverted list's far-end scroll range only after motion settles. */ +export function useKeyboardStreamInset() { + const settledShift = useSettledKeyboardShift(); + return useMemo( + () => + resolveStreamKeyboardInset({ + platform: Platform.OS === "ios" ? "ios" : "android", + settledShift, + }), + [settledShift], + ); +} diff --git a/packages/app/src/keyboard/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx new file mode 100644 index 0000000000..2a9e9d581d --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import type { ViewProps } from "react-native"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { useKeyboardShift } from "./context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + bottomClearance = 0, + style, + ...props +}: KeyboardTranslateViewProps) { + const { shift } = useKeyboardShift(); + const keyboardStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }], + })); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx new file mode 100644 index 0000000000..348fe828d0 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -0,0 +1,45 @@ +import { useMemo, type ReactNode } from "react"; +import { Animated, type ViewProps } from "react-native"; +import { useKeyboardAnimation } from "react-native-keyboard-controller"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; + /** Space below the view's resting position that the keyboard may fill before the view moves. */ + bottomClearance?: number; +} + +export function KeyboardTranslateView({ + children, + enabled = true, + bottomClearance = 0, + style, + ...props +}: KeyboardTranslateViewProps) { + const insets = useSafeAreaInsets(); + const { height, progress } = useKeyboardAnimation(); + const translateY = useMemo( + () => + Animated.add( + Animated.add(height, Animated.multiply(progress, insets.bottom)), + bottomClearance, + ).interpolate({ + inputRange: [-1, 0], + outputRange: [-1, 0], + extrapolateLeft: "extend", + extrapolateRight: "clamp", + }), + [bottomClearance, height, insets.bottom, progress], + ); + const keyboardStyle = useMemo( + () => ({ transform: [{ translateY: enabled ? translateY : 0 }] }), + [enabled, translateY], + ); + + return ( + + {children} + + ); +} diff --git a/packages/app/src/keyboard/shift/internal/translate.tsx b/packages/app/src/keyboard/shift/internal/translate.tsx new file mode 100644 index 0000000000..6a0506c301 --- /dev/null +++ b/packages/app/src/keyboard/shift/internal/translate.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; +import { View, type ViewProps } from "react-native"; + +interface KeyboardTranslateViewProps extends ViewProps { + children: ReactNode; + enabled?: boolean; + bottomClearance?: number; +} + +export function KeyboardTranslateView({ + children, + enabled: _enabled, + bottomClearance: _bottomClearance, + ...props +}: KeyboardTranslateViewProps) { + return {children}; +} diff --git a/packages/app/src/keyboard/shortcut-string.test.ts b/packages/app/src/keyboard/shortcut-string.test.ts index e7cffee597..8d48d5d986 100644 --- a/packages/app/src/keyboard/shortcut-string.test.ts +++ b/packages/app/src/keyboard/shortcut-string.test.ts @@ -22,6 +22,45 @@ function keyboardEvent(overrides: Partial): KeyboardEvent { } describe("keyboardEventToComboString", () => { + it("captures the Dvorak character rather than its QWERTY position", () => { + expect( + keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })), + ).toBe("Cmd+B"); + }); + + it("captures shifted Dvorak letters and punctuation at other physical positions", () => { + expect( + keyboardEventToComboString( + keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }), + ), + ).toBe("Shift+Cmd+T"); + expect( + keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })), + ).toBe("Ctrl+;"); + expect( + keyboardEventToComboString( + keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }), + ), + ).toBe("Ctrl+Shift+/"); + }); + + it("keeps named keys and rewritten Option characters capturable", () => { + expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe( + "ArrowLeft", + ); + expect( + keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })), + ).toBe("Alt+T"); + }); + + it("does not record a named key that dispatch does not support", () => { + expect( + keyboardEventToComboString( + keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }), + ), + ).toBeNull(); + }); + it("captures the minus key (regression: previously returned null)", () => { expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-"); }); diff --git a/packages/app/src/keyboard/shortcut-string.ts b/packages/app/src/keyboard/shortcut-string.ts index 06a25bd373..8b632befbd 100644 --- a/packages/app/src/keyboard/shortcut-string.ts +++ b/packages/app/src/keyboard/shortcut-string.ts @@ -245,8 +245,17 @@ export function heldModifiersFromEvent(event: KeyboardEvent): string | null { return parts.length > 0 ? parts.join("+") : null; } +/** + * Whether a keydown carries a modifier key itself rather than a key pressed + * with one. The browser emits one of these before every combo that holds a + * modifier, and it is the prefix of a combo, never a combo of its own. + */ +export function isModifierKeyCode(code: string): boolean { + return MODIFIER_CODES.has(code); +} + export function keyboardEventToComboString(event: KeyboardEvent): string | null { - if (MODIFIER_CODES.has(event.code)) { + if (isModifierKeyCode(event.code)) { return null; } @@ -257,7 +266,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null if (event.shiftKey) parts.push("Shift"); if (event.metaKey) parts.push("Cmd"); - const humanKey = CODE_TO_KEY[event.code]; + // Capture the same logical character that dispatch matches. Keep the code + // fallback for named keys and Option combinations whose key is rewritten. + const eventKey = event.key.toLowerCase(); + const logicalKey = Object.entries(KEY_MAP).find( + ([, mapping]) => + mapping.key?.length === 1 && + (mapping.key.toLowerCase() === eventKey || + (event.shiftKey && mapping.shiftedKey === event.key)), + )?.[0]; + const humanKey = logicalKey ?? CODE_TO_KEY[event.code]; if (humanKey) { parts.push(humanKey); } else { diff --git a/packages/app/src/lib/overlay-root.ts b/packages/app/src/lib/overlay-root.ts index 60cbaa30e3..9ffd81725f 100644 --- a/packages/app/src/lib/overlay-root.ts +++ b/packages/app/src/lib/overlay-root.ts @@ -88,6 +88,7 @@ interface WebOverlayEntry { getScope: () => HTMLElement | null; getKeyHandler: () => WebOverlayKeyHandler; restoreFocus: HTMLElement | null; + manageFocus: boolean; } const webOverlayEntries: WebOverlayEntry[] = []; @@ -140,7 +141,7 @@ function focusFirstElement(scope: HTMLElement): void { function handleWebOverlayFocus(event: FocusEvent): void { const top = getTopWebOverlay(); const scope = top?.getScope(); - if (!scope || scope.contains(event.target as Node)) return; + if (!top?.manageFocus || !scope || scope.contains(event.target as Node)) return; if (webOverlayFocusCheckQueued) return; // React can autofocus a child before its parent scope ref attaches. Defer @@ -149,8 +150,10 @@ function handleWebOverlayFocus(event: FocusEvent): void { webOverlayFocusCheckQueued = true; queueMicrotask(() => { webOverlayFocusCheckQueued = false; - const currentScope = getTopWebOverlay()?.getScope(); - if (!currentScope || currentScope.contains(document.activeElement)) return; + const currentTop = getTopWebOverlay(); + const currentScope = currentTop?.getScope(); + if (!currentTop?.manageFocus || !currentScope || currentScope.contains(document.activeElement)) + return; focusFirstElement(currentScope); }); } @@ -169,7 +172,7 @@ export function dispatchTopWebOverlayKeyDown(event: KeyboardEvent): boolean { const scope = top?.getScope(); if (!top || !scope) return false; - if (event.key === "Tab") { + if (top.manageFocus && event.key === "Tab") { const focusable = getFocusableElements(scope); const first = focusable[0]; const last = focusable[focusable.length - 1]; @@ -214,7 +217,12 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio const focusFrame = window.requestAnimationFrame(() => { const scope = entry.getScope(); - if (getTopWebOverlay() === entry && scope && !scope.contains(document.activeElement)) { + if ( + entry.manageFocus && + getTopWebOverlay() === entry && + scope && + !scope.contains(document.activeElement) + ) { focusFirstElement(scope); } }); @@ -225,6 +233,7 @@ function addWebOverlay(entry: WebOverlayEntry): (options?: RemoveWebOverlayOptio if (index !== -1) webOverlayEntries.splice(index, 1); detachWebOverlayListeners(); if ( + entry.manageFocus && options?.restoreFocus !== false && entry.restoreFocus && document.contains(entry.restoreFocus) @@ -239,17 +248,20 @@ interface WebOverlayRegistration { layer: number; onKeyDown: WebOverlayKeyHandler; restoreFocusRef?: React.RefObject; + /** Hover surfaces claim overlay keys while preserving the user's existing focus. */ + manageFocus?: boolean; } /** * Registers a focus scope in the same relative layer model used for painting. - * Only the highest painted overlay receives keyboard input; focus is trapped - * there and restored to the opener when that overlay closes. + * Only the highest painted overlay receives keyboard input. By default, focus is trapped + * there and restored to the opener when it closes; hover surfaces preserve focus instead. */ export function useWebOverlayRegistration({ active, layer, onKeyDown, + manageFocus = true, restoreFocusRef: preferredRestoreFocusRef, }: WebOverlayRegistration) { const idRef = useRef(Symbol("web-overlay")); @@ -305,10 +317,11 @@ export function useWebOverlayRegistration({ getScope: () => scopeRef.current, getKeyHandler: () => keyHandlerRef.current, restoreFocus: capturedRestoreFocusRef.current, + manageFocus, }; removeEntryRef.current = addWebOverlay(entry); registeredRef.current = true; - }, []); + }, [manageFocus]); const setScope = useCallback( (node: unknown) => { diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index b1d8a0036e..77059dfc39 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -42,7 +42,15 @@ export function MobilePanelOverlay({ const overlayAnimatedStyle = useAnimatedStyle(() => { const isVisible = isLeft ? position.value < 0 : position.value > 0; - return { display: isVisible ? ("flex" as const) : ("none" as const) }; + if (isWeb) { + return { display: isVisible ? ("flex" as const) : ("none" as const) }; + } + // Preserve native scroll ranges while hidden. Collapsing layout makes Android + // spring retained scroll offsets back to zero on touch cancellation or release. + return { + opacity: isVisible ? 1 : 0, + pointerEvents: isVisible ? ("box-none" as const) : ("none" as const), + }; }, [isLeft]); const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel; @@ -79,10 +87,7 @@ export function MobilePanelOverlay({ pointerEvents={overlayPointerEvents} style={styles.overlay} > - + { - it("boots the host registry and starts the managed-daemon decision as one operation", async () => { + it("boots the host registry and starts the managed-daemon decision independently", async () => { const events: string[] = []; const shouldStartDaemon = async () => true; const store = { @@ -39,9 +40,10 @@ describe("startHostRuntimeBootstrap", () => { expect(events).toEqual(["boot", "daemon-start-decision"]); expect(await receivedDecisions[0]).toBe(true); + expect(events).toEqual(["boot", "daemon-start-decision"]); }); - it("waits for the host registry to load before evaluating managed-daemon startup", async () => { + it("loads the registry before evaluating managed-daemon startup", async () => { const events: string[] = []; let resolveBoot!: () => void; const booted = new Promise((resolve) => { @@ -393,3 +395,36 @@ describe("resolveHostIndexRoute", () => { ).toEqual("/open-project"); }); }); + +describe("host runtime app lifecycle", () => { + it.each(["inactive", "background"] as const)( + "applies initial visibility and forwards lifecycle changes when mounted %s", + (currentState) => { + const visibility: boolean[] = []; + let listener: ((state: "active" | "inactive" | "background") => void) | undefined; + let subscribed = true; + const dispose = bindHostRuntimeAppState( + { setAppVisible: (visible) => visibility.push(visible) }, + { + currentState, + addEventListener: (_event, handler) => { + listener = handler; + return { + remove: () => { + subscribed = false; + }, + }; + }, + }, + ); + expect(visibility).toEqual([false]); + listener?.("active"); + listener?.("inactive"); + listener?.("background"); + listener?.("active"); + expect(visibility).toEqual([false, true, false, false, true]); + dispose(); + expect(subscribed).toBe(false); + }, + ); +}); diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 6fbceb8a21..8ee1f7bfb2 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -1,3 +1,4 @@ +import type { AppState } from "react-native"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartCondition, @@ -250,3 +251,14 @@ export function resolveStartupRoute(input: ResolveStartupRouteInput): StartupRou return resolveReadyIndexStartupRoute(input); } + +export function bindHostRuntimeAppState( + store: { setAppVisible: (visible: boolean) => void }, + appState: Pick, +): () => void { + const subscription = appState.addEventListener("change", (state) => { + store.setAppVisible(state === "active"); + }); + store.setAppVisible(appState.currentState === "active"); + return () => subscription.remove(); +} diff --git a/packages/app/src/navigation/settings-navigation.ts b/packages/app/src/navigation/settings-navigation.ts index ae7eb6efdd..123b23077a 100644 --- a/packages/app/src/navigation/settings-navigation.ts +++ b/packages/app/src/navigation/settings-navigation.ts @@ -11,6 +11,7 @@ import { } from "@/utils/host-routes"; export type SettingsView = + | { kind: "plugin"; serverId: string; pluginId: string; screenId: string } | { kind: "root" } | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } @@ -32,7 +33,8 @@ export function returnFromSettings(view: SettingsView): void { return; } - const parent = - view.kind === "project" ? buildProjectsSettingsRoute(view.serverId) : buildSettingsRoute(); + let parent: Href = buildSettingsRoute(); + if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins"); + if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId); router.dismissTo(parent as Href); } diff --git a/packages/app/src/navigation/themed-stack.tsx b/packages/app/src/navigation/themed-stack.tsx index cf83f701c9..010877c061 100644 --- a/packages/app/src/navigation/themed-stack.tsx +++ b/packages/app/src/navigation/themed-stack.tsx @@ -1,15 +1,29 @@ import type { NativeStackNavigationOptions } from "@react-navigation/native-stack"; import { Stack } from "expo-router"; -import { type ReactNode, useMemo } from "react"; +import { isValidElement, type ReactElement, type ReactNode, useCallback, useMemo } from "react"; import { withUnistyles } from "react-native-unistyles"; +import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; interface ThemedStackBaseProps { backgroundColor: string; children?: ReactNode; screenOptions?: NativeStackNavigationOptions; + // Screens whose content is another navigator. That navigator owns its own screens' appearance + // boundary; wrapping it here would remount it. + nestedNavigatorScreens?: readonly string[]; } -function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedStackBaseProps) { +interface ScreenContent { + route: { name: string }; + children: ReactNode; +} + +function ThemedStackBase({ + backgroundColor, + children, + screenOptions, + nestedNavigatorScreens, +}: ThemedStackBaseProps) { const themedScreenOptions = useMemo( () => ({ ...screenOptions, @@ -17,8 +31,21 @@ function ThemedStackBase({ backgroundColor, children, screenOptions }: ThemedSta }), [backgroundColor, screenOptions], ); + // Appearance changes remount each screen's content below the native stack, so the navigator + // and its screen containers keep their identity. See docs/unistyles.md. + const screenLayout = useCallback( + ({ route, children: content }: ScreenContent): ReactElement => { + if (nestedNavigatorScreens?.includes(route.name) && isValidElement(content)) return content; + return {content}; + }, + [nestedNavigatorScreens], + ); - return {children}; + return ( + + {children} + + ); } export const ThemedStack = withUnistyles(ThemedStackBase, (theme) => ({ diff --git a/packages/app/src/navigation/workspace-route-navigation.test.ts b/packages/app/src/navigation/workspace-route-navigation.test.ts index 704345f9a0..a331451ed5 100644 --- a/packages/app/src/navigation/workspace-route-navigation.test.ts +++ b/packages/app/src/navigation/workspace-route-navigation.test.ts @@ -7,23 +7,36 @@ import { function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) { const dispatch = vi.fn(); - const navigationRef = { - current: { - isReady: () => options.ready ?? true, - getRootState: () => rootState, - dispatch, + let ready = options.ready ?? true; + const readyListeners = new Set<() => void>(); + const navigation = { + isReady: () => ready, + getRootState: () => rootState, + dispatch, + addListener: (event: string, listener: () => void) => { + if (event === "ready") readyListeners.add(listener); + return () => readyListeners.delete(listener); }, + }; + const navigationRef = { + ...navigation, + current: navigation, } as unknown as NavigationContainerRefWithCurrent; - return { navigationRef, dispatch }; + return { + navigationRef, + dispatch, + becomeReady() { + ready = true; + for (const listener of readyListeners) listener(); + }, + }; } describe("navigateToHostWorkspaceRoute", () => { beforeEach(() => { vi.clearAllMocks(); - registerWorkspaceRouteNavigationRef({ - current: null, - } as unknown as NavigationContainerRefWithCurrent)(); + registerWorkspaceRouteNavigationRef(createNavigationRef({}).navigationRef)(); }); it("falls back to route navigation when no host route is mounted yet", () => { @@ -131,4 +144,74 @@ describe("navigateToHostWorkspaceRoute", () => { }, }); }); + + it("waits for the root navigator before applying a route intent", () => { + const { navigationRef, becomeReady } = createNavigationRef( + { + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }, + { ready: false }, + ); + registerWorkspaceRouteNavigationRef(navigationRef); + const dismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + becomeReady(); + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + becomeReady(); + expect(dismissTo).toHaveBeenCalledTimes(1); + }); + + it("retains an intent submitted before the root ref registers", () => { + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + expect(dismissTo).not.toHaveBeenCalled(); + + const root = createNavigationRef({}, { ready: false }); + const unregister = registerWorkspaceRouteNavigationRef(root.navigationRef); + expect(dismissTo).not.toHaveBeenCalled(); + root.becomeReady(); + expect(dismissTo).toHaveBeenCalledExactlyOnceWith("/h/server-1/workspace/workspace-a"); + unregister(); + }); + + it("keeps only the latest route intent while the root navigator is unavailable", () => { + const { navigationRef, becomeReady } = createNavigationRef({}, { ready: false }); + registerWorkspaceRouteNavigationRef(navigationRef); + const firstDismissTo = vi.fn(); + const latestDismissTo = vi.fn(); + + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { + dismissTo: firstDismissTo, + }); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-b", { + dismissTo: latestDismissTo, + }); + becomeReady(); + + expect(firstDismissTo).not.toHaveBeenCalled(); + expect(latestDismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-b"); + }); + + it("preserves a pending route intent across root navigator re-registration", () => { + const first = createNavigationRef({}, { ready: false }); + const unregisterFirst = registerWorkspaceRouteNavigationRef(first.navigationRef); + const dismissTo = vi.fn(); + navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo }); + + unregisterFirst(); + first.becomeReady(); + expect(dismissTo).not.toHaveBeenCalled(); + + const replacement = createNavigationRef({ + key: "root-stack", + routes: [{ key: "launcher", name: "index" }], + }); + registerWorkspaceRouteNavigationRef(replacement.navigationRef); + + expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a"); + }); }); diff --git a/packages/app/src/navigation/workspace-route-navigation.ts b/packages/app/src/navigation/workspace-route-navigation.ts index 79fc1d8c2f..06d0d23e07 100644 --- a/packages/app/src/navigation/workspace-route-navigation.ts +++ b/packages/app/src/navigation/workspace-route-navigation.ts @@ -1,4 +1,8 @@ -import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react-navigation/native"; +import type { + NavigationAction, + NavigationContainerRef, + NavigationContainerRefWithCurrent, +} from "@react-navigation/native"; import { router, type Href } from "expo-router"; import { encodeWorkspaceIdForPathSegment, @@ -19,12 +23,16 @@ const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps let rootNavigationRef: NavigationContainerRefWithCurrent | null = null; +let pendingIntent: { route: string; deps: NavigateToHostWorkspaceRouteDeps } | null = null; export function registerWorkspaceRouteNavigationRef( ref: NavigationContainerRefWithCurrent, ): () => void { rootNavigationRef = ref; + const unsubscribe = ref.addListener("ready", flushPendingIntent); + flushPendingIntent(); return () => { + unsubscribe(); if (rootNavigationRef === ref) { rootNavigationRef = null; } @@ -95,10 +103,12 @@ function findStackWithMountedRouteName( return null; } -function dispatchHostWorkspacePopTo(route: string): boolean { +function dispatchHostWorkspacePopTo( + route: string, + navigation: NavigationContainerRef, +): boolean { const selection = parseHostWorkspaceRouteFromPathname(route); - const navigation = rootNavigationRef?.current; - if (!selection || !navigation?.isReady()) { + if (!selection) { return false; } @@ -137,9 +147,15 @@ export function navigateToHostWorkspaceRoute( route: string, deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps, ): void { - if (dispatchHostWorkspacePopTo(route)) { - return; - } + pendingIntent = { route, deps }; + flushPendingIntent(); +} - deps.dismissTo(route); +function flushPendingIntent(): void { + const navigation = rootNavigationRef?.current; + if (!pendingIntent || !navigation?.isReady()) return; + + const { route, deps } = pendingIntent; + pendingIntent = null; + if (!dispatchHostWorkspacePopTo(route, navigation)) deps.dismissTo(route); } diff --git a/packages/app/src/pane-find/find-shortcut.test.ts b/packages/app/src/pane-find/find-shortcut.test.ts new file mode 100644 index 0000000000..5d64bdee8d --- /dev/null +++ b/packages/app/src/pane-find/find-shortcut.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { isFindShortcut } from "./find-shortcut"; + +const MAC = { isMac: true }; +const NON_MAC = { isMac: false }; + +function keydown( + overrides: Partial>, +) { + return { + key: "f", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...overrides, + }; +} + +describe("on macOS", () => { + it("opens Find with Command+F", () => { + expect(isFindShortcut(keydown({ metaKey: true }), MAC)).toBe(true); + }); + + it("leaves Control+F to text editing", () => { + expect(isFindShortcut(keydown({ ctrlKey: true }), MAC)).toBe(false); + }); + + it("ignores Control+Command+F", () => { + expect(isFindShortcut(keydown({ metaKey: true, ctrlKey: true }), MAC)).toBe(false); + }); + + it("ignores Shift and Alt variants", () => { + expect(isFindShortcut(keydown({ metaKey: true, shiftKey: true }), MAC)).toBe(false); + expect(isFindShortcut(keydown({ metaKey: true, altKey: true }), MAC)).toBe(false); + }); +}); + +describe("off macOS", () => { + it("opens Find with Control+F", () => { + expect(isFindShortcut(keydown({ ctrlKey: true }), NON_MAC)).toBe(true); + }); + + it("ignores Meta+F", () => { + expect(isFindShortcut(keydown({ metaKey: true }), NON_MAC)).toBe(false); + }); + + it("ignores Control+Meta+F", () => { + expect(isFindShortcut(keydown({ ctrlKey: true, metaKey: true }), NON_MAC)).toBe(false); + }); + + it("ignores Shift and Alt variants", () => { + expect(isFindShortcut(keydown({ ctrlKey: true, shiftKey: true }), NON_MAC)).toBe(false); + expect(isFindShortcut(keydown({ ctrlKey: true, altKey: true }), NON_MAC)).toBe(false); + }); +}); + +it("matches an uppercase F from a shifted keyboard layout", () => { + expect(isFindShortcut(keydown({ key: "F", metaKey: true }), MAC)).toBe(true); + expect(isFindShortcut(keydown({ key: "F", ctrlKey: true }), NON_MAC)).toBe(true); +}); + +it("ignores other keys", () => { + expect(isFindShortcut(keydown({ key: "g", metaKey: true }), MAC)).toBe(false); + expect(isFindShortcut(keydown({ key: "g", ctrlKey: true }), NON_MAC)).toBe(false); +}); diff --git a/packages/app/src/pane-find/find-shortcut.ts b/packages/app/src/pane-find/find-shortcut.ts new file mode 100644 index 0000000000..f4c1c2b57b --- /dev/null +++ b/packages/app/src/pane-find/find-shortcut.ts @@ -0,0 +1,24 @@ +export interface FindShortcutPlatform { + isMac: boolean; +} + +/** + * The one Find keystroke policy: Command+F on macOS, Control+F everywhere else. + * + * Control+F is a macOS text-editing binding that moves the caret forward, so a + * surface that also claims it there eats a keystroke the text editor owns. + * + * The platform is a parameter rather than a lookup because this module is + * imported by the terminal emulator runtime, which is bundled on its own into + * the terminal WebView and cannot load React Native. App surfaces pass + * `findShortcutPlatform()` from `@/pane-find`. + */ +export function isFindShortcut( + event: Pick, + platform: FindShortcutPlatform, +): boolean { + const primaryModifier = platform.isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f"; +} diff --git a/packages/app/src/pane-find/index.tsx b/packages/app/src/pane-find/index.tsx new file mode 100644 index 0000000000..761ac187e1 --- /dev/null +++ b/packages/app/src/pane-find/index.tsx @@ -0,0 +1,374 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useImperativeHandle, + useRef, + useState, + type ReactNode, + type KeyboardEvent, +} from "react"; +import { + Text, + View, + type NativeSyntheticEvent, + type TextInputKeyPressEventData, + type TextInputProps, +} from "react-native"; +import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, X } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { EditingTextInput, type EditingTextInputHandle } from "@/components/ui/text-input"; +import { + createControlGeometry, + resolveControlInteractionStyles, +} from "@/components/ui/control-geometry"; +import { + mutedIconColorMapping, + smallIconButtonChromeFrameSize, +} from "@/components/ui/icon-button-chrome"; +import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut"; + +export { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut"; + +/** The platform every Find surface judges the shortcut against. */ +export function findShortcutPlatform(): FindShortcutPlatform { + return { isMac: getShortcutOs() === "mac" }; +} + +const TextInput = withUnistyles(EditingTextInput, (theme) => ({ + placeholderTextColor: theme.colors.foregroundMuted, +})); +const ArrowUpIcon = withUnistyles(ArrowUp, mutedIconColorMapping); +const ArrowDownIcon = withUnistyles(ArrowDown, mutedIconColorMapping); +const CloseIcon = withUnistyles(X, mutedIconColorMapping); +const ChevronDownIcon = withUnistyles(ChevronDown, mutedIconColorMapping); +const ChevronRightIcon = withUnistyles(ChevronRight, mutedIconColorMapping); + +export interface PaneFindHandle { + focus(): void; +} + +export interface PaneFindProps { + query: string; + status: string; + canNavigate: boolean; + onQueryChange(query: string): void; + onNext(): void; + onPrevious(): void; + onClose(): void; + replace?: { + value: string; + onChange(value: string): void; + onReplace(): void; + onReplaceAll(): void; + }; +} + +/** + * The bordered box around one Find input. It owns the field chrome so the query + * row and the replacement row land on the same rails, and so the match count can + * sit inside the query box instead of widening the widget. + */ +const FindField = forwardRef< + EditingTextInputHandle, + { + label: string; + initialValue: string; + height: number; + trailing?: ReactNode; + onChangeText(value: string): void; + onKeyPress(event: NativeSyntheticEvent): void; + autoFocus?: boolean; + returnKeyType?: TextInputProps["returnKeyType"]; + } +>(function FindField( + { label, initialValue, height, trailing, onChangeText, onKeyPress, autoFocus, returnKeyType }, + ref, +) { + const [focused, setFocused] = useState(false); + const onFocus = useCallback(() => setFocused(true), []); + const onBlur = useCallback(() => setFocused(false), []); + const fieldStyle = useMemo( + () => [ + styles.field, + { minHeight: height }, + resolveControlInteractionStyles( + { + controlRest: styles.controlRest, + controlHover: styles.controlHover, + controlActive: styles.controlActive, + }, + { focused }, + ), + ], + [focused, height], + ); + return ( + + + {trailing} + + ); +}); + +/** Pane-local chrome. The content owner supplies search state and commands. */ +export const PaneFind = forwardRef(function PaneFind( + { query, status, canNavigate, onQueryChange, onNext, onPrevious, onClose, replace }, + ref, +) { + const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); + const controlSize = smallIconButtonChromeFrameSize(isCompact); + const glyphSize = paneContentToolbarIconSize(isCompact); + const rowHeight = Math.max(COLLAPSED_FIELD_HEIGHT, controlSize); + // Keep the replace actions on the same row height as the icon controls beside them. + const actionSize = isCompact ? "sm" : "xs"; + const input = useRef(null); + const replacementInput = useRef(null); + useLayoutEffect(() => { + if (input.current?.getText() !== query) input.current?.replaceText(query); + }, [query]); + useLayoutEffect(() => { + if (replace && replacementInput.current?.getText() !== replace.value) + replacementInput.current?.replaceText(replace.value); + }, [replace]); + const [replaceExpanded, setReplaceExpanded] = useState(false); + const focus = useCallback(() => { + input.current?.focus(); + const text = input.current?.getText() ?? ""; + input.current?.replaceText(text, { start: 0, end: text.length }); + }, []); + useImperativeHandle(ref, () => ({ focus }), [focus]); + + const onKeyPress = useCallback( + (event: NativeSyntheticEvent) => { + const key = event.nativeEvent as TextInputKeyPressEventData & { + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + isComposing?: boolean; + keyCode?: number; + }; + if (isImeComposingKeyboardEvent(key)) return; + const shortcut = { + key: key.key, + metaKey: key.metaKey === true, + ctrlKey: key.ctrlKey === true, + shiftKey: key.shiftKey === true, + altKey: key.altKey === true, + }; + if (isFindShortcut(shortcut, findShortcutPlatform())) { + // RN Web inputs stop keydown before the pane's document listener. + event.preventDefault(); + event.stopPropagation(); + focus(); + } else if (key.key === "Escape" || key.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (key.key === "Escape") onClose(); + else if (key.shiftKey) onPrevious(); + else onNext(); + } + }, + [focus, onClose, onNext, onPrevious], + ); + const toggleReplace = useCallback(() => setReplaceExpanded((expanded) => !expanded), []); + const gutterStyle = useMemo( + () => [styles.gutter, { width: controlSize, height: rowHeight }], + [controlSize, rowHeight], + ); + const matchCount = useMemo( + () => ( + + {status} + + ), + [status, t], + ); + + return ( + { + if (event.key === "Escape" && !isImeComposingKeyboardEvent(event.nativeEvent)) { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }, + } + : {})} + > + {replace ? ( + + + {replaceExpanded ? ( + + ) : ( + + )} + + + ) : null} + + + + + + + + + + + + + + {replace && replaceExpanded ? ( + + + + + + ) : null} + + + ); +}); + +/** Desktop find rows run one step tighter than a form field — this is pane chrome. */ +const COLLAPSED_FIELD_HEIGHT = 28; +const FIND_WIDGET_WIDTH = 340; + +const styles = StyleSheet.create((theme) => { + const geometry = createControlGeometry(theme); + + return { + widget: { + width: FIND_WIDGET_WIDTH, + maxWidth: "100%", + flexDirection: "row", + padding: theme.spacing[1.5], + gap: theme.spacing[1], + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + ...theme.shadow.md, + }, + // The disclosure column spans both rows so the two fields share a leading rail. + gutter: { alignItems: "center", justifyContent: "center", flexShrink: 0 }, + rows: { flex: 1, minWidth: 0, gap: theme.spacing[1] }, + row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1] }, + field: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + }, + controlRest: { ...geometry.controlRest }, + controlHover: { ...geometry.controlHover }, + controlActive: { ...geometry.controlActive }, + input: { + flex: 1, + minWidth: 0, + paddingHorizontal: 0, + paddingVertical: 0, + color: theme.colors.foreground, + outlineWidth: 0, + outlineColor: "transparent", + ...geometry.fieldTextSm, + }, + status: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + // Ghost actions sit on the field's rail, so they carry the field's padding. + replaceAction: { paddingHorizontal: theme.spacing[2] }, + }; +}); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 5156ee8f28..2d8a605c3f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -15,16 +16,16 @@ import React, { } from "react"; import { useTranslation } from "react-i18next"; import { StyleSheet as RNStyleSheet, Text, View } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { shallow, useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; -import { KeyboardDock } from "@/components/keyboard-dock"; +import { ComposerDock } from "@/composer/dock"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; +import { RetainedChatContent } from "./retained-chat-content"; import { Composer } from "@/composer"; import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat"; import { @@ -33,20 +34,11 @@ import { } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; -import { getProviderIcon } from "@/components/provider-icons"; -import { - ToastViewport, - useToastHost, - type ToastApi, - type ToastState, -} from "@/components/toast-host"; +import { useProviderIcon } from "@/components/provider-icons"; +import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host"; import type { WorkspaceComposerAttachment } from "@/attachments/types"; import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; -import { - COMPACT_FORM_FACTOR_WIDTH, - MAX_CONTENT_WIDTH, - useIsCompactFormFactor, -} from "@/constants/layout"; +import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; @@ -56,14 +48,12 @@ import { type AgentScreenMissingState, type AgentScreenViewState, useAgentScreenStateMachine, + type AgentScreenReadySyncState, } from "@/hooks/use-agent-screen-state-machine"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; -import { - reconcileReconnectToastState, - type ReconnectToastState, -} from "@/panels/reconnect-toast-state"; +import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; @@ -71,7 +61,6 @@ import { useHasPluginComposerPills } from "@/plugins"; import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor"; import { type HostRuntimeConnectionStatus, - getHostRuntimeConnectionStatusSince, useHostRuntimeClient, useHostRuntimeConnectionStatus, useHostRuntimeIsConnected, @@ -124,10 +113,6 @@ interface ChatAgentStateShape { lastError?: Agent["lastError"] | null; } -const RECONNECT_TOAST_DELAY_MS = 1_000; - -const reconnectToastStateByServerId = new Map(); - interface ChatAgentSelectedState extends ChatAgentStateShape { archivedAt: Date | null; requiresAttention: boolean; @@ -310,11 +295,7 @@ function storeFetchedAgentDetail(input: { const store = useSessionStore.getState(); if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { - store.setAgents(input.serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated); } else { store.setAgentDetails(input.serverId, (previous) => { const next = new Map(previous); @@ -362,7 +343,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = getProviderIcon(provider); + const icon = useProviderIcon(provider, context.serverId); return { label: label ?? "", @@ -387,13 +368,15 @@ function AgentPanel() { invariant(target.kind === "agent", "AgentPanel requires agent target"); return ( - + + + ); } @@ -414,11 +397,7 @@ function DraftPanel() { (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, agent); - return next; - }); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], @@ -584,7 +563,6 @@ function AgentPanelContent({ isPaneFocused={isPaneFocused} client={runtimeClient} isConnected={runtimeIsConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -597,7 +575,6 @@ function AgentPanelBody({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -606,7 +583,6 @@ function AgentPanelBody({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -633,8 +609,6 @@ function AgentPanelBody({ const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); - const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const resolvePendingAgent = useWorkspaceLayoutStore((state) => state.resolvePendingAgent); useEffect(() => { lookupAttemptTokenRef.current += 1; @@ -646,9 +620,6 @@ function AgentPanelBody({ return; } if (agentState.id) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } if (lookupState.tag !== "idle") { setLookupState({ tag: "idle" }); } @@ -671,9 +642,6 @@ function AgentPanelBody({ return; } if (!result) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message: `Agent not found: ${agentId}`, @@ -682,9 +650,6 @@ function AgentPanelBody({ } storeFetchedAgentDetail({ serverId, result }); - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "idle" }); return; }) @@ -694,25 +659,12 @@ function AgentPanelBody({ } const message = toErrorMessage(error); if (isNotFoundErrorMessage(message)) { - if (workspaceKey) { - resolvePendingAgent(workspaceKey, agentId); - } setLookupState({ tag: "not_found", message }); return; } setLookupState({ tag: "error", message }); }); - }, [ - agentId, - agentState.id, - client, - hasSession, - isConnected, - lookupState.tag, - resolvePendingAgent, - serverId, - workspaceKey, - ]); + }, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]); if (lookupState.tag === "not_found") { return ( @@ -766,7 +718,6 @@ function AgentPanelBody({ isPaneFocused={isPaneFocused} client={client} isConnected={isConnected} - connectionStatus={connectionStatus} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); @@ -779,7 +730,6 @@ function ChatAgentContent({ isPaneFocused, client, isConnected, - connectionStatus, onOpenWorkspaceFile, }: { serverId: string; @@ -788,7 +738,6 @@ function ChatAgentContent({ isPaneFocused: boolean; client: ReturnType; isConnected: boolean; - connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { const { t } = useTranslation(); @@ -798,7 +747,6 @@ function ChatAgentContent({ const streamViewRef = useRef(null); const clearOnAgentBlurRef = useRef<() => void>(() => {}); const wasPaneFocusedRef = useRef(isPaneFocused); - const reconnectToastPresentedRef = useRef(false); const initAttemptTokenRef = useRef(0); const routeBottomAnchorRequestRef = useRef<{ routeKey: string; @@ -885,73 +833,6 @@ function ChatAgentContent({ clearOnAgentBlurRef.current = attentionController.clearOnAgentBlur; }, [attentionController.clearOnAgentBlur]); - const shouldPresentReconnectToast = - isPaneVisible && connectionStatus !== "online" && connectionStatus !== "idle"; - - useEffect(() => { - if (connectionStatus === "online" || connectionStatus === "idle") { - reconnectToastStateByServerId.delete(serverId); - } - - if (!shouldPresentReconnectToast) { - if (reconnectToastPresentedRef.current) { - reconnectToastPresentedRef.current = false; - dismissToast(); - } - return; - } - - const startedAt = getHostRuntimeConnectionStatusSince(serverId) ?? Date.now(); - const previousReconnectToastState = reconnectToastStateByServerId.get(serverId); - const reconnectToastState = reconcileReconnectToastState( - previousReconnectToastState, - startedAt, - ); - if (reconnectToastState !== previousReconnectToastState) { - reconnectToastStateByServerId.set(serverId, reconnectToastState); - } - - if (reconnectToastState.presented) { - if (!reconnectToastPresentedRef.current) { - reconnectToastPresentedRef.current = true; - toastApi.show(t("agentPanel.states.reconnecting"), { - durationMs: null, - icon: ( - - ), - testID: "agent-reconnecting-toast", - }); - } - return; - } - - const delayMs = Math.max(0, startedAt + RECONNECT_TOAST_DELAY_MS - Date.now()); - const timer = setTimeout(() => { - if (reconnectToastStateByServerId.get(serverId) !== reconnectToastState) { - return; - } - reconnectToastState.presented = true; - reconnectToastPresentedRef.current = true; - toastApi.show(t("agentPanel.states.reconnecting"), { - durationMs: null, - icon: ( - - ), - testID: "agent-reconnecting-toast", - }); - }, delayMs); - - return () => clearTimeout(timer); - }, [connectionStatus, dismissToast, serverId, shouldPresentReconnectToast, toastApi, t]); - const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { @@ -1205,6 +1086,7 @@ function ChatAgentContent({ handleComposerHeightChange={handleComposerHeightChange} handleMessageSent={handleMessageSent} handleRewindComplete={handleRewindComplete} + timelineSync={isPaneVisible && viewState.tag === "ready" ? viewState.sync : null} showHistorySyncOverlay={showHistorySyncOverlay} showHistorySyncError={showHistorySyncError} isRetryingHistorySync={isRetryingHistorySync} @@ -1234,6 +1116,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange, handleMessageSent, handleRewindComplete, + timelineSync, showHistorySyncOverlay, showHistorySyncError, isRetryingHistorySync, @@ -1259,6 +1142,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ handleComposerHeightChange: (height: number) => void; handleMessageSent: () => void; handleRewindComplete: () => void; + timelineSync: AgentScreenReadySyncState | null; showHistorySyncOverlay: boolean; showHistorySyncError: boolean; isRetryingHistorySync: boolean; @@ -1295,7 +1179,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ // Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out // when only toast state changes (which does not affect any draft field). const { - text, + textSource, editText, replaceText, textReplacement, @@ -1308,7 +1192,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ } = rawAgentInputDraft; const agentInputDraft = useMemo( (): AgentInputDraft => ({ - text, + textSource, editText, replaceText, textReplacement, @@ -1320,7 +1204,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ composerState, }), [ - text, + textSource, editText, replaceText, textReplacement, @@ -1382,51 +1266,45 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null} ); - const contentContainer = {streamContent}; + + const dockContent = ( + + {streamContent} + + {showHistorySyncError ? ( + + ) : null} + + ); + + const dockOverlay = ( + <> + {showHistorySyncOverlay ? ( + + + + ) : null} + + + + ); + + const dock = ( + + {dockContent} + {composerSection} + {dockOverlay} + + ); return ( - - {contentContainer} - - {showHistorySyncError ? ( - - - - - {t("agentPanel.states.timelineSyncFailed")} - - - - - - ) : null} - - {composerSection} - - {showHistorySyncOverlay ? ( - - - - ) : null} - - - + {dock} {isArchivingCurrentAgent ? ( @@ -1440,13 +1318,47 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ); }); -function DockedChatSurface({ children, disabled }: { children: ReactNode; disabled: boolean }) { +function ChatSurface({ + children, + disabled, +}: { + children: [ReactNode, ReactNode, ReactNode]; + disabled: boolean; +}) { return ( - - - {children} - - + + {children} + + ); +} + +function TimelineSyncErrorCallout({ + isRetrying, + onRetry, +}: { + isRetrying: boolean; + onRetry: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + + {t("agentPanel.states.timelineSyncFailed")} + + + + + ); } @@ -1626,7 +1538,6 @@ function ActiveAgentComposer({ onComposerHeightChange: (height: number) => void; onMessageSent: () => void; }) { - const insets = useSafeAreaInsets(); const isCompactFormFactor = useIsCompactFormFactor(); const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow( COMPACT_FORM_FACTOR_WIDTH, @@ -1701,21 +1612,15 @@ function ActiveAgentComposer({ ], ); - const inputAreaStyle = useMemo( - () => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }], - [insets.bottom], - ); - return ( - + ({ }, timelineSyncCalloutContent: { width: "100%", - maxWidth: MAX_CONTENT_WIDTH, + maxWidth: theme.contentMaxWidth, }, timelineSyncCallout: { flexDirection: "row", @@ -1898,12 +1803,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, color: theme.colors.foregroundMuted, }, - reconnectingStatusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: theme.colors.palette.amber[500], - }, centerState: { flex: 1, alignItems: "center", diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index cf505b63bc..962e095b89 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -128,6 +128,7 @@ export const AgentTracks = memo(function AgentTracks({ new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])), diff --git a/packages/app/src/panels/panel-manifest.test.ts b/packages/app/src/panels/panel-manifest.test.ts index 0a91aad995..c06a87864d 100644 --- a/packages/app/src/panels/panel-manifest.test.ts +++ b/packages/app/src/panels/panel-manifest.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { panelResourceKey, panelSupportsHost } from "@/panels/panel-manifest"; +import { + getPanelManifest, + panelCanLaunchInPane, + panelResourceKey, + panelSupportsHost, +} from "@/panels/panel-manifest"; describe("panel manifest", () => { it("answers host support without React panel registration", () => { @@ -13,6 +18,23 @@ describe("panel manifest", () => { expect(panelSupportsHost("setup", "explorer")).toBe(false); }); + it("lets navigation tabs hide their close button without losing their close action", () => { + expect(getPanelManifest("files").showCloseButton).toBe(false); + expect(getPanelManifest("changes_tree").showCloseButton).toBe(false); + expect(getPanelManifest("agent").showCloseButton).toBe(true); + expect(getPanelManifest("terminal").showCloseButton).toBe(true); + expect(getPanelManifest("file").showCloseButton).toBe(true); + expect(getPanelManifest("working_diff").showCloseButton).toBe(true); + }); + + it("hides a singleton launch only when it already exists in the destination pane", () => { + expect(panelCanLaunchInPane("files", ["files"])).toBe(false); + expect(panelCanLaunchInPane("changes_tree", ["changes_tree"])).toBe(false); + expect(panelCanLaunchInPane("files", ["changes_tree"])).toBe(true); + expect(panelCanLaunchInPane("files", [])).toBe(true); + expect(panelCanLaunchInPane("terminal", ["terminal"])).toBe(true); + }); + it("keeps durable resource identity separate from transient target input", () => { expect( panelResourceKey({ kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 }), diff --git a/packages/app/src/panels/panel-manifest.ts b/packages/app/src/panels/panel-manifest.ts index c608340d09..4122da80f1 100644 --- a/packages/app/src/panels/panel-manifest.ts +++ b/packages/app/src/panels/panel-manifest.ts @@ -5,6 +5,8 @@ export type PaneHost = "main" | "explorer"; export interface PanelManifest { kind: K; supportedHosts: readonly PaneHost[]; + showCloseButton: boolean; + singleton: boolean; resourceKey(target: Extract): string; } @@ -16,62 +18,86 @@ const manifests = { new_tab: { kind: "new_tab", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "new_tab", }, draft: { kind: "draft", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.draftId, }, agent: { kind: "agent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.agentId, }, provider_subagent: { kind: "provider_subagent", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => `${target.parentAgentId}:${target.subagentId}`, }, terminal: { kind: "terminal", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.terminalId, }, browser: { kind: "browser", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.browserId, }, changes_tree: { kind: "changes_tree", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "changes_tree", }, files: { kind: "files", supportedHosts: ["explorer"], + showCloseButton: false, + singleton: true, resourceKey: () => "files", }, pull_request: { kind: "pull_request", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "pull_request", }, file: { kind: "file", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.path, }, working_diff: { kind: "working_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: () => "working_diff", }, plugin: { kind: "plugin", // Plugin targets are narrowed by the target-aware plugin panel capability resolver. supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.context === "agent" ? `${target.pluginId}:${target.panelId}:agent:${target.agentId}` @@ -80,11 +106,15 @@ const manifests = { setup: { kind: "setup", supportedHosts: ["main"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.workspaceId, }, commit_diff: { kind: "commit_diff", supportedHosts: ["main", "explorer"], + showCloseButton: true, + singleton: false, resourceKey: (target) => target.sha, }, } satisfies PanelManifestByKind; @@ -97,6 +127,13 @@ export function panelSupportsHost(kind: WorkspaceTabTarget["kind"], host: PaneHo return getPanelManifest(kind).supportedHosts.includes(host); } +export function panelCanLaunchInPane( + kind: WorkspaceTabTarget["kind"], + panePanelKinds: readonly WorkspaceTabTarget["kind"][], +): boolean { + return !getPanelManifest(kind).singleton || !panePanelKinds.includes(kind); +} + export function panelResourceKey(target: WorkspaceTabTarget): string { const manifest = getPanelManifest(target.kind); return `${target.kind}:${manifest.resourceKey(target as never)}`; diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 46153089e8..6c2d482403 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -4,15 +4,24 @@ import { StyleSheet } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { useShallow } from "zustand/react/shallow"; import { AgentStreamView } from "@/agent-stream/view"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; +import { + resolveComposerTrackControlClearance, + resolveComposerTrackTailClearance, +} from "@/composer/pill-styles"; +import { ComposerTrackBar } from "@/composer/tracks"; +import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { useSubagentsForParent } from "@/subagents/select"; +import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, providerSubagentLifecycleStatus, refreshProviderSubagents, + observeProviderSubagentTimeline, useProviderSubagentStore, } from "@/subagents/provider-store"; import { useTranslation } from "react-i18next"; @@ -24,6 +33,38 @@ import type { TurnPresentation } from "@/timeline/turn-liveness"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const NOOP_SUBAGENT = () => undefined; + +function resolveChildTrackClearance(childCount: number, isCompact: boolean) { + if (childCount === 0) return { tail: 0, controls: 0 }; + return { + tail: resolveComposerTrackTailClearance(isCompact), + controls: resolveComposerTrackControlClearance(isCompact), + }; +} + +function ProviderSubagentChildTrack({ + serverId, + rows, + onOpenProviderSubagent, +}: { + serverId: string; + rows: ReturnType; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; +}) { + if (rows.length === 0) return null; + return ( + + + + ); +} function formatProviderLabel(provider: string): string { return provider @@ -56,7 +97,7 @@ function useProviderSubagentDescriptor( subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel, tooltip: label, titleState: descriptor ? "ready" : "loading", - icon: getProviderIcon(provider), + icon: useProviderIcon(provider, context.serverId), statusBucket: descriptor ? deriveSidebarStateBucket({ status: providerSubagentLifecycleStatus(descriptor.status), @@ -68,7 +109,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace } = usePaneContext(); + const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -86,9 +127,22 @@ function ProviderSubagentPanel() { ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); - // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. - const supported = serverInfo?.features?.providerSubagents === true; + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supported = serverInfo?.features?.projectedSubagentTimeline === true; const [isLoadingOlder, setIsLoadingOlder] = useState(false); + const isCompact = useIsCompactFormFactor(); + const childRows = useSubagentsForParent({ + serverId, + parentAgentId: target.parentAgentId, + providerParentSubagentId: target.subagentId, + }); + const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const openProviderChild = useCallback( + (parentAgentId: string, subagentId: string) => { + openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + }, + [openTab], + ); useEffect(() => { if (!client || !supported) return; @@ -97,23 +151,28 @@ function ProviderSubagentPanel() { useEffect(() => { if (!client || !supported) return; - void client - .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - }) - .then((payload) => { - useProviderSubagentStore.getState().replaceTimeline(serverId, payload); - return undefined; - }) - .catch(() => undefined); - }, [client, serverId, supported, target.parentAgentId, target.subagentId]); + return observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + limit: TIMELINE_FETCH_PAGE_SIZE, + reportError: (error) => { + console.error("[ProviderSubagentTimeline] Failed to refresh child history", { + error, + serverId, + parentAgentId: target.parentAgentId, + subagentId: target.subagentId, + }); + }, + }); + }, [client, supported, serverId, target.parentAgentId, target.subagentId]); const loadOlder = useCallback((): boolean => { if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { return false; } - const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstSeq = timeline.cursor?.startSeq ?? null; if (firstSeq === null) return false; setIsLoadingOlder(true); void client @@ -138,7 +197,7 @@ function ProviderSubagentPanel() { target.subagentId, timeline, ]); - const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null; + const firstTimelineSeq = timeline?.cursor?.startSeq ?? null; const progressKey = timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null; const subtitle = descriptor?.subtitle?.trim(); @@ -207,6 +266,13 @@ function ProviderSubagentPanel() { onOpenWorkspaceFile={openFileInWorkspace} readOnly historyPagination={historyPagination} + bottomOverlayTailClearance={childTrackClearance.tail} + bottomOverlayControlClearance={childTrackClearance.controls} + /> + ); diff --git a/packages/app/src/panels/pull-request/index.tsx b/packages/app/src/panels/pull-request/index.tsx index 8759ae561f..148da86914 100644 --- a/packages/app/src/panels/pull-request/index.tsx +++ b/packages/app/src/panels/pull-request/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; @@ -14,11 +14,14 @@ import { import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { useSettings } from "@/hooks/use-settings"; +import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; +import { autoOpenWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; + import { getPullRequestIdentity, resolvePullRequestContentState } from "./state"; /** - * Whether this workspace's branch has a pull request. Detection only offers the - * pull request as something the user can open; it never opens a tab on its own. + * Whether this workspace's branch has a pull request. */ export function useHasPullRequest(input: { serverId: string; @@ -33,6 +36,21 @@ export function useHasPullRequest(input: { return getPullRequestIdentity(status) !== null; } +/** Connects desktop PR detection to the persisted layout after hydration. */ +export function usePullRequestAutoAdd(input: { + workspaceKey: string | null; + hasPullRequest: boolean; + enabled: boolean; +}): void { + const hydrated = useWorkspaceLayoutStoreHydrated(); + const destination = useSettings((settings) => settings.pullRequestOpenLocation); + useEffect(() => { + if (hydrated && input.enabled && input.hasPullRequest) { + autoOpenWorkspacePullRequest({ workspaceKey: input.workspaceKey, destination }); + } + }, [hydrated, input.enabled, input.hasPullRequest, input.workspaceKey, destination]); +} + export function PullRequestContent(input: { serverId: string; workspaceId?: string | null; diff --git a/packages/app/src/panels/reconnect-toast-state.test.ts b/packages/app/src/panels/reconnect-toast-state.test.ts deleted file mode 100644 index 2813ca485b..0000000000 --- a/packages/app/src/panels/reconnect-toast-state.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { reconcileReconnectToastState, type ReconnectToastState } from "./reconnect-toast-state"; - -describe("reconnect toast presentation state", () => { - it("resets after the host recovers without a mounted agent panel", () => { - const presented: ReconnectToastState = { - connectionStatusStartedAt: 100, - presented: true, - }; - - expect(reconcileReconnectToastState(presented, 100)).toBe(presented); - expect(reconcileReconnectToastState(presented, 200)).toEqual({ - connectionStatusStartedAt: 200, - presented: false, - }); - }); -}); diff --git a/packages/app/src/panels/reconnect-toast-state.ts b/packages/app/src/panels/reconnect-toast-state.ts deleted file mode 100644 index ff690614fc..0000000000 --- a/packages/app/src/panels/reconnect-toast-state.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface ReconnectToastState { - connectionStatusStartedAt: number; - presented: boolean; -} - -export function reconcileReconnectToastState( - state: ReconnectToastState | undefined, - connectionStatusStartedAt: number, -): ReconnectToastState { - if (state?.connectionStatusStartedAt === connectionStatusStartedAt) { - return state; - } - - return { - connectionStatusStartedAt, - presented: false, - }; -} diff --git a/packages/app/src/panels/retained-chat-content.tsx b/packages/app/src/panels/retained-chat-content.tsx new file mode 100644 index 0000000000..0ed1d2dfc8 --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +/** + * Native: a hidden chat stays mounted and keeps rendering. Suspending a retained + * native subtree hides its host views, tears down layout effects, and detaches + * refs, which changes Fabric ownership (docs/mobile-panels.md). Activity-gated + * work already stops through the retained-panel signal. + */ +export function RetainedChatContent({ children }: { children: ReactNode }) { + return children; +} diff --git a/packages/app/src/panels/retained-chat-content.web.tsx b/packages/app/src/panels/retained-chat-content.web.tsx new file mode 100644 index 0000000000..938a0e48ca --- /dev/null +++ b/packages/app/src/panels/retained-chat-content.web.tsx @@ -0,0 +1,17 @@ +import React, { useEffect, useState, type ReactNode } from "react"; +import { Freeze } from "react-freeze"; +import { useRetainedPanelActive } from "@/components/retained-panel"; + +/** + * Web only: suspends a hidden chat so its subscribers stop rendering while the + * model keeps streaming. DOM nodes have no Fabric ownership to lose, so + * suspension is safe here; native keeps the subtree live (see the base file). + */ +export function RetainedChatContent({ children }: { children: ReactNode }) { + const active = useRetainedPanelActive(); + const [frozen, setFrozen] = useState(!active); + // First let visibility-gated queries and animations receive active=false. + // The following commit freezes every chat subscriber while the model keeps streaming. + useEffect(() => setFrozen(!active), [active]); + return {children}; +} diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 9934bec864..3648bf5a1c 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -145,6 +145,7 @@ function BlockedSetupNotice({ return ( <> ) : null} - {runError ? : null} + {runError ? : null} ); } diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 32dd1f188d..8082bd36e5 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -69,7 +69,7 @@ function useTerminalPanelDescriptor( label, subtitle: t("workspace.tabs.fallback.terminal"), tooltip: label, - titleState: "ready", + titleState: terminalsQuery.isPending ? "loading" : "ready", icon: Terminal, statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity), }; diff --git a/packages/app/src/plugins/actions.ts b/packages/app/src/plugins/actions.ts index 0d9278a702..b61eae7c1d 100644 --- a/packages/app/src/plugins/actions.ts +++ b/packages/app/src/plugins/actions.ts @@ -1,17 +1,19 @@ -import { callPluginRpc } from "@getpaseo/plugin/host"; +import { callPluginRpc } from "@getpaseo/plugin/client/host"; import type { PluginAgentCommandContext, PluginCommandCapabilities, PluginPanelLocation, + PluginScreenParams, PluginWorkspaceCommandContext, -} from "@getpaseo/plugin"; -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +} from "@getpaseo/plugin/client"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { resolvePluginPanelOpenLocation } from "./workspace-panels/locations"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; +import { parsePluginOpenScreenInput } from "./surface-contribution"; import type { InstalledPlugin } from "./types"; export interface PluginNavigation { - openSurface(pluginId: string, surfaceId: string): void; + openSettings(pluginId: string, screenId: string): void; + openSurface(pluginId: string, surfaceId: string, params?: PluginScreenParams): void; openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void; openAgentPanel( pluginId: string, @@ -23,36 +25,40 @@ export interface PluginNavigation { export function createPluginCapabilities( plugin: InstalledPlugin, - runtime: PluginSurfaceRuntime, navigation: PluginNavigation, ): PluginCommandCapabilities { + function openScreen(input: unknown) { + const { screenId, params } = parsePluginOpenScreenInput(plugin, input); + navigation.openSurface(plugin.id, screenId, params); + } return { - paseo: runtime.paseo, - rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input), - openSurface(surfaceId) { - if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) { - throw new Error(`Plugin surface is unavailable: ${surfaceId}`); - } - navigation.openSurface(plugin.id, surfaceId); + paseo: plugin.paseo, + rpc: (contract, input) => callPluginRpc(contract, plugin.invoke, input), + openSettings(screenId) { + if (!plugin.settingsScreens.some((screen) => screen.id === screenId)) + throw new Error(`Plugin settings screen is unavailable: ${screenId}`); + navigation.openSettings(plugin.id, screenId); }, + openScreen, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + openSurface: (screenId) => openScreen({ screenId }), }; } export function createPluginAgentActionContext(input: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; navigation: PluginNavigation; state: PluginClientStateSource; workspaceId: string; agentId: string; }): PluginAgentCommandContext | null { - const { plugin, runtime, navigation, state, workspaceId, agentId } = input; + const { plugin, navigation, state, workspaceId, agentId } = input; const workspace = state.getWorkspace(workspaceId); const agent = state.getAgent(agentId); if (!workspace || !agent || agent.workspaceId !== workspace.id) return null; return { context: "agent", - ...createPluginCapabilities(plugin, runtime, navigation), + ...createPluginCapabilities(plugin, navigation), workspace, agent, openPanel(panelId, options) { @@ -70,17 +76,16 @@ export function createPluginAgentActionContext(input: { export function createPluginWorkspaceActionContext(input: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; navigation: PluginNavigation; state: PluginClientStateSource; workspaceId: string; }): PluginWorkspaceCommandContext | null { - const { plugin, runtime, navigation, state, workspaceId } = input; + const { plugin, navigation, state, workspaceId } = input; const workspace = state.getWorkspace(workspaceId); if (!workspace) return null; return { context: "workspace", - ...createPluginCapabilities(plugin, runtime, navigation), + ...createPluginCapabilities(plugin, navigation), workspace, openPanel(panelId, options) { const panel = plugin.workspacePanels.find( diff --git a/packages/app/src/plugins/attachments/picker.tsx b/packages/app/src/plugins/attachments/picker.tsx index f25d5c4d20..a63c1eb4e0 100644 --- a/packages/app/src/plugins/attachments/picker.tsx +++ b/packages/app/src/plugins/attachments/picker.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { withUnistyles } from "react-native-unistyles"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PluginAttachmentItem, PluginAttachmentSourceContribution } from "@getpaseo/plugin"; -import { searchPluginAttachments } from "@getpaseo/plugin/host"; +import { searchPluginAttachments } from "@getpaseo/plugin/client/host"; import type { LucideIcon } from "lucide-react-native"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMenuItem } from "@/composer/input/input"; diff --git a/packages/app/src/plugins/buttons/index.ts b/packages/app/src/plugins/buttons/index.ts new file mode 100644 index 0000000000..3cf26bcd50 --- /dev/null +++ b/packages/app/src/plugins/buttons/index.ts @@ -0,0 +1 @@ +export { pluginButtonStore } from "./store"; diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts new file mode 100644 index 0000000000..4a77a81311 --- /dev/null +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -0,0 +1,173 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { PluginButton } from "@getpaseo/plugin/client"; +import type { InstalledPlugin } from "../types"; +import { PluginButtonStore, buttonMatches } from "./model"; + +function installation(): InstalledPlugin { + return { + lifetime: new AbortController(), + id: "review", + serverId: "host-a", + clientBundle: "bundle", + queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, + cleanup: () => undefined, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +function store() { + return new PluginButtonStore({ + validateIconName(name) { + if (name !== "Scan") throw new Error("Unknown icon"); + }, + }); +} + +function button(onPress = () => {}): PluginButton { + return { title: "Review", icon: "Scan", behavior: { kind: "action", onPress } }; +} + +describe("plugin buttons", () => { + it("updates visibility in place, closes hidden content, and scopes each placement", () => { + const buttons = store(); + const plugin = installation(); + const header = buttons.addHeaderButton(plugin, { + id: "review", + workspaceId: "workspace", + button: button(), + }); + buttons.addComposerPill(plugin, { + id: "review", + workspaceId: "workspace", + agentId: "agent", + button: button(), + }); + const first = buttons.getSnapshot()[0]; + expect(buttonMatches(first, "host-a", "workspace", null)).toBe(true); + expect(buttonMatches(first, "host-b", "workspace", null)).toBe(false); + expect(buttonMatches(first, "host-a", "another-workspace", null)).toBe(false); + expect(buttonMatches(first, "host-a", "workspace", "agent")).toBe(false); + expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "agent")).toBe(true); + expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "other-agent")).toBe( + false, + ); + buttons.setOpen(first.key, true); + header.update({ visible: false, label: "3 reviews" }); + expect(buttonMatches(buttons.getSnapshot()[0], "host-a", "workspace", null)).toBe(false); + expect(buttons.getSnapshot()[0].open).toBe(false); + header.update({ visible: true }); + expect(buttons.getSnapshot().map((entry) => entry.key)).toEqual([first.key, first.key + 1]); + expect(buttons.getSnapshot()[0].button.label).toBe("3 reviews"); + header.remove(); + header.remove(); + header.update({ visible: true }); + expect(buttons.getSnapshot().map((entry) => entry.placement)).toEqual(["composer"]); + }); + + it("rejects duplicates within a target and validates updates atomically", () => { + const buttons = store(); + const plugin = installation(); + const contribution = { id: "review", workspaceId: "workspace", button: button() }; + const header = buttons.addHeaderButton(plugin, contribution); + expect(() => buttons.addHeaderButton(plugin, contribution)).toThrow( + "Duplicate plugin button: review", + ); + expect(() => buttons.addHeaderButton(installation(), contribution)).not.toThrow(); + expect(() => + buttons.addHeaderButton(plugin, { ...contribution, workspaceId: "other" }), + ).not.toThrow(); + expect(() => header.update({ title: "Changed", icon: "Invalid" })).toThrow("Unknown icon"); + expect(buttons.getSnapshot()[0].button.title).toBe("Review"); + }); + + it("prevents repeated presses and cannot resurrect a removed pending button", async () => { + const buttons = store(); + let finish = () => {}; + const pending = new Promise((resolve) => { + finish = resolve; + }); + let calls = 0; + const registration = buttons.addHeaderButton(installation(), { + id: "review", + workspaceId: "workspace", + button: button(() => { + calls++; + return pending; + }), + }); + const key = buttons.getSnapshot()[0].key; + const action = buttons.run(key); + await buttons.run(key); + expect(calls).toBe(1); + expect(buttons.getSnapshot()[0].pending).toBe(true); + registration.remove(); + finish(); + await action; + expect(buttons.getSnapshot()).toEqual([]); + }); + + it("allows retry after failure and respects disabled actions and menu ancestors", async () => { + const buttons = store(); + let calls = 0; + const registration = buttons.addHeaderButton(installation(), { + id: "review", + workspaceId: "workspace", + button: button(() => { + if (++calls === 1) throw new Error("Review unavailable"); + }), + }); + const key = buttons.getSnapshot()[0].key; + await expect(buttons.run(key)).rejects.toThrow("Review unavailable"); + expect(buttons.getSnapshot()[0].pending).toBe(false); + await buttons.run(key); + expect(calls).toBe(2); + registration.update({ disabled: true }); + await buttons.run(key); + expect(calls).toBe(2); + registration.update({ + disabled: false, + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "nested", + title: "Nested", + disabled: true, + behavior: { + kind: "menu", + items: [ + { + kind: "item", + id: "run", + title: "Run", + behavior: { + kind: "action", + onPress() { + calls++; + }, + }, + }, + ], + }, + }, + ], + }, + }); + await buttons.run(key, ["nested", "run"]); + expect(calls).toBe(2); + }); +}); diff --git a/packages/app/src/plugins/buttons/model.ts b/packages/app/src/plugins/buttons/model.ts new file mode 100644 index 0000000000..25e7490b33 --- /dev/null +++ b/packages/app/src/plugins/buttons/model.ts @@ -0,0 +1,178 @@ +import type { + PluginButtonBehavior, + PluginButtonContext, + PluginButtonRegistration, + PluginHeaderButtonContribution, + PluginComposerPillContribution, +} from "@getpaseo/plugin/client"; +import type { InstalledPlugin } from "../types"; +import { + requireButtonId, + validateButton, + type ButtonValidation, + type ResolvedPluginButton, +} from "./validation"; + +export type ButtonPlacement = "header" | "composer"; + +export interface RegisteredPluginButton { + key: number; + id: string; + installation: InstalledPlugin; + placement: ButtonPlacement; + context: PluginButtonContext; + button: ResolvedPluginButton; + pending: boolean; + open: boolean; +} + +function resolveAction( + behavior: PluginButtonBehavior, + path: readonly string[], +): (() => void | Promise) | null { + if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null; + if (behavior.kind !== "menu") return null; + const item = behavior.items.find((entry) => entry.id === path[0]); + if (!item || item.kind !== "item" || item.visible === false || item.disabled) return null; + return resolveAction(item.behavior, path.slice(1)); +} + +export class PluginButtonStore { + private entries: readonly RegisteredPluginButton[] = []; + private nextKey = 0; + private listeners = new Set<() => void>(); + + constructor(private validation: ButtonValidation) {} + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getSnapshot = (): readonly RegisteredPluginButton[] => this.entries; + + addHeaderButton( + installation: InstalledPlugin, + input: PluginHeaderButtonContribution, + ): PluginButtonRegistration { + return this.add(installation, input, "header", { + context: "workspace", + workspaceId: input.workspaceId.trim(), + }); + } + + addComposerPill( + installation: InstalledPlugin, + input: PluginComposerPillContribution, + ): PluginButtonRegistration { + if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent"); + return this.add(installation, input, "composer", { + context: "agent", + workspaceId: input.workspaceId.trim(), + agentId: input.agentId.trim(), + }); + } + + private add( + installation: InstalledPlugin, + input: PluginHeaderButtonContribution, + placement: ButtonPlacement, + context: PluginButtonContext, + ): PluginButtonRegistration { + const id = requireButtonId(input.id); + if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace"); + const duplicate = this.entries.some( + (entry) => + entry.installation === installation && + entry.placement === placement && + entry.id === id && + entry.context.workspaceId === context.workspaceId && + (entry.context.context === "workspace" || + (context.context === "agent" && entry.context.agentId === context.agentId)), + ); + if (duplicate) throw new Error(`Duplicate plugin button: ${id}`); + const key = this.nextKey++; + const initialButton = validateButton(input.button, this.validation); + this.publish([ + ...this.entries, + { + key, + id, + installation, + placement, + context, + button: initialButton, + pending: false, + open: false, + }, + ]); + return { + update: (patch) => { + const entry = this.entries.find((candidate) => candidate.key === key); + if (!entry) return; + const button = validateButton({ ...entry.button, ...patch }, this.validation); + const close = !button.visible || button.disabled || patch.behavior !== undefined; + this.replace(key, { button, open: close ? false : entry.open }); + }, + remove: () => { + const next = this.entries.filter((entry) => entry.key !== key); + if (next.length !== this.entries.length) this.publish(next); + }, + }; + } + + setOpen(key: number, open: boolean): void { + const entry = this.entries.find((candidate) => candidate.key === key); + if (!entry) return; + if (open && (!entry.button.visible || entry.button.disabled || entry.pending)) return; + this.replace(key, { open }); + } + + async run(key: number, path: readonly string[] = []): Promise { + const entry = this.entries.find((candidate) => candidate.key === key); + if (!entry || !entry.button.visible || entry.button.disabled || entry.pending) return; + const action = resolveAction(entry.button.behavior, path); + if (!action) return; + this.replace(key, { pending: true }); + try { + await action(); + } finally { + this.replace(key, { pending: false }); + } + } + + private replace( + key: number, + patch: Partial>, + ): void { + if (!this.entries.some((entry) => entry.key === key)) return; + this.publish(this.entries.map((entry) => (entry.key === key ? { ...entry, ...patch } : entry))); + } + + private publish(entries: readonly RegisteredPluginButton[]): void { + this.entries = entries; + for (const listener of this.listeners) listener(); + } +} + +export function buttonMatches( + entry: RegisteredPluginButton, + serverId: string, + workspaceId: string, + agentId: string | null, +): boolean { + if ( + entry.installation.serverId !== serverId || + entry.context.workspaceId !== workspaceId || + !entry.button.visible + ) + return false; + if (agentId === null) return entry.placement === "header"; + return ( + entry.placement === "composer" && + entry.context.context === "agent" && + entry.context.agentId === agentId + ); +} diff --git a/packages/app/src/plugins/buttons/store.ts b/packages/app/src/plugins/buttons/store.ts new file mode 100644 index 0000000000..5dbc847333 --- /dev/null +++ b/packages/app/src/plugins/buttons/store.ts @@ -0,0 +1,4 @@ +import { resolvePluginIcon } from "../icons"; +import { PluginButtonStore } from "./model"; + +export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon }); diff --git a/packages/app/src/plugins/buttons/validation.ts b/packages/app/src/plugins/buttons/validation.ts new file mode 100644 index 0000000000..650c21fac2 --- /dev/null +++ b/packages/app/src/plugins/buttons/validation.ts @@ -0,0 +1,95 @@ +import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client"; + +export interface ButtonValidation { + validateIconName(name: string): void; +} + +export function requireButtonId(value: string): string { + if (typeof value !== "string" || !/^[a-z][a-z0-9-]*$/.test(value)) + throw new Error(`Invalid plugin button id: ${value}`); + return value; +} + +function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (typeof value !== "boolean") + throw new Error("Plugin button visibility and disabled state must be booleans"); + return value; +} + +function requireText(value: string, field: string): string { + if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`); + return value.trim(); +} + +function isComponent(value: unknown): boolean { + return ( + typeof value === "function" || + (typeof value === "object" && value !== null && "$$typeof" in value) + ); +} + +function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void { + if (typeof icon === "string") validation.validateIconName(icon); + else if (!isComponent(icon)) + throw new Error("Plugin button icon must be a Lucide name or component"); +} + +function validateBehavior( + behavior: PluginButtonBehavior, + validation: ButtonValidation, +): PluginButtonBehavior { + switch (behavior.kind) { + case "action": + if (typeof behavior.onPress !== "function") + throw new Error("Plugin button action needs onPress"); + return { ...behavior }; + case "popover": + if (!isComponent(behavior.Content)) throw new Error("Plugin button popover needs Content"); + return { ...behavior }; + case "menu": { + const ids = new Set(); + return { + kind: "menu", + items: behavior.items.map((item) => { + requireButtonId(item.id); + if (ids.has(item.id)) throw new Error(`Duplicate plugin button menu item: ${item.id}`); + ids.add(item.id); + if (item.kind === "separator") return { ...item }; + if (item.kind !== "item") throw new Error("Invalid plugin button menu entry"); + if (item.icon !== undefined) validateIcon(item.icon, validation); + return { + ...item, + title: requireText(item.title, "menu item title"), + visible: optionalBoolean(item.visible, true), + disabled: optionalBoolean(item.disabled, false), + behavior: validateBehavior(item.behavior, validation), + }; + }), + }; + } + default: + throw new Error("Invalid plugin button behavior"); + } +} + +export type ResolvedPluginButton = PluginButton & { + visible: boolean; + disabled: boolean; + label: string | undefined; +}; + +export function validateButton( + button: PluginButton, + validation: ButtonValidation, +): ResolvedPluginButton { + validateIcon(button.icon, validation); + return { + ...button, + title: requireText(button.title, "title"), + label: button.label === undefined ? undefined : requireText(button.label, "label"), + visible: optionalBoolean(button.visible, true), + disabled: optionalBoolean(button.disabled, false), + behavior: validateBehavior(button.behavior, validation), + }; +} diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx new file mode 100644 index 0000000000..7eae9a046d --- /dev/null +++ b/packages/app/src/plugins/buttons/view.tsx @@ -0,0 +1,654 @@ +import type { + PluginButtonBehavior, + PluginButtonIcon, + PluginButtonMenuEntry, + PluginHostProps, +} from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react"; +import { Pressable, Text, View, useWindowDimensions } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { + MenuRoot, + MenuTrigger, + MenuItem, + MenuSeparator, + MenuSubTrigger, + useMenuContext, + type MenuPageDefinition, +} from "@/components/ui/menu"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { + iconButtonChromeStyle, + type IconButtonChromeState, +} from "@/components/ui/icon-button-chrome"; +import { composerPillStyles } from "@/composer/pill-styles"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { Icon } from "../icons"; +import { + PluginEnvironmentProvider, + PluginPopoverContent, + PluginPopoverSurface, + type PluginEnvironment, +} from "../popover"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import { buttonMatches, type RegisteredPluginButton } from "./model"; +import { pluginButtonStore } from "./store"; +import { resolvePluginPlatform } from "../platform"; + +interface ButtonView { + entry: RegisteredPluginButton; + props: PluginHostProps & RegisteredPluginButton["context"]; + environment: PluginEnvironment; + toast: ReturnType; +} + +const ROOT_PATH: readonly string[] = []; + +const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const errorIconMapping = (theme: Theme) => ({ color: theme.colors.statusDanger }); +const ThemedMoreIcon = withUnistyles(MoreHorizontal); +const ThemedErrorIcon = withUnistyles(AlertCircle); + +function headerButtonStyle(compact: boolean, state: IconButtonChromeState, disabled = false) { + if (compact) return iconButtonChromeStyle({ size: "large", state, disabled }); + return [ + styles.headerButton, + styles.button, + (state.hovered || state.pressed || state.open) && styles.active, + disabled && styles.disabled, + ]; +} + +function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) { + return ( + {children} + ); +} + +function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) { + const color = view.props.theme.colors.foregroundMuted; + const size = view.entry.placement === "composer" ? 14 : 16; + return ( + + {typeof icon === "string" ? ( + + ) : ( + renderCustomIcon(icon, view, size, color) + )} + + ); +} + +function renderCustomIcon( + CustomIcon: Exclude, + view: ButtonView, + size: number, + color: string, +) { + return ; +} + +function pressButton(view: ButtonView, path: readonly string[]) { + void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => { + view.toast.error(error instanceof Error ? error.message : String(error)); + }); +} + +function visibleMenuItems( + items: readonly PluginButtonMenuEntry[], +): readonly PluginButtonMenuEntry[] { + const visible: PluginButtonMenuEntry[] = []; + for (const item of items) { + if (item.kind === "item" && item.visible === false) continue; + if (item.kind === "separator" && (visible.length === 0 || visible.at(-1)?.kind === "separator")) + continue; + visible.push(item); + } + if (visible.at(-1)?.kind === "separator") visible.pop(); + return visible; +} + +function ButtonMenuItem({ + view, + title, + icon, + behavior, + disabled, + path, + pageId, +}: { + view: ButtonView; + title: string; + icon?: PluginButtonIcon; + behavior: PluginButtonBehavior; + disabled: boolean; + path: readonly string[]; + pageId: string; +}) { + const select = useCallback(() => pressButton(view, path), [view, path]); + const leading = useMemo( + () => (icon ? : null), + [icon, view], + ); + if (behavior.kind !== "action") { + return ( + + {title} + + ); + } + return ( + + {title} + + ); +} + +function ButtonBody({ + view, + behavior, + path, +}: { + view: ButtonView; + behavior: PluginButtonBehavior; + path: readonly string[]; +}) { + const menu = useMenuContext("PluginButton"); + const close = useCallback(() => menu.setOpen(false), [menu]); + if (behavior.kind === "popover") { + const Content = behavior.Content; + return ( + + + + ); + } + if (behavior.kind !== "menu") return null; + return visibleMenuItems(behavior.items).map((item) => { + if (item.kind === "separator") return ; + const itemPath = [...path, item.id]; + return ( + + ); + }); +} + +function ButtonSurfaceBody({ + view, + behavior, + path, +}: { + view: ButtonView; + behavior: PluginButtonBehavior; + path: readonly string[]; +}) { + return ( + + + + + + ); +} + +function buttonPageId(view: ButtonView, path: readonly string[]): string { + return [view.entry.key, ...path].join("/"); +} + +function buttonPages( + view: ButtonView, + behavior: PluginButtonBehavior, + path: readonly string[] = [], +): MenuPageDefinition[] { + if (behavior.kind !== "menu") return []; + return behavior.items.flatMap((item) => { + if (item.kind === "separator" || item.visible === false || item.behavior.kind === "action") + return []; + const itemPath = [...path, item.id]; + return [ + { + id: buttonPageId(view, itemPath), + title: item.title, + hoverIntent: item.behavior.kind === "menu", + content: , + }, + ...buttonPages(view, item.behavior, itemPath), + ]; + }); +} + +function ButtonControl({ view }: { view: ButtonView }) { + const { entry, props } = view; + const { button } = entry; + const composer = entry.placement === "composer"; + const disabled = button.disabled || entry.pending; + const expanded = button.behavior.kind !== "action"; + const chevron = !composer && !props.layout.compact && expanded; + let label = button.label; + if (composer) label = button.label ?? button.title; + else if (props.layout.compact) label = undefined; + const press = useCallback(() => pressButton(view, []), [view]); + const setOpen = useCallback( + (open: boolean) => pluginButtonStore.setOpen(entry.key, open), + [entry.key], + ); + const buttonStyle = useCallback( + ({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => + composer + ? [ + composerPillStyles.body, + styles.button, + (hovered || pressed || entry.open) && styles.active, + disabled && styles.disabled, + ] + : headerButtonStyle(props.layout.compact, { hovered, pressed, open: entry.open }, disabled), + [composer, disabled, entry.open, props.layout.compact], + ); + const contents = ( + <> + {entry.pending ? ( + + + + ) : ( + + )} + {label ? ( + + {label} + + ) : null} + {chevron ? ( + + + + ) : null} + + ); + const accessibilityState = useMemo( + () => ({ busy: entry.pending, disabled }), + [entry.pending, disabled], + ); + const trigger = expanded ? ( + + {contents} + + ) : ( + + {contents} + + ); + const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]); + return ( + + + {trigger} + + {button.title} + + + {expanded ? ( + + + + ) : null} + + ); +} + +function BrokenButton({ + title, + error, + compact, +}: { + title: string; + error: string; + compact: boolean; +}) { + return ( + + + + + + {error} + + + ); +} + +function createButtonView({ + entry, + client, + toast, + compact, + hostLabel, + theme, +}: { + entry: RegisteredPluginButton; + client: ReturnType; + toast: ReturnType; + compact: boolean; + hostLabel: string; + theme: PluginTheme; +}): ButtonView | null { + if (!client) return null; + return { + entry, + toast, + environment: { + installation: entry.installation, + toast, + state: createPluginClientStateSource(entry.installation.serverId), + }, + props: { + ...entry.context, + theme, + host: { id: entry.installation.serverId, label: hostLabel }, + layout: { compact, platform: resolvePluginPlatform() }, + }, + }; +} + +function PluginButtonHost({ + entry, + compact, + hostLabel, + theme, +}: { + entry: RegisteredPluginButton; + compact: boolean; + hostLabel: string; + theme: PluginTheme; +}) { + const client = useHostRuntimeClient(entry.installation.serverId); + const toast = useToast(); + const view = useMemo( + () => createButtonView({ entry, client, toast, compact, hostLabel, theme }), + [entry, client, toast, compact, hostLabel, theme], + ); + const renderError = useCallback( + (error: string) => , + [entry.button.title, compact], + ); + if (!view) return null; + return ( + + + + + + ); +} + +const ThemedPluginButton = withUnistyles(PluginButtonHost); + +// Overflow rows keep their own plugin runtime; a menu can contain several installations. +function OverflowButton({ view }: { view: ButtonView }) { + const { button } = view.entry; + return ( + + ); +} + +function OverflowPages({ + entries, + compact, + hostLabel, + theme, +}: { + entries: readonly RegisteredPluginButton[]; + compact: boolean; + hostLabel: string; + theme: PluginTheme; +}) { + // The header belongs to one host, but each button keeps its installation's query cache and RPCs. + const client = useHostRuntimeClient(entries[0].installation.serverId); + const toast = useToast(); + const menuContent = useMemo(() => { + const pages: MenuPageDefinition[] = []; + const rows: ReactNode[] = []; + for (const entry of entries) { + const view = createButtonView({ entry, client, toast, compact, hostLabel, theme }); + if (!view) continue; + rows.push( + + + + + , + ); + if (entry.button.behavior.kind === "action") continue; + pages.push( + { + id: buttonPageId(view, []), + title: entry.button.title, + hoverIntent: false, + content: ( + + ), + }, + ...buttonPages(view, entry.button.behavior), + ); + } + return { pages, rows }; + }, [entries, client, toast, theme, hostLabel, compact]); + const { t } = useTranslation(); + return ( + + {menuContent.rows} + + ); +} + +const ThemedOverflowPages = withUnistyles(OverflowPages); + +function useButtons(serverId: string, workspaceId: string, agentId: string | null) { + const entries = useSyncExternalStore( + pluginButtonStore.subscribe, + pluginButtonStore.getSnapshot, + pluginButtonStore.getSnapshot, + ); + return useMemo( + () => entries.filter((entry) => buttonMatches(entry, serverId, workspaceId, agentId)), + [entries, serverId, workspaceId, agentId], + ); +} + +export function useHasPluginComposerPills( + serverId: string, + workspaceId: string, + agentId: string, +): boolean { + return useButtons(serverId, workspaceId, agentId).length > 0; +} + +export function PluginComposerPills({ + serverId, + workspaceId, + agentId, + compact, +}: { + serverId: string; + workspaceId: string; + agentId: string; + compact: boolean; +}) { + const entries = useButtons(serverId, workspaceId, agentId); + const hosts = useHosts(); + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + return entries.map((entry) => ( + + )); +} + +export function PluginHeaderButtons({ + serverId, + workspaceId, +}: { + serverId: string; + workspaceId: string; +}) { + const entries = useButtons(serverId, workspaceId, null); + const compact = useIsCompactFormFactor(); + const { width } = useWindowDimensions(); + const hosts = useHosts(); + const { t } = useTranslation(); + const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; + const limit = compact || width < 1100 ? 1 : 3; + const visible = entries.slice(0, limit); + const overflow = entries.slice(limit); + const overflowStyle = useCallback( + (state: IconButtonChromeState) => headerButtonStyle(compact, state), + [compact], + ); + if (entries.length === 0) return null; + return ( + + {visible.map((entry) => ( + + ))} + {overflow.length > 0 ? ( + + + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + headerButtons: { + flexDirection: "row", + alignItems: "center", + gap: { xs: theme.spacing[1], md: theme.spacing[2] }, + }, + headerButton: { + height: { xs: 32, md: HEADER_CONTROL_HEIGHT }, + minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT }, + paddingHorizontal: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + }, + button: { flexShrink: 1, minWidth: 0, maxWidth: 160 }, + active: { backgroundColor: theme.colors.surface2 }, + disabled: { opacity: theme.opacity[50] }, + tooltipLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground }, + composerLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + label: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1 }, + icon: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + overflow: "hidden", + }, +})); diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 3647334f73..0eedfde3ab 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -1,3 +1,5 @@ +import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; +import { pluginSettingsKey } from "./settings/use-settings"; import { useEffect } from "react"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostFeature } from "@/runtime/host-features"; @@ -11,13 +13,14 @@ export function PluginCatalogSync({ serverId: string; client: DaemonClient; }) { + const audio = useVoiceAudioEngineOptional(); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); useEffect(() => { let cancelled = false; let refreshQueue = Promise.resolve(); - if (!supported) { + if (!supported || !audio) { pluginRegistry.removeHost(serverId); return; } @@ -34,6 +37,7 @@ export function PluginCatalogSync({ pluginRegistry.installCatalog(serverId, catalog, { replacePluginId, client, + audio, }); } return undefined; @@ -47,18 +51,38 @@ export function PluginCatalogSync({ ); return refreshQueue; }; - void refresh(); - const unsubscribe = client.on("status", (message) => { - if (message.payload.status === "plugin_catalog_changed") { - const pluginId = message.payload.pluginId; - if (typeof pluginId === "string") void refresh(pluginId); - } + const observation = client.observeEvents([ + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + ]); + observation.subscribe({ + snapshot: () => { + void refresh(); + }, + update: (message) => { + if (message.type !== "status") return; + if (message.payload.status === "plugin_settings_changed") { + const { pluginId, settingsId } = message.payload; + if (typeof settingsId === "string") { + const plugin = pluginRegistry + .getSnapshot() + .find((item) => item.serverId === serverId && item.id === pluginId); + void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) }); + } + } + if (message.payload.status === "plugin_catalog_changed") { + const pluginId = message.payload.pluginId; + if (typeof pluginId === "string") void refresh(pluginId); + } + }, }); return () => { cancelled = true; - unsubscribe(); + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release catalog", error)); }; - }, [client, connected, serverId, supported]); + }, [audio, client, connected, serverId, supported]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/client-runtime.test.ts b/packages/app/src/plugins/client-runtime.test.ts deleted file mode 100644 index 2be61bbc2e..0000000000 --- a/packages/app/src/plugins/client-runtime.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginComposerPillProps } from "@getpaseo/plugin"; -import { afterEach, describe, expect, it } from "vitest"; -import type { InstalledPlugin } from "./types"; -import { createPluginClientRuntime } from "./client-runtime"; -import { pluginComposerPillStore } from "./composer-pills/store"; - -function Pill(_props: PluginComposerPillProps) { - return null; -} - -function installation(): InstalledPlugin { - return { - id: "review", - serverId: "host-a", - clientBundle: "bundle", - queryClient: new QueryClient(), - cleanup: () => undefined, - surfaces: [], - sidebarItems: [], - workspacePanels: [], - commandCenterItems: [], - clientSlashCommands: [], - attachmentSources: [], - themes: [], - timelineTransformers: [], - timelineRenderers: [], - }; -} - -const daemonClient = {} as DaemonClient; -const installations: InstalledPlugin[] = []; - -afterEach(() => { - for (const plugin of installations.splice(0)) { - pluginComposerPillStore.removeInstallation(plugin); - } -}); - -describe("plugin client-side lifecycle", () => { - it("lets the client runtime add and remove a targeted composer pill", () => { - const plugin = installation(); - installations.push(plugin); - const client = createPluginClientRuntime(plugin, daemonClient); - - expect(pluginComposerPillStore.getSnapshot()).toEqual([]); - const remove = client.addComposerPill({ - id: "review-ready", - title: "Open review", - workspaceId: "workspace-a", - agentId: "agent-a", - Component: Pill, - onPress() {}, - }); - - expect( - pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({ - id: contribution.id, - workspaceId: contribution.workspaceId, - agentId: contribution.agentId, - })), - ).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]); - - remove(); - expect(pluginComposerPillStore.getSnapshot()).toEqual([]); - }); - - it("rejects duplicate pills only within the same plugin target", () => { - const plugin = installation(); - installations.push(plugin); - const contribution = { - id: "review-ready", - title: "Open review", - workspaceId: "workspace-a", - agentId: "agent-a", - Component: Pill, - onPress() {}, - }; - - pluginComposerPillStore.add(plugin, contribution); - expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow( - "Duplicate composer pill: review-ready", - ); - expect(() => - pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }), - ).not.toThrow(); - }); -}); diff --git a/packages/app/src/plugins/client-runtime.ts b/packages/app/src/plugins/client-runtime.ts index cef8bbb341..f9074383bd 100644 --- a/packages/app/src/plugins/client-runtime.ts +++ b/packages/app/src/plugins/client-runtime.ts @@ -1,5 +1,7 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin"; +import { createPlayAudio, type AudioEngine } from "@/audio"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { createPluginHosts } from "./hosts"; +import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client"; import { createPluginAgentActionContext, createPluginCapabilities, @@ -8,48 +10,47 @@ import { import { createPluginClientStateSource } from "./client-state/source"; import type { PluginClientRuntime } from "./evaluate"; import { createPluginNavigation } from "./navigation"; -import { pluginComposerPillStore } from "./composer-pills/store"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; +import { pluginButtonStore } from "./buttons"; import type { InstalledPlugin } from "./types"; export function createPluginClientRuntime( installation: InstalledPlugin, - daemonClient: DaemonClient, + audio: Pick, ): PluginClientRuntime { - const runtime = createPluginSurfaceRuntime(daemonClient, installation.id); - if (!runtime) throw new Error("Plugin host is offline"); const state = createPluginClientStateSource(installation.serverId); const capabilities = createPluginCapabilities( installation, - runtime, createPluginNavigation({ serverId: installation.serverId, workspaceId: null }), ); return { ...capabilities, + playAudio: createPlayAudio(audio, installation.lifetime.signal), + hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal), addComposerPill(contribution) { - return pluginComposerPillStore.add(installation, contribution); + return pluginButtonStore.addComposerPill(installation, contribution); + }, + addHeaderButton(contribution) { + return pluginButtonStore.addHeaderButton(installation, contribution); }, openPanel(panelId, options) { - openClientPanel({ installation, runtime, state, panelId, options }); + openClientPanel({ installation, state, panelId, options }); }, }; } function openClientPanel(input: { installation: InstalledPlugin; - runtime: NonNullable>; state: ReturnType; panelId: string; options: PluginClientOpenPanelOptions; }): void { - const { installation, runtime, state, panelId, options } = input; + const { installation, state, panelId, options } = input; const workspaceId = options.workspaceId.trim(); const agentId = options.agentId?.trim(); const navigation = createPluginNavigation({ serverId: installation.serverId, workspaceId }); const action = agentId ? createPluginAgentActionContext({ plugin: installation, - runtime, navigation, state, workspaceId, @@ -57,7 +58,6 @@ function openClientPanel(input: { }) : createPluginWorkspaceActionContext({ plugin: installation, - runtime, navigation, state, workspaceId, diff --git a/packages/app/src/plugins/client-slash-commands/index.ts b/packages/app/src/plugins/client-slash-commands/index.ts index a99a0fa538..9ab66ac195 100644 --- a/packages/app/src/plugins/client-slash-commands/index.ts +++ b/packages/app/src/plugins/client-slash-commands/index.ts @@ -1,10 +1,8 @@ import { useMemo } from "react"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { createPluginAgentActionContext, createPluginWorkspaceActionContext } from "../actions"; import { createPluginClientStateSource } from "../client-state/source"; import { createPluginNavigation } from "../navigation"; import { useInstalledPlugins } from "../registry"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; export interface PluginClientSlashCommand { pluginId: string; @@ -19,10 +17,9 @@ export function usePluginClientSlashCommands(input: { workspaceId: string | null | undefined; agentId: string; }): PluginClientSlashCommand[] { - const client = useHostRuntimeClient(input.serverId); const installed = useInstalledPlugins(); return useMemo(() => { - if (!client || !input.workspaceId) return []; + if (!input.workspaceId) return []; const workspaceId = input.workspaceId; const state = createPluginClientStateSource(input.serverId); const navigation = createPluginNavigation({ @@ -31,28 +28,10 @@ export function usePluginClientSlashCommands(input: { }); const commands = installed .filter((plugin) => plugin.serverId === input.serverId) - .flatMap((plugin) => { - const runtime = createPluginSurfaceRuntime(client, plugin.id); - if (!runtime) return []; - return (plugin.clientSlashCommands ?? []).flatMap((contribution) => { - const context = - contribution.context === "agent" - ? createPluginAgentActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - agentId: input.agentId, - }) - : createPluginWorkspaceActionContext({ - plugin, - runtime, - navigation, - state, - workspaceId, - }); - if (!context) return []; + .flatMap((plugin) => + plugin.clientSlashCommands.flatMap((contribution) => { + if (contribution.context === "agent" && !state.getAgent(input.agentId)) return []; + if (!state.getWorkspace(workspaceId)) return []; return [ { pluginId: plugin.id, @@ -60,19 +39,30 @@ export function usePluginClientSlashCommands(input: { description: contribution.description, argumentHint: contribution.argumentHint, async run(args: string) { - if (contribution.context === "agent" && context.context === "agent") { + const context = + contribution.context === "agent" + ? createPluginAgentActionContext({ + plugin, + navigation, + state, + workspaceId, + agentId: input.agentId, + }) + : createPluginWorkspaceActionContext({ + plugin, + navigation, + state, + workspaceId, + }); + if (contribution.context === "agent" && context?.context === "agent") await contribution.onSubmit({ ...context, args }); - } else if ( - contribution.context === "workspace" && - context.context === "workspace" - ) { + else if (contribution.context === "workspace" && context?.context === "workspace") await contribution.onSubmit({ ...context, args }); - } }, }, ]; - }); - }); + }), + ); return commands; - }, [client, input.agentId, input.serverId, input.workspaceId, installed]); + }, [input.agentId, input.serverId, input.workspaceId, installed]); } diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 26ac4069dc..cbbbaaed4b 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -25,7 +25,7 @@ const agent: Agent = { id: "agent-1", provider: "codex", status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date("2026-08-16T10:01:00.000Z"), updatedAt: new Date("2026-08-16T10:02:00.000Z"), lastUserMessageAt: null, diff --git a/packages/app/src/plugins/client-state/source.ts b/packages/app/src/plugins/client-state/source.ts index d7aaee597e..142c1f3575 100644 --- a/packages/app/src/plugins/client-state/source.ts +++ b/packages/app/src/plugins/client-state/source.ts @@ -1,4 +1,4 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import { useSessionStore } from "@/stores/session-store"; import { selectWorkspace } from "@/stores/session-store-hooks/selectors"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 51a6df5029..a2544bd034 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -4,9 +4,9 @@ import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { defineRpc, type PluginAgentSnapshot, - type PluginCommandCenterItemContribution, type PluginWorkspaceSnapshot, } from "@getpaseo/plugin"; +import { type PluginCommandCenterItemContribution } from "@getpaseo/plugin/client"; import { describe, expect, it } from "vitest"; import { z } from "zod"; import type { InstalledPlugin } from "../types"; @@ -60,10 +60,24 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), + paseo: createPaseoApi( + new DaemonClient({ + url: "ws://127.0.0.1:1", + clientId: "plugin-command-test", + clientType: "cli", + }), + ), + invoke: async (method: string, input: unknown) => { + expect(method).toBe("review.inspect"); + return { value: inspect.input.parse(input).value + 1 }; + }, queryClient: new QueryClient(), cleanup: () => {}, - surfaces: [{ id: "main", Component: () => null }], - sidebarItems: [], + settingsScreens: [], + surfaces: [{ id: "main", title: "Main", Component: () => null }], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [ { id: "details", @@ -119,30 +133,14 @@ function stateSource() { }; } -function createRuntime(pluginId: string) { - const client = new DaemonClient({ - url: "ws://127.0.0.1:1", - clientId: "plugin-command-test", - clientType: "cli", - }); - return { - paseo: createPaseoApi(client), - invoke: async (method: string, input: unknown) => { - expect(pluginId).toBe("review"); - expect(method).toBe("review.inspect"); - return { value: inspect.input.parse(input).value + 1 }; - }, - }; -} - describe("plugin Command Center contributions", () => { it("shows only contributions whose synchronous context exists", () => { const installed = plugin(() => undefined); const common = { plugins: [installed], - runtime: createRuntime, state: stateSource(), navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, @@ -185,14 +183,13 @@ describe("plugin Command Center contributions", () => { context.openSurface("main"); context.openPanel("details", { location: "explorer" }); }); - const runtime = createRuntime("review"); const actions = buildPluginCommandCenterContributions({ plugins: [installed], - runtime: () => runtime, state: stateSource(), workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface(pluginId, surfaceId) { opened.push(`${pluginId}/surface/${surfaceId}`); }, @@ -211,7 +208,8 @@ describe("plugin Command Center contributions", () => { await actions.find((action) => action.id === "review:agent")?.run(); expect(rpcValue).toBe(5); - expect(receivedPaseo).toBe(runtime.paseo); + // Commands use the plugin's one client. + expect(receivedPaseo).toBe(installed.paseo); expect(opened).toEqual(["review/surface/main", "review/agent/details/agent-1/explorer"]); }); @@ -219,11 +217,11 @@ describe("plugin Command Center contributions", () => { expect( buildPluginCommandCenterContributions({ plugins: [], - runtime: createRuntime, state: stateSource(), workspaceId: workspace.id, agentId: agent.id, navigation: { + openSettings() {}, openSurface() {}, openWorkspacePanel() {}, openAgentPanel() {}, diff --git a/packages/app/src/plugins/command-center/contributions.ts b/packages/app/src/plugins/command-center/contributions.ts index fbe67e034f..be4cb8068a 100644 --- a/packages/app/src/plugins/command-center/contributions.ts +++ b/packages/app/src/plugins/command-center/contributions.ts @@ -1,15 +1,13 @@ -import type { PluginClientStateSource } from "@getpaseo/plugin/host"; +import type { PluginClientStateSource } from "@getpaseo/plugin/client/host"; import type { CommandCenterContribution } from "@/command-center/contributions"; import { getCommandCenterIcon } from "@/command-center/icon"; import { resolvePluginIcon } from "../icons"; import { resolvePluginPanelOpenLocation } from "../workspace-panels/locations"; -import type { PluginSurfaceRuntime } from "../surface-runtime"; import type { InstalledPlugin } from "../types"; import { createPluginCapabilities, type PluginNavigation } from "../actions"; export interface PluginCommandCenterSource { plugins: readonly InstalledPlugin[]; - runtime(pluginId: string): PluginSurfaceRuntime; state: PluginClientStateSource; workspaceId: string | null; agentId: string | null; @@ -22,12 +20,11 @@ export function buildPluginCommandCenterContributions( ): CommandCenterContribution[] { const contributions: CommandCenterContribution[] = []; for (const plugin of source.plugins) { - const runtime = source.runtime(plugin.id); - const common = createPluginCapabilities(plugin, runtime, source.navigation); for (const [rank, item] of plugin.commandCenterItems.entries()) { if (item.context === "workspace" && !source.workspaceId) continue; if (item.context === "agent" && (!source.workspaceId || !source.agentId)) continue; const run = async () => { + const common = createPluginCapabilities(plugin, source.navigation); try { if (item.context === "global") { await item.onSelect({ context: "global", ...common }); diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 2ee0d24ff2..faf3b9215f 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -11,7 +11,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; import { hostIdFromPathname } from "../routes"; import { useInstalledPlugins } from "../registry"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; import { buildPluginCommandCenterContributions } from "./contributions"; import { getFocusedAgentId } from "./context"; import { createPluginNavigation } from "../navigation"; @@ -53,11 +52,6 @@ export function PluginCommandCenterActions() { if (!client || !serverId || !stateSource) return []; return buildPluginCommandCenterContributions({ plugins, - runtime(pluginId) { - const runtime = createPluginSurfaceRuntime(client, pluginId); - if (!runtime) throw new Error("Plugin host is offline"); - return runtime; - }, state: stateSource, workspaceId: workspaceExists ? workspaceId : null, agentId: agentExists ? focusedAgentId : null, diff --git a/packages/app/src/plugins/composer-pills/index.tsx b/packages/app/src/plugins/composer-pills/index.tsx deleted file mode 100644 index 12797bb08d..0000000000 --- a/packages/app/src/plugins/composer-pills/index.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginComposerPillProps, PluginTheme } from "@getpaseo/plugin"; -import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; -import { Platform, Pressable, type PressableStateCallbackType } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { LoadingSpinner } from "@/components/ui/loading-spinner"; -import { composerPillStyles } from "@/composer/pill-styles"; -import { useToast } from "@/contexts/toast-context"; -import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; -import type { Theme } from "@/styles/theme"; -import { createPluginClientStateSource } from "../client-state/source"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; -import { SurfaceErrorBoundary } from "../surface-error-boundary"; -import { toPluginTheme } from "../theme"; -import type { InstalledPlugin, PluginComposerPillContribution } from "../types"; -import { pluginComposerPillStore } from "./store"; - -const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); - -function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - -function PluginComposerPill({ - plugin, - contribution, - serverId, - workspaceId, - agentId, - compact, - hostLabel, - theme, -}: { - plugin: InstalledPlugin; - contribution: PluginComposerPillContribution; - serverId: string; - workspaceId: string; - agentId: string; - compact: boolean; - hostLabel: string; - theme: PluginTheme; -}) { - const client = useHostRuntimeClient(serverId); - const toast = useToast(); - const [pending, setPending] = useState(false); - const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]); - const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - const props = useMemo( - () => ({ - theme, - host: { id: serverId, label: hostLabel }, - layout: { compact, platform: resolvePlatform() }, - workspaceId, - agentId, - }), - [agentId, compact, hostLabel, serverId, theme, workspaceId], - ); - const press = useCallback(async () => { - if (!runtime || pending) return; - setPending(true); - try { - await contribution.onPress(); - } catch (error) { - toast.error(error instanceof Error ? error.message : String(error)); - } finally { - setPending(false); - } - }, [contribution, pending, runtime, toast]); - const pillStyle = useCallback( - ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ - composerPillStyles.body, - styles.pill, - (hovered || pressed) && composerPillStyles.bodyActive, - pending && styles.disabled, - ], - [pending], - ); - const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]); - if (!runtime) return null; - const Component = contribution.Component; - return ( - - - - - - {pending ? : null} - - - - - ); -} - -const ThemedPluginComposerPill = withUnistyles(PluginComposerPill); - -export function PluginComposerPills({ - serverId, - workspaceId, - agentId, - compact, -}: { - serverId: string; - workspaceId: string; - agentId: string; - compact: boolean; -}) { - const registrations = useSyncExternalStore( - pluginComposerPillStore.subscribe, - pluginComposerPillStore.getSnapshot, - pluginComposerPillStore.getSnapshot, - ); - const hosts = useHosts(); - const visible = useMemo( - () => - registrations.filter( - ({ installation, contribution }) => - installation.serverId === serverId && - contribution.workspaceId === workspaceId && - contribution.agentId === agentId, - ), - [agentId, registrations, serverId, workspaceId], - ); - const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - return ( - <> - {visible.map(({ installation, contribution }) => ( - - ))} - - ); -} - -export function useHasPluginComposerPills( - serverId: string, - workspaceId: string, - agentId: string, -): boolean { - const registrations = useSyncExternalStore( - pluginComposerPillStore.subscribe, - pluginComposerPillStore.getSnapshot, - pluginComposerPillStore.getSnapshot, - ); - return registrations.some( - ({ installation, contribution }) => - installation.serverId === serverId && - contribution.workspaceId === workspaceId && - contribution.agentId === agentId, - ); -} - -const styles = StyleSheet.create(() => ({ - pill: { - flexShrink: 1, - minWidth: 0, - }, - disabled: { - opacity: 0.5, - }, -})); diff --git a/packages/app/src/plugins/composer-pills/store.ts b/packages/app/src/plugins/composer-pills/store.ts deleted file mode 100644 index ae273eb2b5..0000000000 --- a/packages/app/src/plugins/composer-pills/store.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { PluginComposerPillContribution } from "@getpaseo/plugin"; -import type { InstalledPlugin } from "../types"; - -const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/; - -export interface RegisteredPluginComposerPill { - installation: InstalledPlugin; - contribution: PluginComposerPillContribution; -} - -class PluginComposerPillStore { - private entries: RegisteredPluginComposerPill[] = []; - private readonly listeners = new Set<() => void>(); - - subscribe = (listener: () => void): (() => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries; - - add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void { - const contribution = validateContribution(input); - const duplicate = this.entries.some( - (entry) => - entry.installation === installation && - entry.contribution.workspaceId === contribution.workspaceId && - entry.contribution.agentId === contribution.agentId && - entry.contribution.id === contribution.id, - ); - if (duplicate) { - throw new Error(`Duplicate composer pill: ${contribution.id}`); - } - const entry = { installation, contribution }; - this.entries = [...this.entries, entry]; - this.publish(); - let removed = false; - return () => { - if (removed) return; - removed = true; - this.entries = this.entries.filter((candidate) => candidate !== entry); - this.publish(); - }; - } - - removeInstallation(installation: InstalledPlugin): void { - const next = this.entries.filter((entry) => entry.installation !== installation); - if (next.length === this.entries.length) return; - this.entries = next; - this.publish(); - } - - private publish(): void { - for (const listener of this.listeners) listener(); - } -} - -function validateContribution( - contribution: PluginComposerPillContribution, -): PluginComposerPillContribution { - const id = contribution.id.trim(); - const title = contribution.title.trim(); - const workspaceId = contribution.workspaceId.trim(); - const agentId = contribution.agentId.trim(); - if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`); - if (!title) throw new Error(`Composer pill ${id} has no title`); - if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`); - if (!agentId) throw new Error(`Composer pill ${id} has no agent`); - if (typeof contribution.Component !== "function") { - throw new Error(`Composer pill ${id} is not a component`); - } - if (typeof contribution.onPress !== "function") { - throw new Error(`Composer pill ${id} has no callback`); - } - return { ...contribution, id, title, workspaceId, agentId }; -} - -export const pluginComposerPillStore = new PluginComposerPillStore(); diff --git a/packages/app/src/plugins/contribution-host.ts b/packages/app/src/plugins/contribution-host.ts index cbead73be5..9614594802 100644 --- a/packages/app/src/plugins/contribution-host.ts +++ b/packages/app/src/plugins/contribution-host.ts @@ -7,3 +7,17 @@ export function getPreferredPluginContributionHost(contributionId: string): stri export function rememberPluginContributionHost(contributionId: string, serverId: string): void { preferredHosts.set(contributionId, serverId); } + +/** + * The host a plugin's screens and sidebar items share: the host last picked on one of its screens + * or last opened from one of its items. + */ +export function pluginScreensHostKey(pluginId: string): string { + return `${pluginId}/screens`; +} + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +/** The host an `addSidebarItem` row last opened, which its screen's host switcher also sets. */ +export function legacySidebarItemHostKey(pluginId: string, itemId: string): string { + return `${pluginId}/sidebar/${itemId}`; +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 698053102a..310ef5f7b9 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -4,10 +4,15 @@ import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate"; const runtime = { paseo: {}, async rpc() {}, + openSettings() {}, + openScreen() {}, openSurface() {}, openPanel() {}, + addHeaderButton() { + return { update() {}, remove() {} }; + }, addComposerPill() { - return () => undefined; + return { update() {}, remove() {} }; }, } as unknown as PluginClientRuntime; @@ -16,7 +21,7 @@ function evaluatePluginClientBundle(id: string, source: string) { } function bundle(body: string): string { - return `(function() { + return `(function(require) { const module = { exports: {} }; module.exports.default = function(plugin) { ${body}; return function() {}; }; return module.exports; @@ -24,6 +29,42 @@ function bundle(body: string): string { } describe("evaluatePluginClientBundle", () => { + it("releases button registrations when client setup throws", () => { + let active = 0; + function addButton() { + active++; + return { + update() {}, + remove() { + active--; + }, + }; + } + expect(() => + runPluginClientBundle( + "failed-buttons", + bundle(` + plugin.addHeaderButton({ id: "header", workspaceId: "workspace", button: { title: "Header", icon: "Scan", behavior: { kind: "action", onPress() {} } } }); + plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } }); + throw new Error("Setup failed"); + `), + { ...runtime, addHeaderButton: addButton, addComposerPill: addButton }, + ), + ).toThrow("Setup failed"); + expect(active).toBe(0); + }); + + it("accepts memoized settings screens", () => { + const plugin = evaluatePluginClientBundle( + "settings", + bundle(` + const Component = require("react").memo(function Settings() { return null; }); + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }); + `), + ); + expect(plugin.settingsScreens.map((screen) => screen.id)).toEqual(["display"]); + }); + it("returns idempotent removers for every client registration", () => { let pillCount = 0; const plugin = runPluginClientBundle( @@ -32,12 +73,14 @@ describe("evaluatePluginClientBundle", () => { function Component() { return null; } const schema = { safeParse(value) { return { success: true, data: value }; } }; globalThis.__pluginRemovals = [ - plugin.addSurface("main", Component), - plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }), + plugin.addScreen({ id: "main", title: "Main", Component }), + plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }), + plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component }), + plugin.addSidebarFooterItem({ id: "status", title: "Status", Component }), plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }), plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }), plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }), - plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }), + plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } }).remove, plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }), plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }), plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }), @@ -48,8 +91,11 @@ describe("evaluatePluginClientBundle", () => { ...runtime, addComposerPill() { pillCount += 1; - return () => { - pillCount -= 1; + return { + update() {}, + remove() { + pillCount -= 1; + }, }; }, }, @@ -59,7 +105,9 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, - plugin.sidebarItems, + plugin.settingsScreens, + plugin.sidebarItems.header, + plugin.sidebarItems.footer, plugin.workspacePanels, plugin.commandCenterItems, plugin.clientSlashCommands, @@ -77,7 +125,9 @@ describe("evaluatePluginClientBundle", () => { expect( [ plugin.surfaces, - plugin.sidebarItems, + plugin.settingsScreens, + plugin.sidebarItems.header, + plugin.sidebarItems.footer, plugin.workspacePanels, plugin.commandCenterItems, plugin.clientSlashCommands, @@ -134,21 +184,125 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("Timeline transformer bad-query has invalid item type: settled"); }); - it("collects a surface and its sidebar placement", () => { + it("collects screens and sidebar header and footer items", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + function Screen() { return null; } + const Item = require("react").memo(function Item() { return null; }); + plugin.addScreen({ id: "main", title: " Main ", Component: Screen }); + plugin.addScreen({ id: "bot", title: (params) => params.botId, Component: Screen }); + plugin.addSidebarHeaderItem({ id: "main", title: " Example ", Component: Item }); + plugin.addSidebarFooterItem({ id: "main", title: "Status", Component: Item }); + `), + ); + + expect(plugin.id).toBe("example"); + expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main", "bot"]); + expect(plugin.surfaces[0]?.title).toBe("Main"); + expect(plugin.surfaces[1]?.title).toBeTypeOf("function"); + expect(plugin.sidebarItems.header.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "Example" }, + ]); + expect(plugin.sidebarItems.footer.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "Status" }, + ]); + expect(plugin.legacySidebarItems).toEqual([]); + }); + + it.each([ + [`plugin.addScreen("main", Screen);`, "addScreen takes { id, title, Component }"], + [`plugin.addScreen({ id: "main", Component: Screen });`, "Screen main needs a title"], + [ + `plugin.addScreen({ id: "main", title: " ", Component: Screen });`, + "Screen main needs a title", + ], + [ + `plugin.addScreen({ id: "main", title: 42, Component: Screen });`, + "Screen main needs a title", + ], + [ + `plugin.addScreen({ id: "main", title: "Main", Component: "Main" });`, + "Screen main is not a component", + ], + ])("rejects a malformed screen: %s", (call, message) => { + expect(() => + evaluatePluginClientBundle("example", bundle(`function Screen() { return null; } ${call}`)), + ).toThrow(message); + }); + + it("rejects duplicate and malformed sidebar items within a section", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + function Item() { return null; } + plugin.addSidebarFooterItem({ id: "main", title: "One", Component: Item }); + plugin.addSidebarFooterItem({ id: "main", title: "Two", Component: Item }); + `), + ), + ).toThrow("Duplicate sidebar footer item: main"); + expect(() => + evaluatePluginClientBundle( + "example", + bundle(`plugin.addSidebarHeaderItem({ id: "main", title: "Main", Component: "Main" });`), + ), + ).toThrow("Sidebar item main is not a component"); + expect(() => + evaluatePluginClientBundle( + "example", + bundle( + `plugin.addSidebarHeaderItem({ id: "main", title: " ", Component() { return null; } });`, + ), + ), + ).toThrow("Sidebar item main has no title"); + }); + + it("expands the addSurface and addSidebarItem aliases to a screen and a legacy header item", () => { const plugin = evaluatePluginClientBundle( "example", bundle(` function Surface() { return null; } plugin.addSurface("main", Surface); - plugin.addSidebarItem({ id: "main", title: "Example", icon: "Blocks", surface: "main" }); + plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); `), ); - expect(plugin.id).toBe("example"); - expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); - expect(plugin.sidebarItems).toEqual([ - { id: "main", title: "Example", icon: "Blocks", surface: "main" }, + expect(plugin.surfaces.map(({ id, title }) => ({ id, title }))).toEqual([ + { id: "main", title: "main" }, ]); + expect(plugin.sidebarItems).toEqual({ header: [], footer: [] }); + expect(plugin.legacySidebarItems).toEqual([ + { id: "entry", title: "Example", icon: "Blocks", surface: "main" }, + ]); + }); + + it("releases an addSidebarItem alias's header id", () => { + const plugin = evaluatePluginClientBundle( + "example", + bundle(` + plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } }); + const remove = plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); + remove(); + plugin.addSidebarHeaderItem({ id: "entry", title: "Replacement", Component() { return null; } }); + `), + ); + + expect(plugin.sidebarItems.header.map((item) => item.title)).toEqual(["Replacement"]); + expect(plugin.legacySidebarItems).toEqual([]); + }); + + it("rejects a header item that reuses an addSidebarItem id", () => { + expect(() => + evaluatePluginClientBundle( + "example", + bundle(` + plugin.addScreen({ id: "main", title: "Main", Component: function Screen() { return null; } }); + plugin.addSidebarItem({ id: "entry", title: "Example", icon: "Blocks", surface: "main" }); + plugin.addSidebarHeaderItem({ id: "entry", title: "Other", Component() { return null; } }); + `), + ), + ).toThrow("Duplicate sidebar header item: entry"); }); it("collects a declarative attachment source", () => { @@ -426,16 +580,16 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("must return a cleanup function"); }); - it("provides the host Icon component through @getpaseo/plugin", () => { + it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon } = require("@getpaseo/plugin"); + const { Icon } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { - plugin.addSurface("main", function Surface() { + plugin.addScreen({ id: "main", title: "Main", Component: function Surface() { return Icon({ name: "Settings", size: 18, color: "#123456" }); - }); + } }); return function() {}; }; return module.exports; @@ -448,11 +602,11 @@ describe("evaluatePluginClientBundle", () => { expect(element).toMatchObject({ props: { size: 18, color: "#123456" } }); }); - it("provides Paseo UI through @getpaseo/plugin/react-native", () => { + it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => { const plugin = evaluatePluginClientBundle( "example", `(function(require) { - const { Icon, Modal, useToast } = require("@getpaseo/plugin/react-native"); + const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native"); const module = { exports: {} }; module.exports.default = function(plugin) { if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") { @@ -468,6 +622,43 @@ describe("evaluatePluginClientBundle", () => { expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]); }); + it("keeps shared and client runtime exports separate", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { + const shared = require("@getpaseo/plugin"); + const client = require("@getpaseo/plugin/client"); + const { ExternalLink } = require("@getpaseo/plugin/client/ui"); + if (typeof ExternalLink !== "function") throw new Error("ExternalLink"); + for (const name of ["usePaseo", "useRpc", "useSettings", "useAgent", "useWorkspace", "openExternalUrl"]) { + if (name in shared || typeof client[name] !== "function") throw new Error(name); + } + if ("Icon" in shared || typeof shared.PluginAttachmentItemSchema.parse !== "function") throw new Error("shared exports"); + return { default() { return () => {}; } }; + })`, + ), + ).not.toThrow(); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + "@getpaseo/plugin/client/host", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/host", + "@paseo/plugin", + ])("rejects %s in the client loader", (specifier) => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { require("${specifier}"); return {}; })`, + ), + ).toThrow("not available in plugin client code"); + }); + it("resolves shared RPC helpers from @getpaseo/plugin", () => { const plugin = evaluatePluginClientBundle( "example", @@ -519,3 +710,39 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("setup exploded"); }); }); + +it("binds imported getters to each originating installation across delayed callbacks", async () => { + const calls: string[] = []; + const hostRuntime = (installation: string): PluginClientRuntime => ({ + ...runtime, + hosts: { + getSnapshot: () => [], + subscribe: () => () => {}, + getPaseoClient(serverId) { + calls.push(`${installation}/${serverId}`); + return runtime.paseo; + }, + }, + }); + const source = bundle(` + const { getPaseoClient } = require("@getpaseo/plugin/client"); + getPaseoClient("entry-host"); + plugin.addCommandCenterItem({ + id: "read", title: "Read", icon: "Server", context: "global", + onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); }, + }); + `); + const first = runPluginClientBundle("same-id", source, hostRuntime("first")); + const second = runPluginClientBundle("same-id", source, hostRuntime("second")); + // Both callbacks run after the second bundle has evaluated. + await first.commandCenterItems[0].onSelect({} as never); + await second.commandCenterItems[0].onSelect({} as never); + expect(calls).toEqual([ + "first/entry-host", + "second/entry-host", + "first/target-host", + "second/target-host", + ]); + await first.cleanup(); + await second.cleanup(); +}); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 14ace0607a..a2703b6ad2 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -1,3 +1,9 @@ +import type { createPluginHosts } from "./hosts"; +import { openExternalUrl } from "@/utils/open-external-url"; +import * as pluginUiRuntime from "./react-native/ui"; +import { useSettings } from "./settings/use-settings"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginClientRuntime from "@getpaseo/plugin/client"; import * as React from "react"; import * as ReactJsxRuntime from "react/jsx-runtime"; // eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative. @@ -6,27 +12,28 @@ import * as ReactNative from "react-native"; import * as ReactQuery from "@tanstack/react-query"; import * as Zod from "zod"; import { - defineAttachmentSource, - defineRpc, type PluginAttachmentSourceContribution, - type PluginCommandCenterItemContribution, type PluginCleanup, + type PluginThemeContribution, +} from "@getpaseo/plugin"; +import { + type PluginCommandCenterItemContribution, type PluginClientContext, type PluginClientSlashCommandContribution, type PluginSidebarContribution, + type PluginScreenContribution, + type PluginScreenProps, + type PluginScreenTitle, + type PluginSidebarItemContribution, type PluginSurfaceProps, - type PluginThemeContribution, type PluginTimelineRendererContribution, type PluginTimelineTransformerContribution, type PluginWorkspacePanelContribution, - usePaseo, - useAgent, - useWorkspace, - useRpc, -} from "@getpaseo/plugin"; -import type { EvaluatedPlugin } from "./types"; + type PluginButtonRegistration, +} from "@getpaseo/plugin/client"; +import type { EvaluatedPlugin, PluginSidebarSection } from "./types"; import type { ComponentType } from "react"; -import { Icon, resolvePluginIcon } from "./icons"; +import { resolvePluginIcon } from "./icons"; import { pluginReactNativeRuntime } from "./react-native/runtime"; import { parsePluginThemeContribution } from "./themes"; @@ -62,16 +69,38 @@ function normalizePanelLocations( return normalized; } +function isComponentType(value: unknown): boolean { + return ( + typeof value === "function" || + (typeof value === "object" && value !== null && "$$typeof" in value) + ); +} + function requireId(value: string, label: string): string { const id = value.trim(); if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid ${label}: ${value}`); return id; } +/** A non-empty string, or a function of the screen's params resolved when the header renders. */ +function requireScreenTitle(screenId: string, title: unknown): PluginScreenTitle { + if (typeof title === "function") return title as PluginScreenTitle; + if (typeof title === "string" && title.trim()) return title.trim(); + throw new Error(`Screen ${screenId} needs a title: a non-empty string or a function of params`); +} + export type PluginClientRuntime = Pick< PluginClientContext, - "paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill" ->; + | "playAudio" + | "paseo" + | "rpc" + | "openSettings" + | "openScreen" + | "openSurface" + | "openPanel" + | "addComposerPill" + | "addHeaderButton" +> & { hosts: ReturnType }; export function runPluginClientBundle( id: string, @@ -81,7 +110,9 @@ export function runPluginClientBundle( ): EvaluatedPlugin { const collector: Omit = { surfaces: [], - sidebarItems: [], + settingsScreens: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -91,7 +122,11 @@ export function runPluginClientBundle( timelineRenderers: [], }; const surfaceIds = new Set(); - const sidebarItemIds = new Set(); + const settingsScreenIds = new Set(); + const sidebarItemIds: Record> = { + header: new Set(), + footer: new Set(), + }; const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -101,6 +136,23 @@ export function runPluginClientBundle( const timelineRendererIds = new Set(); const removals = new Set(); let setupComplete = false; + let stopped = false; + function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration { + let active = true; + const remove = () => { + if (!active) return; + active = false; + registration.remove(); + removals.delete(remove); + }; + removals.add(remove); + return { + update: (patch) => { + if (active && !stopped) registration.update(patch); + }, + remove, + }; + } const notifyChange = () => { if (setupComplete) onChange(); }; @@ -120,36 +172,89 @@ export function runPluginClientBundle( removals.add(remove); return remove; } + function addSidebarItem( + section: PluginSidebarSection, + contribution: PluginSidebarItemContribution, + ): PluginCleanup { + const ids = sidebarItemIds[section]; + const normalizedId = requireId(contribution.id, `sidebar ${section} item id`); + if (ids.has(normalizedId)) + throw new Error(`Duplicate sidebar ${section} item: ${normalizedId}`); + const title = contribution.title.trim(); + if (!title) throw new Error(`Sidebar item ${normalizedId} has no title`); + if (!isComponentType(contribution.Component)) { + throw new Error(`Sidebar item ${normalizedId} is not a component`); + } + ids.add(normalizedId); + return register( + collector.sidebarItems[section], + { id: normalizedId, title, Component: contribution.Component }, + () => ids.delete(normalizedId), + ); + } const pluginContext: PluginClientContext = { ...runtime, - addSurface(surfaceId: string, Component: ComponentType) { - const normalizedId = requireId(surfaceId, "surface id"); - if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`); - if (typeof Component !== "function") - throw new Error(`Surface ${normalizedId} is not a component`); - surfaceIds.add(normalizedId); - return register(collector.surfaces, { id: normalizedId, Component }, () => - surfaceIds.delete(normalizedId), + addSettingsScreen(contribution) { + const screenId = requireId(contribution.id, "settings screen id"); + if (settingsScreenIds.has(screenId)) + throw new Error(`Duplicate settings screen: ${screenId}`); + if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`); + resolvePluginIcon(contribution.icon); + settingsScreenIds.add(screenId); + return register(collector.settingsScreens, { ...contribution, id: screenId }, () => + settingsScreenIds.delete(screenId), ); }, + addScreen(contribution: PluginScreenContribution) { + if (typeof contribution !== "object" || contribution === null) { + throw new Error("addScreen takes { id, title, Component }"); + } + const normalizedId = requireId(contribution.id, "screen id"); + if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate screen: ${normalizedId}`); + const title = requireScreenTitle(normalizedId, contribution.title); + if (!isComponentType(contribution.Component)) { + throw new Error(`Screen ${normalizedId} is not a component`); + } + surfaceIds.add(normalizedId); + return register( + collector.surfaces, + { id: normalizedId, title, Component: contribution.Component }, + () => surfaceIds.delete(normalizedId), + ); + }, + addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { + return addSidebarItem("header", contribution); + }, + addSidebarFooterItem(contribution: PluginSidebarItemContribution) { + return addSidebarItem("footer", contribution); + }, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + // Titled by its id; a legacy item pointing at it lends the header its own title instead. + addSurface(surfaceId: string, Component: ComponentType) { + if (!isComponentType(Component)) throw new Error(`Screen ${surfaceId} is not a component`); + return pluginContext.addScreen({ + id: surfaceId, + title: surfaceId, + Component: (props: PluginScreenProps) => React.createElement(Component, props), + }); + }, + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 addSidebarItem(contribution: PluginSidebarContribution) { const normalizedId = requireId(contribution.id, "sidebar item id"); - if (sidebarItemIds.has(normalizedId)) - throw new Error(`Duplicate sidebar item: ${normalizedId}`); - if (!contribution.title.trim()) throw new Error(`Sidebar item ${normalizedId} has no title`); if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`); resolvePluginIcon(contribution.icon.trim()); - sidebarItemIds.add(normalizedId); - return register( - collector.sidebarItems, - { - id: normalizedId, - title: contribution.title.trim(), - icon: contribution.icon.trim(), - surface: requireId(contribution.surface, "sidebar surface id"), - }, - () => sidebarItemIds.delete(normalizedId), - ); + const legacy = { + id: normalizedId, + title: contribution.title.trim(), + icon: contribution.icon.trim(), + surface: requireId(contribution.surface, "sidebar surface id"), + }; + // Shares the header's ids: the sidebar orders both kinds of item under one key. + const ids = sidebarItemIds.header; + if (ids.has(normalizedId)) throw new Error(`Duplicate sidebar header item: ${normalizedId}`); + if (!legacy.title) throw new Error(`Sidebar item ${normalizedId} has no title`); + ids.add(normalizedId); + return register(collector.legacySidebarItems, legacy, () => ids.delete(normalizedId)); }, addWorkspacePanel(contribution: PluginWorkspacePanelContribution) { const normalizedId = requireId(contribution.id, "workspace panel id"); @@ -323,39 +428,36 @@ export function runPluginClientBundle( ); }, addComposerPill(contribution) { - const removePill = runtime.addComposerPill(contribution); - let active = true; - const remove = () => { - if (!active) return; - active = false; - removePill(); - removals.delete(remove); - }; - removals.add(remove); - return remove; + if (stopped) throw new Error("Plugin has stopped"); + return trackButton(runtime.addComposerPill(contribution)); + }, + addHeaderButton(contribution) { + if (stopped) throw new Error("Plugin has stopped"); + return trackButton(runtime.addHeaderButton(contribution)); }, }; const runtimeRequire = (name: string): unknown => { + if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime; if (name === "react") return React; if (name === "react/jsx-runtime") return ReactJsxRuntime; if (name === "react-native") return ReactNative; - if (name === "@getpaseo/plugin") { + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/client") return { - defineAttachmentSource, - defineRpc, - Icon, - usePaseo, - useAgent, - useWorkspace, - useRpc, + ...pluginClientRuntime, + useSettings, + openExternalUrl, + getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId), + useHosts: () => + React.useSyncExternalStore( + runtime.hosts.subscribe, + runtime.hosts.getSnapshot, + runtime.hosts.getSnapshot, + ), }; - } - if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") { + if (name === "@getpaseo/plugin/client/react-native") { return pluginReactNativeRuntime; } - if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") { - return {}; - } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; throw new Error(`Module "${name}" is not available in plugin client code`); @@ -370,34 +472,38 @@ export function runPluginClientBundle( if (typeof setup !== "function") { throw new Error(`Plugin ${id} must default export a function`); } - const entryCleanup = setup(pluginContext); - if (typeof entryCleanup !== "function") { - throw new Error(`Plugin ${id} contribution must return a cleanup function`); - } - + let entryCleanup: PluginCleanup | undefined; try { - for (const item of collector.sidebarItems) { + entryCleanup = setup(pluginContext); + if (typeof entryCleanup !== "function") + throw new Error(`Plugin ${id} contribution must return a cleanup function`); + for (const item of collector.legacySidebarItems) { if (!surfaceIds.has(item.surface)) { throw new Error(`Sidebar item ${item.id} references missing surface ${item.surface}`); } } } catch (error) { + stopped = true; try { - void Promise.resolve(entryCleanup()).catch((cleanupError) => { + void Promise.resolve( + typeof entryCleanup === "function" ? entryCleanup() : entryCleanup, + ).catch((cleanupError) => { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); }); } catch (cleanupError) { console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError); + } finally { + for (const remove of removals) remove(); } throw error; } setupComplete = true; - let stopped = false; + const cleanupEntry = entryCleanup; const cleanup = async () => { if (stopped) return; stopped = true; try { - await entryCleanup(); + await cleanupEntry(); } finally { for (const remove of removals) remove(); } @@ -406,7 +512,9 @@ export function runPluginClientBundle( id, cleanup, surfaces: collector.surfaces, + settingsScreens: collector.settingsScreens, sidebarItems: collector.sidebarItems, + legacySidebarItems: collector.legacySidebarItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, diff --git a/packages/app/src/plugins/host-navigation-model.ts b/packages/app/src/plugins/host-navigation-model.ts new file mode 100644 index 0000000000..f651370a9d --- /dev/null +++ b/packages/app/src/plugins/host-navigation-model.ts @@ -0,0 +1,42 @@ +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store"; +import { isHttpUrl } from "@/utils/http-url"; + +interface HostNavigationOwner { + browserAvailable: boolean; + openAgent(input: { serverId: string; agentId: string }): void; + openWorkspace(input: NavigateToWorkspaceInput): void; + resolveWorkspace(input: { serverId: string; workspaceId: string }): string | null; + createBrowser(input: { initialUrl: string }): { browserId: string }; +} + +export function createPluginHostNavigation( + serverId: string, + owner: HostNavigationOwner, +): NonNullable { + return { + openAgent: ({ agentId, serverId: targetServerId }) => + owner.openAgent({ serverId: targetServerId ?? serverId, agentId }), + openWorkspace: ({ workspaceId, serverId: targetServerId }) => + owner.openWorkspace({ serverId: targetServerId ?? serverId, workspaceId }), + openBrowser: owner.browserAvailable + ? ({ url, workspaceId, serverId: targetServerId }) => { + if (!isHttpUrl(url)) throw new Error("Only absolute HTTP(S) URLs are supported."); + if (!workspaceId.trim()) throw new Error("workspaceId is required."); + const destinationServerId = targetServerId ?? serverId; + const destinationWorkspaceId = owner.resolveWorkspace({ + serverId: destinationServerId, + workspaceId, + }); + if (!destinationWorkspaceId) + throw new Error("Workspace is unavailable on the requested host."); + const { browserId } = owner.createBrowser({ initialUrl: url }); + owner.openWorkspace({ + serverId: destinationServerId, + workspaceId: destinationWorkspaceId, + target: { kind: "browser", browserId }, + }); + } + : undefined, + }; +} diff --git a/packages/app/src/plugins/host-navigation.test.ts b/packages/app/src/plugins/host-navigation.test.ts new file mode 100644 index 0000000000..22538b3976 --- /dev/null +++ b/packages/app/src/plugins/host-navigation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { createPluginHostNavigation } from "./host-navigation-model"; + +describe("plugin host navigation", () => { + function setup(electron = true) { + const destinations: unknown[] = []; + const browsers: string[] = []; + const workspaces = new Set(["selected:one", "remote:two"]); + const navigation = createPluginHostNavigation("selected", { + browserAvailable: electron, + resolveWorkspace: ({ serverId, workspaceId }) => + workspaces.has(`${serverId}:${workspaceId}`) ? workspaceId : null, + openAgent: (input) => destinations.push(input), + openWorkspace: (input) => destinations.push(input), + createBrowser: ({ initialUrl }) => { + browsers.push(initialUrl); + return { browserId: `browser-${browsers.length}` }; + }, + }); + return { navigation, destinations, browsers, workspaces }; + } + + it("creates and focuses a local browser in the selected or explicit host workspace", () => { + const { navigation, destinations, browsers } = setup(); + navigation.openBrowser!({ url: "https://example.com/one", workspaceId: "one" }); + navigation.openBrowser!({ + url: "https://example.com/two", + workspaceId: "two", + serverId: "remote", + }); + expect(browsers).toEqual(["https://example.com/one", "https://example.com/two"]); + expect(destinations).toEqual([ + { + serverId: "selected", + workspaceId: "one", + target: { kind: "browser", browserId: "browser-1" }, + }, + { + serverId: "remote", + workspaceId: "two", + target: { kind: "browser", browserId: "browser-2" }, + }, + ]); + }); + + it("refuses unknown or removed workspaces before creating browser records", () => { + const { navigation, destinations, browsers, workspaces } = setup(); + expect(() => + navigation.openBrowser!({ url: "https://example.com", workspaceId: "missing" }), + ).toThrow("Workspace is unavailable"); + expect(() => + navigation.openBrowser!({ + url: "https://example.com", + workspaceId: "one", + serverId: "unknown", + }), + ).toThrow("Workspace is unavailable"); + workspaces.delete("selected:one"); + expect(() => + navigation.openBrowser!({ url: "https://example.com", workspaceId: "one" }), + ).toThrow("Workspace is unavailable"); + expect(destinations).toEqual([]); + expect(browsers).toEqual([]); + }); + + it("exposes no browser capability outside Electron and creates no tabs", () => { + const { navigation, destinations, browsers } = setup(false); + expect(navigation.openBrowser).toBeUndefined(); + expect(destinations).toEqual([]); + expect(browsers).toEqual([]); + }); + + it.each(["javascript:alert(1)", "file:///tmp/file", "/relative", "invalid"])( + "rejects %s before creating a browser", + (url) => { + const { navigation, browsers, destinations } = setup(); + expect(() => navigation.openBrowser!({ url, workspaceId: "one" })).toThrow("HTTP(S)"); + expect(browsers).toEqual([]); + expect(destinations).toEqual([]); + }, + ); + + it("rejects an empty workspace before creating a browser", () => { + const { navigation, browsers } = setup(); + expect(() => navigation.openBrowser!({ url: "https://example.com", workspaceId: "" })).toThrow( + "workspaceId", + ); + expect(browsers).toEqual([]); + }); +}); diff --git a/packages/app/src/plugins/host-navigation.ts b/packages/app/src/plugins/host-navigation.ts index 835cc41657..5f30f014aa 100644 --- a/packages/app/src/plugins/host-navigation.ts +++ b/packages/app/src/plugins/host-navigation.ts @@ -1,16 +1,30 @@ -import type { PluginSurfaceProps } from "@getpaseo/plugin"; +import type { PluginSurfaceProps } from "@getpaseo/plugin/client"; +import { useSessionStore } from "@/stores/session-store"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { useMemo } from "react"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { getIsElectron } from "@/constants/platform"; +import { createWorkspaceBrowser } from "@/desktop/browser/store"; +import { createPluginHostNavigation } from "./host-navigation-model"; + export function usePluginHostNavigation( serverId: string, ): NonNullable { return useMemo( - () => ({ - openAgent: ({ agentId }) => navigateToAgent({ serverId, agentId }), - openWorkspace: ({ workspaceId }) => navigateToWorkspace({ serverId, workspaceId }), - }), + () => + createPluginHostNavigation(serverId, { + browserAvailable: getIsElectron(), + openAgent: navigateToAgent, + openWorkspace: navigateToWorkspace, + createBrowser: createWorkspaceBrowser, + resolveWorkspace: ({ serverId: targetServerId, workspaceId }) => + resolveWorkspaceMapKeyByIdentity({ + workspaces: useSessionStore.getState().sessions[targetServerId]?.workspaces, + workspaceId, + }), + }), [serverId], ); } diff --git a/packages/app/src/plugins/hosts/index.test.ts b/packages/app/src/plugins/hosts/index.test.ts new file mode 100644 index 0000000000..287c1480eb --- /dev/null +++ b/packages/app/src/plugins/hosts/index.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { createPluginHosts, type PluginHostsSource } from "./index"; + +function registry() { + const hosts = [ + { serverId: "a", label: "Alpha", password: "secret" }, + { serverId: "b", label: "Beta", password: "secret" }, + ]; + const snapshots = new Map>>(); + const listeners = new Set<() => void>(); + const source: PluginHostsSource = { + getHosts: () => hosts, + getSnapshot: (id) => snapshots.get(id) ?? null, + subscribeAll(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + subscribeHostList(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + const lifetime = new AbortController(); + const runtime = createPluginHosts(source, lifetime.signal); + return { + hosts, + snapshots, + listeners, + lifetime, + runtime, + source, + publish() { + for (const listener of listeners) listener(); + }, + }; +} +function ignoreUpdate() {} + +function connection(id: string) { + return new DaemonClient({ url: `ws://${id}`, clientId: "test", reconnect: { enabled: false } }); +} + +describe("plugin host access", () => { + it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => { + const h = registry(); + expect(h.runtime.getSnapshot()).toEqual([ + { serverId: "a", label: "Alpha", status: "offline" }, + { serverId: "b", label: "Beta", status: "offline" }, + ]); + const before = h.runtime.getSnapshot(); + h.publish(); + expect(h.runtime.getSnapshot()).toBe(before); + let updates = 0; + h.runtime.subscribe(() => updates++); + h.snapshots.set("b", { connectionStatus: "connecting", client: null }); + h.publish(); + expect(updates).toBe(1); + expect(h.runtime.getSnapshot()[1].status).toBe("connecting"); + h.hosts[1].label = "Renamed"; + h.publish(); + expect(h.runtime.getSnapshot()[1].label).toBe("Renamed"); + h.lifetime.abort(); + expect(h.listeners.size).toBe(0); + }); + + it("rejects unknown and disconnected targets without falling through to another host", () => { + const h = registry(); + h.snapshots.set("a", { connectionStatus: "online", client: connection("a") }); + expect(() => h.runtime.getPaseoClient("missing")).toThrow("Unknown Paseo host: missing"); + expect(() => h.runtime.getPaseoClient("b")).toThrow("Paseo host is disconnected: b"); + h.lifetime.abort(); + }); + + it("isolates installation lifetimes and releases APIs when a connection is replaced", async () => { + const h = registry(); + const client = connection("b"); + h.snapshots.set("b", { connectionStatus: "online", client }); + h.publish(); + const otherLifetime = new AbortController(); + const other = createPluginHosts(h.source, otherLifetime.signal); + const api = h.runtime.getPaseoClient("b"); + expect(api).toBe(h.runtime.getPaseoClient("b")); + expect(api).not.toBe(other.getPaseoClient("b")); + expect(api).not.toHaveProperty("connect"); + expect(api).not.toHaveProperty("close"); + api.agents.subscribe(ignoreUpdate); + h.snapshots.set("b", { connectionStatus: "offline", client }); + h.publish(); + expect(() => h.runtime.getPaseoClient("b")).toThrow("disconnected"); + expect(() => api.config.get()).toThrow("Paseo host is disconnected: b"); + h.snapshots.set("b", { connectionStatus: "online", client }); + h.publish(); + expect(h.runtime.getPaseoClient("b")).toBe(api); + h.snapshots.set("b", { connectionStatus: "online", client: connection("new-b") }); + h.publish(); + expect(() => api.agents.subscribe(ignoreUpdate)).toThrow("disposed"); + expect(() => api.config.get()).toThrow("Paseo client is released: b"); + expect(h.runtime.getPaseoClient("b")).not.toBe(api); + h.lifetime.abort(); + expect(() => h.runtime.getPaseoClient("b")).toThrow("Plugin has stopped"); + expect(() => other.getPaseoClient("b").agents.subscribe(ignoreUpdate)).not.toThrow(); + otherLifetime.abort(); + }); +}); + +it("reacquires a fresh API after explicit disposal without affecting a later borrower", async () => { + const h = registry(); + h.snapshots.set("b", { connectionStatus: "online", client: connection("b") }); + const first = h.runtime.getPaseoClient("b"); + await first.dispose(); + const second = h.runtime.getPaseoClient("b"); + expect(second).not.toBe(first); + expect(() => second.agents.subscribe(ignoreUpdate)).not.toThrow(); + expect(() => first.config.get()).toThrow("Paseo client is released: b"); + await first.dispose(); + expect(h.runtime.getPaseoClient("b")).toBe(second); + h.lifetime.abort(); + expect(() => second.agents.subscribe(ignoreUpdate)).toThrow("disposed"); +}); diff --git a/packages/app/src/plugins/hosts/index.ts b/packages/app/src/plugins/hosts/index.ts new file mode 100644 index 0000000000..f3a6ace947 --- /dev/null +++ b/packages/app/src/plugins/hosts/index.ts @@ -0,0 +1,109 @@ +import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PluginHostSummary } from "@getpaseo/plugin/client"; + +export interface PluginHostsSource { + getHosts(): readonly { serverId: string; label: string }[]; + getSnapshot(serverId: string): { + connectionStatus: PluginHostSummary["status"]; + client: DaemonClient | null; + } | null; + subscribeAll(listener: () => void): () => void; + subscribeHostList(listener: () => void): () => void; +} + +/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */ +export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) { + const clients = new Map< + string, + { client: DaemonClient; api: PaseoApi; lifetime: AbortController } + >(); + const listeners = new Set<() => void>(); + let snapshot: readonly PluginHostSummary[] = []; + function readHosts(): readonly PluginHostSummary[] { + return source.getHosts().map(({ serverId, label }) => ({ + serverId, + label, + status: source.getSnapshot(serverId)?.connectionStatus ?? "offline", + })); + } + function resolve(serverId: string): DaemonClient { + if (signal.aborted) throw new Error("Plugin has stopped"); + if (!source.getHosts().some((host) => host.serverId === serverId)) { + throw new Error(`Unknown Paseo host: ${serverId}`); + } + const host = source.getSnapshot(serverId); + if (host?.connectionStatus !== "online" || !host.client) { + throw new Error(`Paseo host is disconnected: ${serverId}`); + } + return host.client; + } + function refresh() { + for (const [serverId, entry] of clients) { + if ( + !source.getHosts().some((host) => host.serverId === serverId) || + source.getSnapshot(serverId)?.client !== entry.client + ) { + entry.lifetime.abort(); + clients.delete(serverId); + } + } + const next = readHosts(); + if (JSON.stringify(snapshot) === JSON.stringify(next)) return; + snapshot = next; + for (const listener of listeners) listener(); + } + snapshot = readHosts(); + const unsubscribeRuntime = source.subscribeAll(refresh); + const unsubscribeHosts = source.subscribeHostList(refresh); + function stop() { + unsubscribeRuntime(); + unsubscribeHosts(); + listeners.clear(); + for (const entry of clients.values()) entry.lifetime.abort(); + clients.clear(); + } + if (signal.aborted) stop(); + else signal.addEventListener("abort", stop, { once: true }); + return { + getSnapshot: () => snapshot, + subscribe(listener: () => void) { + if (signal.aborted) return () => {}; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + getPaseoClient(serverId: string): PaseoApi { + const client = resolve(serverId); + const existing = clients.get(serverId); + if (existing?.client === client) return existing.api; + existing?.lifetime.abort(); + const lifetime = new AbortController(); + // Guard even retained SDK handles against unload, offline calls, and replaced connections. + // Methods keep the real receiver because DaemonClient owns private state. + const borrowed = new Proxy(client, { + get(target, key) { + const value: unknown = Reflect.get(target, key, target); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + if (lifetime.signal.aborted) throw new Error(`Paseo client is released: ${serverId}`); + if (resolve(serverId) !== client) { + throw new Error(`Paseo connection changed; call getPaseoClient again: ${serverId}`); + } + return Reflect.apply(value, target, args); + }; + }, + }); + const api = createPaseoApi(borrowed, { signal: lifetime.signal }); + const dispose = api.dispose; + api.dispose = () => { + if (clients.get(serverId)?.api === api) clients.delete(serverId); + lifetime.abort(); + return dispose(); + }; + clients.set(serverId, { client, api, lifetime }); + return api; + }, + }; +} diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index 1e3fdb4cbf..3c5ec581ce 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -1,6 +1,6 @@ import { createElement, type ReactElement } from "react"; import * as LucideIcons from "lucide-react-native"; -import type { PluginIconProps } from "@getpaseo/plugin"; +import type { PluginIconProps } from "@getpaseo/plugin/client"; import type { LucideIcon } from "lucide-react-native"; function findPluginIcon(name: string): LucideIcon | null { diff --git a/packages/app/src/plugins/index.ts b/packages/app/src/plugins/index.ts index b473ed1f41..e221f4208f 100644 --- a/packages/app/src/plugins/index.ts +++ b/packages/app/src/plugins/index.ts @@ -3,4 +3,8 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes"; export { PluginSurfaceScreen } from "./surface-screen"; export { usePluginAttachmentPicker } from "./attachments/picker"; export { PluginResourceAttachmentPill } from "./attachments/pill"; -export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills"; +export { + PluginComposerPills, + PluginHeaderButtons, + useHasPluginComposerPills, +} from "./buttons/view"; diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx new file mode 100644 index 0000000000..949bc7480f --- /dev/null +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -0,0 +1,71 @@ +import { usePaseo, useRpc } from "@getpaseo/plugin/client"; +import { defineRpc } from "@getpaseo/plugin"; +import type { PaseoApi } from "@getpaseo/client"; +import { QueryClient } from "@tanstack/react-query"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { expect, it } from "vitest"; +import { z } from "zod"; +import { PluginInstallationProvider } from "./installation-provider"; +import type { InstalledPlugin } from "./types"; + +const status = defineRpc({ + name: "deploys.status", + input: z.object({}), + output: z.object({ state: z.string() }), +}); + +function installation(invoked: string[]): InstalledPlugin { + return { + id: "deploys", + serverId: "host", + clientBundle: "", + lifetime: new AbortController(), + paseo: { dispose: async () => undefined } as PaseoApi, + invoke: async (method) => { + invoked.push(method); + return { state: "green" }; + }, + queryClient: new QueryClient(), + cleanup: () => undefined, + settingsScreens: [], + surfaces: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], + workspacePanels: [], + commandCenterItems: [], + clientSlashCommands: [], + attachmentSources: [], + themes: [], + timelineTransformers: [], + timelineRenderers: [], + }; +} + +it("gives every surface the installation's client and RPCs on its first frame", async () => { + const invoked: string[] = []; + const plugin = installation(invoked); + const received: PaseoApi[] = []; + let rpc: ((input: Record) => Promise) | null = null; + function Surface({ name }: { name: string }) { + received.push(usePaseo()); + rpc = useRpc(status); + return {name}; + } + + const markup = ["Screen", "Sidebar item", "Popover"] + .map((name) => + renderToStaticMarkup( + + + , + ), + ) + .join(""); + + expect(markup).toBe("ScreenSidebar itemPopover"); + expect(received).toEqual([plugin.paseo, plugin.paseo, plugin.paseo]); + expect(received.every((paseo) => paseo === plugin.paseo)).toBe(true); + await expect(rpc!({})).resolves.toEqual({ state: "green" }); + expect(invoked).toEqual(["deploys.status"]); +}); diff --git a/packages/app/src/plugins/runtime-boundary.tsx b/packages/app/src/plugins/installation-provider.tsx similarity index 52% rename from packages/app/src/plugins/runtime-boundary.tsx rename to packages/app/src/plugins/installation-provider.tsx index 7f2d62c054..40d06ef035 100644 --- a/packages/app/src/plugins/runtime-boundary.tsx +++ b/packages/app/src/plugins/installation-provider.tsx @@ -1,22 +1,20 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/host"; -import type { ReactNode } from "react"; +import { PaseoApiProvider, PluginRpcProvider } from "@getpaseo/plugin/client/host"; +import React, { type ReactNode } from "react"; import type { InstalledPlugin } from "./types"; -import type { PluginSurfaceRuntime } from "./surface-runtime"; -export function PluginRuntimeBoundary({ +/** Every plugin surface renders under its installation: one query cache, one Paseo client, RPCs. */ +export function PluginInstallationProvider({ plugin, - runtime, children, }: { plugin: InstalledPlugin; - runtime: PluginSurfaceRuntime; children: ReactNode; }) { return ( - - {children} + + {children} ); diff --git a/packages/app/src/plugins/navigation.ts b/packages/app/src/plugins/navigation.ts index cd1f9c129b..c1a50a98fc 100644 --- a/packages/app/src/plugins/navigation.ts +++ b/packages/app/src/plugins/navigation.ts @@ -1,5 +1,6 @@ +import { buildPluginSettingsRoute } from "./settings/routes"; import { router } from "expo-router"; -import type { PluginPanelLocation } from "@getpaseo/plugin"; +import type { PluginPanelLocation } from "@getpaseo/plugin/client"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildPluginSurfaceRoute } from "./routes"; @@ -19,8 +20,13 @@ export function createPluginNavigation(input: { return { mode: "pane" as const, paneId }; } return { - openSurface(pluginId, surfaceId) { - router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId })); + openSettings(pluginId, screenId) { + router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)); + }, + openSurface(pluginId, surfaceId, params) { + router.push( + buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }, params), + ); }, openWorkspacePanel(pluginId, panelId, location) { if (!workspaceId) throw new Error("No active workspace"); diff --git a/packages/app/src/plugins/platform.ts b/packages/app/src/plugins/platform.ts new file mode 100644 index 0000000000..816095e255 --- /dev/null +++ b/packages/app/src/plugins/platform.ts @@ -0,0 +1,9 @@ +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { Platform } from "react-native"; + +/** The platform plugins see in `layout.platform`. */ +export function resolvePluginPlatform(): PluginHostProps["layout"]["platform"] { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return "web"; +} diff --git a/packages/app/src/plugins/popover.tsx b/packages/app/src/plugins/popover.tsx new file mode 100644 index 0000000000..f9680ebb29 --- /dev/null +++ b/packages/app/src/plugins/popover.tsx @@ -0,0 +1,59 @@ +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { ReactNode } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MenuSurface, type MenuSurfaceProps } from "@/components/ui/menu"; +import { ToastApiProvider, type useToast } from "@/contexts/toast-context"; +import type { createPluginClientStateSource } from "./client-state/source"; +import { PluginInstallationProvider } from "./installation-provider"; +import type { InstalledPlugin } from "./types"; + +export interface PluginEnvironment { + installation: InstalledPlugin; + state: ReturnType; + toast: ReturnType; +} + +interface PluginEnvironmentProviderProps { + environment: PluginEnvironment; + children: ReactNode; +} + +/** + * The providers plugin UI runs under. Render it around a contribution and again inside every + * surface it opens: native sheets teleport their children, so providers around the trigger alone + * cannot reach a popover body. + */ +export function PluginEnvironmentProvider({ + environment, + children, +}: PluginEnvironmentProviderProps) { + return ( + + + {children} + + + ); +} + +type PluginPopoverSurfaceProps = Pick< + MenuSurfaceProps, + "children" | "pages" | "sheetTitle" | "sheetTrailing" | "side" | "align" | "offset" | "testID" +>; + +/** + * The surface every plugin popover opens in: anchored to its trigger on wide layouts, a bottom + * sheet on compact ones when the enclosing `MenuRoot` uses `compactMode="sheet"`. + */ +export function PluginPopoverSurface(props: PluginPopoverSurfaceProps) { + return ; +} + +export function PluginPopoverContent({ children }: { children: ReactNode }) { + return {children}; +} + +const styles = StyleSheet.create((theme) => ({ + content: { padding: theme.spacing[3], gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/plugins/react-native/clipboard.ts b/packages/app/src/plugins/react-native/clipboard.ts new file mode 100644 index 0000000000..4357a0baa1 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.ts @@ -0,0 +1,6 @@ +export async function copyText(text: string): Promise { + const { setStringAsync } = require("expo-clipboard") as typeof import("expo-clipboard"); + if (!(await setStringAsync(text))) { + throw new Error("Clipboard copy was denied or is unavailable"); + } +} diff --git a/packages/app/src/plugins/react-native/clipboard.web.ts b/packages/app/src/plugins/react-native/clipboard.web.ts new file mode 100644 index 0000000000..7207c2c689 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.web.ts @@ -0,0 +1,6 @@ +// Expo's web fallback reports success even when document.execCommand returns +// false. Use the browser promise so the SDK can promise success or rejection. +export async function copyText(text: string): Promise { + if (!navigator.clipboard) throw new Error("Clipboard copy is unavailable in this browser"); + await navigator.clipboard.writeText(text); +} diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index 38728b56b6..b61cd925b1 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -1,6 +1,10 @@ import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/react-native"; -import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/host"; +import type { + ModalComponent, + ModalContentProps, + ModalProps, +} from "@getpaseo/plugin/client/react-native"; +import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host"; import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react"; import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context"; @@ -30,7 +34,12 @@ function useModalContext(): ModalContextValue { return context; } -function ModalContent({ children }: ModalContentProps) { +function ModalContent({ + children, + style, + contentContainerStyle, + scrollable = true, +}: ModalContentProps) { const modal = useModalContext(); const queryClient = useQueryClient(); const toast = useAppToast(); @@ -52,6 +61,10 @@ function ModalContent({ children }: ModalContentProps) { onClose={modal.dismiss} header={modal.header} contextBridge={contextBridge} + bodyStyle={style} + contentStyle={contentContainerStyle} + scrollable={scrollable} + desktopHeight={scrollable ? undefined : "85%"} > {children} diff --git a/packages/app/src/plugins/react-native/runtime.ts b/packages/app/src/plugins/react-native/runtime.ts index 4ac8ef3349..bdf39910a4 100644 --- a/packages/app/src/plugins/react-native/runtime.ts +++ b/packages/app/src/plugins/react-native/runtime.ts @@ -1,6 +1,18 @@ import { Icon } from "../icons"; import { Modal } from "./modal"; +import { ScrollView, FlatList } from "./scroll-view"; +import { TextInput } from "./text-input"; +import { copyText } from "./clipboard"; import { useToast } from "./toast"; import { useRevealedText } from "@/hooks/use-revealed-text"; -export const pluginReactNativeRuntime = { Icon, Modal, useRevealedText, useToast }; +export const pluginReactNativeRuntime = { + Icon, + Modal, + ScrollView, + FlatList, + TextInput, + copyText, + useRevealedText, + useToast, +}; diff --git a/packages/app/src/plugins/react-native/scroll-view.tsx b/packages/app/src/plugins/react-native/scroll-view.tsx new file mode 100644 index 0000000000..40dcc2f192 --- /dev/null +++ b/packages/app/src/plugins/react-native/scroll-view.tsx @@ -0,0 +1,25 @@ +import { forwardRef } from "react"; +import type { + ScrollView as NativeScrollView, + ScrollViewProps, + FlatListProps, + FlatList as NativeFlatList, +} from "react-native"; +import type * as Shared from "@/components/ui/scroll-view"; + +// Plugin evaluation also runs before the native UI is ready. Load presentation +// dependencies at render time, as Modal does, rather than evaluating them on import. +export const ScrollView = forwardRef( + function ScrollView(props, ref) { + const { ScrollView: HostScrollView } = + require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +); + +export const FlatList = forwardRef, FlatListProps>( + function FlatList(props, ref) { + const { FlatList: HostFlatList } = require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +) as typeof Shared.FlatList; diff --git a/packages/app/src/plugins/react-native/text-input.tsx b/packages/app/src/plugins/react-native/text-input.tsx new file mode 100644 index 0000000000..0330e68533 --- /dev/null +++ b/packages/app/src/plugins/react-native/text-input.tsx @@ -0,0 +1,12 @@ +import { forwardRef, type ElementRef } from "react"; +import type { TextInputProps } from "react-native"; + +import type { SheetAwareTextInput } from "@/components/ui/text-input/sheet-aware"; + +export const TextInput = forwardRef, TextInputProps>( + function TextInput(props, ref) { + const { SheetAwareTextInput } = + require("../../components/ui/text-input/sheet-aware") as typeof import("../../components/ui/text-input/sheet-aware"); + return ; + }, +); diff --git a/packages/app/src/plugins/react-native/toast.ts b/packages/app/src/plugins/react-native/toast.ts index e144390bfa..fd4d503799 100644 --- a/packages/app/src/plugins/react-native/toast.ts +++ b/packages/app/src/plugins/react-native/toast.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { ToastApi, ToastOptions } from "@getpaseo/plugin/react-native"; +import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native"; import { useToast as useAppToast } from "@/contexts/toast-api-context"; export function useToast(): ToastApi { diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts new file mode 100644 index 0000000000..1d7a72011f --- /dev/null +++ b/packages/app/src/plugins/react-native/ui.ts @@ -0,0 +1,14 @@ +export { + SettingsGroup, + SettingsSection, + SettingsCard, + SettingsRow, + SettingsSwitch, + SettingsSelect, + SettingsInput, + SettingsAction, +} from "@/components/settings"; + +export { ExternalLink } from "@/components/ui/external-link"; + +export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit"; diff --git a/packages/app/src/plugins/registry-lifecycle.test.ts b/packages/app/src/plugins/registry-lifecycle.test.ts new file mode 100644 index 0000000000..ecec420e3c --- /dev/null +++ b/packages/app/src/plugins/registry-lifecycle.test.ts @@ -0,0 +1,147 @@ +import { createPluginHosts } from "./hosts"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PaseoApi } from "@getpaseo/client"; +import { expect, test } from "vitest"; +import { PluginRegistry } from "./registry"; + +const audio = { play: async () => 0 }; + +/** A host client that records open event observations by their first event name. */ +function observingClient() { + const open = new Set(); + const client = { + observeEvents(events: string[]) { + const [name] = events; + open.add(name); + return { + ready: Promise.resolve({ subscriptionId: name }), + subscribe: () => () => undefined, + release: async () => { + open.delete(name); + }, + }; + }, + invokePluginRpc: async () => null, + } as unknown as DaemonClient; + return { client, open }; +} + +function registry() { + const setupClients = new Map(); + const plugins = new PluginRegistry({ + version: "0.8.0", + createRuntime: (installation) => { + setupClients.set(installation.id, installation.paseo); + return { + hosts: createPluginHosts( + { + getHosts: () => [], + getSnapshot: () => null, + subscribeAll: () => () => {}, + subscribeHostList: () => () => {}, + }, + installation.lifetime.signal, + ), + paseo: installation.paseo, + rpc: async () => { + throw new Error("Unexpected plugin RPC"); + }, + openScreen: () => {}, + openSurface: () => {}, + openSettings: () => {}, + playAudio: async () => {}, + openPanel: () => {}, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), + }; + }, + }); + return { ...observingClient(), plugins, setupClients }; +} + +/** A plugin whose setup opens an observation named after it and never releases it. */ +function catalog(id: string, body = "return function() {};") { + return { + id, + requirements: { paseo: ">=0.8.0" }, + clientBundle: `(function() { return { default: function(plugin) { plugin.paseo.observeEvents(["${id}"]); ${body} } }; })`, + }; +} + +test("setup and every surface share the installation's one Paseo client", () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("deploys")], { client: h.client, audio }); + + const [installation] = h.plugins.getSnapshot(); + expect(h.setupClients.get("deploys")).toBe(installation.paseo); + expect(h.open).toEqual(new Set(["deploys"])); +}); + +test("teardown ends every subscription the plugin still holds", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("disabled"), catalog("kept")], { + client: h.client, + audio, + }); + expect(h.open).toEqual(new Set(["disabled", "kept"])); + + // Disabling one plugin removes it from the catalog. + h.plugins.installCatalog("host", [catalog("kept")], { client: h.client, audio }); + await expect.poll(() => [...h.open]).toEqual(["kept"]); + + h.plugins.removeHost("host"); + await expect.poll(() => h.open.size).toBe(0); +}); + +test("reloading a plugin ends the old installation's subscriptions", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("reloaded")], { client: h.client, audio }); + const [first] = h.plugins.getSnapshot(); + + h.plugins.installCatalog("host", [catalog("reloaded")], { + client: h.client, + audio, + replacePluginId: "reloaded", + }); + + const [second] = h.plugins.getSnapshot(); + expect(second).not.toBe(first); + expect(second.paseo).not.toBe(first.paseo); + await expect(first.paseo.dispose()).resolves.toBeUndefined(); + expect(() => first.paseo.observeEvents(["project.update"])).toThrow("Paseo API is disposed"); + expect(h.open).toEqual(new Set(["reloaded"])); +}); + +test("failed plugin initialization ends the subscriptions it opened", async () => { + const h = registry(); + h.plugins.installCatalog("host", [catalog("failed", 'throw new Error("setup failed");')], { + client: h.client, + audio, + }); + await expect.poll(() => h.open.size).toBe(0); + expect(h.plugins.getSnapshot()).toEqual([]); +}); + +test("unloading ends the subscriptions even when plugin cleanup throws, preserving another plugin", async () => { + const h = registry(); + const failing = catalog("failing", 'return function() { throw new Error("cleanup failed"); };'); + const surviving = catalog("surviving", "return function() {};"); + h.plugins.installCatalog("host", [failing, surviving], { client: h.client, audio }); + h.plugins.installCatalog("host", [surviving], { client: h.client, audio }); + await expect.poll(() => [...h.open]).toEqual(["surviving"]); + expect(h.plugins.getSnapshot().map((plugin) => plugin.id)).toEqual(["surviving"]); + h.plugins.removeHost("host"); + await expect.poll(() => h.open.size).toBe(0); +}); + +test("an invalid async client entry is disposed and its rejected continuation is observed", async () => { + const h = registry(); + h.plugins.installCatalog( + "host", + [catalog("async-entry", 'return Promise.reject(new Error("asynchronous setup failed"));')], + { client: h.client, audio }, + ); + await expect.poll(() => h.open.size).toBe(0); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(h.plugins.getSnapshot()).toEqual([]); +}); diff --git a/packages/app/src/plugins/registry-requirements.test.ts b/packages/app/src/plugins/registry-requirements.test.ts new file mode 100644 index 0000000000..eb141e94c2 --- /dev/null +++ b/packages/app/src/plugins/registry-requirements.test.ts @@ -0,0 +1,101 @@ +import { createPluginHosts } from "./hosts"; +import { afterEach, expect, it } from "vitest"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginRegistry } from "./registry"; + +const audio = { play: async () => 0 }; + +const client = new DaemonClient({ url: "ws://unused.test", clientId: "plugin-requirements-test" }); +const registries: PluginRegistry[] = []; +function registry(version: string) { + let starts = 0; + let cleanups = 0; + const result = new PluginRegistry({ + version, + createRuntime(installation) { + starts++; + return { + hosts: createPluginHosts( + { + getHosts: () => [], + getSnapshot: () => null, + subscribeAll: () => () => {}, + subscribeHostList: () => () => {}, + }, + installation.lifetime.signal, + ), + paseo: installation.paseo, + rpc: async () => { + throw new Error("No RPC in this plugin"); + }, + openSettings() { + cleanups++; + }, + openScreen() {}, + openSurface() {}, + async playAudio() {}, + openPanel() {}, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), + }; + }, + }); + registries.push(result); + return { result, starts: () => starts, cleanups: () => cleanups }; +} +const clientBundle = + '(function() { return { default: function(client) { return () => client.openSettings("cleanup"); } }; })'; +afterEach(() => { + for (const value of registries.splice(0)) value.removeHost("host"); +}); + +it("checks the app version before creating a runtime or evaluating plugin code", () => { + const { result, starts } = registry("0.8.0"); + result.installCatalog( + "host", + [ + { + id: "example", + requirements: { paseo: ">=0.9.0" }, + clientBundle: "throw new Error('executed')", + }, + ], + { client, audio }, + ); + expect(starts()).toBe(0); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "example")).toBe( + 'Plugin "example" requires Paseo >=0.9.0. Your app is 0.8.0. Use a compatible plugin version or update the app.', + ); +}); + +it("rejects catalogs without requirements from pre-0.8 daemons", () => { + const { result, starts } = registry("0.8.0"); + result.installCatalog("host", [{ id: "example", clientBundle }], { client, audio }); + expect(starts()).toBe(0); + expect(result.getEvaluationError("host", "example")).toContain( + "https://paseo.sh/docs/plugins/migration", + ); +}); + +it("unloads on a requirement-only edit and recovers after correction", () => { + const { result, starts, cleanups } = registry("0.8.0"); + const install = (paseo: string) => + result.installCatalog("host", [{ id: "example", clientBundle, requirements: { paseo } }], { + client, + audio, + }); + install("^0.8.0"); + expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); + install("^0.8.0"); + expect(starts()).toBe(1); + install(">=0.9.0"); + expect(cleanups()).toBe(1); + expect(starts()).toBe(1); + expect(result.getSnapshot()).toEqual([]); + expect(result.getEvaluationError("host", "example")).toContain("requires Paseo >=0.9.0"); + install(">=0.8.0"); + expect(starts()).toBe(2); + expect(result.getSnapshot().map(({ id }) => id)).toEqual(["example"]); + expect(result.getEvaluationError("host", "example")).toBeUndefined(); +}); diff --git a/packages/app/src/plugins/registry.test.ts b/packages/app/src/plugins/registry.test.ts index a0fc4c068f..9e8d246a95 100644 --- a/packages/app/src/plugins/registry.test.ts +++ b/packages/app/src/plugins/registry.test.ts @@ -1,17 +1,21 @@ +import appPackage from "../../package.json"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry as registry } from "./registry"; +const audio = { play: async () => 0 }; + vi.mock("./navigation", () => ({ createPluginNavigation: () => ({}), })); vi.mock("./client-runtime", () => ({ createPluginClientRuntime: () => ({ - paseo: {}, + paseo: { dispose: async () => {} }, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, - addComposerPill: () => () => undefined, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), }), })); @@ -25,7 +29,11 @@ const pluginRegistry = { catalog: Parameters[1], options: { replacePluginId?: string } = {}, ) { - return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient }); + return registry.installCatalog( + serverId, + catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })), + { ...options, client: daemonClient, audio }, + ); }, }; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index d7f5845e5a..e349446bfe 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -1,22 +1,30 @@ +import type { AudioEngine } from "@/audio"; import { useMemo, useSyncExternalStore } from "react"; import { QueryClient } from "@tanstack/react-query"; +import { createPaseoApi } from "@getpaseo/client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { resolveAppVersion } from "@/utils/app-version"; import { createPluginClientRuntime } from "./client-runtime"; import { runPluginClientBundle } from "./evaluate"; import type { InstalledPlugin } from "./types"; -interface CatalogPlugin { - id: string; - clientBundle: string; -} +type CatalogPlugin = Awaited>[number]; -class PluginRegistry { +export class PluginRegistry { private readonly byHost = new Map(); private readonly listeners = new Set<() => void>(); private snapshot: InstalledPlugin[] = []; private readonly disposed = new WeakSet(); private readonly evaluationErrors = new Map(); + constructor( + private readonly dependencies: { + version: string | null; + createRuntime: typeof createPluginClientRuntime; + }, + ) {} + subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -34,6 +42,7 @@ class PluginRegistry { options: { replacePluginId?: string; client: DaemonClient; + audio: Pick; }, ): boolean { const previous = this.byHost.get(serverId) ?? []; @@ -45,7 +54,8 @@ class PluginRegistry { (plugin) => plugin.id !== options.replacePluginId && plugin.id === entry.id && - plugin.clientBundle === entry.clientBundle, + plugin.clientBundle === entry.clientBundle && + plugin.requirements?.paseo === entry.requirements?.paseo, ); return existing ? [existing] : []; }); @@ -57,8 +67,10 @@ class PluginRegistry { } const installed = catalog.flatMap((entry) => { const key = `${serverId}/${entry.id}`; + let installation: InstalledPlugin | undefined; try { if (!entry.clientBundle) return []; + assertPluginCompatibility({ ...entry, version: this.dependencies.version, runtime: "app" }); const existing = preserved.find( (plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle, ); @@ -66,14 +78,21 @@ class PluginRegistry { this.evaluationErrors.delete(key); return [existing]; } - const installation: InstalledPlugin = { + const client = options.client; + installation = { + lifetime: new AbortController(), + paseo: createPaseoApi(client), + invoke: (method, input) => client.invokePluginRpc(entry.id, method, input), id: entry.id, serverId, clientBundle: entry.clientBundle, + requirements: entry.requirements, queryClient: new QueryClient(), cleanup: () => undefined, surfaces: [], - sidebarItems: [], + settingsScreens: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -82,16 +101,28 @@ class PluginRegistry { timelineTransformers: [], timelineRenderers: [], }; - const evaluated = runPluginClientBundle( - entry.id, - entry.clientBundle, - createPluginClientRuntime(installation, options.client), - () => this.publish(), + const runtime = this.dependencies.createRuntime(installation, options.audio); + const evaluated = runPluginClientBundle(entry.id, entry.clientBundle, runtime, () => + this.publish(), ); Object.assign(installation, evaluated); + const paseo = installation.paseo; + installation.cleanup = async () => { + const results = await Promise.allSettled([paseo.dispose(), evaluated.cleanup()]); + const failures = results.filter((result) => result.status === "rejected"); + if (failures.length) + throw new AggregateError( + failures.map((result) => result.reason), + "Plugin cleanup failed", + ); + }; this.evaluationErrors.delete(key); return [installation]; } catch (error) { + installation?.lifetime.abort(); + void installation?.paseo + .dispose() + .catch((failure) => console.warn(`[Plugins] API cleanup failed for ${key}`, failure)); this.evaluationErrors.set(key, error instanceof Error ? error.message : String(error)); console.warn(`[Plugins] Failed to evaluate ${serverId}/${entry.id}`, error); return []; @@ -128,6 +159,7 @@ class PluginRegistry { private dispose(plugin: InstalledPlugin): void { if (this.disposed.has(plugin)) return; this.disposed.add(plugin); + plugin.lifetime.abort(); plugin.queryClient.clear(); try { void Promise.resolve(plugin.cleanup()).catch((error) => { @@ -148,7 +180,10 @@ class PluginRegistry { } } -export const pluginRegistry = new PluginRegistry(); +export const pluginRegistry = new PluginRegistry({ + version: resolveAppVersion(), + createRuntime: createPluginClientRuntime, +}); export function useInstalledPlugins(): InstalledPlugin[] { return useSyncExternalStore( diff --git a/packages/app/src/plugins/routes.test.ts b/packages/app/src/plugins/routes.test.ts index 18c6bfb1a0..b05b55da6b 100644 --- a/packages/app/src/plugins/routes.test.ts +++ b/packages/app/src/plugins/routes.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildLegacyPluginSurfaceRedirectRoute, buildPluginSurfaceRoute } from "./routes"; +import { + buildLegacyPluginSurfaceRedirectRoute, + buildPluginSurfaceRoute, + parsePluginSurfaceRoute, + pluginScreenParamsFromRoute, +} from "./routes"; describe("buildPluginSurfaceRoute", () => { it("keeps direct surfaces and sidebar contributions in separate route namespaces", () => { @@ -11,9 +16,79 @@ describe("buildPluginSurfaceRoute", () => { ); }); + it("puts screen params in the query under their own prefix, encoded", () => { + expect( + buildPluginSurfaceRoute( + "local", + "bots", + { kind: "surface", id: "bot" }, + { botId: "bot 2", "a&b": "x=y" }, + ), + ).toBe("/h/local/plugin/bots/surface/bot?param.botId=bot%202¶m.a%26b=x%3Dy"); + expect(buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, {})).toBe( + "/h/local/plugin/bots/surface/bot", + ); + }); + it("redirects legacy plugin surface URLs to their sidebar contribution identity", () => { expect(buildLegacyPluginSurfaceRedirectRoute("host/one", "review", "overview/item")).toBe( "/h/host%2Fone/plugin/review/sidebar/overview%2Fitem", ); }); }); + +describe("parsePluginSurfaceRoute", () => { + it("reads both route namespaces back from the paths buildPluginSurfaceRoute produces", () => { + expect( + parsePluginSurfaceRoute( + buildPluginSurfaceRoute("host/one", "review", { kind: "surface", id: "overview" }), + ), + ).toEqual({ + serverId: "host/one", + pluginId: "review", + identity: { kind: "surface", id: "overview" }, + }); + expect(parsePluginSurfaceRoute("/h/local/plugin/review/sidebar/item")).toEqual({ + serverId: "local", + pluginId: "review", + identity: { kind: "sidebar", id: "item" }, + }); + }); + + it("ignores routes that are not plugin screens", () => { + expect(parsePluginSurfaceRoute("/h/local/workspace/abc")).toBeNull(); + expect(parsePluginSurfaceRoute("/h/local/plugin/review/settings/general")).toBeNull(); + }); +}); + +describe("pluginScreenParamsFromRoute", () => { + function searchParams(route: string): Record { + // Expo Router's search params: the route's segments merged with its query. + const url = new URL(route, "http://paseo.test"); + return { + serverId: "local", + pluginId: "bots", + contributionKind: "surface", + contributionId: "bot", + ...Object.fromEntries(url.searchParams), + }; + } + + it("round-trips params whose keys match the route's own segments", () => { + const params = { serverId: "x", pluginId: "y", botId: "bot-2" }; + const route = buildPluginSurfaceRoute("local", "bots", { kind: "surface", id: "bot" }, params); + expect(pluginScreenParamsFromRoute(searchParams(route))).toEqual(params); + }); + + it("ignores the route's segments and non-string values", () => { + expect( + pluginScreenParamsFromRoute({ + serverId: "local", + contributionId: "bot", + "param.botId": "bot-2", + "param.tags": ["a", "b"], + "param.missing": undefined, + }), + ).toEqual({ botId: "bot-2" }); + }); +}); diff --git a/packages/app/src/plugins/routes.ts b/packages/app/src/plugins/routes.ts index d4fbf2ebef..3c51fe4a3b 100644 --- a/packages/app/src/plugins/routes.ts +++ b/packages/app/src/plugins/routes.ts @@ -1,16 +1,58 @@ +import type { PluginScreenParams } from "@getpaseo/plugin/client"; import type { PluginSurfaceContributionIdentity } from "./surface-contribution"; type PluginSurfaceRoute = `/h/${string}/plugin/${string}/${Kind}/${string}`; +/** + * Expo Router merges the route's segments (`serverId`, `pluginId`, ...) into the same search params + * as the query. Prefixing screen param keys keeps any plugin key clear of them. + */ +const SCREEN_PARAM_PREFIX = "param."; + +/** Screen params ride in the route's query, so reload, history and deep links keep them. */ export function buildPluginSurfaceRoute( serverId: string, pluginId: string, identity: Identity, + params: PluginScreenParams = {}, ): PluginSurfaceRoute { - return `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}` as PluginSurfaceRoute< - Identity["kind"] - >; + const path = `/h/${encodeURIComponent(serverId)}/plugin/${encodeURIComponent(pluginId)}/${identity.kind}/${encodeURIComponent(identity.id)}`; + const query = Object.entries(params) + .map( + ([key, value]) => + `${encodeURIComponent(SCREEN_PARAM_PREFIX + key)}=${encodeURIComponent(value)}`, + ) + .join("&"); + return (query ? `${path}?${query}` : path) as PluginSurfaceRoute; +} + +/** Checks params a plugin passed to `openScreen`: an object of string values. Throws otherwise. */ +export function parsePluginScreenParams(value: unknown): PluginScreenParams { + if (value === undefined) return {}; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("Plugin screen params must be an object of strings"); + } + const params: PluginScreenParams = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new Error(`Plugin screen param ${key} must be a string`); + } + params[key] = entry; + } + return params; +} + +/** The screen params in a plugin screen route's search params (`useLocalSearchParams`). */ +export function pluginScreenParamsFromRoute( + routeParams: Readonly>, +): PluginScreenParams { + const params: PluginScreenParams = {}; + for (const [key, value] of Object.entries(routeParams)) { + if (!key.startsWith(SCREEN_PARAM_PREFIX) || typeof value !== "string") continue; + params[key.slice(SCREEN_PARAM_PREFIX.length)] = value; + } + return params; } export function buildLegacyPluginSurfaceRedirectRoute( @@ -33,3 +75,21 @@ export function hostIdFromPathname(pathname: string): string | null { return null; } } + +export function parsePluginSurfaceRoute(pathname: string): { + serverId: string; + pluginId: string; + identity: PluginSurfaceContributionIdentity; +} | null { + const match = /^\/h\/([^/]+)\/plugin\/([^/]+)\/(sidebar|surface)\/([^/]+)\/?$/.exec(pathname); + if (!match) return null; + try { + return { + serverId: decodeURIComponent(match[1]), + pluginId: decodeURIComponent(match[2]), + identity: { kind: match[3] as "sidebar" | "surface", id: decodeURIComponent(match[4]) }, + }; + } catch { + return null; + } +} diff --git a/packages/app/src/plugins/settings/index.tsx b/packages/app/src/plugins/settings/index.tsx new file mode 100644 index 0000000000..0fda205671 --- /dev/null +++ b/packages/app/src/plugins/settings/index.tsx @@ -0,0 +1,158 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Platform, Text, View } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { router } from "expo-router"; +import { ArrowLeft } from "lucide-react-native"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { Button } from "@/components/ui/button"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useHostFeature } from "@/runtime/host-features"; +import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { useInstalledPlugin } from "../registry"; +import { PluginInstallationProvider } from "../installation-provider"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import { buildPluginSettingsRoute } from "./routes"; + +interface SettingsIdentity { + serverId: string; + pluginId: string; + screenId: string; +} + +function PluginSettingsMenuItem({ + serverId, + pluginId, + screenId, + title, + disabled, +}: SettingsIdentity & { title: string; disabled?: boolean }) { + const open = useCallback( + () => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)), + [serverId, pluginId, screenId], + ); + return ( + + {title} + + ); +} + +export function PluginSettingsMenuItems({ + serverId, + pluginId, + disabled, +}: Omit & { disabled?: boolean }) { + const plugin = useInstalledPlugin(serverId, pluginId); + const supported = useHostFeature(serverId, "pluginSettings"); + if (!supported || !plugin) return null; + return ( + <> + {plugin.settingsScreens.map((screen) => ( + + ))} + + ); +} + +function SettingsContent({ + serverId, + pluginId, + screenId, + theme, +}: SettingsIdentity & { theme: PluginHostProps["theme"] }) { + const { t } = useTranslation(); + const plugin = useInstalledPlugin(serverId, pluginId); + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const supported = useHostFeature(serverId, "pluginSettings"); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const [attempt, setAttempt] = useState(0); + const screen = plugin?.settingsScreens.find((item) => item.id === screenId); + const retry = useCallback(() => setAttempt((value) => value + 1), []); + const renderError = useCallback( + (error: string) => ( + + {error} + + + ), + [retry, t], + ); + const host = useMemo( + () => ({ + id: serverId, + label: hosts.find((candidate) => candidate.serverId === serverId)?.label ?? serverId, + }), + [hosts, serverId], + ); + const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web"; + const layout = useMemo( + () => ({ compact, platform }), + [compact, platform], + ); + if (!connected) + return {t("settings.plugins.screens.offline")}; + // COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05. + if (!supported) return {t("settings.plugins.screens.update")}; + if (!plugin || !screen || !client) + return {t("settings.plugins.screens.unavailable")}; + const Component = screen.Component; + return ( + + + + + + + + ); +} +const ThemedSettingsContent = withUnistyles(SettingsContent); +// Settings routes carry no screen params. +const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); +export function PluginSettingsContent({ + onBackToPlugins, + showBackToPlugins, + ...identity +}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) { + const { t } = useTranslation(); + return ( + + {showBackToPlugins ? ( + + ) : null} + + + ); +} +const styles = StyleSheet.create((theme) => ({ + message: { color: theme.colors.foregroundMuted }, + backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] }, +})); diff --git a/packages/app/src/plugins/settings/routes.ts b/packages/app/src/plugins/settings/routes.ts new file mode 100644 index 0000000000..be2fcfe897 --- /dev/null +++ b/packages/app/src/plugins/settings/routes.ts @@ -0,0 +1,6 @@ +export function buildPluginSettingsRoute(serverId: string, pluginId: string, screenId: string) { + return { + pathname: "/settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" as const, + params: { serverId, pluginId, screenId }, + }; +} diff --git a/packages/app/src/plugins/settings/use-settings.ts b/packages/app/src/plugins/settings/use-settings.ts new file mode 100644 index 0000000000..0cefc1878f --- /dev/null +++ b/packages/app/src/plugins/settings/use-settings.ts @@ -0,0 +1,85 @@ +import type { SettingsState } from "@getpaseo/plugin/client"; +import { useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRpc } from "@getpaseo/plugin/client"; +import { type SettingsDefinition } from "@getpaseo/plugin"; +import { settingsRpc } from "@getpaseo/plugin"; +import { useReplicaQuery } from "@/data/query"; +import { z, type ZodType } from "zod"; + +export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const; +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} + +export function useSettings( + definition: SettingsDefinition, +): SettingsState { + const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]); + const read = useRpc(rpc.read); + const write = useRpc(rpc.write); + const reset = useRpc(rpc.reset); + const client = useQueryClient(); + const key = pluginSettingsKey(definition.id); + const query = useReplicaQuery({ + queryKey: key, + pushEvent: "plugin_settings_changed", + queryFn: () => read({}), + retry: false, + }); + const mutation = useMutation({ + mutationFn: async ( + input: { revision: string; values: z.output } | { revision: string }, + ) => { + const result = + "values" in input + ? await write({ + revision: input.revision, + values: z.json().parse(await definition.schema.parseAsync(input.values)), + }) + : await reset(input); + if (result.status !== "saved") throw new Error(result.error); + client.setQueryData(key, { ...result, status: "ready" }); + }, + }); + async function save(values: z.output, revision: string) { + try { + await mutation.mutateAsync({ values, revision }); + return true; + } catch { + return false; + } + } + async function resetValues() { + if (!query.data) return false; + try { + await mutation.mutateAsync({ revision: query.data.revision }); + return true; + } catch { + return false; + } + } + const actions = { + saving: mutation.isPending, + saveError: mutation.error ? message(mutation.error) : null, + save, + reset: resetValues, + reload: async () => { + mutation.reset(); + await query.refetch(); + }, + }; + if (query.isPending) return { ...actions, status: "loading" }; + if (query.isError) return { ...actions, status: "error", error: message(query.error) }; + if (query.data.status === "invalid") return { ...actions, ...query.data }; + const parsed = definition.schema.safeParse(query.data.values); + if (!parsed.success) + return { + ...actions, + status: "invalid", + revision: query.data.revision, + error: parsed.error.message, + }; + return { ...actions, ...query.data, values: parsed.data }; +} diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index dbecea7662..314e5fe1e7 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -1,24 +1,35 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; -import { groupPluginSidebarContributions } from "./sidebar-groups"; +import { + legacySidebarItemHostKey, + pluginScreensHostKey, + rememberPluginContributionHost, +} from "./contribution-host"; +import { groupPluginSidebarItems, selectPluginSidebarTarget } from "./sidebar-groups"; -function installed(serverId: string, contributionId = "main"): InstalledPlugin { +function installed( + serverId: string, + contributionId = "main", + legacySidebarItems: InstalledPlugin["legacySidebarItems"] = [], + pluginId = "example", +): InstalledPlugin { return { - id: "example", + id: pluginId, cleanup: () => undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), - surfaces: [{ id: "surface", Component: () => null }], - sidebarItems: [ - { - id: contributionId, - title: "Example", - icon: "Blocks", - surface: "surface", - }, - ], + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, + settingsScreens: [], + surfaces: [{ id: "surface", title: "Surface", Component: () => null }], + sidebarItems: { + header: [{ id: contributionId, title: "Example", Component: () => null }], + footer: [{ id: "status", title: "Status", Component: () => null }], + }, + legacySidebarItems, workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -29,9 +40,9 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin { }; } -describe("groupPluginSidebarContributions", () => { +describe("groupPluginSidebarItems", () => { it("coalesces the same plugin contribution across hosts", () => { - const groups = groupPluginSidebarContributions([installed("host-a"), installed("host-b")]); + const groups = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "header"); expect(groups).toHaveLength(1); expect(groups[0]?.targets.map((target) => target.plugin.serverId)).toEqual([ @@ -41,14 +52,66 @@ describe("groupPluginSidebarContributions", () => { }); it("keeps different contribution ids separate", () => { - const groups = groupPluginSidebarContributions([ - installed("host-a", "main"), - installed("host-b", "settings"), - ]); + const groups = groupPluginSidebarItems( + [installed("host-a", "main"), installed("host-b", "settings")], + "header", + ); expect(groups.map((group) => group.key)).toEqual([ "example/sidebar/main", "example/sidebar/settings", ]); }); + + it("keeps a legacy addSidebarItem as its own kind, with its icon, in the header only", () => { + const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }]; + const plugins = [installed("host-a", "main", legacy), installed("host-b", "main", legacy)]; + const [legacyGroup, itemGroup] = groupPluginSidebarItems(plugins, "header"); + + expect(legacyGroup).toMatchObject({ kind: "legacy", title: "Legacy", icon: "Blocks" }); + expect(legacyGroup?.targets.map((target) => target.plugin.serverId)).toEqual([ + "host-a", + "host-b", + ]); + expect(itemGroup?.kind).toBe("item"); + expect(groupPluginSidebarItems(plugins, "footer").map((group) => group.kind)).toEqual(["item"]); + }); + + it("groups only the requested section", () => { + const groups = groupPluginSidebarItems([installed("host-a")], "footer"); + + expect(groups.map((group) => group.key)).toEqual(["example/sidebar-footer/status"]); + }); +}); + +describe("selectPluginSidebarTarget", () => { + const legacy = [{ id: "entry", title: "Legacy", icon: "Blocks", surface: "surface" }]; + + function hostsOf(section: "header" | "footer", pluginId: string) { + const plugins = ["host-a", "host-b"].map((serverId) => + installed(serverId, "main", legacy, pluginId), + ); + return groupPluginSidebarItems(plugins, section).map( + (group) => selectPluginSidebarTarget(group, null).plugin.serverId, + ); + } + + it("prefers the current route's host", () => { + const [group] = groupPluginSidebarItems([installed("host-a"), installed("host-b")], "footer"); + expect(selectPluginSidebarTarget(group!, "host-b").plugin.serverId).toBe("host-b"); + }); + + it("carries the host picked on any of a plugin's screens to all its items, but not legacy rows", () => { + expect(hostsOf("header", "screens")).toEqual(["host-a", "host-a"]); + rememberPluginContributionHost(pluginScreensHostKey("screens"), "host-b"); + // Header: the legacy row keeps its own host; the item follows the screens' host. + expect(hostsOf("header", "screens")).toEqual(["host-a", "host-b"]); + expect(hostsOf("footer", "screens")).toEqual(["host-b"]); + expect(hostsOf("footer", "other")).toEqual(["host-a"]); + }); + + it("keeps a legacy row's host under its own key", () => { + rememberPluginContributionHost(legacySidebarItemHostKey("legacy-row", "entry"), "host-b"); + expect(hostsOf("header", "legacy-row")).toEqual(["host-b", "host-a"]); + }); }); diff --git a/packages/app/src/plugins/sidebar-groups.ts b/packages/app/src/plugins/sidebar-groups.ts index 05c868f283..c764a53dcc 100644 --- a/packages/app/src/plugins/sidebar-groups.ts +++ b/packages/app/src/plugins/sidebar-groups.ts @@ -1,29 +1,65 @@ -import type { InstalledPlugin, PluginSidebarContribution } from "./types"; +import { + getPreferredPluginContributionHost, + legacySidebarItemHostKey, + pluginScreensHostKey, +} from "./contribution-host"; +import type { + InstalledPlugin, + PluginSidebarContribution, + PluginSidebarItemContribution, + PluginSidebarSection, +} from "./types"; export interface PluginSidebarTarget { + plugin: InstalledPlugin; + item: PluginSidebarItemContribution; +} + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +export interface PluginLegacySidebarTarget { plugin: InstalledPlugin; item: PluginSidebarContribution; } -export interface PluginSidebarGroup { +interface PluginSidebarGroupBase { key: string; pluginId: string; contributionId: string; title: string; - icon: string; - targets: PluginSidebarTarget[]; } -export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): PluginSidebarGroup[] { +/** + * One sidebar item, coalesced across every host that contributes it. A `legacy` group is an + * `addSidebarItem` row: the app draws it with its registered icon and it opens its sidebar route. + */ +export type PluginSidebarGroup = + | (PluginSidebarGroupBase & { kind: "item"; targets: PluginSidebarTarget[] }) + | (PluginSidebarGroupBase & { + kind: "legacy"; + icon: string; + targets: PluginLegacySidebarTarget[]; + }); + +function groupKey(pluginId: string, section: PluginSidebarSection, itemId: string) { + return section === "header" + ? legacySidebarItemHostKey(pluginId, itemId) + : `${pluginId}/sidebar-${section}/${itemId}`; +} + +export function groupPluginSidebarItems( + plugins: InstalledPlugin[], + section: PluginSidebarSection, +): PluginSidebarGroup[] { const groups = new Map(); for (const plugin of plugins) { - for (const item of plugin.sidebarItems) { - const key = `${plugin.id}/sidebar/${item.id}`; + const legacyItems = section === "header" ? plugin.legacySidebarItems : []; + for (const item of legacyItems) { + const key = groupKey(plugin.id, section, item.id); const existing = groups.get(key); - if (existing) { - existing.targets.push({ plugin, item }); - } else { + if (existing?.kind === "legacy") existing.targets.push({ plugin, item }); + else if (!existing) { groups.set(key, { + kind: "legacy", key, pluginId: plugin.id, contributionId: item.id, @@ -33,6 +69,41 @@ export function groupPluginSidebarContributions(plugins: InstalledPlugin[]): Plu }); } } + for (const item of plugin.sidebarItems[section]) { + const key = groupKey(plugin.id, section, item.id); + const existing = groups.get(key); + if (existing?.kind === "item") existing.targets.push({ plugin, item }); + else if (!existing) { + groups.set(key, { + kind: "item", + key, + pluginId: plugin.id, + contributionId: item.id, + title: item.title, + targets: [{ plugin, item }], + }); + } + } } return [...groups.values()]; } + +/** + * The host a group's row uses: the current route's host, else the remembered one, else the first. + * A legacy row remembers its own host; other items share the host last used with the plugin's + * screens. + */ +export function selectPluginSidebarTarget( + group: Group, + currentHostId: string | null, +): Group["targets"][number] { + const rememberedHostId = getPreferredPluginContributionHost( + group.kind === "legacy" ? group.key : pluginScreensHostKey(group.pluginId), + ); + const targets: Group["targets"] = group.targets; + return ( + targets.find((target) => target.plugin.serverId === currentHostId) ?? + targets.find((target) => target.plugin.serverId === rememberedHostId) ?? + targets[0] + ); +} diff --git a/packages/app/src/plugins/sidebar-items.tsx b/packages/app/src/plugins/sidebar-items.tsx deleted file mode 100644 index 97c0c6fa6d..0000000000 --- a/packages/app/src/plugins/sidebar-items.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { router, usePathname } from "expo-router"; -import { useCallback } from "react"; -import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; -import { resolvePluginIcon } from "./icons"; -import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes"; -import { - getPreferredPluginContributionHost, - rememberPluginContributionHost, -} from "./contribution-host"; -import { type PluginSidebarGroup, type PluginSidebarTarget } from "./sidebar-groups"; - -function selectTarget( - group: PluginSidebarGroup, - currentHostId: string | null, -): PluginSidebarTarget { - const current = group.targets.find((target) => target.plugin.serverId === currentHostId); - if (current) return current; - const rememberedHostId = getPreferredPluginContributionHost(group.key); - const remembered = group.targets.find((target) => target.plugin.serverId === rememberedHostId); - return remembered ?? group.targets[0]; -} - -export function PluginSidebarItemRow({ - group, - onBeforeNavigate, -}: { - group: PluginSidebarGroup; - onBeforeNavigate?: () => void; -}) { - const pathname = usePathname(); - const target = selectTarget(group, hostIdFromPathname(pathname)); - const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, { - kind: "sidebar", - id: group.contributionId, - }); - const isActive = group.targets.some( - (candidate) => - pathname === - buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, { - kind: "sidebar", - id: group.contributionId, - }), - ); - const navigate = useCallback(() => { - rememberPluginContributionHost(group.key, target.plugin.serverId); - onBeforeNavigate?.(); - router.push(route); - }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); - return ( - - ); -} diff --git a/packages/app/src/plugins/sidebar-items/frame.ts b/packages/app/src/plugins/sidebar-items/frame.ts new file mode 100644 index 0000000000..f5f42e5d56 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/frame.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import type { View } from "react-native"; +import type { PluginSidebarSection } from "../types"; + +/** What the host tells the kit components about the sidebar item they render in. */ +export interface SidebarItemFrame { + section: PluginSidebarSection; + title: string; + testID: string; + /** A row calls this before its `onPress`, so a popover that press opens anchors to it. */ + anchorTo: (node: View | null) => void; + /** Rows offer themselves on mount, so a popover opened without a press anchors to a row. */ + offerAnchor: (node: View | null) => void; + releaseAnchor: (node: View | null) => void; +} + +export const SidebarItemFrameContext = createContext(null); + +export function useSidebarItemFrame(componentName: string): SidebarItemFrame { + const frame = useContext(SidebarItemFrameContext); + if (!frame) throw new Error(`${componentName} must render inside a sidebar item`); + return frame; +} diff --git a/packages/app/src/plugins/sidebar-items/index.tsx b/packages/app/src/plugins/sidebar-items/index.tsx new file mode 100644 index 0000000000..11657c5d35 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/index.tsx @@ -0,0 +1,245 @@ +import type { + PluginHostProps, + PluginOpenScreenInput, + PluginPopoverProps, + PluginScreenLocation, + PluginSidebarItemProps, +} from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { router, useGlobalSearchParams, usePathname } from "expo-router"; +import { useCallback, useMemo, useState, type ComponentType, type RefObject } from "react"; +import { type View } from "react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { + SidebarPopoverRoot, + SidebarPopoverSurface, + useSidebarPopoverAnchor, +} from "@/components/sidebar/sidebar-popover"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "../client-state/source"; +import { pluginScreensHostKey, rememberPluginContributionHost } from "../contribution-host"; +import { + PluginEnvironmentProvider, + PluginPopoverContent, + type PluginEnvironment, +} from "../popover"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes"; +import { + selectPluginSidebarTarget, + type PluginSidebarGroup, + type PluginSidebarTarget, +} from "../sidebar-groups"; +import { currentPluginScreen, parsePluginOpenScreenInput } from "../surface-contribution"; +import { SurfaceErrorBoundary } from "../surface-error-boundary"; +import { toPluginTheme } from "../theme"; +import type { PluginSidebarSection } from "../types"; +import { SidebarItemFrameContext, type SidebarItemFrame } from "./frame"; +import { LegacyPluginSidebarRow } from "./legacy"; +import { resolvePluginPlatform } from "../platform"; + +export { SidebarRow, SidebarSeparator } from "./kit"; + +type PopoverContent = ComponentType; + +const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +type PluginItemGroup = Extract; + +function renderNothing() { + return null; +} + +interface PluginSidebarItemEntryProps { + group: PluginSidebarGroup; + section: PluginSidebarSection; + /** Anchors a popover opened by an item that renders no kit component. */ + fallbackAnchorRef: RefObject; + onBeforeNavigate?: () => void; +} + +type PluginSidebarItemHostProps = Omit & { + group: PluginItemGroup; +}; + +/** + * One plugin sidebar item. Renders the item's `Component` from the host `selectPluginSidebarTarget` picks, + * inside the plugin's runtime and its own error boundary: a throwing item renders nothing. A legacy + * `addSidebarItem` group renders the app's own row. + */ +export function PluginSidebarItem({ group, ...props }: PluginSidebarItemEntryProps) { + if (group.kind === "legacy") { + return ; + } + return ; +} + +function PluginSidebarItemHost({ + group, + section, + fallbackAnchorRef, + onBeforeNavigate, + theme, +}: PluginSidebarItemHostProps & { theme: PluginTheme }) { + const pathname = usePathname(); + const routeParams = useGlobalSearchParams(); + const { plugin, item } = selectPluginSidebarTarget(group, hostIdFromPathname(pathname)); + const client = useHostRuntimeClient(plugin.serverId); + const toast = useToast(); + // The content outlives `open` so the surface can play its exit and native sheet teardown. + const [popover, setPopover] = useState(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const environment = useMemo( + () => + client + ? { + installation: plugin, + toast, + state: createPluginClientStateSource(plugin.serverId), + } + : null, + [client, plugin, toast], + ); + const showPopover = useCallback((content: PopoverContent | null) => { + // A function passed to a state setter is an updater, so wrap the component. + if (content) setPopover(() => content); + setPopoverOpen(content !== null); + }, []); + const handleOpenChange = useCallback((open: boolean) => { + if (!open) setPopoverOpen(false); + }, []); + if (!environment) return null; + return ( + + + + + + + + ); +} + +const ThemedPluginSidebarItem = withUnistyles(PluginSidebarItemHost); + +function SidebarItemContent({ + plugin, + item, + section, + theme, + currentScreen, + environment, + popover, + showPopover, + fallbackAnchorRef, + onBeforeNavigate, +}: { + plugin: PluginSidebarTarget["plugin"]; + item: PluginSidebarTarget["item"]; + section: PluginSidebarSection; + theme: PluginTheme; + currentScreen: PluginScreenLocation | null; + environment: PluginEnvironment; + popover: PopoverContent | null; + showPopover: (content: PopoverContent | null) => void; + fallbackAnchorRef: RefObject; + onBeforeNavigate?: () => void; +}) { + const { anchorTo, offerAnchor, releaseAnchor, anchorToFallback } = + useSidebarPopoverAnchor("PluginSidebarItem"); + const hosts = useHosts(); + const compact = useIsCompactFormFactor(); + const hostLabel = + hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId; + + const hostProps = useMemo( + () => ({ + theme, + host: { id: plugin.serverId, label: hostLabel }, + layout: { compact, platform: resolvePluginPlatform() }, + }), + [compact, hostLabel, plugin.serverId, theme], + ); + const openScreen = useCallback( + (input: PluginOpenScreenInput) => { + const { screenId, params } = parsePluginOpenScreenInput(plugin, input); + rememberPluginContributionHost(pluginScreensHostKey(plugin.id), plugin.serverId); + showPopover(null); + onBeforeNavigate?.(); + router.push( + buildPluginSurfaceRoute( + plugin.serverId, + plugin.id, + { kind: "surface", id: screenId }, + params, + ), + ); + }, + [onBeforeNavigate, plugin, showPopover], + ); + const openPopover = useCallback( + (Content: PopoverContent) => { + anchorToFallback(fallbackAnchorRef); + showPopover(Content); + }, + [anchorToFallback, fallbackAnchorRef, showPopover], + ); + const close = useCallback(() => showPopover(null), [showPopover]); + const frame = useMemo( + () => ({ + section, + title: item.title, + testID: + section === "header" + ? `plugin-sidebar-${plugin.id}-${item.id}` + : `plugin-sidebar-${section}-${plugin.id}-${item.id}`, + anchorTo, + offerAnchor, + releaseAnchor, + }), + [anchorTo, item.id, item.title, offerAnchor, plugin.id, releaseAnchor, section], + ); + const itemProps = useMemo( + () => ({ ...hostProps, currentScreen, openScreen, openPopover }), + [currentScreen, hostProps, openPopover, openScreen], + ); + const Item = item.Component; + const Popover = popover; + return ( + + + {Popover ? ( + + + + + + + + + + ) : null} + + ); +} diff --git a/packages/app/src/plugins/sidebar-items/kit.tsx b/packages/app/src/plugins/sidebar-items/kit.tsx new file mode 100644 index 0000000000..5796d572ca --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/kit.tsx @@ -0,0 +1,72 @@ +import type { SidebarIcon, SidebarRowProps } from "@getpaseo/plugin/client/ui"; +import { useCallback, useEffect, useRef } from "react"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { SidebarSeparator as AppSidebarSeparator } from "@/components/sidebar/sidebar-separator"; +import { resolvePluginIcon } from "../icons"; +import { useSidebarItemFrame } from "./frame"; + +function resolveIcon(icon: SidebarIcon) { + return typeof icon === "string" ? resolvePluginIcon(icon) : icon; +} + +/** + * An item may render several rows. Any press inside a row, on the row or on its trailing content, + * makes that row the popover anchor: the capture-phase responder check runs before the pressed + * child claims the touch (native and web), and returns false so the child still handles it. + */ +export function SidebarRow({ id, icon, label, onPress, active, trailing }: SidebarRowProps) { + const frame = useSidebarItemFrame("SidebarRow"); + const { anchorTo, offerAnchor, releaseAnchor } = frame; + const rowRef = useRef(null); + useEffect(() => { + const node = rowRef.current; + offerAnchor(node); + return () => releaseAnchor(node); + }, [offerAnchor, releaseAnchor]); + const anchorHere = useCallback(() => { + anchorTo(rowRef.current); + return false; + }, [anchorTo]); + // Keyboard activation skips the responder system, so the row's own press anchors as well. + const handlePress = useCallback(() => { + anchorTo(rowRef.current); + onPress(); + }, [anchorTo, onPress]); + return ( + + + + ); +} + +/** The sidebar's separator line, run edge to edge across the section like the app's own. */ +export function SidebarSeparator() { + const frame = useSidebarItemFrame("SidebarSeparator"); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // Header rows fill the sidebar width; the gap between them is 2px. + header: { + marginVertical: theme.spacing[1], + }, + // The footer pads its rows by `spacing[2]` and spaces them `spacing[1]` apart. + footer: { + marginHorizontal: -theme.spacing[2], + marginVertical: theme.spacing[0.5], + }, +})); diff --git a/packages/app/src/plugins/sidebar-items/legacy.tsx b/packages/app/src/plugins/sidebar-items/legacy.tsx new file mode 100644 index 0000000000..94d3e69029 --- /dev/null +++ b/packages/app/src/plugins/sidebar-items/legacy.tsx @@ -0,0 +1,44 @@ +import { router, usePathname } from "expo-router"; +import { useCallback } from "react"; +import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; +import { rememberPluginContributionHost } from "../contribution-host"; +import { resolvePluginIcon } from "../icons"; +import { buildPluginSurfaceRoute, hostIdFromPathname } from "../routes"; +import { selectPluginSidebarTarget, type PluginSidebarGroup } from "../sidebar-groups"; + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +/** + * An `addSidebarItem({ id, title, icon, surface })` row. It opens `/sidebar/`, so the screen's + * header and host switcher belong to this item. + */ +export function LegacyPluginSidebarRow({ + group, + onBeforeNavigate, +}: { + group: Extract; + onBeforeNavigate?: () => void; +}) { + const pathname = usePathname(); + const identity = { kind: "sidebar", id: group.contributionId } as const; + const target = selectPluginSidebarTarget(group, hostIdFromPathname(pathname)); + const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, identity); + const isActive = group.targets.some( + (candidate) => + pathname === buildPluginSurfaceRoute(candidate.plugin.serverId, group.pluginId, identity), + ); + const navigate = useCallback(() => { + rememberPluginContributionHost(group.key, target.plugin.serverId); + onBeforeNavigate?.(); + router.push(route); + }, [group.key, onBeforeNavigate, route, target.plugin.serverId]); + return ( + + ); +} diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index 653bac17bd..90672ed435 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -2,7 +2,10 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; import type { InstalledPlugin } from "./types"; import { + currentPluginScreen, getPluginSurfaceContributionServerIds, + parsePluginOpenScreenInput, + resolvePluginScreenTitle, resolvePluginSurfaceContribution, } from "./surface-contribution"; @@ -16,10 +19,15 @@ function installation( id: pluginId, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => undefined, - surfaces: surfaces.map((id) => ({ id, Component: () => null })), - sidebarItems: sidebarItems.map((item) => ({ + settingsScreens: [], + surfaces: surfaces.map((id) => ({ id, title: id, Component: () => null })), + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: sidebarItems.map((item) => ({ ...item, title: item.id, icon: "Blocks", @@ -65,6 +73,7 @@ describe("plugin surface contribution identity", () => { expect(getPluginSurfaceContributionServerIds(installations, "review", identity)).toEqual([ "host-v1", "host-v2", + "same-surface", ]); }); @@ -80,4 +89,129 @@ describe("plugin surface contribution identity", () => { "same-surface", ]); }); + + it("opens a direct screen without borrowing a legacy item that points at it", () => { + const resolved = resolvePluginSurfaceContribution(installations[0] ?? null, { + kind: "surface", + id: "surface-v1", + }); + + expect(resolved.sidebarItem).toBeNull(); + expect(resolved.surface?.id).toBe("surface-v1"); + }); + + it("resolves an old sidebar link to the same-id screen once the plugin has migrated", () => { + const resolved = resolvePluginSurfaceContribution(installations[2] ?? null, { + kind: "sidebar", + id: "overview", + }); + + expect(resolved.sidebarItem).toBeNull(); + expect(resolved.surface?.id).toBe("overview"); + expect( + resolvePluginSurfaceContribution(installations[2] ?? null, { kind: "sidebar", id: "missing" }) + .surface, + ).toBeNull(); + }); +}); + +describe("currentPluginScreen", () => { + const plugin = installation( + "local", + ["overview", "details"], + [{ id: "legacy", surface: "details" }], + ); + + it("reads the open screen and its params from direct and legacy routes on the installation's host", () => { + expect( + currentPluginScreen(plugin, "/h/local/plugin/review/surface/overview", { + serverId: "local", + pluginId: "review", + contributionKind: "surface", + contributionId: "overview", + "param.botId": "bot-2", + }), + ).toEqual({ screenId: "overview", params: { botId: "bot-2" } }); + expect(currentPluginScreen(plugin, "/h/local/plugin/review/sidebar/legacy", {})).toEqual({ + screenId: "details", + params: {}, + }); + }); + + it("is null on another host, another plugin, or a missing screen", () => { + expect(currentPluginScreen(plugin, "/h/remote/plugin/review/surface/overview", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/plugin/other/surface/overview", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/plugin/review/surface/missing", {})).toBeNull(); + expect(currentPluginScreen(plugin, "/h/local/workspace/abc", {})).toBeNull(); + }); +}); + +describe("parsePluginOpenScreenInput", () => { + const plugin = installation("local", ["bot"]); + + it("accepts a registered screen with or without string params, under any key", () => { + expect(parsePluginOpenScreenInput(plugin, { screenId: "bot" })).toEqual({ + screenId: "bot", + params: {}, + }); + expect(parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: "2" } })).toEqual( + { screenId: "bot", params: { botId: "2" } }, + ); + expect( + parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { serverId: "x" } }), + ).toEqual({ screenId: "bot", params: { serverId: "x" } }); + }); + + it("throws on a bare id, an unknown screen, or params that are not strings", () => { + expect(() => parsePluginOpenScreenInput(plugin, "bot")).toThrow("{ screenId, params? }"); + expect(() => parsePluginOpenScreenInput(plugin, { screenId: "missing" })).toThrow( + "unavailable: missing", + ); + expect(() => + parsePluginOpenScreenInput(plugin, { screenId: "bot", params: { botId: 2 } }), + ).toThrow("botId must be a string"); + expect(() => parsePluginOpenScreenInput(plugin, { screenId: "bot", params: ["2"] })).toThrow( + "must be an object of strings", + ); + }); +}); + +describe("resolvePluginScreenTitle", () => { + const Component = () => null; + const botNames: Record = { "bot-1": "Bot 1", "bot-2": "Bot 2" }; + const bot = { + id: "bot", + title: (params: Record) => botNames[params.botId] ?? "", + Component, + }; + + it("uses a string title as is", () => { + expect(resolvePluginScreenTitle({ id: "deploys", title: "Deploys", Component }, null, {})).toBe( + "Deploys", + ); + }); + + it("derives a function title from the params it is given", () => { + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-2" })).toBe("Bot 2"); + expect(resolvePluginScreenTitle(bot, null, { botId: "bot-1" })).toBe("Bot 1"); + }); + + it("falls back to the screen id when the function returns empty or throws", () => { + expect(resolvePluginScreenTitle(bot, null, { botId: "missing" })).toBe("bot"); + const throwing = { + id: "bot", + title: () => { + throw new Error("no bots loaded"); + }, + Component, + }; + expect(resolvePluginScreenTitle(throwing, null, {})).toBe("bot"); + }); + + it("uses the title of a legacy item that points at the screen", () => { + const legacy = { id: "entry", title: "Legacy entry", icon: "Server", surface: "main" }; + expect(resolvePluginScreenTitle({ id: "main", title: "main", Component }, legacy, {})).toBe( + "Legacy entry", + ); + }); }); diff --git a/packages/app/src/plugins/surface-contribution.ts b/packages/app/src/plugins/surface-contribution.ts index 743ebbc1a7..830677f164 100644 --- a/packages/app/src/plugins/surface-contribution.ts +++ b/packages/app/src/plugins/surface-contribution.ts @@ -1,39 +1,100 @@ +import type { PluginScreenLocation, PluginScreenParams } from "@getpaseo/plugin/client"; +import { + parsePluginScreenParams, + parsePluginSurfaceRoute, + pluginScreenParamsFromRoute, +} from "./routes"; import type { InstalledPlugin } from "./types"; export type PluginSurfaceContributionIdentity = | { kind: "sidebar"; id: string } | { kind: "surface"; id: string }; +/** + * A `sidebar` identity is a legacy `addSidebarItem` route: it resolves to that item and its screen, + * or, once the plugin has moved that item to `addScreen` under the same id, to that screen. + */ export function resolvePluginSurfaceContribution( plugin: InstalledPlugin | null, identity: PluginSurfaceContributionIdentity | null, ): { - sidebarItem: InstalledPlugin["sidebarItems"][number] | null; + sidebarItem: InstalledPlugin["legacySidebarItems"][number] | null; surface: InstalledPlugin["surfaces"][number] | null; } { - if (!identity) return { sidebarItem: null, surface: null }; + if (!identity || !plugin) return { sidebarItem: null, surface: null }; const sidebarItem = identity.kind === "sidebar" - ? (plugin?.sidebarItems.find((contribution) => contribution.id === identity.id) ?? null) + ? (plugin.legacySidebarItems.find((contribution) => contribution.id === identity.id) ?? null) : null; - const surfaceId = identity.kind === "sidebar" ? sidebarItem?.surface : identity.id; + const surfaceId = sidebarItem ? sidebarItem.surface : identity.id; const surface = surfaceId - ? (plugin?.surfaces.find((contribution) => contribution.id === surfaceId) ?? null) + ? (plugin.surfaces.find((contribution) => contribution.id === surfaceId) ?? null) : null; return { sidebarItem, surface }; } +/** + * The screen header's title. A legacy `addSidebarItem` pointing at the screen lends its title, as + * before screens had one. A title function that throws or returns an empty string falls back to the + * screen id, so a bad title never takes the screen down. + */ +export function resolvePluginScreenTitle( + screen: InstalledPlugin["surfaces"][number], + legacySidebarItem: InstalledPlugin["legacySidebarItems"][number] | null, + params: PluginScreenParams, +): string { + if (legacySidebarItem) return legacySidebarItem.title; + if (typeof screen.title === "string") return screen.title; + try { + const title = screen.title(params); + if (typeof title === "string" && title.trim()) return title.trim(); + } catch (error) { + console.warn(`[Plugins] Screen ${screen.id} title failed`, error); + } + return screen.id; +} + +/** + * The installation's screen open at `pathname` with the route's search params, or null when the + * route shows anything else. + */ +export function currentPluginScreen( + plugin: InstalledPlugin, + pathname: string, + routeParams: Readonly>, +): PluginScreenLocation | null { + const route = parsePluginSurfaceRoute(pathname); + if (!route || route.serverId !== plugin.serverId || route.pluginId !== plugin.id) return null; + const screenId = resolvePluginSurfaceContribution(plugin, route.identity).surface?.id; + return screenId ? { screenId, params: pluginScreenParamsFromRoute(routeParams) } : null; +} + +/** Checks an `openScreen` input against the installation's screens. Throws when it is invalid. */ +export function parsePluginOpenScreenInput( + plugin: InstalledPlugin, + input: unknown, +): PluginScreenLocation { + if (typeof input !== "object" || input === null || !("screenId" in input)) { + throw new Error("openScreen takes { screenId, params? }"); + } + const { screenId } = input; + if (typeof screenId !== "string" || !plugin.surfaces.some((surface) => surface.id === screenId)) { + throw new Error(`Plugin screen is unavailable: ${String(screenId)}`); + } + const params = "params" in input ? input.params : undefined; + return { screenId, params: parsePluginScreenParams(params) }; +} + export function getPluginSurfaceContributionServerIds( installations: readonly InstalledPlugin[], pluginId: string, identity: PluginSurfaceContributionIdentity, ): string[] { return installations - .filter((installation) => { - if (installation.id !== pluginId) return false; - return identity.kind === "sidebar" - ? installation.sidebarItems.some((contribution) => contribution.id === identity.id) - : installation.surfaces.some((contribution) => contribution.id === identity.id); - }) + .filter( + (installation) => + installation.id === pluginId && + resolvePluginSurfaceContribution(installation, identity).surface !== null, + ) .map((installation) => installation.serverId); } diff --git a/packages/app/src/plugins/surface-error-boundary.tsx b/packages/app/src/plugins/surface-error-boundary.tsx index 7a3db639ea..89c6f6b468 100644 --- a/packages/app/src/plugins/surface-error-boundary.tsx +++ b/packages/app/src/plugins/surface-error-boundary.tsx @@ -7,6 +7,7 @@ interface SurfaceErrorBoundaryProps { installation: object; resetKey?: unknown; Surface: unknown; + renderError?(error: string): ReactNode; } interface SurfaceErrorBoundaryState { @@ -40,6 +41,7 @@ export class SurfaceErrorBoundary extends Component< render() { if (this.state.error) { + if (this.props.renderError) return this.props.renderError(this.state.error); return Plugin failed: {this.state.error}; } return this.props.children; diff --git a/packages/app/src/plugins/surface-runtime.test.tsx b/packages/app/src/plugins/surface-runtime.test.tsx deleted file mode 100644 index ec3ad2d4d1..0000000000 --- a/packages/app/src/plugins/surface-runtime.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PaseoApi } from "@getpaseo/client"; -import { PaseoApiProvider } from "@getpaseo/plugin/host"; -import { usePaseo } from "@getpaseo/plugin"; -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; - -function clientWithWorkspace(id: string) { - const createWorkspace = vi.fn(async () => ({ - error: null, - workspace: { - id, - projectId: `project-${id}`, - workspaceDirectory: `/tmp/${id}`, - name: id, - status: "active", - }, - })); - const createAgent = vi.fn(async () => ({ - id: `agent-${id}`, - provider: "codex", - cwd: `/tmp/${id}`, - workspaceId: id, - status: "idle", - })); - const invokePluginRpc = vi.fn(async () => id); - return { - client: { createWorkspace, createAgent, invokePluginRpc } as unknown as DaemonClient, - createWorkspace, - createAgent, - invokePluginRpc, - }; -} - -function borrowFromAppProvider(paseo: PaseoApi): PaseoApi { - let borrowed: PaseoApi | null = null; - function PluginSurface() { - borrowed = usePaseo(); - return null; - } - renderToStaticMarkup( - - - , - ); - if (!borrowed) throw new Error("Plugin surface did not receive Paseo API"); - return borrowed; -} - -describe("plugin surface host runtime", () => { - it("creates a PR worktree and agent through usePaseo on the selected app host", async () => { - const selected = clientWithWorkspace("workspace-a"); - const runtime = createPluginSurfaceRuntime(selected.client, "workspace-plugin"); - if (!runtime) throw new Error("Expected selected host runtime"); - - const paseo = borrowFromAppProvider(runtime.paseo); - const workspace = await paseo.workspaces.create({ - source: { - kind: "worktree", - cwd: "/tmp/repository", - action: "checkout", - checkoutSource: { kind: "change_request", forge: "github", number: 42 }, - }, - }); - const agent = await workspace.agents.create({ - config: { provider: "codex/gpt-5" }, - prompt: "Review PR #42", - }); - - expect(workspace.id).toBe("workspace-a"); - expect(agent.id).toBe("agent-workspace-a"); - expect(selected.createWorkspace).toHaveBeenCalledOnce(); - expect(selected.createAgent).toHaveBeenCalledOnce(); - }); - - it("switches all plugin calls when the selected host changes", async () => { - const hostA = clientWithWorkspace("workspace-a"); - const hostB = clientWithWorkspace("workspace-b"); - const first = createPluginSurfaceRuntime(hostA.client, "same-plugin"); - const second = createPluginSurfaceRuntime(hostB.client, "same-plugin"); - if (!first || !second) throw new Error("Expected online host runtimes"); - - await first.invoke("host", {}); - await borrowFromAppProvider(second.paseo).workspaces.create({ - source: { kind: "directory", path: "/tmp/workspace-b" }, - }); - - expect(hostA.invokePluginRpc).toHaveBeenCalledWith("same-plugin", "host", {}); - expect(hostA.createWorkspace).not.toHaveBeenCalled(); - expect(hostB.createWorkspace).toHaveBeenCalledOnce(); - }); - - it("keeps an offline selected host unavailable instead of borrowing another host", () => { - const otherHost = clientWithWorkspace("workspace-online"); - - expect(createPluginSurfaceRuntime(null, "same-plugin")).toBeNull(); - expect(otherHost.createWorkspace).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/app/src/plugins/surface-runtime.ts b/packages/app/src/plugins/surface-runtime.ts deleted file mode 100644 index b3635b9122..0000000000 --- a/packages/app/src/plugins/surface-runtime.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; - -export interface PluginSurfaceRuntime { - paseo: PaseoApi; - invoke(method: string, input: unknown): Promise; -} - -export function createPluginSurfaceRuntime( - client: DaemonClient | null, - pluginId: string, -): PluginSurfaceRuntime | null { - if (!client) return null; - return { - paseo: createPaseoApi(client), - invoke: (method, input) => client.invokePluginRpc(pluginId, method, input), - }; -} diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index 849f357ba5..378965cd8f 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,14 +1,17 @@ import { router, useLocalSearchParams } from "expo-router"; -import type { PluginSurfaceProps, PluginTheme } from "@getpaseo/plugin"; -import { ChevronDown, X } from "lucide-react-native"; -import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; -import { Platform, Pressable, Text, View } from "react-native"; +import type { PluginScreenParams, PluginScreenProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { X } from "lucide-react-native"; +import { useCallback, useMemo, type ComponentType } from "react"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { CompactHeader } from "@/components/headers/compact-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostPicker } from "@/components/hosts/host-picker"; +import { HostFilter } from "@/components/hosts/host-filter"; +import type { HostPickerHost } from "@/components/hosts/host-picker"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; @@ -17,16 +20,21 @@ import { usePluginHostNavigation } from "./host-navigation"; import { resolvePluginIcon } from "./icons"; import { toPluginTheme } from "./theme"; import { useInstalledPlugin, usePluginInstallations } from "./registry"; -import { buildPluginSurfaceRoute } from "./routes"; -import { rememberPluginContributionHost } from "./contribution-host"; +import { buildPluginSurfaceRoute, pluginScreenParamsFromRoute } from "./routes"; +import { + legacySidebarItemHostKey, + pluginScreensHostKey, + rememberPluginContributionHost, +} from "./contribution-host"; import { SurfaceErrorBoundary } from "./surface-error-boundary"; -import { createPluginSurfaceRuntime } from "./surface-runtime"; -import { PluginRuntimeBoundary } from "./runtime-boundary"; +import { PluginInstallationProvider } from "./installation-provider"; import { getPluginSurfaceContributionServerIds, + resolvePluginScreenTitle, resolvePluginSurfaceContribution, type PluginSurfaceContributionIdentity, } from "./surface-contribution"; +import { resolvePluginPlatform } from "./platform"; const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -34,7 +42,6 @@ const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme), }); const ThemedX = withUnistyles(X); -const ThemedChevronDown = withUnistyles(ChevronDown); function routeParam(value: string | string[] | undefined): string { return typeof value === "string" ? value : ""; @@ -54,105 +61,80 @@ const ThemedPluginHeaderIcon = withUnistyles(PluginHeaderIcon); function SurfaceRenderer({ Surface, - runtime, plugin, layout, host, + params, theme, }: { - Surface: ComponentType; - runtime: NonNullable>; + Surface: ComponentType; plugin: NonNullable>; - layout: PluginSurfaceProps["layout"]; - host: PluginSurfaceProps["host"]; + layout: PluginScreenProps["layout"]; + host: PluginScreenProps["host"]; + params: PluginScreenParams; theme: PluginTheme; }) { const navigation = usePluginHostNavigation(host.id); return ( - - - + + + ); } const ThemedSurfaceRenderer = withUnistyles(SurfaceRenderer); -function resolvePlatform(): PluginSurfaceProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - -function PluginHostSwitcher({ +function PluginHostFilter({ serverId, pluginId, identity, - serverIds, + params, + hosts, }: { serverId: string; pluginId: string; identity: PluginSurfaceContributionIdentity; - serverIds: string[]; + params: PluginScreenParams; + hosts: HostPickerHost[]; }) { - const allHosts = useHosts(); - const hosts = useMemo( - () => allHosts.filter((host) => serverIds.includes(host.serverId)), - [allHosts, serverIds], - ); - const [open, setOpen] = useState(false); - const anchorRef = useRef(null); - const selectedLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const selectHost = useCallback( (nextServerId: string) => { - rememberPluginContributionHost(`${pluginId}/${identity.kind}/${identity.id}`, nextServerId); - router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity)); + // A legacy row remembers its own host; a screen's host carries to all the plugin's items. + rememberPluginContributionHost( + identity.kind === "sidebar" + ? legacySidebarItemHostKey(pluginId, identity.id) + : pluginScreensHostKey(pluginId), + nextServerId, + ); + router.replace(buildPluginSurfaceRoute(nextServerId, pluginId, identity, params)); }, - [identity, pluginId], + [identity, params, pluginId], ); - const openPicker = useCallback(() => setOpen(true), []); - const show = serverIds.length > 1 && hosts.length > 1; - if (!show) return null; - return ( - - - - - {selectedLabel} - - - - - + selectedHost={serverId} + onSelectHost={selectHost} + includeAllHost={false} + triggerTestID="plugin-host-filter-trigger" + /> ); } export function PluginSurfaceScreen() { - const params = useLocalSearchParams<{ + const routeParams = useLocalSearchParams<{ serverId?: string | string[]; pluginId?: string | string[]; contributionKind?: string | string[]; contributionId?: string | string[]; }>(); - const serverId = routeParam(params.serverId); - const pluginId = routeParam(params.pluginId); - const contributionKind = routeParam(params.contributionKind); - const contributionId = routeParam(params.contributionId); + const serverId = routeParam(routeParams.serverId); + const pluginId = routeParam(routeParams.pluginId); + const contributionKind = routeParam(routeParams.contributionKind); + const contributionId = routeParam(routeParams.contributionId); + const paramsKey = JSON.stringify(pluginScreenParamsFromRoute(routeParams)); + // Keyed by content: the route hands back a new object on every render. + const params = useMemo(() => JSON.parse(paramsKey), [paramsKey]); const identity = useMemo(() => { if (contributionKind !== "sidebar" && contributionKind !== "surface") return null; return { kind: contributionKind, id: contributionId }; @@ -161,25 +143,31 @@ export function PluginSurfaceScreen() { const installations = usePluginInstallations(pluginId); const hosts = useHosts(); const client = useHostRuntimeClient(serverId); - const runtime = useMemo(() => createPluginSurfaceRuntime(client, pluginId), [client, pluginId]); const compact = useIsCompactFormFactor(); const { sidebarItem, surface } = useMemo( () => resolvePluginSurfaceContribution(plugin, identity), [identity, plugin], ); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const contributionServerIds = useMemo( + // The hosts this contribution is installed on; the filter shows when there is a choice. + const filterHosts = useMemo(() => { + if (!identity) return []; + const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity); + return hosts.filter((candidate) => serverIds.includes(candidate.serverId)); + }, [hosts, identity, installations, pluginId]); + const title = useMemo( () => - identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], - [identity, installations, pluginId], + surface + ? resolvePluginScreenTitle(surface, sidebarItem, params) + : (sidebarItem?.title ?? (pluginId || "Plugin")), + [params, pluginId, sidebarItem, surface], ); - const title = sidebarItem?.title ?? surface?.id ?? (pluginId || "Plugin"); const Icon = sidebarItem ? resolvePluginIcon(sidebarItem.icon) : null; const close = useCallback(() => { if (router.canGoBack()) router.back(); else router.replace(`/h/${encodeURIComponent(serverId)}`); }, [serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); const headerLeft = useMemo( () => ( @@ -189,22 +177,28 @@ export function PluginSurfaceScreen() { ) : null} - {title} + {title} ), [Icon, title], ); + const hostFilter = useMemo( + () => + identity && filterHosts.length > 1 ? ( + + ) : null, + [filterHosts, identity, params, pluginId, serverId], + ); const headerRight = useMemo( () => ( <> - {identity ? ( - - ) : null} + {hostFilter} ), - [close, contributionServerIds, identity, pluginId, serverId], + [close, hostFilter], ); return ( - + {compact ? ( + + ) : ( + + )} - {plugin && surface && runtime ? ( + {plugin && surface && client ? ( @@ -261,19 +265,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.statusDanger, padding: theme.spacing[4], }, - hostSwitcher: { - maxWidth: 180, - minHeight: 32, - paddingHorizontal: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface1, - }, - hostSwitcherText: { - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, })); diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 6081a33e86..c1548d0521 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -49,9 +49,14 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install cleanup: () => undefined, serverId, clientBundle: serverId, + lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, + settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 27e734d40d..c64e498333 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -11,10 +11,15 @@ function plugin(input: { id: input.id, serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => {}, + settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/projection.test.ts b/packages/app/src/plugins/timeline/projection.test.ts index ebb2df48c8..2dc95ed339 100644 --- a/packages/app/src/plugins/timeline/projection.test.ts +++ b/packages/app/src/plugins/timeline/projection.test.ts @@ -73,6 +73,48 @@ describe("plugin timeline projection", () => { expect(transform).toHaveBeenCalledOnce(); }); + it("preserves whether a notification row came from an agent error or a notice", () => { + const transform: TimelineItemTransform = vi.fn(() => undefined); + const timestamp = new Date("2026-01-01T00:00:00.000Z"); + + projectPluginTimelineItems( + [ + { + kind: "notification", + sourceType: "error", + id: "error-1", + level: "error", + message: "Agent failed", + timestamp, + }, + { + kind: "notification", + sourceType: "notification", + id: "notice-1", + level: "error", + message: "Extension reported an error", + timestamp, + }, + ], + transform, + ); + + expect(transform).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ item: { type: "error", message: "Agent failed" } }), + ); + expect(transform).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + item: { + type: "notification", + level: "error", + message: "Extension reported an error", + }, + }), + ); + }); + it("reprojects when streaming produces a new source row", () => { const transform: TimelineItemTransform = vi.fn(() => undefined); projectPluginTimelineItems([thought("First")], transform); diff --git a/packages/app/src/plugins/timeline/projection.ts b/packages/app/src/plugins/timeline/projection.ts index 7e60392eef..0750bdb66a 100644 --- a/packages/app/src/plugins/timeline/projection.ts +++ b/packages/app/src/plugins/timeline/projection.ts @@ -2,7 +2,12 @@ import type { AgentTimelineItem, ToolCallTimelineItem } from "@getpaseo/protocol import type { AgentToolCallData, PluginTimelineStreamItem, StreamItem } from "@/types/stream"; import type { TimelineItemTransform } from "./model"; -const projectionCache = new WeakMap>(); +type AssistantPhase = "streaming" | "complete"; + +const projectionCache = new WeakMap< + TimelineItemTransform, + WeakMap>> +>(); function cloneAndFreeze(value: T): T { if (Array.isArray(value)) { @@ -58,8 +63,10 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { } case "todo_list": return { type: "todo", items: item.items }; - case "activity_log": - return item.activityType === "error" ? { type: "error", message: item.message } : null; + case "notification": + return item.sourceType === "error" + ? { type: "error", message: item.message } + : { type: "notification", level: item.level, message: item.message }; case "compaction": return { type: "compaction", @@ -75,6 +82,7 @@ function sourceTimelineItem(item: StreamItem): AgentTimelineItem | null { function transformSourceItem( item: StreamItem, transformTimelineItem: TimelineItemTransform, + assistantPhase: AssistantPhase, ): StreamItem[] { const source = sourceTimelineItem(item); if (!source) return [item]; @@ -83,7 +91,9 @@ function transformSourceItem( item.kind === "tool_call" && item.payload.source === "agent" && item.payload.data.status === "running"; - const phase = isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; + const isStreamingAssistant = item.kind === "assistant_message" && assistantPhase === "streaming"; + const phase = + isStreamingAssistant || isStreamingThought || isStreamingToolCall ? "streaming" : "complete"; const transformed = transformTimelineItem({ item: cloneAndFreeze(source), phase, @@ -94,6 +104,7 @@ function transformSourceItem( const projected: PluginTimelineStreamItem = { kind: "plugin", id: `${pluginItem.pluginId}/${pluginItem.id}`, + sourceId: item.id, timestamp: item.timestamp, pluginId: pluginItem.pluginId, pluginItemId: pluginItem.id, @@ -110,6 +121,7 @@ function transformSourceItem( export function projectPluginTimelineItems( items: StreamItem[], transformTimelineItem: TimelineItemTransform | undefined, + assistantPhase: AssistantPhase = "complete", ): StreamItem[] { if (!transformTimelineItem) return items; let bySource = projectionCache.get(transformTimelineItem); @@ -119,13 +131,13 @@ export function projectPluginTimelineItems( } let changed = false; const projected = items.flatMap((item) => { - const cached = bySource.get(item); + const cached = bySource.get(item)?.[assistantPhase]; if (cached) { changed = changed || cached.length !== 1 || cached[0] !== item; return cached; } - const output = transformSourceItem(item, transformTimelineItem); - bySource.set(item, output); + const output = transformSourceItem(item, transformTimelineItem, assistantPhase); + bySource.set(item, { ...bySource.get(item), [assistantPhase]: output }); changed = changed || output.length !== 1 || output[0] !== item; return output; }); diff --git a/packages/app/src/plugins/timeline/view.test.tsx b/packages/app/src/plugins/timeline/view.test.tsx index 870a51742b..d915216105 100644 --- a/packages/app/src/plugins/timeline/view.test.tsx +++ b/packages/app/src/plugins/timeline/view.test.tsx @@ -1,17 +1,20 @@ /** * @vitest-environment jsdom */ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import React, { act } from "react"; +import appPackage from "../../../package.json"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import React, { act, StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { renderToStaticMarkup } from "react-dom/server"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const hostClient = vi.hoisted(() => ({ invokePluginRpc: async () => null })); +const selectedHost = vi.hoisted(() => ({ client: null as DaemonClient | null })); vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => ({ invokePluginRpc: async () => null }) as unknown as DaemonClient, + useHostRuntimeClient: () => selectedHost.client ?? (hostClient as unknown as DaemonClient), useHosts: () => [{ serverId: "host-1", label: "Local" }], })); -vi.mock("@/constants/layout", () => ({ +vi.mock("@/constants/layout", async (importOriginal) => ({ + ...(await importOriginal()), useIsCompactFormFactor: () => false, })); vi.mock("../navigation", () => ({ @@ -20,30 +23,24 @@ vi.mock("../navigation", () => ({ vi.mock("../client-runtime", () => ({ createPluginClientRuntime: () => ({ paseo: {}, + dispose: () => {}, rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, - addComposerPill: () => () => undefined, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), }), })); vi.mock("../icons", () => ({ Icon: () => null, resolvePluginIcon: () => () => null, })); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: (theme: object) => object) => - factory({ - colors: { foregroundMuted: "gray", statusDanger: "red" }, - spacing: [0, 4, 8, 12, 16], - }), - }, - withUnistyles: (Component: React.ComponentType) => Component, -})); import { pluginRegistry } from "../registry"; import { PluginTimelineItemView } from "./view"; +const audio = { play: async () => 0 }; + const bundle = `(function(require) { const React = require("react"); return { default: function(plugin) { @@ -115,29 +112,51 @@ afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); for (const container of containers.splice(0)) container.remove(); pluginRegistry.removeHost("host-1"); + selectedHost.client = null; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe("PluginTimelineItemView", () => { - it("validates and renders the matching plugin component", () => { - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], { - client: daemonClient, - }); - - const markup = renderToStaticMarkup( - , + it("validates and renders the matching plugin component", async () => { + pluginRegistry.installCatalog( + "host-1", + [{ id: "reports", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], + { + client: daemonClient, + audio, + }, ); - expect(markup).toContain("Four tests passed"); + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + await act(async () => + root.render( + , + ), + ); + expect(container.textContent).toContain("Four tests passed"); }); it("contains renderer crashes to one timeline item", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], { - client: daemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: failingBundle, + }, + ], + { + client: daemonClient, + audio, + }, + ); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); @@ -162,9 +181,20 @@ describe("PluginTimelineItemView", () => { it("recovers when a streaming item's data changes", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); - pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], { - client: daemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: recoveringBundle, + }, + ], + { + client: daemonClient, + audio, + }, + ); const container = document.createElement("div"); containers.push(container); document.body.appendChild(container); @@ -185,3 +215,142 @@ describe("PluginTimelineItemView", () => { expect(container.textContent).toBe("Recovered"); }); }); + +it("a renderer releases its observation on the plugin's client when it crashes, and remounts in StrictMode", async () => { + let receive: Parameters[0] = () => {}; + let opened: () => void = () => {}; + let nextId = 0; + const retained = new Set(); + const released: string[] = []; + const deliver = (message: unknown) => + receive(JSON.stringify({ type: "session", message }), false); + const client = new DaemonClient({ + url: "ws://surface-observation.test", + clientId: "surface-lifetime-test", + transportFactory: () => ({ + send(data) { + if (typeof data !== "string") throw new Error("Expected a JSON request"); + const frame = JSON.parse(data) as { + type: string; + message?: { type: string; requestId: string; subscriptionId: string }; + }; + if (frame.type === "hello") + deliver({ + type: "status", + payload: { + status: "server_info", + serverId: "host-1", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + const request = frame.message; + if (request?.type === "session.events.set_subscription.request") { + const id = `surface-${++nextId}`; + retained.add(id); + deliver({ + type: "session.events.set_subscription.response", + payload: { + requestId: request.requestId, + subscriptionId: id, + events: ["project.update"], + snapshots: [], + }, + }); + } else if (request?.type === "subscription.release.request") { + retained.delete(request.subscriptionId); + released.push(request.subscriptionId); + deliver({ type: "subscription.release.response", payload: request }); + } + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + opened = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }), + }); + const connecting = client.connect(); + opened(); + await connecting; + selectedHost.client = client; + const liveBundle = `(function(require) { + const React = require("react"); + const { usePaseo } = require("@getpaseo/plugin/client"); + return { default(plugin) { + function Card(props) { + const paseo = usePaseo(); + React.useEffect(() => { + const owner = paseo.observeEvents(["project.update"]); + return () => { void owner.release(); }; + }, [paseo]); + if (props.item.data.label === "explode") throw new Error("owned renderer failed"); + return React.createElement("span", null, props.item.data.label); + } + plugin.addTimelineRenderer({ kind:"test-report", version:1, schema:{safeParse(data){return {success:true,data};}}, Component:Card }); + return () => {}; + }}; + })`; + const survivor = client.observeEvents(["project.update"]); + const updates: unknown[] = []; + survivor.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + await survivor.ready; + pluginRegistry.installCatalog( + "host-1", + [ + { + id: "reports", + requirements: { paseo: `>=${appPackage.version}` }, + clientBundle: liveBundle, + }, + ], + { client, audio }, + ); + const container = document.createElement("div"); + containers.push(container); + const root = createRoot(container); + roots.push(root); + const render = (item: typeof timelineItem) => + act(async () => + root.render( + + + , + ), + ); + try { + await render(timelineItem); + expect(retained.size).toBe(2); + const initialIds = new Set(retained); + await render(failingTimelineItem); + expect(container.textContent).toContain("Plugin failed:"); + expect([...retained]).toEqual([survivor.subscriptionId]); + expect(released.some((id) => initialIds.has(id))).toBe(true); + deliver({ + type: "project.update", + payload: { subscriptionId: survivor.subscriptionId, kind: "remove", projectId: "still-live" }, + }); + expect(updates).toHaveLength(1); + await render(recoveredTimelineItem); + expect(container.textContent).toContain("Recovered"); + expect(retained.size).toBe(2); + expect([...retained].filter((id) => !initialIds.has(id))).toHaveLength(1); + await act(async () => root.unmount()); + roots.splice(roots.indexOf(root), 1); + expect([...retained]).toEqual([survivor.subscriptionId]); + } finally { + await survivor.release(); + await client.close(); + } +}); diff --git a/packages/app/src/plugins/timeline/view.tsx b/packages/app/src/plugins/timeline/view.tsx index 443172263b..90f6e0e1a2 100644 --- a/packages/app/src/plugins/timeline/view.tsx +++ b/packages/app/src/plugins/timeline/view.tsx @@ -1,7 +1,8 @@ -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; -import type { PluginHostProps, PluginTheme, PluginTimelineItemProps } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; +import type { PluginTimelineItemProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; import React, { type ComponentType, useMemo } from "react"; -import { Platform, Text } from "react-native"; +import { Text } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; @@ -9,19 +10,13 @@ import type { Theme } from "@/styles/theme"; import type { PluginTimelineStreamItem } from "@/types/stream"; import { createPluginClientStateSource } from "../client-state/source"; import { useInstalledPlugin } from "../registry"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { toPluginTheme } from "../theme"; +import { resolvePluginPlatform } from "../platform"; const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); -function resolvePlatform(): PluginHostProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - function TimelineItemUnavailable() { return Plugin timeline item unavailable.; } @@ -59,18 +54,14 @@ function PluginTimelineItemBody({ ); const parsed = parseRendererData(renderer, item.data); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, item.pluginId), - [client, item.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); - if (!plugin || !renderer || !parsed || !runtime) { + if (!plugin || !renderer || !parsed || !client) { return ; } @@ -90,11 +81,11 @@ function PluginTimelineItemBody({ }; return ( - + - + ); } diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 0006fe553a..bab295d623 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -1,18 +1,26 @@ +import type { PaseoApi } from "@getpaseo/client"; import type { QueryClient } from "@tanstack/react-query"; +import type { PluginRequirements } from "@getpaseo/protocol/messages"; import type { PluginAttachmentSourceContribution, + PluginCleanup, + PluginThemeContribution, +} from "@getpaseo/plugin"; +import type { PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, - PluginCleanup, PluginComposerPillContribution, PluginSidebarContribution, - PluginSurfaceContribution, - PluginThemeContribution, + PluginSidebarItemContribution, + PluginScreenContribution, + PluginSettingsScreenContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, PluginPanelLocation, PluginWorkspacePanelContribution, -} from "@getpaseo/plugin"; +} from "@getpaseo/plugin/client"; + +export type PluginSidebarSection = "header" | "footer"; export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelContribution & { locations: readonly PluginPanelLocation[]; @@ -21,8 +29,12 @@ export type EvaluatedPluginWorkspacePanelContribution = PluginWorkspacePanelCont export interface EvaluatedPlugin { id: string; cleanup: PluginCleanup; - surfaces: PluginSurfaceContribution[]; - sidebarItems: PluginSidebarContribution[]; + surfaces: PluginScreenContribution[]; + settingsScreens: PluginSettingsScreenContribution[]; + sidebarItems: Record; + // COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 + /** `addSidebarItem` registrations, so `/plugin//sidebar/` routes keep resolving. */ + legacySidebarItems: PluginSidebarContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; @@ -33,7 +45,13 @@ export interface EvaluatedPlugin { } export interface InstalledPlugin extends EvaluatedPlugin { + lifetime: AbortController; + /** The plugin's one Paseo client, `usePaseo()` in every surface; disposed at teardown. */ + paseo: PaseoApi; + /** Calls one of the plugin's server RPC methods on its host. */ + invoke(method: string, input: unknown): Promise; serverId: string; + requirements?: PluginRequirements; clientBundle: string; queryClient: QueryClient; } @@ -44,7 +62,9 @@ export type { PluginClientSlashCommandContribution, PluginComposerPillContribution, PluginSidebarContribution, - PluginSurfaceContribution, + PluginSidebarItemContribution, + PluginScreenContribution, + PluginSettingsScreenContribution, PluginThemeContribution, PluginTimelineRendererContribution, PluginTimelineTransformerContribution, diff --git a/packages/app/src/plugins/workspace-panels/locations.test.ts b/packages/app/src/plugins/workspace-panels/locations.test.ts index d0e69478da..af5cade055 100644 --- a/packages/app/src/plugins/workspace-panels/locations.test.ts +++ b/packages/app/src/plugins/workspace-panels/locations.test.ts @@ -1,8 +1,11 @@ +import appPackage from "../../../package.json"; import { describe, expect, it, vi } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { pluginRegistry } from "../registry"; import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations"; +const audio = { play: async () => 0 }; + vi.mock("../navigation", () => ({ createPluginNavigation: () => ({}), })); @@ -12,7 +15,8 @@ vi.mock("../client-runtime", () => ({ rpc: async () => undefined, openSurface: () => undefined, openPanel: () => undefined, - addComposerPill: () => () => undefined, + addComposerPill: () => ({ update() {}, remove() {} }), + addHeaderButton: () => ({ update() {}, remove() {} }), }), })); @@ -30,9 +34,14 @@ function install(locations: readonly ("workspace" | "explorer")[]) { return function() {}; }}; })`; - pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], { - client: {} as DaemonClient, - }); + pluginRegistry.installCatalog( + "host-1", + [{ id: "review", requirements: { paseo: `>=${appPackage.version}` }, clientBundle: bundle }], + { + client: {} as DaemonClient, + audio, + }, + ); return pluginRegistry.getSnapshot()[0]!; } diff --git a/packages/app/src/plugins/workspace-panels/locations.ts b/packages/app/src/plugins/workspace-panels/locations.ts index 7248ccbe61..1c8cfbfb90 100644 --- a/packages/app/src/plugins/workspace-panels/locations.ts +++ b/packages/app/src/plugins/workspace-panels/locations.ts @@ -1,4 +1,7 @@ -import type { PluginPanelLocation, PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { + PluginPanelLocation, + PluginWorkspacePanelContribution, +} from "@getpaseo/plugin/client"; import type { PaneHost } from "@/panels/panel-manifest"; import { panelSupportsHost } from "@/panels/panel-manifest"; import type { PluginWorkspaceTabTarget, WorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 2acca92bd2..998f7e90ff 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,13 +1,9 @@ -import type { - PluginAgentPanelProps, - PluginHostProps, - PluginTheme, - PluginWorkspacePanelProps, -} from "@getpaseo/plugin"; -import { PluginClientStateProvider } from "@getpaseo/plugin/host"; +import type { PluginAgentPanelProps, PluginWorkspacePanelProps } from "@getpaseo/plugin/client"; +import type { PluginTheme } from "@getpaseo/plugin"; +import { PluginClientStateProvider } from "@getpaseo/plugin/client/host"; import { CircleAlert } from "lucide-react-native"; import { useMemo } from "react"; -import { Platform, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -23,21 +19,15 @@ import { createPluginClientStateSource } from "../client-state/source"; import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; -import { PluginRuntimeBoundary } from "../runtime-boundary"; -import { createPluginSurfaceRuntime } from "../surface-runtime"; +import { PluginInstallationProvider } from "../installation-provider"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; +import { resolvePluginPlatform } from "../platform"; const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme), }); -function resolvePlatform(): PluginHostProps["layout"]["platform"] { - if (Platform.OS === "ios") return "ios"; - if (Platform.OS === "android") return "android"; - return "web"; -} - function PluginPanelBody({ theme }: { theme: PluginTheme }) { const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); @@ -54,22 +44,18 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { ); }); const client = useHostRuntimeClient(serverId); - const runtime = useMemo( - () => createPluginSurfaceRuntime(client, target.pluginId), - [client, target.pluginId], - ); const compact = useIsCompactFormFactor(); const hosts = useHosts(); const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const host = useMemo(() => ({ id: serverId, label: hostLabel }), [hostLabel, serverId]); - const layout = useMemo(() => ({ compact, platform: resolvePlatform() }), [compact]); + const layout = useMemo(() => ({ compact, platform: resolvePluginPlatform() }), [compact]); const stateSource = useMemo(() => createPluginClientStateSource(serverId), [serverId]); const navigation = usePluginHostNavigation(serverId); if (!plugin || !contribution || !workspaceExists) { return ; } - if (!runtime) { + if (!client) { return ; } @@ -110,9 +96,9 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { Surface={Surface} key={`${serverId}/${target.pluginId}/${target.panelId}/${target.context}`} > - + {panel} - + ); } diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 8ca204b8ba..a7e46de422 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -8,10 +8,15 @@ function installed(): InstalledPlugin { id: "review", serverId: "host-1", clientBundle: "bundle", + lifetime: new AbortController(), queryClient: new QueryClient(), + paseo: {} as InstalledPlugin["paseo"], + invoke: async () => undefined, cleanup: () => {}, + settingsScreens: [], surfaces: [], - sidebarItems: [], + sidebarItems: { header: [], footer: [] }, + legacySidebarItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/plugins/workspace-panels/resolution.ts b/packages/app/src/plugins/workspace-panels/resolution.ts index b2436c6c52..7a9355e72a 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.ts @@ -1,4 +1,4 @@ -import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin"; +import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin/client"; import type { InstalledPlugin } from "../types"; import type { PluginWorkspaceTabTarget } from "@/workspace-tabs/model"; diff --git a/packages/app/src/polyfills/install-early.ts b/packages/app/src/polyfills/install-early.ts new file mode 100644 index 0000000000..0ee6ca02fa --- /dev/null +++ b/packages/app/src/polyfills/install-early.ts @@ -0,0 +1,12 @@ +// Side-effect module imported first by index.ts. Babel hoists imports above +// statements, so polyfill calls written in index.ts would run only after every +// imported module had evaluated. Modules that read these globals at load time, +// such as @xterm/headless reading navigator.userAgent, need them installed here. +import { polyfillCrypto } from "./crypto"; +import { polyfillNavigator } from "./navigator"; +import { polyfillScreenOrientation } from "./screen-orientation"; + +polyfillCrypto(); +polyfillNavigator(); +// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API. +polyfillScreenOrientation(); diff --git a/packages/app/src/polyfills/navigator-startup.test.ts b/packages/app/src/polyfills/navigator-startup.test.ts new file mode 100644 index 0000000000..49a0fc69a2 --- /dev/null +++ b/packages/app/src/polyfills/navigator-startup.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createContext, runInContext } from "node:vm"; +import ts from "typescript"; +import { expect, it } from "vitest"; + +it("installs navigator before the app entry loads its other dependencies", () => { + const entry = ts.createSourceFile( + "index.ts", + readFileSync(new URL("../../index.ts", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + ); + const firstImport = entry.statements.find(ts.isImportDeclaration); + expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"'); + const early = ts.createSourceFile( + "install-early.ts", + readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"), + ts.ScriptTarget.Latest, + ); + expect( + early.statements.some( + (statement) => + ts.isExpressionStatement(statement) && + ts.isCallExpression(statement.expression) && + statement.expression.expression.getText(early) === "polyfillNavigator", + ), + ).toBe(true); +}); + +it("initializes the native navigator before evaluating the real terminal dependency", () => { + const require = createRequire(import.meta.url); + const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8"); + const navigatorSource = ts.transpileModule( + readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }, + ).outputText; + // Each VM owns its globals, so the native navigator shape never replaces + // globals in the test runner or needs a native-module mock. + const uninitialized = createContext({ + navigator: {}, + window: {}, + module: { exports: {} }, + exports: {}, + }); + expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/); + + const initialized = createContext({ + navigator: {}, + window: {}, + module: { exports: {} }, + exports: {}, + }); + runInContext(navigatorSource, initialized); + runInContext("exports.polyfillNavigator()", initialized); + expect(() => runInContext(terminalSource, initialized)).not.toThrow(); +}); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index ca8be17b85..ae00dd9528 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -5,7 +5,7 @@ import type { ProviderSnapshotEntry, } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import { filterSelectableModels } from "./model-catalog"; @@ -286,19 +286,22 @@ export function resolveEffectiveComposerThinkingOptionId( return selectedModelDefinition?.defaultThinkingOptionId ?? ""; } -export function buildDraftCommandConfig(input: { +export function buildDraftCommandTarget(input: { selection: ProviderSelectionState; cwd: string; effectiveModelId: string; effectiveThinkingOptionId: string; featureValues?: Record; -}): DraftCommandConfig | undefined { +}): DraftCommandTarget { const cwd = input.cwd.trim(); - if (!input.selection.provider || !cwd) { - return undefined; + if (!cwd) { + return { status: "needs-project" }; + } + if (!input.selection.provider) { + return { status: "needs-provider" }; } - return { + const config = { provider: input.selection.provider, cwd, ...(input.selection.modeOptions.length > 0 && input.selection.modeId !== "" @@ -310,6 +313,7 @@ export function buildDraftCommandConfig(input: { : {}), ...(input.featureValues ? { featureValues: input.featureValues } : {}), }; + return { status: "ready", config }; } export function resolveSubmissionReadiness(input: { diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 5b6cc2a509..3912d87e13 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest"; import { resolveAgentForm, resolveFormState, + resolveEffectiveModel, resolveThinkingOptionId, mergeSelectedComposerPreferences, - combineInitialValues, buildProviderDefinitionMap, buildProviderDefinitionMapForStatuses, resolveDefaultModel, @@ -90,12 +90,11 @@ function makeState( ): AgentFormReducerState { return { form: { - serverId: null, provider: null, modeId: "", model: "", thinkingOptionId: "", - workingDir: "", + ...overrides, }, userModified: { ...INITIAL_USER_MODIFIED, ...modified }, @@ -262,35 +261,6 @@ describe("resolveThinkingOptionId", () => { }); }); -describe("combineInitialValues", () => { - it("returns undefined when no initial values and no initial server id", () => { - expect(combineInitialValues(undefined, null)).toBeUndefined(); - }); - - it("does not inject a null serverId override when initialValues are present but serverId is absent", () => { - const combined = combineInitialValues({}, null); - expect(combined).toEqual({}); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("injects serverId from options when provided", () => { - expect(combineInitialValues({}, "daemon-1")).toEqual({ serverId: "daemon-1" }); - }); - - it("keeps other initial values without forcing serverId", () => { - const combined = combineInitialValues({ workingDir: "/repo" }, null); - expect(combined).toEqual({ workingDir: "/repo" }); - expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false); - }); - - it("respects an explicit serverId override (including null) over initialServerId", () => { - expect(combineInitialValues({ serverId: null }, "daemon-1")).toEqual({ serverId: null }); - expect(combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({ - serverId: "daemon-2", - }); - }); -}); - describe("mergeSelectedComposerPreferences", () => { it("stores the selected model for the selected provider", () => { expect( @@ -534,7 +504,7 @@ describe("resolveFormState", () => { expect(resolved.thinkingOptionId).toBe("low"); }); - it("clears an invalid provider instead of falling back to the first allowed provider", () => { + it("preserves the remembered provider when it is absent from the available catalogue", () => { const resolved = resolveFormState( undefined, { provider: "codex" }, @@ -545,7 +515,7 @@ describe("resolveFormState", () => { claudeProviderMap, ); - expect(resolved.provider).toBeNull(); + expect(resolved.provider).toBe("codex"); }); it("preserves a user-selected provider and model while that provider is loading during refresh", () => { @@ -582,12 +552,10 @@ describe("resolveFormState", () => { {}, null, { - serverId: false, provider: true, modeId: true, model: true, thinkingOptionId: true, - workingDir: false, }, makeState({ provider: "codex", @@ -677,7 +645,7 @@ describe("resolveFormState", () => { expect(resolved.modeId).toBe("auto"); }); - it("ignores disabled ready providers when resolving selectable defaults", () => { + it("preserves saved intent even when a provider is disabled", () => { const entries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -715,8 +683,8 @@ describe("resolveFormState", () => { selectableProviderMap, ); - expect(resolved.provider).toBe("codex"); - expect(resolved.modeId).toBe("auto"); + expect(resolved.provider).toBe("claude"); + expect(resolved.modeId).toBe(""); }); it("excludes disabled providers from the selectable provider map without removing them from snapshot definitions", () => { @@ -752,7 +720,7 @@ describe("resolveFormState", () => { expect(providerDefinitions.map((d) => d.id)).toEqual(["codex", "claude"]); }); - it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => { + it("preserves a user-selected provider when the refreshed snapshot marks it unavailable", () => { const unavailableEntries: ProviderSnapshotEntry[] = [ { provider: "codex", @@ -785,7 +753,13 @@ describe("resolveFormState", () => { undefined, {}, null, - { ...INITIAL_USER_MODIFIED, provider: true }, + { + ...INITIAL_USER_MODIFIED, + provider: true, + modeId: true, + model: true, + thinkingOptionId: true, + }, makeState({ provider: "codex", modeId: "full-access", @@ -796,10 +770,10 @@ describe("resolveFormState", () => { resolvableProviderMap, ); - expect(resolved.provider).toBeNull(); - expect(resolved.modeId).toBe(""); - expect(resolved.model).toBe(""); - expect(resolved.thinkingOptionId).toBe(""); + expect(resolved.provider).toBe("codex"); + expect(resolved.modeId).toBe("full-access"); + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("xhigh"); }); it("does not force fallback provider when allowed provider map is empty", () => { @@ -817,8 +791,63 @@ describe("resolveFormState", () => { }); }); +it("keeps the explicit model when a refreshed catalogue no longer lists it", () => { + const resolved = resolveFormState( + undefined, + { provider: "codex", providerPreferences: { codex: { model: "gpt-6-astra" } } }, + CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + expect(resolved.model).toBe("gpt-6-astra"); + // Label/persistence lookup must not reinterpret the submitted ID as another model. + expect(resolveEffectiveModel(CODEX_MODELS, resolved.model)).toBeNull(); +}); + describe("resolveAgentForm", () => { describe("resolution state", () => { + it.each(["error", "unavailable"] as const)( + "restores a remembered model after opening against a %s provider snapshot", + (status) => { + const preferences = { + provider: "codex", + providerPreferences: { codex: { model: "gpt-5.3-codex" } }, + }; + const snapshotEntries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + enabled: true, + status, + models: [], + }, + ]; + const opened = resolveAgentForm(makeState(), { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", []]]), + allowedProviderMap: buildProviderDefinitionMapForStatuses({ + snapshotEntries, + providerDefinitions: buildProviderDefinitions(snapshotEntries), + statuses: new Set(["ready", "loading"]), + }), + }); + const recovered = resolveAgentForm(opened, { + type: "COMPLETE_RESOLUTION", + initialValues: undefined, + preferences, + providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), + allowedProviderMap: codexProviderMap, + }); + + expect(recovered.form).toMatchObject({ + provider: "codex", + model: "gpt-5.3-codex", + }); + }, + ); + it("requests resolution without changing the current form values", () => { const state = makeState( { provider: "codex", modeId: "auto", model: "gpt-5.3-codex" }, @@ -833,7 +862,7 @@ describe("resolveAgentForm", () => { }); it("completes a pending open resolution when snapshot models arrive late", () => { - const state = resolveAgentForm(makeState({ serverId: "host-1" }), { + const state = resolveAgentForm(makeState(), { type: "REQUEST_RESOLUTION", }); const next = resolveAgentForm(state, { @@ -855,7 +884,7 @@ describe("resolveAgentForm", () => { }); it("does not change settled selection when a background snapshot has different defaults", () => { - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -885,7 +914,7 @@ describe("resolveAgentForm", () => { }); it("prefills edit hydration from initial values", () => { - const state = makeState({ serverId: "host-1" }); + const state = makeState(); const next = resolveAgentForm(state, { type: "COMPLETE_RESOLUTION", initialValues: { @@ -893,7 +922,6 @@ describe("resolveAgentForm", () => { modeId: "full-access", model: "gpt-5.3-codex", thinkingOptionId: "low", - workingDir: "/repo", }, preferences: { provider: "claude" }, providerModelsByProvider: makeProviderModelsByProvider([["codex", CODEX_MODELS]]), @@ -904,7 +932,6 @@ describe("resolveAgentForm", () => { expect(next.form.modeId).toBe("full-access"); expect(next.form.model).toBe("gpt-5.3-codex"); expect(next.form.thinkingOptionId).toBe("low"); - expect(next.form.workingDir).toBe("/repo"); }); it("keeps a user model change after resolution has completed", () => { @@ -912,7 +939,7 @@ describe("resolveAgentForm", () => { ...CODEX_MODELS, { provider: "codex", id: "gpt-5.4-codex", label: "gpt-5.4-codex" }, ]; - const settled = resolveAgentForm(makeState({ serverId: "host-1" }), { + const settled = resolveAgentForm(makeState(), { type: "COMPLETE_RESOLUTION", initialValues: undefined, preferences: { @@ -958,26 +985,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_SERVER_ID", () => { - it("updates serverId without marking it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID", value: "host-1" }); - - expect(next.form.serverId).toBe("host-1"); - expect(next.userModified.serverId).toBe(false); - }); - }); - - describe("SET_SERVER_ID_FROM_USER", () => { - it("updates serverId and marks it user-modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_SERVER_ID_FROM_USER", value: "host-2" }); - - expect(next.form.serverId).toBe("host-2"); - expect(next.userModified.serverId).toBe(true); - }); - }); - describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { it("sets provider, model, and default mode; marks both modified", () => { const state = makeState(); @@ -1167,51 +1174,6 @@ describe("resolveAgentForm", () => { }); }); - describe("SET_WORKING_DIR", () => { - it("updates workingDir without marking it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { type: "SET_WORKING_DIR", value: "/home/user/proj" }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(false); - }); - }); - - describe("SET_WORKING_DIR_FROM_USER", () => { - it("updates workingDir and marks it modified", () => { - const state = makeState(); - const next = resolveAgentForm(state, { - type: "SET_WORKING_DIR_FROM_USER", - value: "/home/user/proj", - }); - - expect(next.form.workingDir).toBe("/home/user/proj"); - expect(next.userModified.workingDir).toBe(true); - }); - }); - - describe("AUTO_SELECT_SERVER", () => { - it("sets serverId when currently null", () => { - const state = makeState({ serverId: null }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next.form.serverId).toBe("host-1"); - }); - - it("does not override an already-set serverId", () => { - const state = makeState({ serverId: "existing" }); - const next = resolveAgentForm(state, { - type: "AUTO_SELECT_SERVER", - candidateServerId: "host-1", - }); - - expect(next).toBe(state); - }); - }); - describe("RESET", () => { it("keeps form values but marks them unresolved for the next open", () => { const state = makeState( @@ -1276,3 +1238,34 @@ describe("resolveAgentForm", () => { }); }); }); + +it("owns input readiness, reopening and user edits in the reducer", () => { + const inputs = { + type: "INPUTS_CHANGED" as const, + serverId: "host", + isVisible: true, + isCreateFlow: true, + isPreferencesLoading: true, + hasSnapshot: false, + initialValues: undefined, + preferences: { provider: "codex", providerPreferences: { codex: { model: "astra" } } }, + allowedProviderMap: new Map(), + providerModelsByProvider: new Map(), + }; + let state = resolveAgentForm(makeState(), inputs); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); + state = resolveAgentForm(state, { + type: "SET_MODEL_FROM_USER", + modelId: "manual", + availableModels: null, + providerPrefs: undefined, + }); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form.model).toBe("manual"); + state = resolveAgentForm(state, { ...inputs, isVisible: false }); + expect(state.resolution.status).toBe("pending"); + state = resolveAgentForm(state, { ...inputs, isPreferencesLoading: false, hasSnapshot: true }); + expect(state.form).toMatchObject({ provider: "codex", model: "astra" }); +}); diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 9fa187200e..08e88fd7a3 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -12,30 +12,24 @@ import { import { findModelByReference } from "./model-catalog"; export interface FormInitialValues { - serverId?: string | null; provider?: AgentProvider; modeId?: string | null; model?: string | null; thinkingOptionId?: string | null; - workingDir?: string; } export interface FormState { - serverId: string | null; provider: AgentProvider | null; modeId: string; model: string; thinkingOptionId: string; - workingDir: string; } export interface UserModifiedFields { - serverId: boolean; provider: boolean; modeId: boolean; model: boolean; thinkingOptionId: boolean; - workingDir: boolean; } export type ProviderModelsByProvider = Map; @@ -46,15 +40,18 @@ export interface AgentFormReducerState { form: FormState; userModified: UserModifiedFields; resolution: AgentFormResolutionState; + inputs?: { + serverId: string | null; + initialValues: FormInitialValues | undefined; + active: boolean; + }; } export const INITIAL_USER_MODIFIED: UserModifiedFields = { - serverId: false, provider: false, modeId: false, model: false, thinkingOptionId: false, - workingDir: false, }; export const PENDING_AGENT_FORM_RESOLUTION: AgentFormResolutionState = { status: "pending" }; @@ -68,7 +65,21 @@ export const RESOLVABLE_PROVIDER_STATUSES = new Set(["ready"]); +interface AgentFormInputs { + type: "INPUTS_CHANGED"; + serverId: string | null; + isVisible: boolean; + isCreateFlow: boolean; + isPreferencesLoading: boolean; + hasSnapshot: boolean; + initialValues: FormInitialValues | undefined; + preferences: FormPreferences | null; + providerModelsByProvider: ProviderModelsByProvider; + allowedProviderMap: Map; +} + export type AgentFormAction = + | AgentFormInputs | { type: "REQUEST_RESOLUTION" } | { type: "COMPLETE_RESOLUTION"; @@ -77,8 +88,6 @@ export type AgentFormAction = providerModelsByProvider: ProviderModelsByProvider; allowedProviderMap: Map; } - | { type: "SET_SERVER_ID"; value: string | null } - | { type: "SET_SERVER_ID_FROM_USER"; value: string | null } | { type: "SET_PROVIDER_AND_MODEL_FROM_USER"; provider: AgentProvider; @@ -106,9 +115,6 @@ export type AgentFormAction = } | { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" } | { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string } - | { type: "SET_WORKING_DIR"; value: string } - | { type: "SET_WORKING_DIR_FROM_USER"; value: string } - | { type: "AUTO_SELECT_SERVER"; candidateServerId: string } | { type: "RESET" }; type CompleteResolutionAction = Extract; @@ -144,7 +150,7 @@ export function resolveEffectiveModel( ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) return null; if (!normalizeSelectedModelId(modelId)) return null; - return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels); + return findModelByReference(availableModels, modelId) ?? null; } function resolvePreferredThinkingOptionId(input: { @@ -216,36 +222,12 @@ export function mergeSelectedComposerPreferences(args: { }); } -export function combineInitialValues( - initialValues: FormInitialValues | undefined, - initialServerId: string | null, -): FormInitialValues | undefined { - const hasExplicitServerId = initialValues?.serverId !== undefined; - const serverIdFromOptions = initialServerId === null ? undefined : initialServerId; - - if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) { - return undefined; - } - - if (hasExplicitServerId) { - return { ...initialValues, serverId: initialValues?.serverId }; - } - - if (serverIdFromOptions !== undefined) { - return { ...initialValues, serverId: serverIdFromOptions }; - } - - return initialValues; -} - export function hasFormStateChanged(prev: FormState, next: FormState): boolean { return ( - prev.serverId !== next.serverId || prev.provider !== next.provider || prev.modeId !== next.modeId || prev.model !== next.model || - prev.thinkingOptionId !== next.thinkingOptionId || - prev.workingDir !== next.workingDir + prev.thinkingOptionId !== next.thinkingOptionId ); } @@ -282,29 +264,11 @@ function resolveProvider(input: { userModified: boolean; initialValues: FormInitialValues | undefined; preferences: FormPreferences | null; - allowedProviderMap: Map; }): AgentProvider | null { - const { currentProvider, userModified, initialValues, preferences, allowedProviderMap } = input; - if (userModified) { - if ( - currentProvider && - allowedProviderMap.size > 0 && - !allowedProviderMap.has(currentProvider) - ) { - return null; - } - return currentProvider; - } - if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) { - return initialValues.provider; - } - if (preferences?.provider && allowedProviderMap.has(preferences.provider)) { - return preferences.provider; - } - if (currentProvider && allowedProviderMap.size > 0 && !allowedProviderMap.has(currentProvider)) { - return null; - } - return currentProvider; + const { currentProvider, userModified, initialValues, preferences } = input; + // Discovery readiness does not change the user's saved or explicit choice. + if (userModified) return currentProvider; + return initialValues?.provider ?? preferences?.provider ?? currentProvider; } function resolveModeId(input: { @@ -340,16 +304,22 @@ function resolveModelField(input: { if (!provider) return ""; const initialModel = normalizeSelectedModelId(initialValues?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model); - const defaultModelId = resolveDefaultModelId(availableModels); + // COMPAT(default-model-id): added in v0.7.2, remove after 2026-12-06. + // Older drafts used "default" before providers exposed concrete model IDs. + if ((initialModel || preferredModel) === "default" && availableModels?.length) { + return ( + findModelByReference(availableModels, "default")?.id || resolveDefaultModelId(availableModels) + ); + } if (initialModel) { return !availableModels ? initialModel - : resolveCanonicalModelId(availableModels, initialModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, initialModel) || initialModel; } if (preferredModel) { return !availableModels ? preferredModel - : resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId; + : resolveCanonicalModelId(availableModels, preferredModel) || preferredModel; } return ""; } @@ -403,7 +373,6 @@ export function resolveFormState( userModified: userModified.provider, initialValues, preferences, - allowedProviderMap, }); const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined; @@ -447,14 +416,6 @@ export function resolveFormState( }); } - if (!userModified.serverId && initialValues?.serverId !== undefined) { - result.serverId = initialValues.serverId; - } - - if (!userModified.workingDir && initialValues?.workingDir !== undefined) { - result.workingDir = initialValues.workingDir; - } - return result; } @@ -615,11 +576,45 @@ function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) }; } +function sameInitialValues(left: FormInitialValues = {}, right: FormInitialValues = {}): boolean { + return ( + left.provider === right.provider && + left.model === right.model && + left.modeId === right.modeId && + left.thinkingOptionId === right.thinkingOptionId + ); +} + +function receiveInputs( + state: AgentFormReducerState, + action: AgentFormInputs, +): AgentFormReducerState { + const active = action.isVisible && action.isCreateFlow; + const previous = state.inputs; + const initial = action.initialValues; + const changed = + previous?.active !== active || + previous.serverId !== action.serverId || + !sameInitialValues(previous.initialValues, initial); + let next = state; + if (changed) { + next = { + ...resolveAgentForm(state, { type: action.isVisible ? "REQUEST_RESOLUTION" : "RESET" }), + inputs: { serverId: action.serverId, initialValues: initial, active }, + }; + } + if (!active || action.isPreferencesLoading || !action.serverId || !action.hasSnapshot) + return next; + return completeResolution(next, { ...action, type: "COMPLETE_RESOLUTION" }); +} + export function resolveAgentForm( state: AgentFormReducerState, action: AgentFormAction, ): AgentFormReducerState { switch (action.type) { + case "INPUTS_CHANGED": + return receiveInputs(state, action); case "REQUEST_RESOLUTION": return { ...state, @@ -630,16 +625,6 @@ export function resolveAgentForm( case "COMPLETE_RESOLUTION": return completeResolution(state, action); - case "SET_SERVER_ID": - return { ...state, form: { ...state.form, serverId: action.value } }; - - case "SET_SERVER_ID_FROM_USER": - return { - ...state, - form: { ...state.form, serverId: action.value }, - userModified: { ...state.userModified, serverId: true }, - }; - case "SET_PROVIDER_AND_MODEL_FROM_USER": { const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); @@ -730,20 +715,6 @@ export function resolveAgentForm( userModified: { ...state.userModified, thinkingOptionId: true }, }; - case "SET_WORKING_DIR": - return { ...state, form: { ...state.form, workingDir: action.value } }; - - case "SET_WORKING_DIR_FROM_USER": - return { - ...state, - form: { ...state.form, workingDir: action.value }, - userModified: { ...state.userModified, workingDir: true }, - }; - - case "AUTO_SELECT_SERVER": - if (state.form.serverId) return state; - return { ...state, form: { ...state.form, serverId: action.candidateServerId } }; - case "RESET": return { ...state, diff --git a/packages/app/src/provider-usage/card.tsx b/packages/app/src/provider-usage/card.tsx deleted file mode 100644 index 10ef161fcc..0000000000 --- a/packages/app/src/provider-usage/card.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { useMemo } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; -import { StatusBadge } from "@/components/ui/status-badge"; -import type { Theme } from "@/styles/theme"; -import { ProviderUsageBalanceBar } from "./balance-bar"; -import { formatAgo } from "./format"; -import type { ProviderUsage } from "./types"; -import { ProviderUsageWindowBar } from "./window-bar"; - -interface ProviderUsageIconProps { - iconKey: string; - size: number; - color?: string; -} - -function ProviderUsageIcon({ iconKey, size, color = "" }: ProviderUsageIconProps) { - const Icon = getProviderIcon(iconKey); - return ; -} - -const ThemedProviderUsageIcon = withUnistyles(ProviderUsageIcon); - -const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -function statusText(usage: ProviderUsage): string | null { - if (usage.status === "available") return null; - return usage.status === "error" ? "Error" : "Unavailable"; -} - -function footerText(usage: ProviderUsage): string | null { - const updated = formatAgo(usage.fetchedAt); - const parts = [usage.sourceLabel, updated ? `Updated ${updated}` : null].filter( - (part): part is string => typeof part === "string" && part.length > 0, - ); - return parts.length > 0 ? parts.join(" · ") : null; -} - -export function ProviderUsageCard({ - usage, - compact = false, -}: { - usage: ProviderUsage; - compact?: boolean; -}) { - const status = statusText(usage); - const footer = footerText(usage); - const balances = usage.balances ?? []; - const details = usage.details ?? []; - - const containerStyle = useMemo( - () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], - [compact], - ); - const dotStyle = useMemo( - () => [ - styles.statusDot, - usage.status === "available" && styles.statusDotAvailable, - usage.status === "error" && styles.statusDotError, - ], - [usage.status], - ); - - return ( - - - - - {usage.displayName} - - {usage.planLabel ? : null} - - {status ? ( - - - {status} - - ) : null} - - - {usage.error ? ( - - {usage.error} - - ) : null} - - {usage.windows.length > 0 || balances.length > 0 ? ( - - {usage.windows.map((window) => ( - - ))} - {balances.map((balance) => ( - - ))} - - ) : null} - - {details.length > 0 ? ( - - {details.map((detail) => ( - - - {detail.label} - - - {detail.value} - - - ))} - - ) : null} - - {footer ? ( - - {footer} - - ) : null} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - gap: theme.spacing[3], - }, - containerPadded: { - gap: theme.spacing[4], - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - containerCompact: { - gap: theme.spacing[3], - }, - header: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - name: { - flexShrink: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - headerSpacer: { - flex: 1, - }, - statusRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - }, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: theme.colors.foregroundMuted, - }, - statusDotAvailable: { - backgroundColor: theme.colors.statusSuccess, - }, - statusDotError: { - backgroundColor: theme.colors.statusDanger, - }, - statusLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - bars: { - gap: theme.spacing[3], - }, - details: { - gap: theme.spacing[1], - }, - detailRow: { - flexDirection: "row", - justifyContent: "space-between", - gap: theme.spacing[2], - }, - detailLabel: { - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - detailValue: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, - footer: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, -})); diff --git a/packages/app/src/provider-usage/copy.ts b/packages/app/src/provider-usage/copy.ts deleted file mode 100644 index 1dcc817ef5..0000000000 --- a/packages/app/src/provider-usage/copy.ts +++ /dev/null @@ -1,18 +0,0 @@ -// User-facing copy for the provider-usage surfaces, centralized so localization -// is a single-file change. INTEGRATION: move these into the i18n resources -// (a `providerUsage` namespace across all locales) and swap to `t(...)` at the -// call sites once the feature is wired to data. Kept inline for now because the -// surfaces are not yet mounted and the locale files are being edited elsewhere. -export const providerUsageCopy = { - title: "Plan usage", - refresh: "Refresh", - refreshing: "Refreshing...", - loading: "Loading usage...", - empty: "No usage data", - errorTitle: "Unable to load usage", - hostUnavailable: "Connect to this host to see provider usage", - hostUpgradeRequired: "Update the host to see provider usage", - clientUnavailable: "Host connection is not ready", - retry: "Try again", - tooltipLoading: "Loading plan usage…", -} as const; diff --git a/packages/app/src/provider-usage/list.tsx b/packages/app/src/provider-usage/list.tsx deleted file mode 100644 index c9ff6742b2..0000000000 --- a/packages/app/src/provider-usage/list.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Fragment } from "react"; -import { View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { settingsStyles } from "@/styles/settings"; -import { ProviderUsageCard } from "./card"; -import type { ProviderUsage } from "./types"; - -export function ProviderUsageList({ providers }: { providers: ProviderUsage[] }) { - return ( - - {providers.map((usage, index) => ( - - {index > 0 ? : null} - - - ))} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - backgroundColor: theme.colors.border, - }, -})); diff --git a/packages/app/src/provider-usage/settings-section.tsx b/packages/app/src/provider-usage/settings-section.tsx deleted file mode 100644 index cb8e8f6b55..0000000000 --- a/packages/app/src/provider-usage/settings-section.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { RefreshCw } from "lucide-react-native"; -import { useMemo } from "react"; -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { Alert } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; -import { providerUsageCopy } from "./copy"; -import { ProviderUsageList } from "./list"; -import type { ProviderUsageView } from "./types"; - -export function ProviderUsageSettingsSection({ - view, - onRefresh, -}: { - view: ProviderUsageView; - onRefresh: () => void; -}) { - const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); - - const refreshButton = useMemo( - () => ( - - ), - [busy, onRefresh], - ); - - return ( - - - - ); -} - -function ProviderUsageBody({ - view, - onRefresh, -}: { - view: ProviderUsageView; - onRefresh: () => void; -}) { - if (view.kind === "loading") { - return ( - - {providerUsageCopy.loading} - - ); - } - - if (view.kind === "error") { - return ( - - - - ); - } - - if (view.payload.providers.length === 0) { - return ( - - {providerUsageCopy.empty} - - ); - } - - return ; -} - -const styles = StyleSheet.create((theme) => ({ - emptyCard: { - padding: theme.spacing[4], - alignItems: "center", - }, - emptyText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/provider-usage/tone.ts b/packages/app/src/provider-usage/tone.ts deleted file mode 100644 index 5cf6f68bab..0000000000 --- a/packages/app/src/provider-usage/tone.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ProviderUsageTone } from "./types"; - -export function deriveTone(usedPct: number | null | undefined): ProviderUsageTone { - if (usedPct == null) return "default"; - if (usedPct > 90) return "danger"; - if (usedPct >= 70) return "warning"; - return "default"; -} diff --git a/packages/app/src/provider-usage/tooltip-section.tsx b/packages/app/src/provider-usage/tooltip-section.tsx deleted file mode 100644 index 6a5a896301..0000000000 --- a/packages/app/src/provider-usage/tooltip-section.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Text, View } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { ProviderUsageCard } from "./card"; -import { providerUsageCopy } from "./copy"; -import type { ProviderUsage, ProviderUsageView } from "./types"; - -function matchProvider( - providers: ProviderUsage[], - activeProviderId: string | null | undefined, -): ProviderUsage | null { - if (!activeProviderId) return null; - const target = activeProviderId.toLowerCase(); - return providers.find((usage) => usage.providerId.toLowerCase() === target) ?? null; -} - -// Renders the active agent's provider usage inside the context-meter tooltip. -// Returns nothing when the active provider has no usage entry, so the meter's -// own context section stays the whole tooltip. -export function ProviderUsageTooltipSection({ - view, - activeProviderId, -}: { - view: ProviderUsageView; - activeProviderId: string | null | undefined; -}) { - if (view.kind === "loading") { - return ( - <> - - {providerUsageCopy.tooltipLoading} - - ); - } - - if (view.kind === "error") { - return ( - <> - - {view.message} - - ); - } - - const usage = matchProvider(view.payload.providers, activeProviderId); - if (!usage) return null; - - return ( - <> - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - divider: { - height: 1, - // Same token the popover draws its own outline with, so the rule reads as the - // popover's edge. `border` is invisible here (equals the popover background). - backgroundColor: theme.colors.borderAccent, - marginVertical: theme.spacing[2], - // Cancel the tooltip content's horizontal padding so the rule spans edge to edge. - marginHorizontal: -theme.spacing[2], - }, - detail: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, - error: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - lineHeight: theme.fontSize.sm * 1.4, - }, -})); diff --git a/packages/app/src/provider-usage/types.ts b/packages/app/src/provider-usage/types.ts deleted file mode 100644 index 52ff7c31f0..0000000000 --- a/packages/app/src/provider-usage/types.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageDetail, - ProviderUsageListResponseMessage, - ProviderUsageStatus, - ProviderUsageTone, - ProviderUsageWindow, -} from "@getpaseo/protocol/messages"; - -export type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageDetail, - ProviderUsageStatus, - ProviderUsageTone, - ProviderUsageWindow, -}; - -export type ProviderUsageBalanceUnit = ProviderUsageBalance["unit"]; -export type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; - -export type ProviderUsageView = - | { kind: "loading" } - | { kind: "error"; message: string } - | { kind: "ready"; payload: ProviderUsageListPayload; isRefreshing: boolean }; diff --git a/packages/app/src/provider-usage/use-provider-usage.ts b/packages/app/src/provider-usage/use-provider-usage.ts deleted file mode 100644 index ce133726c3..0000000000 --- a/packages/app/src/provider-usage/use-provider-usage.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { providerUsageCopy } from "./copy"; -import type { ProviderUsageListPayload, ProviderUsageView } from "./types"; - -export const PROVIDER_USAGE_STALE_TIME_MS = 5 * 60 * 1000; - -type ProviderUsageClient = Pick; - -export function providerUsageQueryKey(serverId: string | null | undefined) { - return ["providerUsage", serverId ?? ""] as const; -} - -async function fetchProviderUsage(client: ProviderUsageClient): Promise { - return client.listProviderUsage(); -} - -interface UseProviderUsageOptions { - enabled?: boolean; -} - -export function useProviderUsage( - serverId: string | null | undefined, - options: UseProviderUsageOptions = {}, -): { - view: ProviderUsageView; - refresh: () => Promise; - canFetch: boolean; -} { - const queryClient = useQueryClient(); - const client = useHostRuntimeClient(serverId ?? ""); - const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const supportsProviderUsage = useSessionStore( - (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true, - ); - const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]); - const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage); - const enabled = Boolean((options.enabled ?? true) && canFetch); - - const queryFn = useCallback(async () => { - if (!client) { - throw new Error(providerUsageCopy.clientUnavailable); - } - return fetchProviderUsage(client); - }, [client]); - - const query = useQuery({ - queryKey, - queryFn, - enabled, - staleTime: PROVIDER_USAGE_STALE_TIME_MS, - refetchOnMount: true, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }); - - const refresh = useCallback(async () => { - if (!canFetch) return; - await queryClient.invalidateQueries({ queryKey }); - await queryClient.fetchQuery({ - queryKey, - queryFn, - staleTime: PROVIDER_USAGE_STALE_TIME_MS, - }); - }, [canFetch, queryClient, queryFn, queryKey]); - - const view = useMemo(() => { - if (!serverId || !client || !isConnected) { - return { kind: "error", message: providerUsageCopy.hostUnavailable }; - } - if (!supportsProviderUsage) { - return { kind: "error", message: providerUsageCopy.hostUpgradeRequired }; - } - if (query.data) { - return { - kind: "ready", - payload: query.data, - isRefreshing: query.isFetching, - }; - } - if (query.isError) { - return { - kind: "error", - message: query.error instanceof Error ? query.error.message : String(query.error), - }; - } - return { kind: "loading" }; - }, [ - client, - isConnected, - query.data, - query.error, - query.isError, - query.isFetching, - serverId, - supportsProviderUsage, - ]); - - return { view, refresh, canFetch }; -} diff --git a/packages/app/src/provider-usage/window-bar.tsx b/packages/app/src/provider-usage/window-bar.tsx deleted file mode 100644 index 0bd198521b..0000000000 --- a/packages/app/src/provider-usage/window-bar.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useMemo } from "react"; -import { Text, View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet } from "react-native-unistyles"; -import { clampPct, formatPct, formatResetLabel } from "./format"; -import { deriveTone } from "./tone"; -import type { ProviderUsageTone, ProviderUsageWindow } from "./types"; - -function resolveUsedPct(window: ProviderUsageWindow): number | null { - if (window.usedPct != null) return window.usedPct; - if (window.remainingPct != null) return 100 - window.remainingPct; - return null; -} - -function fillToneStyle(tone: ProviderUsageTone) { - switch (tone) { - case "ok": - return styles.fillOk; - case "warning": - return styles.fillWarning; - case "danger": - return styles.fillDanger; - default: - return styles.fillDefault; - } -} - -export function ProviderUsageWindowBar({ window }: { window: ProviderUsageWindow }) { - const usedPct = resolveUsedPct(window); - const tone = window.tone ?? deriveTone(usedPct); - - const fillWidth = clampPct(usedPct ?? 0); - const fillStyle = useMemo>( - () => [styles.fill, fillToneStyle(tone), { width: `${fillWidth}%` }], - [fillWidth, tone], - ); - - const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; - const trailing = isAtRisk - ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt); - - return ( - - - - {window.label} - - - {usedPct != null ? formatPct(usedPct) : "—"} - {trailing ? ( - {` · ${trailing}`} - ) : null} - - - - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - gap: 3, - }, - labelRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - gap: theme.spacing[2], - }, - label: { - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - value: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - reset: { - color: theme.colors.foregroundMuted, - fontWeight: theme.fontWeight.normal, - }, - atRisk: { - color: theme.colors.statusDanger, - fontWeight: theme.fontWeight.normal, - }, - track: { - height: 4, - borderRadius: 2, - backgroundColor: theme.colors.surface3, - overflow: "hidden", - }, - fill: { - height: 4, - borderRadius: 2, - }, - fillDefault: { - backgroundColor: theme.colors.foregroundMuted, - }, - fillOk: { - backgroundColor: theme.colors.statusSuccess, - }, - fillWarning: { - backgroundColor: theme.colors.statusWarning, - }, - fillDanger: { - backgroundColor: theme.colors.statusDanger, - }, -})); diff --git a/packages/app/src/review/comment-sheet.tsx b/packages/app/src/review/comment-sheet.tsx new file mode 100644 index 0000000000..d54ec0ec96 --- /dev/null +++ b/packages/app/src/review/comment-sheet.tsx @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { FormTextInput } from "@/components/ui/form-field"; +import { Button } from "@/components/ui/button"; +import type { InlineReviewActions, InlineReviewEditorState } from "./geometry"; + +export function ReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, + enabled, +}: { + enabled: boolean | undefined; + editor: InlineReviewEditorState | null; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + if (enabled === false || !editor) return null; + return ( + + ); +} + +function OpenReviewCommentSheet({ + editor, + reviewActions, + onChangeBody, +}: { + editor: InlineReviewEditorState; + reviewActions: InlineReviewActions; + onChangeBody: (body: string) => void; +}) { + const { t } = useTranslation(); + const context = `${editor.target.filePath} · ${editor.target.side === "old" ? "−" : "+"}${editor.target.lineNumber}`; + const header = useMemo( + () => ({ + title: t("review.comment.label"), + subtitle: {context}, + }), + [context, t], + ); + const { onSaveEditor, onCancelEditor } = reviewActions; + const handleSave = useCallback(() => onSaveEditor(editor.body), [onSaveEditor, editor.body]); + const canSave = editor.body.trim().length > 0; + const footer = useMemo( + () => ( + + + + + ), + [canSave, handleSave, onCancelEditor, t], + ); + return ( + + + + ); +} + +const COMMENT_SNAP_POINTS = ["50%", "90%"]; + +const styles = StyleSheet.create((theme) => ({ + context: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + input: { flex: 1, minHeight: 0, textAlignVertical: "top" }, + actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] }, +})); diff --git a/packages/app/src/review/editor/index.test.ts b/packages/app/src/review/editor/index.test.ts new file mode 100644 index 0000000000..6735adef34 --- /dev/null +++ b/packages/app/src/review/editor/index.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createReviewEditor, createReviewEditorScope, type ReviewCommentWriter } from "./index"; +import type { ReviewDraftComment } from "../state"; +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; + +const target: ReviewableDiffTarget = { + key: "src/example.ts:new:2", + filePath: "src/example.ts", + side: "new", + lineNumber: 2, + hunkHeader: "@@ -1,2 +1,2 @@", + hunkIndex: 0, + lineIndex: 2, + oldLineNumber: null, + newLineNumber: 2, + lineType: "add", + content: "const value = next;", +}; + +function memoryWriter() { + const drafts = new Map(); + let sequence = 0; + const writer: ReviewCommentWriter = { + saveComment: ({ key, id, comment }) => { + const current = drafts.get(key) ?? []; + if (id && current.some((saved) => saved.id === id)) { + drafts.set( + key, + current.map((saved) => (saved.id === id ? { ...saved, ...comment } : saved)), + ); + } else { + const saved = { + ...comment, + id: String(++sequence), + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; + drafts.set(key, [...current, saved]); + } + }, + deleteComment: ({ key, id }) => { + drafts.set( + key, + (drafts.get(key) ?? []).filter((comment) => comment.id !== id), + ); + }, + }; + return { writer, comments: (key: string) => drafts.get(key) ?? [] }; +} + +describe("review editor", () => { + it("saves trimmed comments, edits the same record, and cancels without altering saved comments", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.setBody(" First comment "); + editor.save(editor.getState()!.body); + expect(editor.getState()).toBeNull(); + const saved = storage.comments("first")[0]!; + expect(saved).toMatchObject({ + filePath: target.filePath, + side: "new", + lineNumber: 2, + body: "First comment", + }); + editor.edit(target, saved); + editor.setBody("Discarded edit"); + editor.cancel(); + expect(storage.comments("first")).toEqual([saved]); + editor.edit(target, saved); + editor.save(" Updated comment "); + expect(storage.comments("first")).toEqual([{ ...saved, body: "Updated comment" }]); + editor.start(target); + editor.setBody("Discarded new comment"); + editor.cancel(); + expect(storage.comments("first")).toHaveLength(1); + }); + + it("keeps an open edit saveable after its submitted record is acknowledged", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save("Submitted body"); + const saved = storage.comments("first")[0]!; + editor.edit(target, saved); + editor.setBody("Typed before acknowledgement"); + storage.writer.deleteComment({ key: "first", id: saved.id }); + expect(editor.getState()!.body).toBe("Typed before acknowledgement"); + editor.save(editor.getState()!.body); + expect(storage.comments("first")).toMatchObject([ + { + body: "Typed before acknowledgement", + filePath: target.filePath, + lineNumber: target.lineNumber, + }, + ]); + }); + + it("rejects empty saves and repeated saves after the editor closes", () => { + const storage = memoryWriter(); + const editor = createReviewEditor("first", storage.writer); + editor.start(target); + editor.save(" \n "); + expect(editor.getState()).toEqual({ target, commentId: null, body: "" }); + expect(storage.comments("first")).toEqual([]); + editor.save("Saved once"); + editor.save("Duplicate"); + expect(storage.comments("first").map((comment) => comment.body)).toEqual(["Saved once"]); + }); + + it("isolates draft ownership and closes an editor when its saved comment is deleted", () => { + const storage = memoryWriter(); + const first = createReviewEditor("first", storage.writer); + const second = createReviewEditor("second", storage.writer); + first.start(target); + first.save("First workspace"); + const saved = storage.comments("first")[0]!; + first.edit(target, saved); + expect(second.getState()).toBeNull(); + second.start(target); + second.save("Second workspace"); + first.delete(saved.id); + expect(first.getState()).toBeNull(); + expect(storage.comments("first")).toEqual([]); + expect(storage.comments("second").map((comment) => comment.body)).toEqual(["Second workspace"]); + }); +}); + +it("retains an open editor through remounts without exposing it on another surface or draft", async () => { + const storage = memoryWriter(); + const scope = createReviewEditorScope(storage.writer); + const editor = scope.forDraft("first", "diff"); + const unsubscribe = editor.subscribe(() => {}); + editor.start(target); + editor.setBody("Unsaved wide body"); + unsubscribe(); + await Promise.resolve(); + const compact = scope.forDraft("first", "diff"); + expect(compact.getState()!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "combined").getState()).toBeNull(); + expect(scope.forDraft("second", "diff").getState()).toBeNull(); + compact.save(compact.getState()!.body); + expect(storage.comments("first")[0]!.body).toBe("Unsaved wide body"); + expect(scope.forDraft("first", "diff").getState()).toBeNull(); +}); diff --git a/packages/app/src/review/editor/index.ts b/packages/app/src/review/editor/index.ts new file mode 100644 index 0000000000..6a7741eceb --- /dev/null +++ b/packages/app/src/review/editor/index.ts @@ -0,0 +1,84 @@ +import type { ReviewableDiffTarget } from "@/utils/diff-layout"; +import type { InlineReviewEditorState } from "../geometry"; +import type { ReviewDraftComment } from "../state"; + +export interface ReviewCommentWriter { + saveComment: (input: { + key: string; + id: string | null; + comment: Pick; + }) => void; + deleteComment: (input: { key: string; id: string }) => void; +} + +/** One editor belongs to one review draft; replacing the draft creates a fresh editor. */ +export function createReviewEditor(key: string, writer: ReviewCommentWriter) { + return buildReviewEditor(key, writer, () => {}); +} + +function buildReviewEditor(key: string, writer: ReviewCommentWriter, onUnused: () => void) { + let editor: InlineReviewEditorState | null = null; + const listeners = new Set<() => void>(); + function publish(next: InlineReviewEditorState | null) { + editor = next; + listeners.forEach((listener) => listener()); + if (!editor && listeners.size === 0) onUnused(); + } + return { + getState: () => editor, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + queueMicrotask(() => { + if (!editor && listeners.size === 0) onUnused(); + }); + }; + }, + start: (target: ReviewableDiffTarget) => publish({ target, commentId: null, body: "" }), + edit: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => + publish({ target, commentId: comment.id, body: comment.body }), + setBody: (body: string) => { + if (editor) publish({ ...editor, body }); + }, + cancel: () => publish(null), + save: (body: string) => { + const trimmedBody = body.trim(); + if (!editor || !trimmedBody) return; + writer.saveComment({ + key, + id: editor.commentId, + comment: { + filePath: editor.target.filePath, + side: editor.target.side, + lineNumber: editor.target.lineNumber, + body: trimmedBody, + }, + }); + publish(null); + }, + delete: (id: string) => { + writer.deleteComment({ key, id }); + if (editor?.commentId === id) publish(null); + }, + }; +} + +/** An open draft survives presentation remounts; each presentation owns its editor for that draft. */ +export function createReviewEditorScope(writer: ReviewCommentWriter) { + const editors = new Map>(); + return { + forDraft(key: string, presentation: string) { + const owner = JSON.stringify([key, presentation]); + let model = editors.get(owner); + if (!model) { + const created = buildReviewEditor(key, writer, () => { + if (editors.get(owner) === created) editors.delete(owner); + }); + model = created; + editors.set(owner, model); + } + return model; + }, + }; +} diff --git a/packages/app/src/review/feedback/index.test.ts b/packages/app/src/review/feedback/index.test.ts new file mode 100644 index 0000000000..b423855b75 --- /dev/null +++ b/packages/app/src/review/feedback/index.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; +import { + createReviewFeedback, + createReviewFeedbackScope, + type ReviewFeedbackPorts, + type ReviewFeedbackSnapshot, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; +import type { ReviewDraftComment } from "../state"; + +function makeComment(id: string, body = id): ReviewDraftComment { + return { + id, + body, + filePath: "sample.ts", + side: "new", + lineNumber: 2, + createdAt: "2026-10-09T00:00:00Z", + updatedAt: "2026-10-09T00:00:00Z", + }; +} +function makeRecipient(agentId: string): FeedbackRecipient { + return { + agentId, + tab: { + key: `agent_${agentId}`, + tabId: `agent_${agentId}`, + kind: "agent", + target: { kind: "agent", agentId }, + }, + }; +} +function makeAttachment(comments: readonly ReviewDraftComment[]): FeedbackAttachment | null { + if (!comments.length) return null; + const line = { + type: "add" as const, + content: "const value = 1", + oldLineNumber: null, + newLineNumber: 2, + }; + return { + kind: "review", + reviewDraftKey: "draft", + commentCount: comments.length, + attachment: { + type: "review", + mimeType: "application/paseo-review", + cwd: "/workspace", + mode: "uncommitted", + baseRef: null, + comments: comments.map((comment) => ({ + filePath: comment.filePath, + side: comment.side, + lineNumber: comment.lineNumber, + body: comment.body, + context: { hunkHeader: "@@ -1 +1 @@", targetLine: line, lines: [line] }, + })), + }, + }; +} +function setup(agentIds: string[], unavailableIds: string[] = [], create = createReviewFeedback) { + let comments = [makeComment("first")]; + let recipients = agentIds.map(makeRecipient); + let connected = true; + let rejectSend: ((error: Error) => void) | null = null; + let resolveSend: (() => void) | null = null; + const sends: Array<{ recipient: FeedbackRecipient; attachment: FeedbackAttachment }> = []; + const read = (): ReviewFeedbackSnapshot => ({ + comments, + recipients, + connected, + attachment: makeAttachment(comments.filter((comment) => !unavailableIds.includes(comment.id))), + }); + const ports: ReviewFeedbackPorts = { + read, + send: (recipient, attachment) => { + sends.push({ recipient, attachment }); + return new Promise((resolve, reject) => { + resolveSend = resolve; + rejectSend = reject; + }); + }, + clearSent: (sent) => { + comments = comments.filter( + (current) => + !sent.some( + (prior) => + prior.id === current.id && + prior.updatedAt === current.updatedAt && + prior.body === current.body, + ), + ); + }, + failedMessage: () => "Failed to send", + }; + const model = create(ports); + return { + model, + ports, + sends, + comments: () => comments, + setComments: (next: ReviewDraftComment[]) => { + comments = next; + model.refresh(); + }, + setRecipients: (ids: string[]) => { + recipients = ids.map(makeRecipient); + model.refresh(); + }, + disconnect: () => { + connected = false; + model.refresh(); + }, + succeed: () => resolveSend!(), + fail: () => rejectSend!(new Error("Host rejected feedback")), + }; +} + +describe("review feedback", () => { + it("counts only attached comments, freezes the sending count, and retains orphans", async () => { + const feedback = setup(["one"], ["orphan"]); + feedback.setComments([makeComment("first"), makeComment("orphan")]); + expect(feedback.model.getState()).toMatchObject({ commentCount: 2, sendableCommentCount: 1 }); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("orphan"), makeComment("added")]); + expect(feedback.model.getState()).toMatchObject({ + phase: { kind: "sending", commentCount: 1 }, + sendableCommentCount: 2, + }); + expect(feedback.sends[0]!.attachment.commentCount).toBe(1); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("orphan"), makeComment("added")]); + }); + it("retains an entirely orphaned draft without claiming any comments are sendable", async () => { + const feedback = setup(["one"], ["first"]); + expect(feedback.model.getState()).toMatchObject({ + commentCount: 1, + sendableCommentCount: 0, + disabledReason: "no-context", + canSend: false, + }); + await feedback.model.press(); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("retains comments and explains why no workspace agent can receive them", async () => { + const feedback = setup([]); + await feedback.model.press(); + expect(feedback.model.getState()).toMatchObject({ + canSend: false, + disabledReason: "no-agents", + commentCount: 1, + }); + expect(feedback.sends).toEqual([]); + expect(feedback.comments()).toEqual([makeComment("first")]); + }); + it("sends once to the only recipient and clears only sent versions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + await feedback.model.press(); + await feedback.model.send("one"); + expect(feedback.sends).toEqual([ + { recipient: makeRecipient("one"), attachment: makeAttachment([makeComment("first")]) }, + ]); + feedback.setComments([makeComment("first", "Edited during send"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([ + makeComment("first", "Edited during send"), + makeComment("added"), + ]); + expect(feedback.model.getState().phase).toEqual({ + kind: "sent", + recipient: makeRecipient("one"), + }); + }); + it("clears unchanged sent comments while preserving additions", async () => { + const feedback = setup(["one"]); + const pending = feedback.model.press(); + feedback.setComments([makeComment("first"), makeComment("added")]); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([makeComment("added")]); + }); + it("asks for a recipient when several tabs exist and validates the selection again", async () => { + const feedback = setup(["one", "two"]); + await feedback.model.press(); + expect(feedback.model.getState().phase).toEqual({ kind: "choosing" }); + expect(feedback.sends).toEqual([]); + feedback.setRecipients(["one"]); + await feedback.model.send("two"); + expect(feedback.sends).toEqual([]); + const pending = feedback.model.send("one"); + feedback.succeed(); + await pending; + expect(feedback.comments()).toEqual([]); + }); + it("retains comments on failure and retries using the latest saved version", async () => { + const feedback = setup(["one"]); + const failed = feedback.model.press(); + feedback.fail(); + await failed; + expect(feedback.model.getState().phase).toEqual({ + kind: "failed", + message: "Host rejected feedback", + }); + expect(feedback.comments()).toEqual([makeComment("first")]); + feedback.setComments([makeComment("first", "Corrected before retry")]); + const retried = feedback.model.press(); + feedback.succeed(); + await retried; + expect(feedback.sends).toHaveLength(2); + expect(feedback.sends[1]!.attachment.attachment.comments[0]!.body).toBe( + "Corrected before retry", + ); + expect(feedback.comments()).toEqual([]); + }); + it("keeps workspace drafts independent and prevents disconnected sends", async () => { + const first = setup(["one"]); + const second = setup(["other"]); + first.disconnect(); + await first.model.press(); + expect(first.model.getState().disabledReason).toBe("disconnected"); + const pending = second.model.press(); + second.succeed(); + await pending; + expect(first.comments()).toEqual([makeComment("first")]); + expect(first.sends).toEqual([]); + expect(second.comments()).toEqual([]); + }); +}); + +describe("shared review submission ownership", () => { + it("blocks another surface and reconnects to the pending draft while other drafts remain usable", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one"], [], (ports) => scope.connect("workspace-one", ports)); + const second = scope.connect("workspace-one", first.ports); + const other = setup(["other"], [], (ports) => scope.connect("workspace-other", ports)); + const unsubscribe = first.model.subscribe(() => {}); + const pending = first.model.press(); + unsubscribe(); + await Promise.resolve(); + const reconnected = scope.connect("workspace-one", first.ports); + expect(second.getState()).toMatchObject({ canSend: false, phase: { kind: "sending" } }); + await second.press(); + await reconnected.send("one"); + expect(first.sends).toHaveLength(1); + const otherPending = other.model.press(); + expect(other.sends).toHaveLength(1); + first.fail(); + await pending; + expect(second.getState().phase).toEqual({ kind: "failed", message: "Host rejected feedback" }); + const retry = second.press(); + first.succeed(); + await retry; + expect(first.sends).toHaveLength(2); + other.succeed(); + await otherPending; + }); + it("opens only the active surface's recipient chooser", async () => { + const scope = createReviewFeedbackScope(); + const first = setup(["one", "two"], [], (ports) => scope.connect("same", ports)); + const second = scope.connect("same", first.ports); + await first.model.press(); + expect(first.model.getState().phase).toEqual({ kind: "choosing" }); + expect(second.getState().phase).toEqual({ kind: "idle" }); + first.model.closeMenu(); + expect(first.model.getState().phase).toEqual({ kind: "idle" }); + }); +}); diff --git a/packages/app/src/review/feedback/index.ts b/packages/app/src/review/feedback/index.ts new file mode 100644 index 0000000000..69e0592fe8 --- /dev/null +++ b/packages/app/src/review/feedback/index.ts @@ -0,0 +1,177 @@ +import type { ComposerAttachment } from "@/attachments/types"; +import type { ReviewDraftComment } from "../state"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +export interface FeedbackRecipient { + agentId: string; + tab: WorkspaceTabDescriptor; +} +export type FeedbackAttachment = Extract; +export interface ReviewFeedbackSnapshot { + comments: readonly ReviewDraftComment[]; + attachment: FeedbackAttachment | null; + recipients: readonly FeedbackRecipient[]; + connected: boolean; +} +export interface ReviewFeedbackPorts { + read: () => ReviewFeedbackSnapshot; + send: (recipient: FeedbackRecipient, attachment: FeedbackAttachment) => Promise; + clearSent: (comments: readonly ReviewDraftComment[]) => void; + failedMessage: () => string; +} +export type FeedbackPhase = + | { kind: "idle" } + | { kind: "choosing" } + | { kind: "sending"; recipient: FeedbackRecipient; commentCount: number } + | { kind: "failed"; message: string } + | { kind: "sent"; recipient: FeedbackRecipient }; +export interface ReviewFeedbackState { + phase: FeedbackPhase; + commentCount: number; + sendableCommentCount: number; + recipients: readonly FeedbackRecipient[]; + disabledReason: "no-agents" | "disconnected" | "no-context" | null; + canSend: boolean; +} + +export function createReviewFeedback(ports: ReviewFeedbackPorts) { + return createFeedbackView(ports, createFeedbackWorkflow()); +} + +/** Surfaces share submission ownership while keeping their chooser and live inputs local. */ +export function createReviewFeedbackScope() { + const workflows = new Map>(); + return { + connect(key: string, ports: ReviewFeedbackPorts) { + let workflow = workflows.get(key); + if (!workflow) { + const created = createFeedbackWorkflow(() => { + if (workflows.get(key) === created) workflows.delete(key); + }); + workflow = created; + workflows.set(key, workflow); + } + return createFeedbackView(ports, workflow); + }, + }; +} + +function createFeedbackWorkflow(onUnused: () => void = () => {}) { + let phase: FeedbackPhase = { kind: "idle" }; + const observers = new Set<() => void>(); + function releaseIfUnused() { + queueMicrotask(() => { + if (observers.size === 0 && phase.kind !== "sending") onUnused(); + }); + } + return { + getPhase: () => phase, + changePhase(next: FeedbackPhase) { + phase = next; + observers.forEach((observer) => observer()); + releaseIfUnused(); + }, + subscribe(observer: () => void) { + observers.add(observer); + return () => { + observers.delete(observer); + releaseIfUnused(); + }; + }, + }; +} + +function createFeedbackView( + ports: ReviewFeedbackPorts, + workflow: ReturnType, +) { + let choosing = false; + const choosingPhase: FeedbackPhase = { kind: "choosing" }; + const getPhase = () => (choosing ? choosingPhase : workflow.getPhase()); + let state = deriveState(ports.read(), getPhase()); + const listeners = new Set<() => void>(); + function publish() { + state = deriveState(ports.read(), getPhase()); + listeners.forEach((listener) => listener()); + } + async function send(agentId: string) { + const snapshot = ports.read(); + const current = deriveState(snapshot, workflow.getPhase()); + const recipient = snapshot.recipients.find((candidate) => candidate.agentId === agentId); + if (!current.canSend || !recipient || !snapshot.attachment) return; + const attachment = snapshot.attachment; + const sentComments = snapshot.comments.filter((comment) => + attachment.attachment.comments.some( + (sent) => + sent.filePath === comment.filePath && + sent.side === comment.side && + sent.lineNumber === comment.lineNumber && + sent.body === comment.body, + ), + ); + choosing = false; + workflow.changePhase({ kind: "sending", recipient, commentCount: attachment.commentCount }); + publish(); + try { + await ports.send(recipient, attachment); + ports.clearSent(sentComments); + workflow.changePhase({ kind: "sent", recipient }); + } catch (error) { + workflow.changePhase({ + kind: "failed", + message: error instanceof Error ? error.message : ports.failedMessage(), + }); + } + publish(); + } + return { + getState: () => { + if (state.phase !== getPhase()) state = deriveState(ports.read(), getPhase()); + return state; + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + const unsubscribe = workflow.subscribe(() => { + choosing = false; + publish(); + }); + return () => { + listeners.delete(listener); + unsubscribe(); + }; + }, + refresh: publish, + press: async () => { + const snapshot = ports.read(); + if (!deriveState(snapshot, workflow.getPhase()).canSend) return; + if (snapshot.recipients.length === 1) { + await send(snapshot.recipients[0]!.agentId); + } else { + choosing = true; + publish(); + } + }, + closeMenu: () => { + if (choosing) { + choosing = false; + publish(); + } + }, + send, + }; +} + +function deriveState(snapshot: ReviewFeedbackSnapshot, phase: FeedbackPhase): ReviewFeedbackState { + let disabledReason: ReviewFeedbackState["disabledReason"] = null; + if (snapshot.recipients.length === 0) disabledReason = "no-agents"; + else if (!snapshot.connected) disabledReason = "disconnected"; + else if (!snapshot.attachment) disabledReason = "no-context"; + return { + phase, + commentCount: snapshot.comments.length, + sendableCommentCount: snapshot.attachment?.commentCount ?? 0, + recipients: snapshot.recipients, + disabledReason, + canSend: snapshot.comments.length > 0 && disabledReason === null && phase.kind !== "sending", + }; +} diff --git a/packages/app/src/review/feedback/surface.tsx b/packages/app/src/review/feedback/surface.tsx new file mode 100644 index 0000000000..7f87f46d88 --- /dev/null +++ b/packages/app/src/review/feedback/surface.tsx @@ -0,0 +1,284 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import { Text, View } from "react-native"; +import { Send } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet } from "react-native-unistyles"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { useMenuContext } from "@/components/ui/menu"; +import { Button } from "@/components/ui/button"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { SPACING } from "@/styles/theme"; +import { FloatingActionButton } from "@/components/ui/floating-action-button"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabIcon, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { FeedbackRecipient, ReviewFeedbackState, createReviewFeedback } from "./index"; + +type FeedbackModel = ReturnType; +interface ReviewFeedbackActionProps { + bottomOffset: number; + model: FeedbackModel; + state: ReviewFeedbackState; + serverId: string; + workspaceId: string; +} + +export function ReviewFeedbackAction({ + enabled, + hasDiff, + ...props +}: Omit & { + workspaceId?: string | null; + enabled: boolean | undefined; + hasDiff: boolean; +}) { + if (enabled === false || !hasDiff) return null; + return ; +} + +function OpenReviewFeedbackAction({ + model, + state, + serverId, + workspaceId, + bottomOffset, +}: ReviewFeedbackActionProps) { + const { t } = useTranslation(); + const { bottom } = useSafeAreaInsets(); + const isCompact = useIsCompactFormFactor(); + const onOpenChange = useCallback( + (open: boolean) => { + if (!open) model.closeMenu(); + }, + [model], + ); + const noteStyle = useMemo( + () => [ + styles.note, + !isCompact && styles.centered, + { + bottom: + bottom + + bottomOffset + + (isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]), + }, + ], + [bottom, bottomOffset, isCompact], + ); + const sending = state.phase.kind === "sending"; + const label = t(sending ? "review.feedback.sending" : "review.feedback.send", { + count: state.phase.kind === "sending" ? state.phase.commentCount : state.sendableCommentCount, + }); + const unavailableNotice = + state.disabledReason && state.commentCount > 0 ? ( + {t(`review.feedback.${state.disabledReason}`)} + ) : null; + let notice: ReactNode = unavailableNotice; + if (state.phase.kind === "failed") + notice = {state.phase.message}; + else if (state.phase.kind === "sent") + notice = ( + <> + + {unavailableNotice} + + ); + return ( + <> + {state.commentCount > 0 ? ( + + + + {state.recipients.map((recipient) => ( + + ))} + + + ) : null} + {notice ? ( + + {notice} + + ) : null} + + ); +} + +function FeedbackTrigger({ + model, + label, + disabled, + bottomInset, +}: { + model: FeedbackModel; + label: string; + disabled: boolean; + bottomInset: number; +}) { + const { triggerRef } = useMenuContext("FeedbackTrigger"); + const isCompact = useIsCompactFormFactor(); + const press = useCallback(() => { + void model.press(); + }, [model]); + if (!isCompact) { + return ( + + + + ); + } + return ( + + ); +} + +function RecipientOption({ + recipient, + model, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + model: FeedbackModel; + serverId: string; + workspaceId: string; +}) { + const choose = useCallback(() => { + void model.send(recipient.agentId); + }, [model, recipient.agentId]); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [choose], + ); + return ( + + {render} + + ); +} + +function RecipientRow({ + presentation, + onSelect, +}: { + presentation: WorkspaceTabPresentation; + onSelect: () => void; +}) { + const leading = useMemo( + () => , + [presentation], + ); + return ( + + {presentation.label} + + ); +} + +function SentNotice({ + recipient, + serverId, + workspaceId, +}: { + recipient: FeedbackRecipient; + serverId: string; + workspaceId: string; +}) { + const { t } = useTranslation(); + const render = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + {t("review.feedback.sent", { recipient: presentation.label })} + + ), + [t], + ); + return ( + + {render} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + wideAction: { position: "absolute", left: 0, right: 0, alignItems: "center" }, + centered: { alignItems: "center" }, + note: { + position: "absolute", + right: theme.spacing[4], + left: theme.spacing[4], + alignItems: "flex-end", + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + backgroundColor: theme.colors.surface1, + padding: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, +})); diff --git a/packages/app/src/review/feedback/use-feedback.ts b/packages/app/src/review/feedback/use-feedback.ts new file mode 100644 index 0000000000..e2b151953e --- /dev/null +++ b/packages/app/src/review/feedback/use-feedback.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { + useReviewDraftComments, + getReviewDraftComments, + clearSentReviewDraftComments, +} from "../store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useAppSettings } from "@/hooks/use-settings"; +import { dispatchComposerAgentMessage } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store"; +import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { SPACING } from "@/styles/theme"; +import { i18n } from "@/i18n/i18next"; +import { + createReviewFeedbackScope, + type FeedbackAttachment, + type FeedbackRecipient, +} from "./index"; + +const feedbackScope = createReviewFeedbackScope(); + +export function useReviewFeedback(input: { + reviewDraftKey: string; + serverId: string; + workspaceId?: string; + attachment: FeedbackAttachment | null; +}) { + const { serverId, reviewDraftKey, workspaceId } = input; + const isCompact = useIsCompactFormFactor(); + const workspaceKey = workspaceId + ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) + : null; + const client = useHostRuntimeClient(serverId); + const connected = useHostRuntimeIsConnected(serverId); + const { settings } = useAppSettings(); + const comments = useReviewDraftComments(reviewDraftKey); + const layout = useWorkspaceLayoutStore((store) => + workspaceKey ? store.layoutByWorkspace[workspaceKey] : undefined, + ); + // The adapter keeps live host and diff inputs available to commands without reconstructing the workflow. + const live = useRef({ + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }); + live.current = { + client, + connected, + attachment: input.attachment, + sendBehavior: settings.sendBehavior, + }; + const model = useMemo( + () => + feedbackScope.connect(reviewDraftKey, { + read: () => ({ + comments: getReviewDraftComments(reviewDraftKey) ?? [], + attachment: live.current.attachment, + connected: live.current.connected, + recipients: readRecipients(workspaceKey), + }), + send: async (recipient, attachment) => { + const { client: targetClient, sendBehavior } = live.current; + if (!targetClient) throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + const activeTurnBehavior = sendBehavior === "steer" ? "steer" : "interrupt"; + await dispatchComposerAgentMessage({ + client: targetClient, + agentId: recipient.agentId, + text: i18n.t("review.feedback.prompt"), + attachments: [attachment], + encodeImages: async () => [], + submission: createMessageSubmissionWriter(serverId), + activeTurnBehavior, + activeTurnId: + activeTurnBehavior === "steer" + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + recipient.agentId, + ).turnId ?? undefined) + : undefined, + }); + }, + clearSent: (sentComments) => + clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }), + failedMessage: () => i18n.t("review.feedback.failed"), + }), + [reviewDraftKey, serverId, workspaceKey], + ); + useEffect(() => { + model.refresh(); + }, [model, comments, layout, connected, input.attachment]); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return { + model, + state, + clearance: + state.commentCount > 0 + ? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44 + : 0, + }; +} + +function readRecipients(workspaceKey: string | null): FeedbackRecipient[] { + const tabs = workspaceKey + ? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey) + : []; + const descriptors = deriveWorkspaceTabModel({ tabs }).tabs; + const recipients: FeedbackRecipient[] = []; + for (const { descriptor } of descriptors) { + if (descriptor.target.kind === "agent") + recipients.push({ agentId: descriptor.target.agentId, tab: descriptor }); + } + return recipients; +} diff --git a/packages/app/src/review/geometry.ts b/packages/app/src/review/geometry.ts index 819c1d29b1..46bbf88ecc 100644 --- a/packages/app/src/review/geometry.ts +++ b/packages/app/src/review/geometry.ts @@ -19,6 +19,7 @@ export interface InlineReviewActions { onStartComment: (target: ReviewableDiffTarget) => void; onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void; onCancelEditor: () => void; + onChangeEditorBody: (body: string) => void; onSaveEditor: (body: string) => void; onDeleteComment: (id: string) => void; } diff --git a/packages/app/src/review/index.ts b/packages/app/src/review/index.ts index 014447090b..308f955259 100644 --- a/packages/app/src/review/index.ts +++ b/packages/app/src/review/index.ts @@ -31,3 +31,8 @@ export { SMALL_ACTION_HIT_SLOP, useInlineReviewController, } from "./surface"; + +export { ReviewCommentSheet } from "./comment-sheet"; + +export { useReviewFeedback } from "./feedback/use-feedback"; +export { ReviewFeedbackAction } from "./feedback/surface"; diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 344b28b61d..222a3f57f3 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -2,7 +2,16 @@ import { describe, expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; -import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store"; +import { + buildReviewAttachmentSnapshot, + buildReviewDraftKey, + addReviewDraftComment, + saveReviewDraftComment, + clearSentReviewDraftComments, + getReviewDraftComments, + resetReviewDraftStore, + useReviewDraftStore, +} from "./store"; import { addCommentToState, clearReviewInState, @@ -304,3 +313,49 @@ describe("buildReviewAttachmentSnapshot", () => { }); }); }); + +describe("successful feedback cleanup", () => { + it("clears sent versions only, preserving edits, additions and other workspace drafts", () => { + resetReviewDraftStore(); + const first = addReviewDraftComment({ key: "first", comment: makeComment() }); + const edited = addReviewDraftComment({ + key: "first", + comment: makeComment({ id: "edited", body: "Before send" }), + }); + const other = addReviewDraftComment({ key: "other", comment: makeComment({ id: "other" }) }); + useReviewDraftStore.getState().updateComment({ + key: "first", + id: edited.id, + updates: { body: "After send" }, + updatedAt: edited.updatedAt, + }); + const added = addReviewDraftComment({ key: "first", comment: makeComment({ id: "added" }) }); + clearSentReviewDraftComments({ key: "first", comments: [first, edited] }); + expect(getReviewDraftComments("first")).toEqual([{ ...edited, body: "After send" }, added]); + expect(getReviewDraftComments("other")).toEqual([other]); + resetReviewDraftStore(); + }); +}); + +it("saves an acknowledged open edit as a new version and keeps subsequent edits on that record", () => { + resetReviewDraftStore(); + const submitted = addReviewDraftComment({ key: "first", comment: makeComment() }); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + saveReviewDraftComment({ + key: "first", + id: submitted.id, + comment: { ...submitted, body: "Open edit after acknowledgement" }, + }); + const restored = getReviewDraftComments("first")![0]!; + expect(restored.body).toBe("Open edit after acknowledgement"); + expect(restored.id).not.toBe(submitted.id); + clearSentReviewDraftComments({ key: "first", comments: [submitted] }); + expect(getReviewDraftComments("first")).toEqual([restored]); + saveReviewDraftComment({ + key: "first", + id: restored.id, + comment: { ...restored, body: "Saved again" }, + }); + expect(getReviewDraftComments("first")).toMatchObject([{ id: restored.id, body: "Saved again" }]); + resetReviewDraftStore(); +}); diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index bac1659751..614572190a 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -320,3 +320,43 @@ export function useReviewAttachmentSnapshot(input: { [comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles], ); } + +/** Acknowledgement removes only the versions included in that submission. */ +export function clearSentReviewDraftComments(input: { + key: string; + comments: readonly ReviewDraftComment[]; +}): void { + const sentById = new Map(input.comments.map((comment) => [comment.id, comment])); + useReviewDraftStore.setState((state) => { + const current = state.drafts[input.key]; + if (!current) return state; + const remaining = current.filter((comment) => { + const sent = sentById.get(comment.id); + return !sent || sent.updatedAt !== comment.updatedAt || sent.body !== comment.body; + }); + if (remaining.length === current.length) return state; + return { drafts: { ...state.drafts, [input.key]: remaining } }; + }); +} + +/** Acknowledgement can remove the original while the user still has an edit open. */ +export function saveReviewDraftComment(input: { + key: string; + id: string | null; + comment: Pick; +}): void { + const store = useReviewDraftStore.getState(); + if (input.id && store.drafts[input.key]?.some((comment) => comment.id === input.id)) { + store.updateComment({ key: input.key, id: input.id, updates: { body: input.comment.body } }); + } else { + store.addComment({ + key: input.key, + comment: { + filePath: input.comment.filePath, + side: input.comment.side, + lineNumber: input.comment.lineNumber, + body: input.comment.body, + }, + }); + } +} diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index e23f321d38..925ebe6b24 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -9,45 +9,24 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { getInlineReviewThreadState, - getInlineReviewThreadViewportStyle, getSplitInlineReviewThreadState, + type InlineReviewActions, +} from "./geometry"; +import { + getInlineReviewThreadViewportStyle, groupInlineReviewCommentsByTarget, InlineReviewEditor, InlineReviewGutterCell, InlineReviewThread, SMALL_ACTION_HIT_SLOP, useInlineReviewController, - type InlineReviewActions, -} from "./index"; +} from "./surface"; void testI18n; -const { theme, pressablePropsByLabel } = vi.hoisted(() => { +const { pressablePropsByLabel } = vi.hoisted(() => { Object.assign(globalThis, { __DEV__: false }); - return { - theme: { - spacing: { 1: 4, 2: 8, 3: 12 }, - borderWidth: { 1: 1 }, - borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 }, - opacity: { 50: 0.5 }, - fontSize: { xs: 11, sm: 13 }, - fontWeight: { normal: "400", medium: "500" }, - lineHeight: { diff: 18 }, - colors: { - accent: "#0a84ff", - accentForeground: "#fff", - border: "#555", - destructive: "#ff453a", - foreground: "#fff", - foregroundMuted: "#aaa", - surface1: "#111", - surface2: "#222", - surface3: "#333", - palette: { white: "#fff" }, - }, - }, - pressablePropsByLabel: new Map>(), - }; + return { pressablePropsByLabel: new Map>() }; }); vi.mock("react-native", async (importOriginal) => { @@ -88,13 +67,16 @@ vi.mock("react-native", async (importOriginal) => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), -})); +vi.mock("react-native-unistyles", async () => { + const { darkTheme: theme } = await import("@/styles/theme"); + return { + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + withUnistyles: (component: T) => component, + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), + }; +}); vi.mock("@/constants/platform", () => ({ getIsElectron: () => false, @@ -103,19 +85,6 @@ vi.mock("@/constants/platform", () => ({ isWeb: true, })); -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - Check: createIcon("Check"), - CircleDot: createIcon("CircleDot"), - Pencil: createIcon("Pencil"), - Plus: createIcon("Plus"), - Trash2: createIcon("Trash2"), - X: createIcon("X"), - }; -}); - function target(overrides: Partial = {}): ReviewableDiffTarget { return { filePath: "src/example.ts", @@ -147,6 +116,7 @@ function buildReviewActions(overrides: Partial = {}): Inlin onStartComment: vi.fn(), onEditComment: vi.fn(), onCancelEditor: vi.fn(), + onChangeEditorBody: () => {}, onSaveEditor: vi.fn(), onDeleteComment: vi.fn(), ...overrides, @@ -312,8 +282,9 @@ describe("git diff inline review helpers", () => { it("keeps the line number visible and only floats the plus for line hover", () => { const reviewTarget = target(); - const { container, queryByText, rerender } = render( + const { queryByTestId, queryByText, rerender } = render( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeNull(); + expect(queryByTestId("add-comment-glyph")).toBeNull(); rerender( { ); expect(queryByText("2")).toBeTruthy(); - expect(container.querySelector("[data-icon='Plus']")).toBeTruthy(); + expect(queryByTestId("add-comment-glyph")).toBeTruthy(); }); }); @@ -384,7 +358,7 @@ describe("InlineReviewEditor", () => { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { const onCancel = vi.fn(); const onSave = vi.fn(); const { getByTestId } = render( - { removeEventListener: vi.fn(), }); const { getByTestId, queryByText } = render( - { expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1"); }); }); + +function ReviewEditorFixture({ + initialBody, + ...props +}: Omit, "body" | "onChangeBody"> & { + initialBody: string; +}) { + const [body, onChangeBody] = React.useState(initialBody); + return ; +} diff --git a/packages/app/src/review/surface.tsx b/packages/app/src/review/surface.tsx index 43b6af914a..7466052456 100644 --- a/packages/app/src/review/surface.tsx +++ b/packages/app/src/review/surface.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pencil, Plus, Trash2 } from "lucide-react-native"; @@ -17,10 +24,17 @@ import { EditingTextInput as TextInput, type EditingTextInputHandle, } from "@/components/ui/text-input"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { createReviewEditorScope } from "./editor"; import { isWeb } from "@/constants/platform"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import type { Theme } from "@/styles/theme"; -import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store"; +import { + useReviewDraftComments, + useReviewDraftStore, + saveReviewDraftComment, + type ReviewDraftComment, +} from "./store"; import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout"; import { INLINE_REVIEW_EDITOR_HEIGHT, @@ -98,92 +112,42 @@ export function groupInlineReviewCommentsByTarget( return grouped; } -export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions { +const reviewEditors = createReviewEditorScope({ + saveComment: saveReviewDraftComment, + deleteComment: (input) => useReviewDraftStore.getState().deleteComment(input), +}); + +export function useInlineReviewController(input: { + reviewDraftKey: string; + presentation?: string; +}): InlineReviewActions & { + sheetEditor: InlineReviewEditorState | null; + onChangeEditorBody: (body: string) => void; +} { + const isCompact = useIsCompactFormFactor(); const reviewComments = useReviewDraftComments(input.reviewDraftKey); const commentsByTarget = useMemo( () => groupInlineReviewCommentsByTarget(reviewComments), [reviewComments], ); - const [editor, setEditor] = useState(null); - const addComment = useReviewDraftStore((state) => state.addComment); - const updateComment = useReviewDraftStore((state) => state.updateComment); - const deleteComment = useReviewDraftStore((state) => state.deleteComment); - - useEffect(() => { - setEditor(null); - }, [input.reviewDraftKey]); - - const handleStartComment = useCallback((target: ReviewableDiffTarget) => { - setEditor({ target, commentId: null, body: "" }); - }, []); - - const handleEditComment = useCallback( - (target: ReviewableDiffTarget, comment: ReviewDraftComment) => { - setEditor({ target, commentId: comment.id, body: comment.body }); - }, - [], + const model = useMemo( + () => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"), + [input.reviewDraftKey, input.presentation], ); - - const handleCancelEditor = useCallback(() => { - setEditor(null); - }, []); - - const handleSaveEditor = useCallback( - (body: string) => { - const trimmedBody = body.trim(); - if (!editor || trimmedBody.length === 0) { - return; - } - - if (editor.commentId) { - updateComment({ - key: input.reviewDraftKey, - id: editor.commentId, - updates: { body: trimmedBody }, - }); - } else { - addComment({ - key: input.reviewDraftKey, - comment: { - filePath: editor.target.filePath, - side: editor.target.side, - lineNumber: editor.target.lineNumber, - body: trimmedBody, - }, - }); - } - setEditor(null); - }, - [addComment, editor, input.reviewDraftKey, updateComment], - ); - - const handleDeleteComment = useCallback( - (id: string) => { - deleteComment({ key: input.reviewDraftKey, id }); - setEditor((current) => (current?.commentId === id ? null : current)); - }, - [deleteComment, input.reviewDraftKey], - ); - - return useMemo( + const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState); + return useMemo( () => ({ commentsByTarget, - editor, - onStartComment: handleStartComment, - onEditComment: handleEditComment, - onCancelEditor: handleCancelEditor, - onSaveEditor: handleSaveEditor, - onDeleteComment: handleDeleteComment, + editor: isCompact ? null : editor, + sheetEditor: isCompact ? editor : null, + onChangeEditorBody: model.setBody, + onStartComment: model.start, + onEditComment: model.edit, + onCancelEditor: model.cancel, + onSaveEditor: model.save, + onDeleteComment: model.delete, }), - [ - commentsByTarget, - editor, - handleCancelEditor, - handleDeleteComment, - handleEditComment, - handleSaveEditor, - handleStartComment, - ], + [commentsByTarget, editor, isCompact, model], ); } @@ -321,7 +285,8 @@ export function InlineReviewThread({ const editorElement = editor ? ( void; onCancel: () => void; onSave: (body: string) => void; testID?: string; }) { const { t } = useTranslation(); const inputRef = useRef(null); - const [body, setBody] = useState(initialBody); const [isFocused, setIsFocused] = useState(false); const trimmedBody = body.trim(); const canSave = trimmedBody.length > 0; @@ -506,7 +472,7 @@ export function InlineReviewEditor({ placeholderTextColor={styles.placeholderColor.color} multiline initialValue={body} - onChangeText={setBody} + onChangeText={onChangeBody} onFocus={handleFocus} onBlur={handleBlur} style={inputStyle} diff --git a/packages/app/src/root-app.tsx b/packages/app/src/root-app.tsx new file mode 100644 index 0000000000..cae9b169d6 --- /dev/null +++ b/packages/app/src/root-app.tsx @@ -0,0 +1,40 @@ +import { useCallback, useState, type ComponentProps } from "react"; +import { ExpoRoot } from "expo-router"; +import Head from "expo-router/head"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { queryClient } from "@/data/query-client"; +import { I18nProvider } from "@/i18n/provider"; +import { RootErrorBoundary } from "@/components/root-error-boundary"; + +// Keep Expo's platform filtering, route root, and lazy import configuration. +const { ctx: context } = require("expo-router/_ctx") as { + ctx: ComponentProps["context"]; +}; + +export function RootApp() { + return ; +} + +export function RootRouter({ context: routes }: Pick, "context">) { + const [generation, setGeneration] = useState(0); + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + return ( + + + + + + {/* Recreate the router at a safe destination before a failed route can mount. */} + + + + + + + ); +} diff --git a/packages/app/src/runtime/daemon-start-service.test.ts b/packages/app/src/runtime/daemon-start-service.test.ts index a988a09e0e..f1cae10118 100644 --- a/packages/app/src/runtime/daemon-start-service.test.ts +++ b/packages/app/src/runtime/daemon-start-service.test.ts @@ -36,6 +36,8 @@ function makeStatus(overrides: Partial = {}): DesktopDaemon home: "/home", version: "0.0.0", desktopManaged: true, + ownedByDesktop: true, + startedAt: "2026-01-01T00:00:00.000Z", error: null, ...overrides, }; @@ -323,6 +325,15 @@ describe("upsertDesktopDaemonConnection", () => { expect(fake.upserts).toEqual([]); }); + it("keeps an already registered host without waiting for a listen address", async () => { + const fake = createFakeStore([makeRelayOnlyHost("srv_desktop")]); + + const result = await upsertDesktopDaemonConnection(fake.store, makeStatus({ listen: null })); + + expect(result).toEqual({ ok: true }); + expect(fake.upserts).toEqual([]); + }); + it("rejects a missing listen address without upserting", async () => { const fake = createFakeStore(); diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index 093158e9ea..b00e3a380c 100644 --- a/packages/app/src/runtime/daemon-start-service.ts +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -27,7 +27,6 @@ export async function upsertDesktopDaemonConnection( if (store.getHosts().some((host) => host.serverId === serverId)) { return { ok: true }; } - const listenAddress = daemon.listen?.trim() ?? ""; if (!listenAddress) { return { ok: false, error: "Desktop daemon did not return a listen address." }; diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index c791949827..648c66c590 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { useSessionStore } from "@/stores/session-store"; +import { + selectAgentTimelineState, + selectAgentTurnPresentation, + useSessionStore, +} from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -51,11 +57,82 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it.each(["delta", "snapshot"] as const)( + "does not erase freshly fetched history when the active directory removes it by %s", + (removal) => { + const serverId = `agent-replica-history-${removal}`; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + const archived = { ...payload("archived"), archivedAt: "2026-09-11T00:00:00.000Z" }; + replica.submitTimelineAgent(replica.captureTimeline("agent"), archived); + store.applyAgentTimelineResponseState(serverId, "agent", { + items: [ + { + kind: "assistant_message", + id: "reply", + text: "server history", + timestamp: new Date(), + timelineCursor: { epoch: "e", seq: 1 }, + }, + ], + head: [], + range: { epoch: "e", startSeq: 1, endSeq: 1 }, + older: "none", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); + if (removal === "delta") replica.applyDelta({ kind: "remove", agentId: "agent" }); + else replica.commitSnapshot([], []); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "synced", + items: [{ text: "server history" }], + }); + replica.remove("agent"); + expect(selectAgentTimelineState(store.getSession(serverId), "agent")).toMatchObject({ + status: "cold", + }); + store.clearSession(serverId); + }, + ); + + it("does not let a late cache read replace newer live turn state", () => { + const serverId = "agent-replica-late-cache"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot([entry(payload("live"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-live", startedAt: null }, + }); + + replica.commitCached(new Map([["agent", normalizeAgentSnapshot(payload("cached"), serverId)]])); + + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").turnId).toBe( + "turn-live", + ); + store.clearSession(serverId); + }); + it("accepts the requested agent from the authoritative timeline after a cache miss", () => { const serverId = "agent-replica-cache-miss"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( true, @@ -66,11 +143,174 @@ describe("AgentDirectoryReplica", () => { store.clearSession(serverId); }); + it("persists stream-only turn transitions through the agent owner", () => { + const serverId = "agent-replica-stream-turn"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const commits: DirectoryReplicaMutation[][] = []; + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + (mutations) => commits.push([...mutations]), + ); + replica.commitSnapshot([entry(payload("agent"))], []); + commits.length = 0; + + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: new Date("2026-08-31T12:00:00.000Z") }, + }); + + expect(commits).toEqual([ + [ + expect.objectContaining({ + kind: "agent", + type: "upsert", + id: "agent", + value: expect.objectContaining({ + status: "idle", + turn: { + phase: "open", + cancellationRequestId: null, + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + }, + }), + }), + ], + ]); + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").isActive).toBe(true); + store.clearSession(serverId); + }); + + it("notifies the queue owner exactly once when a stream turn closes", () => { + const serverId = "agent-replica-stream-stop"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot([entry(payload("agent"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: null }, + }); + + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + replica.applyTurnLiveness("agent", { type: "stream_close", turnId: "turn-1" }); + + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + + it("preserves cancellation state when the same active turn is snapshotted", () => { + const serverId = "agent-replica-cancellation-snapshot"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("running"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + replica.applyTurnLiveness("agent", { type: "cancellation_started", requestId: 7 }); + + replica.applyDelta({ + kind: "upsert", + agent: { + ...payload("same running turn"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + + replica.commitSnapshot( + [ + entry({ + ...payload("same turn from refresh"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toMatchObject({ + phase: "open", + turnId: "turn-1", + cancellationRequestId: 7, + }); + store.clearSession(serverId); + }); + + it("uses turn liveness for stopped transitions even when protocol status disagrees", () => { + const serverId = "agent-replica-mismatched-status"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("open despite idle"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "open", turnId: "turn-1" }, + }); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("idle turn despite running"), status: "running", activeTurn: null }, + project: entry(payload("project")).project, + }); + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("status-only update"), status: "idle", activeTurn: null }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "idle" }, + }); + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + it("preserves an unchanged running agent's turn identity during catch-up", () => { const serverId = "agent-replica-catch-up"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot( [ entry({ @@ -84,9 +324,11 @@ describe("AgentDirectoryReplica", () => { replica.commitChanges([], [], []); - expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({ + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toEqual({ + phase: "open", turnId: "turn-1", startedAt: new Date("2026-07-17T00:01:00.000Z"), + cancellationRequestId: null, }); store.clearSession(serverId); }); @@ -95,7 +337,11 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore .getState() @@ -123,4 +369,25 @@ describe("AgentDirectoryReplica", () => { ).toEqual(directoryPlacement); store.clearSession(serverId); }); + + it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => { + const serverId = "agent-replica-timeline-membership"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + + // A conversation opened from history arrives over its timeline, without the + // project placement the directory carries, and is a member from then on. + replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only")); + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + + replica.commitChanges([], [], []); + + expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true); + store.clearSession(serverId); + }); }); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index c9ff7424a4..37d5a1e4cb 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -2,23 +2,25 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { - applyAgentDirectoryDelta, - type AgentDirectoryDelta, - removeAgentDirectoryReplica, - replaceAgentPendingPermissions, - replaceFetchedAgentDirectory, - upsertAgentReplica, -} from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; +import { resolveProjectPlacement } from "@/utils/project-placement"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +import { AgentStoreProjection } from "./internal/agent-store"; -function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { - return agent.projectPlacement - ? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement } - : null; +// A member hydrated from its timeline or restored from the replica cache carries +// no placement. Every other path in this flow derives one from the working +// directory, so this one does too; returning nothing here would drop the member +// from the merged directory and delete the conversation on the next catch-up. +function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry { + return { + agent: projectAgentSnapshot(agent), + project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }), + }; } export interface AgentLifecycleToken { @@ -30,28 +32,35 @@ export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); private readonly pendingCacheReads = new Set(); + private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, - ) {} + private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, + ) { + this.storeProjection = new AgentStoreProjection(serverId); + } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + snapshot(): Map { + return this.storeProjection.snapshot(); + } + captureCache(agentId: string): AgentLifecycleToken { this.pendingCacheReads.add(agentId); return this.captureTimeline(agentId); } commitCached(agents: Map): void { + const merged = this.storeProjection.commitCached(agents); this.members.clear(); - for (const [agentId, agent] of agents) { + for (const agentId of merged.keys()) { this.members.add(agentId); - useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt); } - useSessionStore.getState().setAgents(this.serverId, agents); } commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { @@ -59,13 +68,8 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; if (this.members.has(agent.id)) return false; this.members.add(agent.id); - useSessionStore.getState().setAgents(this.serverId, (current) => { - if (current.has(agent.id)) return current; - const next = new Map(current); - next.set(agent.id, agent); - return next; - }); - useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + this.storeProjection.accept(agent); + this.storeProjection.publishActivity(agent); return true; } @@ -73,7 +77,7 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } - const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, agent: normalizeAgentSnapshot(payload, this.serverId), @@ -82,19 +86,20 @@ export class AgentDirectoryReplica { ...timelineAgent, projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; - const accepted = upsertAgentReplica(this.serverId, normalized); + const accepted = this.storeProjection.accept(normalized); this.members.add(accepted.id); - replaceAgentPendingPermissions(this.serverId, accepted); - useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + this.storeProjection.replacePendingPermissions(accepted); + this.storeProjection.publishActivity(accepted); if (accepted.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); } + this.persist([this.agentUpsert(accepted)]); return true; } applyDelta(delta: AgentDirectoryDelta): void { const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id); - const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta }); + const result = this.storeProjection.applyDelta(delta); if (delta.kind === "remove") { this.members.delete(delta.agentId); this.advance(delta.agentId); @@ -103,15 +108,28 @@ export class AgentDirectoryReplica { if (!before) this.advance(delta.agent.id); } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + this.persist( + result.agent + ? [this.agentUpsert(result.agent)] + : [{ kind: "agent", type: "delete", id: result.agentId }], + ); + } + + accept(agent: Agent): Agent { + const accepted = this.storeProjection.accept(agent); + this.members.add(accepted.id); + this.persist([this.agentUpsert(accepted)]); + return accepted; } commitSnapshot( entries: FetchAgentsEntry[], deltas: readonly AgentDirectoryDelta[], + persist = true, ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); - const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); - const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + const previous = this.storeProjection.snapshot(); + const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); + const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); for (const agentId of this.pendingCacheReads) { if (!nextIds.has(agentId)) this.advance(agentId); } @@ -122,16 +140,25 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + if (!nextIds.has(agentId)) this.storeProjection.removeFromDirectory(agentId); } this.members.clear(); this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); - const { agents } = replaceFetchedAgentDirectory({ - serverId: this.serverId, - entries: reconciled.entries, - }); - for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + const agents = this.storeProjection.replaceFetched(reconciled); + for (const [agentId, previousAgent] of previous) { + if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { + this.onStoppedRunning(agentId); + } + } + if (persist) { + this.persist([ + ...Array.from(previous.keys()) + .filter((agentId) => !agents.has(agentId)) + .map((id): DirectoryReplicaMutation => ({ kind: "agent", type: "delete", id })), + ...Array.from(agents.values(), (value) => this.agentUpsert(value)), + ]); + } return agents; } @@ -140,36 +167,67 @@ export class AgentDirectoryReplica { removals: readonly { id: string }[], deltas: readonly AgentDirectoryDelta[], ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { - const entry = projectAgentDirectoryEntry(agent); - if (entry) merged.set(agent.id, entry); + merged.set(agent.id, projectAgentDirectoryEntry(agent)); } for (const entry of entries) merged.set(entry.agent.id, entry); const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({ kind: "remove", agentId: id, })); - return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]); + const agents = this.commitSnapshot( + Array.from(merged.values()), + [...removalsAsDeltas, ...deltas], + false, + ); + const touchedIds = new Set([ + ...entries.map((entry) => entry.agent.id), + ...removals.map(({ id }) => id), + ...deltas.map((delta) => (delta.kind === "remove" ? delta.agentId : delta.agent.id)), + ]); + this.persist( + Array.from(touchedIds, (id): DirectoryReplicaMutation => { + const value = agents.get(id); + return value ? this.agentUpsert(value) : { kind: "agent", type: "delete", id }; + }), + ); + return agents; } archive(agentId: string, archivedAt: string): void { this.advance(agentId); - useSessionStore.getState().setAgents(this.serverId, (current) => { - const agent = current.get(agentId); - if (!agent) return current; - const next = new Map(current); - next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) }); - return next; - }); + const archived = this.storeProjection.archive(agentId, archivedAt); + if (archived) this.persist([this.agentUpsert(archived)]); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - removeAgentDirectoryReplica(this.serverId, agentId); + this.storeProjection.remove(agentId); + this.persist([{ kind: "agent", type: "delete", id: agentId }]); + } + + applyTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + const wasRunning = this.storeProjection.get(agentId)?.turn.phase === "open"; + const accepted = this.storeProjection.applyTurn(agentId, transition); + if (!accepted) return; + this.persist([this.agentUpsert(accepted)]); + if (wasRunning && accepted.turn.phase === "idle") this.onStoppedRunning(agentId); + } + + private agentUpsert(agent: Agent): DirectoryReplicaMutation { + return { + kind: "agent", + type: "upsert", + id: agent.id, + value: agent, + }; } private advance(agentId: string): void { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2008abb6a1..5a247590d5 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1,5 +1,15 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; import { afterEach, describe, expect, it } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { + DaemonClient, + WorkspaceLabelListPayload, +} from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { normalizeProjectDescriptor, @@ -8,18 +18,27 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { CachedDirectory } from "@/runtime/replica-cache"; +import { useWorkspaceLabels } from "@/workspace-labels"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, type DirectoryCheckpointStorage, } from "./index"; +import { subscriptionFixture } from "../subscription-fixture"; + type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; type AgentFetchResult = Awaited>; class FakeDirectoryClient { + supportsBackgroundWorkspaces(): boolean { + return false; + } + + supportsWorkspaceLabels = false; + listWorkspaceLabelsCalls = 0; fetchAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; @@ -29,6 +48,7 @@ class FakeDirectoryClient { projectResult: ProjectListResult | null = null; private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; + private pendingProjectFetch: Promise | null = null; private readonly handlers = new Map< SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void> @@ -67,6 +87,14 @@ class FakeDirectoryClient { return complete; } + holdProjectFetch(): (result: ProjectListResult) => void { + let complete!: (result: ProjectListResult) => void; + this.pendingProjectFetch = new Promise((resolve) => { + complete = resolve; + }); + return complete; + } + async fetchAgents(options?: unknown): Promise>> { this.fetchAgentsCalls += 1; this.lastAgentOptions = options; @@ -101,6 +129,11 @@ class FakeDirectoryClient { async listProjects(options?: unknown): Promise { this.listProjectsCalls += 1; this.lastProjectOptions = options; + if (this.pendingProjectFetch) { + const pending = this.pendingProjectFetch; + this.pendingProjectFetch = null; + return pending; + } if (this.projectResult) return this.projectResult; return { requestId: "projects", @@ -116,19 +149,55 @@ class FakeDirectoryClient { }; } - getLastServerInfoMessage(): null { - return null; + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeWorkspaces(options: Parameters[0]) { + return subscriptionFixture(this.fetchWorkspaces(options), (receive) => + this.on("workspace_update", receive), + ); + } + + observeEvents(events: readonly SessionOutboundMessage["type"][]) { + return subscriptionFixture(Promise.resolve({ events }), (receive) => { + const stops = events.map((event) => this.on(event, receive)); + return () => stops.forEach((stop) => stop()); + }); + } + + observeWorkspaceLabels() { + return subscriptionFixture(this.listWorkspaceLabels(), () => () => {}); + } + + async listWorkspaceLabels(): Promise { + this.listWorkspaceLabelsCalls += 1; + return { + requestId: "workspace-labels", + labels: [{ name: "Urgent", color: "red" }], + sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 }, + }; + } + + getLastServerInfoMessage() { + return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null; } } const serverIds = new Set(); -function createDirectory(serverId: string): { +function createDirectory( + serverId: string, + options?: { workspaceLabels?: boolean }, +): { client: FakeDirectoryClient; directory: DirectorySync; } { serverIds.add(serverId); const client = new FakeDirectoryClient(); + client.supportsWorkspaceLabels = options?.workspaceLabels === true; const directory = new DirectorySync(serverId, { onAgentStoppedRunning: () => undefined, markAgentLoading: () => undefined, @@ -176,13 +245,33 @@ function createAgent(serverId: string, id: string) { }; } +function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] { + return { + id, + projectId: "snapshot-project", + projectDisplayName: "Snapshot project", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "local_checkout", + name: id, + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + afterEach(() => { for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); serverIds.clear(); + useWorkspaceLabels.setState({ hosts: {} }); }); describe("DirectorySync session readiness", () => { - it("paints a demanded cached directory while offline", async () => { + it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -224,12 +313,12 @@ describe("DirectorySync session readiness", () => { workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), projects: new Map([[cachedProject.projectId, cachedProject]]), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.refreshAll(); + await directory.restoreCachedDirectory(); expect( useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), @@ -280,7 +369,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -297,11 +386,11 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); - it("persists accepted script status updates through the directory owner", () => { + it("persists accepted script status updates through the directory owner", async () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const commits: CachedDirectory[] = []; + const commits: DirectoryReplicaMutation[][] = []; const directory = new DirectorySync( serverId, { @@ -318,7 +407,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: (_serverId, value) => commits.push(value), + commitDirectoryMutations: (_serverId, mutations) => commits.push([...mutations]), }, ); directory.connectionChanged({ @@ -344,7 +433,16 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - store.setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setDemand({}, true); + await directory.refreshDemand(); + directory.acceptWorkspaces([workspace]); + commits.length = 0; client.emit({ type: "script_status_update", @@ -370,7 +468,17 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] ?.lifecycle, ).toBe("running"); - expect(commits.at(-1)?.workspaces.get(workspace.id)?.scripts[0]?.lifecycle).toBe("running"); + const persisted = commits + .flat() + .find( + ( + mutation, + ): mutation is Extract => + mutation.kind === "workspace" && + mutation.type === "upsert" && + mutation.id === workspace.id, + ); + expect(persisted?.value.scripts[0]?.lifecycle).toBe("running"); directory.dispose(); }); @@ -476,7 +584,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), }; }, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -556,7 +664,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -593,6 +701,63 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("reconciles agent changes on top of the accepted cached baseline", async () => { + const serverId = "cached-agent-changes"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "cached-agent"); + const releaseNetwork = client.holdAgentFetch(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "g", afterSeq: 7 } }, + }), + commitDirectoryMutations: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + + const refresh = directory.refreshAgents(); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + releaseNetwork({ + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] }, + }); + await refresh; + + expect(client.lastAgentOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 7 }, + }); + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true); + directory.dispose(); + }); + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { const serverId = "late-directory-cache"; serverIds.add(serverId); @@ -617,7 +782,7 @@ describe("DirectorySync session readiness", () => { readAgent: async () => undefined, readWorkspace: async () => undefined, readDirectory: () => cacheRead, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -634,6 +799,8 @@ describe("DirectorySync session readiness", () => { features: { workspaceMultiplicity: true, directorySync: true }, }); + directory.setAgentRouteDemand(["agent-1"]); + await directory.refreshDemand(); const refresh = directory.refreshWorkspaces(); await Promise.resolve(); client.emit({ @@ -706,7 +873,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -717,10 +884,7 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); const load = directory.loadCachedAgent(cachedAgent.id); - client.emit({ - type: "agent_deleted", - payload: { agentId: cachedAgent.id, requestId: "delete-live" }, - }); + directory.removeAgent(cachedAgent.id); releaseAgent(cachedAgent); await load; @@ -754,7 +918,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -807,7 +971,7 @@ describe("DirectorySync session readiness", () => { projects: new Map(), checkpoint: { agents: { generation: "generation", afterSeq: 12 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -825,14 +989,14 @@ describe("DirectorySync session readiness", () => { }); await directory.refreshAgents({ - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, }); expect(client.lastAgentOptions).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: `app:${serverId}` }, + subscribe: {}, page: { limit: 200 }, sync: { generation: "generation", afterSeq: 12 }, }); @@ -891,7 +1055,10 @@ describe("DirectorySync session readiness", () => { const serverId = "project-list-sequence"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const writes: unknown[] = []; + const writes: Array<{ + mutations: readonly DirectoryReplicaMutation[]; + checkpoint: unknown; + }> = []; const cachedProjects = [ normalizeProjectDescriptor({ projectId: "project-1", @@ -905,6 +1072,12 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/two", projectKind: "git", }), + normalizeProjectDescriptor({ + projectId: "untouched-project", + projectDisplayName: "Untouched", + projectRootPath: "/repo/untouched", + projectKind: "git", + }), ]; const directory = new DirectorySync( serverId, @@ -923,7 +1096,8 @@ describe("DirectorySync session readiness", () => { projects: new Map(cachedProjects.map((project) => [project.projectId, project])), checkpoint: { projects: { generation: "generation", afterSeq: 4 } }, }), - commitDirectory: (_serverId, value) => writes.push(value.checkpoint), + commitDirectoryMutations: (_serverId, mutations, checkpoint) => + writes.push({ mutations: [...mutations], checkpoint }), }, ); directory.connectionChanged({ @@ -964,9 +1138,52 @@ describe("DirectorySync session readiness", () => { sync: { generation: "generation", afterSeq: 4 }, }); const projects = useSessionStore.getState().sessions[serverId]?.projects; - expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]); + expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1", "untouched-project"]); expect(projects?.get("project-1")?.projectDisplayName).toBe("New name"); - expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } }); + expect(writes.map(({ checkpoint }) => checkpoint)).toContainEqual({ + projects: { generation: "generation", afterSeq: 6 }, + }); + const writtenProjectIds = writes + .flatMap(({ mutations }) => mutations) + .filter((mutation) => mutation.kind === "project") + .map((mutation) => mutation.id); + expect(writtenProjectIds).toEqual(["project-1", "project-2"]); + expect(writtenProjectIds).not.toContain("untouched-project"); + directory.dispose(); + }); + + it("keeps the accepted baseline when an old connection finishes its workspace request", async () => { + const serverId = "superseded-workspace-baseline"; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]); + const release = client.holdWorkspaceFetch(); + const refresh = directory.refreshWorkspaces(); + const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 2 }, + }); + release({ + requestId: "old-empty-snapshot", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] }, + }); + await rejected; + expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([ + "retained", + ]); directory.dispose(); }); @@ -1009,8 +1226,9 @@ describe("DirectorySync session readiness", () => { }); const completeFetch = client.holdWorkspaceFetch(); - const refresh = directory.refreshWorkspaces({ subscribe: true }); - await Promise.resolve(); + directory.setDemand({}, true); + const refresh = directory.refreshDemand(); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); client.emit({ type: "workspace_update", payload: { @@ -1036,9 +1254,19 @@ describe("DirectorySync session readiness", () => { }, }, }); + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" }, + }, + }); completeFetch({ requestId: "workspaces", - entries: [], + entries: [ + createWorkspaceEntry("removed-workspace"), + createWorkspaceEntry("updated-workspace"), + ], emptyProjects: [ { projectId: "snapshot-project", @@ -1061,10 +1289,15 @@ describe("DirectorySync session readiness", () => { expect(projects?.get("workspace-project")).toMatchObject({ projectDisplayName: "Project from workspace update", }); + expect( + [...useSessionStore.getState().sessions[serverId].workspaces.values()].map( + ({ id, name }) => ({ id, name }), + ), + ).toEqual([{ id: "updated-workspace", name: "Live name" }]); directory.dispose(); }); - it("buffers project updates from the online epoch before workspace hydration starts", async () => { + it("ignores passive project events before directory demand", async () => { const serverId = "project-before-workspace-hydration"; const { client, directory } = createDirectory(serverId); const store = useSessionStore.getState(); @@ -1094,12 +1327,433 @@ describe("DirectorySync session readiness", () => { await directory.refreshWorkspaces({ subscribe: true }); expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); - expect( - useSessionStore.getState().sessions[serverId]?.projects.get("early-project"), - ).toMatchObject({ - projectDisplayName: "Early project", - projectRootPath: "/repo/early-project", + expect(useSessionStore.getState().sessions[serverId]?.projects.has("early-project")).toBe( + false, + ); + directory.dispose(); + }); +}); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + storage, + reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }), + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +it("fills every cached workspace beneath live updates received during the SQLite read", async () => { + const serverId = "sqlite-directory-race"; + serverIds.add(serverId); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([serverId]); + const project = normalizeProjectDescriptor({ + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + projectKind: "git", + }); + const workspaces = ["first", "second", "third"].map((id) => + normalizeWorkspaceDescriptor({ + id, + projectId: "project", + projectDisplayName: "Cached", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "local_checkout", + name: id, + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }), + ); + cache.replaceDirectoryBaseline(serverId, { + agents: new Map([["agent", createAgent(serverId, "agent")]]), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), + projects: new Map([[project.projectId, project]]), + }); + await cache.flush(); + const client = new FakeDirectoryClient(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => {}, + markAgentLoading: () => {}, + markAgentReady: () => {}, + markAgentError: () => {}, + }, + cache, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + directory.setAgentRouteDemand(["agent"]); + await directory.refreshDemand(); + let updated = false; + holdRead(async () => { + if (updated) return; + updated = true; + client.emit({ + type: "workspace_update", + payload: { + kind: "upsert", + workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null }, + }, + }); + }); + await directory.restoreCachedDirectory(); + const session = useSessionStore.getState().sessions[serverId]; + expect( + [...session.workspaces.values()] + .map(({ id, name }) => ({ id, name })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([ + { id: "first", name: "Live" }, + { id: "second", name: "second" }, + { id: "third", name: "third" }, + ]); + expect(session.agents.get("agent")?.title).toBe("Cached"); + expect(session.hasWorkspaceDirectorySnapshot).toBe(true); + directory.dispose(); + await cache.flush(); + database.close(); +}); + +function createWorkspaceLabelDirectory(serverId: string) { + const { client, directory } = createDirectory(serverId, { workspaceLabels: true }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { + directorySync: true, + projectList: true, + workspaceLabels: true, + workspaceMultiplicity: true, + }, + }); + return { client, directory }; +} + +async function flushWorkspaceLabels(): Promise { + for (let index = 0; index < 4; index += 1) await Promise.resolve(); +} + +describe("DirectorySync workspace labels", () => { + it("loads the catalog when an agent route is open before the sidebar", async () => { + const serverId = "workspace-labels-route-then-full"; + const { directory } = createWorkspaceLabelDirectory(serverId); + + directory.setAgentRouteDemand(["agent-1"]); + await directory.refreshDemand(); + directory.setDemand({}, true); + await flushWorkspaceLabels(); + + expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({ + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }); + directory.dispose(); + }); + + it("loads the catalog when the sidebar demands the directory first", async () => { + const serverId = "workspace-labels-full-first"; + const { directory } = createWorkspaceLabelDirectory(serverId); + + directory.setDemand({}, true); + await directory.refreshDemand(); + + expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({ + status: "online", + labels: [{ name: "Urgent", color: "red" }], + }); + directory.dispose(); + }); + + it("keeps the picker online during a full refresh", async () => { + const serverId = "workspace-labels-refresh-no-flicker"; + const { client, directory } = createWorkspaceLabelDirectory(serverId); + directory.setDemand({}, true); + await directory.refreshDemand(); + const callsBeforeRefresh = client.listWorkspaceLabelsCalls; + const statuses: string[] = []; + const unsubscribe = useWorkspaceLabels.subscribe((state) => { + const status = state.hosts[serverId]?.status; + if (status) statuses.push(status); + }); + + await directory.refreshDemand(); + + expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh); + expect(statuses).not.toContain("offline"); + unsubscribe(); + directory.dispose(); + }); + + it("restores the catalog after reconnecting", async () => { + const serverId = "workspace-labels-reconnect"; + const { client, directory } = createWorkspaceLabelDirectory(serverId); + directory.setDemand({}, true); + await directory.refreshDemand(); + + directory.connectionChanged({ + client: null, + status: "offline", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline"); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 2 }, + }); + await flushWorkspaceLabels(); + + expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({ + status: "online", + labels: [{ name: "Urgent", color: "red" }], }); directory.dispose(); }); }); + +it.each(["before", "during", "before-metadata", "damaged"] as const)( + "retains unchanged workspaces across route/full demand and restart (cache: %s)", + async (timing) => { + const cacheFirst = timing === "before"; + const damaged = timing === "damaged"; + const serverId = `cache-route-overlap-${timing}`; + serverIds.add(serverId); + const { cache, database, reopen, storage } = createSqliteCache(); + cache.setHosts([serverId]); + const projectEntry: ProjectListResult["projects"][number] = { + projectId: "P", + projectDisplayName: "Project P", + projectRootPath: "/repo", + projectKind: "git", + }; + const project = normalizeProjectDescriptor(projectEntry); + const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({ + id, + projectId: "P", + projectDisplayName: "Project P", + projectRootPath: "/repo", + workspaceDirectory: `/repo/${id}`, + projectKind: "git", + workspaceKind: "local_checkout", + name: id, + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + })); + const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor); + const cursor = { generation: "g", afterSeq: 7 }; + cache.replaceDirectoryBaseline(serverId, { + agents: new Map(), + projects: new Map([[project.projectId, project]]), + workspaces: new Map( + (damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]), + ), + checkpoint: { projects: cursor, workspaces: cursor, agents: cursor }, + }); + await cache.flush(); + if (damaged) { + await storage.apply({ + deletes: [], + upserts: [ + { + serverId, + kind: "checkpoint", + id: "singleton", + payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }), + }, + ], + }); + } + + // The host keeps A/B unchanged. A current cursor legitimately receives no rows. + class UnchangedDirectoryClient extends FakeDirectoryClient { + readonly workspaceSyncRequests: unknown[] = []; + override async fetchWorkspaces( + options?: Parameters[0], + ): Promise { + this.fetchWorkspacesCalls += 1; + this.lastWorkspaceOptions = options; + this.workspaceSyncRequests.push(options?.sync); + const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7; + return { + requestId: "workspaces", + entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]], + emptyProjects: [], + pageInfo: { + hasMore: !changes && !options?.page?.cursor, + nextCursor: !changes && !options?.page?.cursor ? "page-2" : null, + prevCursor: null, + }, + sync: { + generation: "g", + headSeq: 7, + mode: changes ? "changes" : "snapshot", + removals: [], + }, + }; + } + } + const client = new UnchangedDirectoryClient(); + const releaseAgents = client.holdAgentFetch(); + const releaseProjects = client.holdProjectFetch(); + const projectResult: ProjectListResult = { + requestId: "projects", + projects: [projectEntry], + sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] }, + }; + client.projectResult = projectResult; + const callbacks = { + onAgentStoppedRunning: () => {}, + markAgentLoading: () => {}, + markAgentReady: () => {}, + markAgentError: () => {}, + }; + const directory = new DirectorySync(serverId, callbacks, cache); + const publishServerInfo = () => { + useSessionStore.getState().updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true, projectList: true }, + }); + }; + const initializeSession = (ready = true) => { + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + if (ready) publishServerInfo(); + }; + const connection = { + client: client as unknown as DaemonClient, + status: "online" as const, + source: { clientGeneration: 1, connectionEpoch: 1 }, + }; + initializeSession(timing !== "before-metadata"); + directory.connectionChanged(connection); + const visibleIds = () => + [...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort(); + let restarted: DirectorySync | undefined; + const restartedCache = reopen(); + try { + if (cacheFirst) await directory.restoreCachedDirectory(); + directory.setAgentRouteDemand(["agent"]); + if (timing === "before-metadata") { + await directory.restoreCachedDirectory(); + publishServerInfo(); + } + await expect.poll(() => client.listProjectsCalls).toBe(1); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + directory.setDemand({}, true); + await directory.restoreCachedDirectory(); + expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]); + + releaseProjects(projectResult); + releaseAgents({ + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { + generation: "g", + headSeq: 7, + mode: cacheFirst ? "changes" : "snapshot", + removals: [], + }, + }); + await directory.refreshDemand(); + expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} }); + expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]); + expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]); + await cache.flush(); + expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([ + "A", + "B", + ]); + + await directory.refreshAll(); + await cache.flush(); + directory.dispose(); + useSessionStore.getState().clearSession(serverId); + restartedCache.setHosts([serverId]); + const persisted = await restartedCache.readDirectory(serverId); + expect(persisted.checkpoint?.workspaces).toEqual(cursor); + expect + .soft([...persisted.workspaces.keys()].sort(), "persisted baseline") + .toEqual(["A", "B"]); + initializeSession(); + restarted = new DirectorySync(serverId, callbacks, restartedCache); + restarted.connectionChanged(connection); + await restarted.refreshAll(); + expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]); + const beforeUpdate = visibleIds(); + client.emit({ + type: "workspace_update", + payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 }, + }); + expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort()); + client.emit({ + type: "workspace_update", + payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 }, + }); + expect(visibleIds()).toEqual(["A", "B"]); + } finally { + directory.dispose(); + restarted?.dispose(); + await cache.flush(); + await restartedCache.flush(); + database.close(); + } + }, +); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 90648ffd38..6589914825 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -1,3 +1,4 @@ +import type { OwnedSubscription } from "@getpaseo/client"; import type { DaemonClient, FetchAgentsEntry, @@ -12,12 +13,6 @@ import { type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { - readLegacyDaemonWorkspaceDirectory, - buildLegacyWorkspaces, - shouldUseLegacyDaemonWorkspaceDirectory, - stampLegacyWorkspaceIds, -} from "@/workspace/legacy-daemon-workspaces"; import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { AgentDirectoryReplica } from "./agent-replica"; import { @@ -36,7 +31,12 @@ import type { CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, + DirectoryReplicaMutation, } from "@/runtime/replica-cache"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; + +type FetchAgentsPayload = Awaited>; +type FetchWorkspacesPayload = Awaited>; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -56,12 +56,15 @@ function resolveAgentNextPage(pageInfo: AgentPageInfo): { interface AgentSnapshot { entries: FetchAgentsEntry[]; subscriptionId: string | null; - legacy: boolean; syncMode?: "snapshot" | "changes"; syncCursor?: DirectoryCursor; syncRemovals: Array<{ id: string; seq: number }>; } +interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot { + readonly requestCursors: Readonly>; +} + interface AgentPageInfo { hasMore?: boolean; hasMoreAfter?: boolean; @@ -69,6 +72,36 @@ interface AgentPageInfo { afterCursor?: string | null; } +function applyWorkspaceSnapshotPage( + snapshot: WorkspaceDirectorySnapshot, + payload: Awaited>, + firstPage: boolean, +): void { + if (firstPage && payload.sync?.mode !== "changes") snapshot.workspaces.clear(); + if (firstPage) (snapshot.syncModes ??= {}).workspaces = payload.sync?.mode ?? "snapshot"; + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + snapshot.workspaces.set(workspace.id, workspace); + (snapshot.touchedWorkspaceIds ??= new Set()).add(workspace.id); + (snapshot.touchedProjectIds ??= new Set()).add(workspace.projectId); + } + for (const removal of payload.sync?.removals ?? []) { + snapshot.workspaces.delete(removal.id); + (snapshot.touchedWorkspaceIds ??= new Set()).add(removal.id); + } + if (payload.sync) { + (snapshot.syncCursors ??= {}).workspaces = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeProjectDescriptor(entry); + snapshot.projects.set(project.projectId, project); + (snapshot.touchedProjectIds ??= new Set()).add(project.projectId); + } +} + export interface DirectoryConnection { client: DaemonClient | null; status: "online" | "offline"; @@ -79,15 +112,12 @@ export interface DirectoryCheckpointStorage { readAgent(serverId: string, agentId: string): Promise; readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void; + replaceDirectoryBaseline?(serverId: string, directory: CachedDirectory): void; } export interface RefreshAgentDirectoryInput { @@ -107,7 +137,7 @@ export class DirectorySync { AgentDirectoryDelta >(); private readonly workspaceTransactions = new DirectoryTransactionOwner< - WorkspaceDirectorySnapshot, + WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta >(); private readonly agents: AgentDirectoryReplica; @@ -117,7 +147,9 @@ export class DirectorySync { status: "offline", source: { clientGeneration: 0, connectionEpoch: 0 }, }; - private unsubscribe: (() => void) | null = null; + private agentSubscription: OwnedSubscription | null = null; + private workspaceSubscription: OwnedSubscription | null = null; + private eventSubscription: ReturnType | null = null; private readonly abortSessionWaits = new Set<() => void>(); private cacheLoad: Promise | null = null; private cacheAccepted = false; @@ -139,7 +171,9 @@ export class DirectorySync { }, private readonly checkpoints?: DirectoryCheckpointStorage, ) { - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); + const persist = (mutations: readonly DirectoryReplicaMutation[]) => + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); this.workspaces = new WorkspaceDirectoryReplica(serverId); } @@ -154,79 +188,13 @@ export class DirectorySync { return false; } this.flushAbortedTransactions(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); workspaceLabels.disconnect(this.serverId); this.connection = connection; this.abortPendingSessionWaits(); if (!connection.client || connection.status !== "online") return true; - const client = connection.client; - const source = connection.source; - const subscriptions = [ - client.on("agent_update", (message) => { - if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - const recorded = this.agentTransactions.record(source, message.payload); - if (!recorded) { - this.agents.applyDelta(message.payload); - this.noteLiveCursor("agents", message.payload); - this.persistDirectory(); - } - }), - client.on("workspace_update", (message) => { - if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.workspaces.applyDelta(message.payload); - this.noteLiveCursor("workspaces", message.payload); - this.persistDirectory(); - } - }), - client.on("project.update", (message) => { - if (message.type !== "project.update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const recorded = this.workspaceTransactions.record(source, message.payload); - if (!recorded) { - this.workspaces.applyDelta(message.payload); - this.noteLiveCursor("projects", message.payload); - this.persistDirectory(); - } - }), - client.on("script_status_update", (message) => { - if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; - this.revision += 1; - this.workspaceRevision += 1; - const delta: WorkspaceDirectoryDelta = { - kind: "script_status", - update: message.payload, - }; - const recorded = this.workspaceTransactions.record(source, delta); - if (!recorded) { - this.workspaces.applyDelta(delta); - this.persistDirectory(); - } - }), - client.on("agent_deleted", (message) => { - if (message.type === "agent_deleted" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.remove(message.payload.agentId); - this.persistDirectory(); - } - }), - client.on("agent_archived", (message) => { - if (message.type === "agent_archived" && this.isCurrent(client, source)) { - this.revision += 1; - this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistDirectory(); - } - }), - ]; - this.unsubscribe = () => { - for (const unsubscribe of subscriptions) unsubscribe(); - }; + // Reattach labels here because route-only demand can satisfy the epoch before full demand requests them. + void this.connectWorkspaceLabels().catch(() => undefined); if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); return true; } @@ -235,6 +203,7 @@ export class DirectorySync { const wasDemanded = this.fullDemandSources.size > 0; if (demanded) this.fullDemandSources.add(source); else this.fullDemandSources.delete(source); + if (!this.hasDemand()) this.releaseSubscriptions(); if (!wasDemanded && this.fullDemandSources.size > 0) { void this.loadCachedDirectory().catch(() => undefined); if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); @@ -251,6 +220,7 @@ export class DirectorySync { } this.routeDemandIds.clear(); for (const agentId of next) this.routeDemandIds.add(agentId); + if (!this.hasDemand()) this.releaseSubscriptions(); if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) { void this.requestDemandRefresh().catch(() => undefined); } @@ -259,13 +229,65 @@ export class DirectorySync { dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); - this.unsubscribe?.(); - this.unsubscribe = null; + this.releaseSubscriptions(); this.fullDemandSources.clear(); this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); } + private releaseSubscriptions(): void { + for (const subscription of [ + this.agentSubscription, + this.workspaceSubscription, + this.eventSubscription, + ]) { + void subscription?.release().catch(() => undefined); + } + this.agentSubscription = null; + this.workspaceSubscription = null; + this.eventSubscription = null; + this.satisfiedDemandSource = null; + } + + private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void { + this.revision += 1; + if (this.agentTransactions.record(source, delta)) return; + this.agents.applyDelta(delta); + this.noteLiveCursor("agents", delta); + this.persistCheckpoint(); + } + + private receiveWorkspaceDelta( + source: DirectorySourceToken, + delta: WorkspaceDirectoryDelta, + ): void { + this.revision += 1; + this.workspaceRevision += 1; + if (this.workspaceTransactions.record(source, delta)) return; + this.applyWorkspaceDelta(delta); + if (delta.kind !== "script_status") + this.noteLiveCursor( + "projectId" in delta || "project" in delta ? "projects" : "workspaces", + delta, + ); + this.persistCheckpoint(); + } + + private observeDirectoryEvents(): void { + if (this.eventSubscription) return; + const { client, source } = this.requireOnline(); + this.eventSubscription = client.observeEvents(["project.update", "script_status_update"]); + this.eventSubscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (!this.isCurrent(client, source)) return; + if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload); + if (message.type === "script_status_update") + this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload }); + }, + }); + } + private hasDemand(): boolean { return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; } @@ -276,6 +298,7 @@ export class DirectorySync { if (!this.getOnlineConnection()) { return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); } + this.observeDirectoryEvents(); const source = this.connection.source; if ( !force && @@ -352,32 +375,32 @@ export class DirectorySync { if (!checkpoints) return Promise.resolve(); this.cacheLoad ??= (async () => { const revision = this.revision; - const initial = useSessionStore.getState().sessions[this.serverId]; - if (!initial) return; - const initialAgents = initial.agents; - const initialWorkspaces = initial.workspaces; - const initialProjects = initial.projects; - const pristine = - initialAgents.size === 0 && initialWorkspaces.size === 0 && initialProjects.size === 0; const cached = await checkpoints.readDirectory(this.serverId); - if (this.cacheAccepted || !pristine || this.revision !== revision) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if ( - !session || - session.agents !== initialAgents || - session.workspaces !== initialWorkspaces || - session.projects !== initialProjects - ) { - return; - } + if (this.cacheAccepted) return; + if (!useSessionStore.getState().sessions[this.serverId]) return; this.agents.commitCached(cached.agents); this.workspaces.commitCached(cached); - this.cursors = cached.checkpoint ?? {}; + if (this.revision === revision) this.cursors = cached.checkpoint ?? {}; this.cacheAccepted = true; })(); return this.cacheLoad; } + restoreCachedDirectory(): Promise { + return this.loadCachedDirectory(); + } + + applyAgentTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.agents.applyTurnLiveness(agentId, transition); + } + + acceptAgent(agent: Agent): Agent { + return this.agents.accept(agent); + } + async fetchTimeline( agentId: string, request: Parameters[1], @@ -387,7 +410,7 @@ export class DirectorySync { const page = await fetchAgentTimelineOnce(client, agentId, request); if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { this.revision += 1; - this.persistDirectory(); + this.persistCheckpoint(); } return page; } @@ -418,32 +441,12 @@ export class DirectorySync { const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); this.callbacks.markAgentLoading(); try { await this.waitForSession(client, source); - const session = useSessionStore.getState().sessions[this.serverId]; - if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { - const directory = await readLegacyDaemonWorkspaceDirectory({ - client, - subscribe: input.subscribe, - page: input.page, - }); - if ( - !directory || - !this.agentTransactions.isCurrent(transaction) || - !this.isCurrent(client, source) - ) { - throw new DirectoryRefreshSupersededError("legacy fetch no longer current"); - } - transaction.snapshot.entries.push(...stampLegacyWorkspaceIds(directory.entries)); - transaction.snapshot.subscriptionId = directory.subscriptionId; - transaction.snapshot.legacy = true; - } else { - await this.fetchAgents(client, source, transaction, input); - } + await this.fetchAgents(client, source, transaction, input); if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { throw new DirectoryRefreshSupersededError("agent completion no longer current"); } @@ -451,27 +454,10 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } - if (completion.snapshot.legacy) { - const store = useSessionStore.getState(); - const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - store.setWorkspaces(this.serverId, workspaces); - store.setProjects( - this.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), - ); - store.setHasHydratedWorkspaces(this.serverId, true); - } - const deltas = completion.snapshot.legacy - ? completion.deltas.map((delta) => - delta.kind === "upsert" - ? { ...delta, agent: { ...delta.agent, workspaceId: delta.agent.cwd } } - : delta, - ) - : completion.deltas; this.revision += 1; - const agents = this.commitAgentSnapshot(completion.snapshot, deltas); + const agents = this.commitAgentSnapshot(completion.snapshot, completion.deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); - this.persistDirectory(); + this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -496,21 +482,25 @@ export class DirectorySync { const onlineConnection = this.getOnlineConnection(); if (!onlineConnection) return; const { client, source } = onlineConnection; - const transaction = this.workspaceTransactions.begin(source, () => ({ - workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), - projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), - syncCursors: {}, - })); + const transaction = this.workspaceTransactions.begin(source, () => { + const baseline = this.workspaces.snapshot(); + return { + workspaces: new Map(baseline.workspaces), + projects: new Map(baseline.projects), + // Cache hydration can advance the live replica while these requests await responses. + // Every page must use the cursors belonging to the maps captured here. + requestCursors: { ...this.cursors }, + syncCursors: {}, + syncModes: {}, + touchedWorkspaceIds: new Set(), + touchedProjectIds: new Set(), + }; + }); try { await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; - if (serverInfo?.features?.workspaceMultiplicity !== true) { - const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); - return; - } - const supportsProjectList = serverInfo.features?.projectList === true; - const supportsDirectorySync = serverInfo.features?.directorySync === true; + const supportsProjectList = serverInfo?.features?.projectList === true; + const supportsDirectorySync = serverInfo?.features?.directorySync === true; if (supportsProjectList) { await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); } @@ -521,11 +511,15 @@ export class DirectorySync { input?.subscribe === true, supportsDirectorySync, ); - if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); + if (!supportsProjectList) { + this.buildLegacyProjectSnapshot(transaction.snapshot); + (transaction.snapshot.syncModes ??= {}).projects = + transaction.snapshot.syncModes?.workspaces; + } this.completeWorkspaceRefresh(client, source, transaction); } catch (error) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); throw error; } } @@ -533,40 +527,37 @@ export class DirectorySync { private async fetchWorkspaceSnapshot( client: DaemonClient, source: DirectorySourceToken, - transaction: DirectoryTransaction, + transaction: DirectoryTransaction, initialSubscribe: boolean, supportsDirectorySync: boolean, ): Promise { let cursor: string | null = null; let subscribe = initialSubscribe; while (true) { - const payload = await client.fetchWorkspaces({ + const query: Parameters[0] = { + ...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}), sort: [{ key: "activity_at", direction: "desc" }], - ...(subscribe ? { subscribe: {} } : {}), page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, - ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), - }); + ...(supportsDirectorySync + ? { sync: transaction.snapshot.requestCursors.workspaces ?? {} } + : {}), + }; + let payload: FetchWorkspacesPayload; + if (subscribe) { + void this.workspaceSubscription?.release().catch(() => undefined); + const subscription = client.observeWorkspaces(query); + this.workspaceSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update" && this.isCurrent(client, source)) + this.receiveWorkspaceDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchWorkspaces(query); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (cursor === null && payload.sync?.mode !== "changes") { - transaction.snapshot.workspaces.clear(); - } - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - transaction.snapshot.workspaces.set(workspace.id, workspace); - } - for (const removal of payload.sync?.removals ?? []) { - transaction.snapshot.workspaces.delete(removal.id); - } - if (payload.sync) { - (transaction.snapshot.syncCursors ??= {}).workspaces = { - generation: payload.sync.generation, - afterSeq: payload.sync.headSeq, - }; - } - for (const entry of payload.emptyProjects ?? []) { - const project = normalizeProjectDescriptor(entry); - transaction.snapshot.projects.set(project.projectId, project); - } + applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; cursor = payload.pageInfo.nextCursor; subscribe = false; @@ -587,6 +578,11 @@ export class DirectorySync { this.refreshAgents({ subscribe: {} }), this.refreshWorkspaces({ subscribe: true }), ]); + this.checkpoints?.replaceDirectoryBaseline?.(this.serverId, { + agents: this.agents.snapshot(), + ...this.workspaces.snapshot(), + checkpoint: this.cursors, + }); if (this.getOnlineConnection()) await this.connectWorkspaceLabels(); } @@ -600,6 +596,33 @@ export class DirectorySync { }); } + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): void { + const mutations = this.workspaces.acceptWorkspaces(workspaces); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + archiveAgent(agentId: string, archivedAt: string): void { + this.agents.archive(agentId, archivedAt); + } + + removeAgent(agentId: string): void { + this.agents.remove(agentId); + } + + acceptProject(project: ProjectDescriptor): void { + const mutations = this.workspaces.acceptProject(project); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + removeWorkspace(workspaceId: string): void { + const mutations = this.workspaces.removeWorkspaceSnapshot(workspaceId); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + markWorkspacesHydrated(hydrated: boolean): void { + useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated); + } + private async fetchAgents( client: DaemonClient, source: DirectorySourceToken, @@ -610,15 +633,30 @@ export class DirectorySync { let subscribe = input.subscribe; while (true) { const limit = input.page?.limit ?? PAGE_LIMIT; - const payload = await client.fetchAgents({ + const query: Omit = { ...(input.filter ? { filter: input.filter } : { scope: "active" as const }), + ...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}), sort: AGENT_SORT, - ...(subscribe ? { subscribe } : {}), page: cursor ? { limit, cursor } : { limit }, ...(!input.filter && cursor === null && this.supportsDirectorySync() ? { sync: this.readCursors().agents ?? {} } : {}), - }); + }; + let payload: FetchAgentsPayload; + if (subscribe) { + if (subscribe.subscriptionId) throw new Error("Subscription IDs are assigned by the host"); + void this.agentSubscription?.release().catch(() => undefined); + const subscription = client.observeAgents(query); + this.agentSubscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && this.isCurrent(client, source)) + this.receiveAgentDelta(source, message.payload); + }, + }); + payload = await subscription.ready; + } else payload = await client.fetchAgents(query); this.assertAgentTransactionCurrent(client, source, transaction); transaction.snapshot.entries.push(...payload.entries); transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; @@ -643,7 +681,7 @@ export class DirectorySync { private assertWorkspaceTransactionCurrent( client: DaemonClient, source: DirectorySourceToken, - transaction: DirectoryTransaction, + transaction: DirectoryTransaction, ): void { if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) { throw new DirectoryRefreshSupersededError("workspace fetch no longer current"); @@ -669,7 +707,6 @@ export class DirectorySync { this.agentTransactions.begin(connection.source, () => ({ entries: [], subscriptionId: null, - legacy: false, syncRemovals: [], })); } @@ -701,20 +738,25 @@ export class DirectorySync { private async fetchProjectSnapshot( client: DaemonClient, source: DirectorySourceToken, - transaction: DirectoryTransaction, + transaction: DirectoryTransaction, supportsDirectorySync: boolean, ): Promise { const payload = await client.listProjects( - supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined, + supportsDirectorySync + ? { sync: transaction.snapshot.requestCursors.projects ?? {} } + : undefined, ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear(); + (transaction.snapshot.syncModes ??= {}).projects = payload.sync?.mode ?? "snapshot"; for (const entry of payload.projects) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(project.projectId); } for (const removal of payload.sync?.removals ?? []) { transaction.snapshot.projects.delete(removal.id); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(removal.id); } if (payload.sync) { (transaction.snapshot.syncCursors ??= {}).projects = { @@ -727,7 +769,7 @@ export class DirectorySync { private completeWorkspaceRefresh( client: DaemonClient, source: DirectorySourceToken, - transaction: DirectoryTransaction, + transaction: DirectoryTransaction, ): void { if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { throw new DirectoryRefreshSupersededError("workspace completion no longer current"); @@ -738,7 +780,22 @@ export class DirectorySync { } this.revision += 1; this.workspaceRevision += 1; - this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const previous = this.readWorkspaceState(); + const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const next = this.workspaces.snapshot(); + const workspaceIds = + completion.snapshot.syncModes?.workspaces === "changes" + ? new Set(completion.snapshot.touchedWorkspaceIds) + : new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]); + const projectIds = + completion.snapshot.syncModes?.projects === "changes" + ? new Set(completion.snapshot.touchedProjectIds) + : new Set([...previous.projects.keys(), ...next.projects.keys()]); + for (const mutation of deltaMutations) { + if (mutation.kind === "workspace") workspaceIds.add(mutation.id); + if (mutation.kind === "project") projectIds.add(mutation.id); + } + this.persistWorkspaceChanges(previous, workspaceIds, projectIds); for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } @@ -747,19 +804,41 @@ export class DirectorySync { const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; this.noteLiveCursor(entity, delta); } - this.persistDirectory(); + this.persistCheckpoint(); } - private persistDirectory(): void { - if (!this.checkpoints) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if (!session) return; - this.checkpoints.commitDirectory(this.serverId, { - agents: session.agents, - workspaces: session.workspaces, - projects: session.projects, - checkpoint: this.cursors, - }); + private persistCheckpoint(): void { + this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); + } + + private readWorkspaceState(): WorkspaceDirectorySnapshot { + return this.workspaces.snapshot(); + } + + private applyWorkspaceDelta(delta: WorkspaceDirectoryDelta): void { + const mutations = this.workspaces.applyDelta(delta); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + private persistWorkspaceChanges( + previous: WorkspaceDirectorySnapshot, + workspaceIds: Set, + projectIds: Set, + ): void { + const next = this.workspaces.snapshot(); + const mutations: DirectoryReplicaMutation[] = []; + for (const id of workspaceIds) { + const value = next.workspaces.get(id); + if (value) mutations.push({ kind: "workspace", type: "upsert", id, value }); + else if (previous.workspaces.has(id)) + mutations.push({ kind: "workspace", type: "delete", id }); + } + for (const id of projectIds) { + const value = next.projects.get(id); + if (value) mutations.push({ kind: "project", type: "upsert", id, value }); + else if (previous.projects.has(id)) mutations.push({ kind: "project", type: "delete", id }); + } + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); } private recordAgentSync( @@ -858,7 +937,7 @@ export class DirectorySync { private flushAbortedTransactions(): void { for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta); - for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta); + for (const delta of this.workspaceTransactions.abort()) this.applyWorkspaceDelta(delta); } private abortPendingSessionWaits(): void { diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts new file mode 100644 index 0000000000..97c8208da5 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -0,0 +1,228 @@ +import equal from "fast-deep-equal"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; +import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function withoutAgent(agentId: string) { + return (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; +} + +function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent { + if (!previous) return incoming; + const activeTurn = + incoming.turn.phase === "open" + ? { turnId: incoming.turn.turnId, startedAt: incoming.turn.startedAt } + : null; + const turn = reduceTurnLiveness(previous.turn, { type: "snapshot", activeTurn }); + return turn === incoming.turn ? incoming : { ...incoming, turn }; +} + +export class AgentStoreProjection { + constructor(private readonly serverId: string) {} + + snapshot(): Map { + return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + } + + get(agentId: string): Agent | undefined { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + } + + commitCached(agents: Map): Map { + const merged = new Map(agents); + for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent); + for (const agent of merged.values()) this.publishActivity(agent); + useSessionStore.getState().setAgents(this.serverId, merged); + return merged; + } + + publishActivity(agent: Agent): void { + useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + } + + accept(agent: Agent): Agent { + let accepted = agent; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const previous = current.get(agent.id); + accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent)); + if (accepted === previous) return current; + const next = new Map(current); + next.set(agent.id, accepted); + return next; + }); + return accepted; + } + + applyDelta(delta: AgentDirectoryDelta): { + agentId: string; + stoppedRunning: boolean; + agent?: Agent; + } { + if (delta.kind === "remove") { + this.removeFromDirectory(delta.agentId); + return { agentId: delta.agentId, stoppedRunning: false }; + } + const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); + const session = useSessionStore.getState().sessions[this.serverId]; + const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previous?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const accepted = this.accept({ + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previous?.projectPlacement, + }); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + this.replacePendingPermissions(accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + return { + agentId: accepted.id, + stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle", + agent: accepted, + }; + } + + replaceFetched(entries: FetchAgentsEntry[]): Map { + const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({ + serverId: this.serverId, + entries, + }); + const store = useSessionStore.getState(); + const current = store.sessions[this.serverId]?.agents ?? new Map(); + const agents = new Map(); + for (const [agentId, fetchedAgent] of fetched) { + const existing = current.get(agentId); + const merged = mergeSnapshotTurn(existing, fetchedAgent); + agents.set(agentId, existing && equal(existing, merged) ? existing : merged); + if (fetchedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + } + store.setAgents(this.serverId, agents); + store.setAgentDetails(this.serverId, (previous) => { + let next: Map | null = null; + for (const agentId of agents.keys()) { + if (!previous.has(agentId)) continue; + next ??= new Map(previous); + next.delete(agentId); + } + return next ?? previous; + }); + store.setAgentLastActivityBatch( + new Map(Array.from(agents.values(), (agent) => [agent.id, agent.lastActivityAt])), + ); + store.setPendingPermissions(this.serverId, new Map(pendingPermissions)); + store.setHasHydratedAgents(this.serverId, true); + return agents; + } + + replacePendingPermissions(agent: Agent): void { + const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions); + for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key); + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pending.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(this.serverId, pending); + } + + removeFromDirectory(agentId: string): void { + const store = useSessionStore.getState(); + const removeKey = withoutAgent(agentId); + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + store.setAgents(this.serverId, removeKey); + store.setAgentDetails(this.serverId, removeKey); + store.setQueuedMessages(this.serverId, removeKey); + store.setInitializingAgents(this.serverId, removeKey); + store.setPendingPermissions(this.serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); + return next.size === current.size ? current : next; + }); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); + const initKey = getInitKey(this.serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } + } + + remove(agentId: string): void { + this.removeFromDirectory(agentId); + // Only entity deletion destroys the transcript. A filtered active-list + // response can arrive after a fresh history response for an archived agent. + const store = useSessionStore.getState(); + const removeKey = withoutAgent(agentId); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + } + + archive(agentId: string, archivedAt: string): Agent | null { + let archived: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + archived = { + ...agent, + turn: { phase: "idle", cancellationRequestId: null }, + archivedAt: new Date(archivedAt), + }; + const next = new Map(current); + next.set(agentId, archived); + return next; + }); + return archived; + } + + applyTurn( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): Agent | null { + const transitions = Array.isArray(transition) ? transition : [transition]; + let accepted: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + if (turn === agent.turn) return current; + accepted = { ...agent, turn }; + const next = new Map(current); + next.set(agentId, accepted); + return next; + }); + return accepted; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index bc3a36fd56..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -16,11 +16,11 @@ import { shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; export type WorkspaceDirectoryDelta = | Extract["payload"] | { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] }; -type ProjectDirectoryDelta = Extract["payload"]; export interface WorkspaceDirectorySnapshot { workspaces: Map; @@ -28,67 +28,33 @@ export interface WorkspaceDirectorySnapshot { syncCursors?: Partial< Record<"projects" | "workspaces", { generation: string; afterSeq: number }> >; -} - -function patchWorkspaceScripts( - workspaces: Map, - update: ScriptStatusUpdateMessage["payload"], -): Map { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces, - workspaceId: update.workspaceId, - }); - if (!workspaceKey) return workspaces; - const existing = workspaces.get(workspaceKey); - if (!existing || equal(existing.scripts, update.scripts)) return workspaces; - const next = new Map(workspaces); - next.set(workspaceKey, { - ...existing, - scripts: update.scripts.map((script) => ({ ...script })), - }); - return next; -} - -function applyProjectDelta( - snapshot: WorkspaceDirectorySnapshot, - delta: ProjectDirectoryDelta, -): void { - if (delta.kind === "remove") { - snapshot.projects.delete(delta.projectId); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); - } - return; - } - - const project = normalizeProjectDescriptor(delta.project); - snapshot.projects.set(project.projectId, project); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId !== project.projectId) continue; - snapshot.workspaces.set(workspaceId, { - ...workspace, - projectDisplayName: project.projectDisplayName, - projectCustomName: project.projectCustomName, - projectCustomIconRevision: project.projectCustomIconRevision, - projectRootPath: project.projectRootPath, - projectKind: project.projectKind, - }); - } + syncModes?: Partial>; + touchedWorkspaceIds?: Set; + touchedProjectIds?: Set; } export class WorkspaceDirectoryReplica { + private workspaces = new Map(); + private projects = new Map(); + private workspaceIdsByProject = new Map>(); + constructor(private readonly serverId: string) {} - applyDelta(delta: WorkspaceDirectoryDelta): void { - const state = this.reconcile(this.read(), [delta]); - this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); + applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] { + if (delta.kind === "script_status") return this.applyScriptStatus(delta.update); + if ("projectId" in delta || "project" in delta) return this.applyProjectDelta(delta); + if (delta.kind === "remove") return this.removeWorkspace(delta); + return this.upsertWorkspace(normalizeWorkspaceDescriptor(delta.workspace)); } commitCached(input: { workspaces: Map; projects: Map; }): void { - this.commit(input, []); + this.replace({ + workspaces: new Map([...input.workspaces, ...this.workspaces]), + projects: new Map([...input.projects, ...this.projects]), + }); useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } @@ -97,79 +63,171 @@ export class WorkspaceDirectoryReplica { project: ProjectDescriptor | undefined, ): void { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; - const snapshot = this.read(); - snapshot.workspaces.set(workspace.id, workspace); - if (project) snapshot.projects.set(project.projectId, project); - this.commit(snapshot, []); + if (project) this.setProject(project); + this.setWorkspace(workspace); } commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], - ): void { - const removedWorkspaceIds = deltas.flatMap((delta) => - delta.kind === "remove" && "id" in delta ? [delta.id] : [], - ); - this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + ): DirectoryReplicaMutation[] { + this.replace(snapshot); + const mutations = deltas.flatMap((delta) => this.applyDelta(delta)); useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true); + return mutations; } - private read(): WorkspaceDirectorySnapshot { - const session = useSessionStore.getState().sessions[this.serverId]; - return { - workspaces: new Map(session?.workspaces), - projects: new Map(session?.projects), - }; + snapshot(): WorkspaceDirectorySnapshot { + return { workspaces: this.workspaces, projects: this.projects }; } - private reconcile( - snapshot: WorkspaceDirectorySnapshot, - deltas: readonly WorkspaceDirectoryDelta[], - ): WorkspaceDirectorySnapshot { - let workspaces = new Map(snapshot.workspaces); - const projects = new Map(snapshot.projects); - for (const [workspaceId, workspace] of workspaces) { - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspaceId); + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { + return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + } + + acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { + this.setProject(project); + return [{ kind: "project", type: "upsert", id: project.projectId, value: project }]; + } + + removeWorkspaceSnapshot(workspaceId: string): DirectoryReplicaMutation[] { + this.deleteWorkspace(workspaceId); + return [{ kind: "workspace", type: "delete", id: workspaceId }]; + } + + private replace(snapshot: WorkspaceDirectorySnapshot): void { + const workspaces = new Map(); + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (!shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.set(workspaceId, workspace); } } - for (const delta of deltas) { - if (delta.kind === "script_status") { - workspaces = patchWorkspaceScripts(workspaces, delta.update); - continue; - } - if ("projectId" in delta || "project" in delta) { - applyProjectDelta({ workspaces, projects }, delta); - continue; - } - if (delta.kind === "remove") { - workspaces.delete(delta.id); - if (delta.emptyProject) { - const project = normalizeProjectDescriptor(delta.emptyProject); - projects.set(project.projectId, project); - } - if (delta.removedProjectId) { - projects.delete(delta.removedProjectId); - } - continue; - } - const workspace = normalizeWorkspaceDescriptor(delta.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspace.id); - } else { - workspaces.set(workspace.id, workspace); - } - } - return { workspaces, projects }; - } - - private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + this.workspaces = workspaces; + this.projects = new Map(snapshot.projects); + this.rebuildProjectIndex(); const store = useSessionStore.getState(); - store.setWorkspaces(this.serverId, snapshot.workspaces); - store.setProjects(this.serverId, snapshot.projects.values()); - for (const workspaceId of removedWorkspaceIds) { - clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + store.setWorkspaces(this.serverId, this.workspaces); + store.setProjects(this.serverId, this.projects.values()); + } + + private applyScriptStatus( + update: ScriptStatusUpdateMessage["payload"], + ): DirectoryReplicaMutation[] { + const workspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces: this.workspaces, + workspaceId: update.workspaceId, + }); + const workspace = workspaceId ? this.workspaces.get(workspaceId) : undefined; + if (!workspace || equal(workspace.scripts, update.scripts)) return []; + const next = { ...workspace, scripts: update.scripts.map((script) => ({ ...script })) }; + this.setWorkspace(next); + return [{ kind: "workspace", type: "upsert", id: next.id, value: next }]; + } + + private applyProjectDelta( + delta: Extract["payload"], + ): DirectoryReplicaMutation[] { + if (delta.kind === "remove") { + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "delete", id: delta.projectId }, + ]; + this.projects.delete(delta.projectId); + useSessionStore.getState().removeProject(this.serverId, delta.projectId); + for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) { + this.deleteWorkspace(workspaceId); + mutations.push({ kind: "workspace", type: "delete", id: workspaceId }); + } + return mutations; + } + + const project = normalizeProjectDescriptor(delta.project); + this.setProject(project); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "upsert", id: project.projectId, value: project }, + ]; + for (const workspaceId of this.workspaceIdsByProject.get(project.projectId) ?? []) { + const workspace = this.workspaces.get(workspaceId); + if (!workspace) continue; + const next = { + ...workspace, + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }; + this.setWorkspace(next); + mutations.push({ kind: "workspace", type: "upsert", id: next.id, value: next }); + } + return mutations; + } + + private removeWorkspace( + delta: Extract["payload"] & { + kind: "remove"; + }, + ): DirectoryReplicaMutation[] { + this.deleteWorkspace(delta.id); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "workspace", type: "delete", id: delta.id }, + ]; + if (delta.emptyProject) { + const project = normalizeProjectDescriptor(delta.emptyProject); + this.setProject(project); + mutations.push({ kind: "project", type: "upsert", id: project.projectId, value: project }); + } + if (delta.removedProjectId) { + this.projects.delete(delta.removedProjectId); + useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId); + mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId }); + } + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id }); + useWorkspaceSetupStore + .getState() + .removeWorkspace({ serverId: this.serverId, workspaceId: delta.id }); + return mutations; + } + + private upsertWorkspace(workspace: WorkspaceDescriptor): DirectoryReplicaMutation[] { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + if (!this.workspaces.has(workspace.id)) return []; + this.deleteWorkspace(workspace.id); + return [{ kind: "workspace", type: "delete", id: workspace.id }]; + } + this.setWorkspace(workspace); + return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }]; + } + + private setWorkspace(workspace: WorkspaceDescriptor): void { + const previous = this.workspaces.get(workspace.id); + if (previous?.projectId !== workspace.projectId) { + this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id); + } + this.workspaces.set(workspace.id, workspace); + const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + projectWorkspaces.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces); + useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]); + } + + private deleteWorkspace(workspaceId: string): void { + const workspace = this.workspaces.get(workspaceId); + if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId); + this.workspaces.delete(workspaceId); + useSessionStore.getState().removeWorkspace(this.serverId, workspaceId); + } + + private setProject(project: ProjectDescriptor): void { + this.projects.set(project.projectId, project); + useSessionStore.getState().upsertProject(this.serverId, project); + } + + private rebuildProjectIndex(): void { + this.workspaceIdsByProject = new Map(); + for (const workspace of this.workspaces.values()) { + const workspaceIds = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + workspaceIds.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, workspaceIds); } } } diff --git a/packages/app/src/runtime/host-confirmation.ts b/packages/app/src/runtime/host-confirmation.ts new file mode 100644 index 0000000000..8fac1031ff --- /dev/null +++ b/packages/app/src/runtime/host-confirmation.ts @@ -0,0 +1,97 @@ +import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import { + hostHasConnection, + relayConnectionFromOffer, + type HostProfile, +} from "@/types/host-connection"; + +/** + * The question shown before a pairing link adds a host that is not saved, or + * changes the key, relay, or TLS setting of a saved one. + */ +export interface HostConfirmationRequest { + /** Identifies this question, so an answer shown for it never answers a newer one. */ + id: number; + serverId: string; + /** Grouped daemon public key the user can compare with the one their daemon shows. */ + keyFingerprint: string; + relayEndpoint: string; + kind: "newHost" | "changedConnection"; +} + +interface PendingConfirmation { + request: HostConfirmationRequest; + resolve: (approved: boolean) => void; +} + +/** + * Owns the one host confirmation the app is waiting on. The host runtime asks + * through `confirmLink`; the view reads `getPending` and replies with `answer`. + * A question waits until it is answered, whether or not a view is showing it. + */ +export class HostConfirmations { + private pending: PendingConfirmation | null = null; + private nextRequestId = 1; + private listeners = new Set<() => void>(); + + getPending(): HostConfirmationRequest | null { + return this.pending?.request ?? null; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** Answers the request with `requestId`; an answer for any other request is ignored. */ + answer(requestId: number, approved: boolean): void { + const current = this.pending; + if (!current || current.request.id !== requestId) return; + this.pending = null; + this.emit(); + current.resolve(approved); + } + + /** Resolves true without asking when the link matches a saved host exactly. */ + confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise { + const connection = relayConnectionFromOffer(offer); + const savedHost = savedHosts.find((host) => host.serverId === offer.serverId); + if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true); + return this.ask({ + id: this.nextRequestId++, + serverId: offer.serverId, + keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64), + relayEndpoint: connection.relayEndpoint, + kind: savedHost ? "changedConnection" : "newHost", + }); + } + + private ask(request: HostConfirmationRequest): Promise { + // One question at a time: a newer one cancels the unanswered one. + this.pending?.resolve(false); + return new Promise((resolve) => { + this.pending = { request, resolve }; + this.emit(); + }); + } + + private emit(): void { + for (const listener of this.listeners) { + listener(); + } + } +} + +/** + * Shows the key material itself (not a re-hash) in groups of four. Keys longer + * than 16 characters show only their first and last 8, so two keys that share + * both ends show the same value. + */ +function formatDaemonKeyFingerprint(daemonPublicKeyB64: string): string { + const normalized = daemonPublicKeyB64.replace(/[^A-Za-z0-9]/g, ""); + const group = (value: string): string => value.replace(/(.{4})/g, "$1 ").trim(); + if (normalized.length <= 16) return group(normalized); + return `${group(normalized.slice(0, 8))} … ${group(normalized.slice(-8))}`; +} diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 431b806aae..61285eb2c6 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1,10 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AppStateStatus } from "react-native"; +import { + bindHostRuntimeAppState, + startHostRuntimeBootstrap, +} from "@/navigation/host-runtime-bootstrap"; import type { DaemonClient, ConnectionState, FetchAgentsEntry, FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; +import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; @@ -23,14 +29,43 @@ import { } from "./host-runtime"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import { subscriptionFixture } from "./subscription-fixture"; +import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential"; +import type { HostConfirmationRequest } from "./host-confirmation"; + +it("requests the managed connection credential through desktop main without a web hint", async () => { + const requests: string[] = []; + const connection: HostConnection = { + id: "direct:localhost:6767", + type: "directTcp", + endpoint: "localhost:6767", + }; + let token = "local-token"; + const invoke = async (listen: string) => { + requests.push(listen); + return token; + }; + expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("local-token"); + token = "rotated-token"; + expect(await readDesktopManagedLocalCredential(connection, invoke)).toBe("rotated-token"); + expect(requests).toEqual(["localhost:6767", "localhost:6767"]); +}); + class FakeDaemonClient { + supportsBackgroundWorkspaces(): boolean { + return false; + } private state: ConnectionState = { status: "idle" }; private listeners = new Set<(status: ConnectionState) => void>(); private error: string | null = null; + authFailureReason: "password_required" | "incorrect_password" | null = null; private heartbeatRttMs: number | null = null; private latencyMeasurementFailure: Error | null = null; private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; + public ensureConnectedCalls = 0; + public connectionVerifications = 0; + public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< Awaited> | ReturnType @@ -54,6 +89,16 @@ class FakeDaemonClient { return () => this.agentUpdateListeners.delete(listener); } + observeAgents(options: Parameters[0]) { + return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) => + this.on("agent_update", receive), + ); + } + + observeEvents() { + return subscriptionFixture(Promise.resolve({}), () => () => {}); + } + async waitForAgentUpdates(): Promise { if (this.agentUpdateListeners.size > 0) return; await new Promise((resolve) => { @@ -104,7 +149,9 @@ class FakeDaemonClient { }); } - ensureConnected(): void { + ensureConnected(options?: { verify?: boolean }): void { + this.ensureConnectedCalls += 1; + if (options?.verify) this.connectionVerifications += 1; if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); } @@ -114,8 +161,27 @@ class FakeDaemonClient { return this.state; } - getLastServerInfoMessage(): null { - return null; + public ownedSubscriptions = true; + private daemonVersion: string | null = "0.8.0"; + + getLastServerInfoMessage(): ReturnType { + if (this.daemonVersion === null) return null; + return { + status: "server_info", + serverId: "srv_test", + hostname: "test", + version: this.daemonVersion, + features: { ownedSubscriptions: this.ownedSubscriptions }, + }; + } + + // Like the real client, the server info is cleared while disconnected and replaced by the + // restarted daemon's handshake before the client reports connected again. + daemonRestartsAs(version: string): void { + this.daemonVersion = null; + this.setConnectionState({ status: "disconnected", reason: "Connection lost" }); + this.daemonVersion = version; + this.setConnectionState({ status: "connected" }); } subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { @@ -170,7 +236,9 @@ class FakeDaemonClient { return result.rttMs; } - setReconnectEnabled(_enabled: boolean): void {} + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabledChanges.push(enabled); + } getLastLivenessRttMs(): number | null { return this.heartbeatRttMs; @@ -352,6 +420,7 @@ function makeHost(input?: Partial): HostProfile { return { serverId: input?.serverId ?? "srv_test", + ...(input?.password ? { password: input.password } : {}), label: input?.label ?? "test host", appearance: input?.appearance ?? defaultHostAppearance(), lifecycle: input?.lifecycle ?? {}, @@ -442,6 +511,63 @@ function makeConnectedProbeClient(latencyMs: number): FakeDaemonClient { return client; } +/** + * Answers every host confirmation the store asks, the way the sheet does, and + * records each request. + */ +function answerHostConfirmations( + store: HostRuntimeStore, + answer: boolean | ((request: HostConfirmationRequest, index: number) => boolean), +): HostConfirmationRequest[] { + const requests: HostConfirmationRequest[] = []; + store.subscribeHostConfirmation(() => { + const pending = store.getPendingHostConfirmation(); + if (!pending) return; + requests.push(pending); + store.answerHostConfirmation( + pending.id, + typeof answer === "function" ? answer(pending, requests.length - 1) : answer, + ); + }); + return requests; +} + +function requirePendingConfirmation(store: HostRuntimeStore): HostConfirmationRequest { + const pending = store.getPendingHostConfirmation(); + if (!pending) throw new Error("Expected a pending host confirmation"); + return pending; +} + +function createPairingStore( + input: { + hostname?: string; + password?: string; + connectAttempts?: string[]; + serverIdForHost?: (host: HostProfile) => string; + storage?: HostRuntimeStorage; + } = {}, +): HostRuntimeStore { + return new HostRuntimeStore({ + storage: input.storage ?? createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host, connection }) => { + input.connectAttempts?.push(connection.id); + if (input.password && host.password !== input.password) { + throw new DaemonAuthenticationError("password_required"); + } + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: input.serverIdForHost?.(host) ?? host.serverId, + hostname: input.hostname ?? "paired", + }; + }, + getClientId: async () => "cid_pairing", + readInitialConnectionHint: () => null, + }, + }); +} + function createMemoryHostRuntimeStorage(entries: Record = {}): HostRuntimeStorage { const values = new Map(Object.entries(entries)); return { @@ -530,6 +656,21 @@ function onceHostListMatches(store: HostRuntimeStore, predicate: () => boolean): }); } +function onceHostSnapshotMatches( + store: HostRuntimeStore, + serverId: string, + predicate: (snapshot: ReturnType) => boolean, +): Promise { + if (predicate(store.getSnapshot(serverId))) return Promise.resolve(); + return new Promise((resolve) => { + const unsubscribe = store.subscribe(serverId, () => { + if (!predicate(store.getSnapshot(serverId))) return; + unsubscribe(); + resolve(); + }); + }); +} + class BrowserClientLifecycle { public active: Array<{ serverId: string; connectionId: string }> = []; @@ -543,6 +684,42 @@ class BrowserClientLifecycle { } describe("HostRuntimeController", () => { + it("publishes an old host and mounts observations through the client interface", async () => { + const host = makeHost(); + const client = new FakeDaemonClient(); + client.ownedSubscriptions = false; + let mounts = 0; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "app-version-gate", + mountClientHandlers: () => { + mounts++; + return () => {}; + }, + }, + }); + const publishedClients: Array = []; + const stop = controller.subscribe(() => publishedClients.push(controller.getSnapshot().client)); + await controller.activateConnection({ connectionId: host.connections[0]!.id }); + expect(controller.getSnapshot()).toMatchObject({ + connectionStatus: "online", + client, + lastError: null, + }); + expect(mounts).toBe(1); + expect(publishedClients).toContain(client); + expect(client.fetchAgentsCalls).toEqual([]); + stop(); + await controller.stop(); + }); + it("replaces the active relay client when re-pairing changes the daemon public key", async () => { const oldRelay: HostConnection = { id: "relay:wss:relay.paseo.sh:443", @@ -815,6 +992,52 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it("does not create a probe client for the selected connection while it reconnects", async () => { + useHostRuntimeClock(); + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ connections: [relay], preferredConnectionId: relay.id }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + const probeAttempts: string[] = []; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("the existing client owns the selected connection"); + }, + connectToDaemon: async ({ connection }) => { + probeAttempts.push(connection.id); + return { + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: relay.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + activeClient.setConnectionState({ status: "disconnected", reason: "network lost" }); + await vi.advanceTimersByTimeAsync(10_000); + + await controller.runProbeCycleNow(); + + expect(probeAttempts).toEqual([]); + expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); + }); + it("rejects probes that resolve to a different server id", async () => { const host = makeHost({ serverId: "srv_old", @@ -1435,6 +1658,104 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it.each(["active", "inactive", "background"] as const)( + "keeps reconnect enabled through inactive/background and resumes immediately (mounted %s)", + async (currentState) => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + let changeAppState: (state: AppStateStatus) => void = () => { + throw new Error("AppState listener not registered"); + }; + const unbind = bindHostRuntimeAppState(store, { + currentState, + addEventListener: (_event, listener) => { + changeAppState = listener; + return { remove: () => {} }; + }, + }); + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { + connectionId: hostAConnection.id, + existingClient: clientA as unknown as DaemonClient, + }, + ], + [ + hostB.serverId, + { + connectionId: hostBConnection.id, + existingClient: clientB as unknown as DaemonClient, + }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("inactive"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + changeAppState("background"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.getConnectionState()).toEqual({ status: "connected" }); + expect(clientB.getConnectionState()).toEqual({ status: "connected" }); + expect(clientA.ensureConnectedCalls).toBe(0); + expect(clientB.ensureConnectedCalls).toBe(0); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + + changeAppState("active"); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientA.connectionVerifications).toBe(1); + expect(clientB.connectionVerifications).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + expect(store.getSnapshot(hostA.serverId)?.connectionStatus).toBe("online"); + expect(store.getSnapshot(hostB.serverId)?.connectionStatus).toBe("online"); + + unbind(); + store.syncHosts([]); + }, + ); + it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); const revocation = createDeferred(); @@ -1483,16 +1804,16 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("loads the host registry without scanning or installing replica rows", async () => { + it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - const backingStore = createMemoryReplicaRowStore(); let fullScans = 0; + const backingStore = createMemoryReplicaRowStore(); const replicaRowStore: ReplicaRowStore = { ...backingStore, - readAll: async () => { + read: async (...args) => { fullScans += 1; - return backingStore.readAll(); + return backingStore.read(...args); }, }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); @@ -1516,7 +1837,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - expect(fullScans).toBe(0); + await vi.waitFor(() => expect(fullScans).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -1803,6 +2124,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], subscribe: {}, page: { limit: 200 }, @@ -1819,6 +2141,7 @@ describe("HostRuntimeStore", () => { await store.refreshAgentDirectory({ serverId: host.serverId }); expect(fakeClient.fetchAgentsCalls[2]).toEqual({ + scope: "active", sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200 }, }); @@ -1857,7 +2180,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -1870,7 +2193,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_no_session" }, + subscribe: {}, page: { limit: 200 }, }); @@ -1878,32 +2201,13 @@ describe("HostRuntimeStore", () => { expect(useSessionStore.getState().sessions[host.serverId]).toBeUndefined(); }); - it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { + it("shows the restarted daemon's version after the same client reconnects", async () => { const host = makeHost({ - serverId: "srv_legacy_workspace_daemon", - connections: [ - { - id: "direct:lan:6767", - type: "directTcp", - endpoint: "lan:6767", - }, - ], + serverId: "srv_restarted", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], }); const fakeClient = new FakeDaemonClient(); fakeClient.setConnectionState({ status: "connected" }); - fakeClient.fetchAgentsResponses.push( - makeFetchAgentsPayload({ - entries: [ - makeFetchAgentsEntry({ - id: "agent-legacy", - cwd: "/repo/legacy-app", - updatedAt: "2026-06-18T12:00:00.000Z", - title: "Legacy daemon agent", - }), - ], - subscriptionId: "app:srv_legacy_workspace_daemon", - }), - ); const store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, @@ -1916,45 +2220,19 @@ describe("HostRuntimeStore", () => { }, }); - const sessionStore = useSessionStore.getState(); - sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); - sessionStore.updateSessionServerInfo(host.serverId, { - serverId: host.serverId, - hostname: null, - version: "0.1.96", - }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ - serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }); - await fakeClient.waitForFetches(1); - await load; + expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.8.0"); - expect(fakeClient.fetchAgentsCalls).toEqual([ - { - sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, - page: { limit: 200 }, - }, - ]); - const session = useSessionStore.getState().sessions[host.serverId]; - expect(session?.agents.get("agent-legacy")?.workspaceId).toBe("/repo/legacy-app"); - expect(Array.from(session?.workspaces.values() ?? [])).toEqual([ - expect.objectContaining({ - id: "/repo/legacy-app", - workspaceDirectory: "/repo/legacy-app", - name: "legacy-app", - }), - ]); + fakeClient.daemonRestartsAs("0.9.1"); + + expect(store.getSnapshot(host.serverId)?.client).toBe(fakeClient); + expect(useSessionStore.getState().sessions[host.serverId]?.serverInfo?.version).toBe("0.9.1"); store.syncHosts([]); - useSessionStore.getState().clearSession(host.serverId); }); - it("drains legacy snapshot and buffered running transitions exactly once", async () => { + it("drains snapshot and buffered running transitions exactly once", async () => { const host = makeHost({ serverId: "srv_legacy_transitions", connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], @@ -2006,11 +2284,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "legacy-snapshot", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "legacy-buffered", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2025,7 +2319,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2047,8 +2341,8 @@ describe("HostRuntimeStore", () => { ({ id, status, workspaceId }) => [id, status, workspaceId], ), ).toEqual([ - ["legacy-snapshot", "idle", "/legacy/repo"], - ["legacy-buffered", "idle", "/legacy/repo"], + ["legacy-snapshot", "idle", snapshotAgent.agent.workspaceId], + ["legacy-buffered", "idle", bufferedAgent.agent.workspaceId], ]); store.syncHosts([]); @@ -2115,7 +2409,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2125,7 +2419,7 @@ describe("HostRuntimeStore", () => { expect(fakeClient.fetchAgentsCalls[0]).toEqual({ scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_paged" }, + subscribe: {}, page: { limit: 200 }, }); expect(fakeClient.fetchAgentsCalls[1]).toEqual({ @@ -2205,7 +2499,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_paged_delta" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2292,12 +2586,13 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - await fakeClient.waitForAgentUpdates(); + await waitForHostOnline(store, host.serverId); const load = store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: `app:${host.serverId}` }, + subscribe: {}, page: { limit: 200 }, }); + await fakeClient.waitForAgentUpdates(); fakeClient.agentUpdate({ kind: "upsert", agent: { ...snapshotEntry.agent, title: "before-session" }, @@ -2343,7 +2638,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); @@ -2411,7 +2706,7 @@ describe("HostRuntimeStore", () => { .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - await store.refreshAgentDirectory({ serverId: host.serverId }); + await store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); @@ -2545,11 +2840,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "snapshot-transition", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "buffered-transition", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2568,7 +2879,7 @@ describe("HostRuntimeStore", () => { ); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -2739,6 +3050,34 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => { + const host = makeHost({ serverId: "srv_steer_queue_drain" }); + const fakeClient = new FakeDaemonClient(); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_steer_queue_drain", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.setQueuedMessages( + host.serverId, + new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer"); + sessionStore.clearSession(host.serverId); + }); + it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); const fakeClient = new FakeDaemonClient(); @@ -2870,7 +3209,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); await waitForHostOnline(store, host.serverId); - const load = store.refreshAgentDirectory({ serverId: host.serverId }); + const load = store.refreshAgentDirectory({ serverId: host.serverId, subscribe: {} }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -2951,7 +3290,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -2969,7 +3308,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(2); @@ -2978,13 +3317,13 @@ describe("HostRuntimeStore", () => { { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, { scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_resubscribe" }, + subscribe: {}, page: { limit: 200 }, }, ]); @@ -3037,12 +3376,14 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, - activeTurn: stale.activeTurn + turn: stale.activeTurn ? { + phase: "open", turnId: stale.activeTurn.turnId, startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + cancellationRequestId: null, } - : null, + : { phase: "idle", cancellationRequestId: null }, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), @@ -3059,7 +3400,7 @@ describe("HostRuntimeStore", () => { await waitForHostOnline(store, host.serverId); await store.refreshAgentDirectory({ serverId: host.serverId, - subscribe: { subscriptionId: "app:srv_archived_rehydrate" }, + subscribe: {}, page: { limit: 200 }, }); await fakeClient.waitForFetches(1); @@ -3180,7 +3521,7 @@ describe("HostRuntimeStore", () => { } }); - it("upsertDirectConnection stores SSL and password settings", async () => { + it("upsertDirectConnection stores SSL on the connection and password on the host", async () => { const store = new HostRuntimeStore({ deps: { createClient: () => new FakeDaemonClient() as unknown as DaemonClient, @@ -3208,9 +3549,9 @@ describe("HostRuntimeStore", () => { type: "directTcp", endpoint: "example.paseo.test:7443", useTls: true, - password: "shared-secret", }, ]); + expect(host?.password).toBe("shared-secret"); store.syncHosts([]); }); @@ -3295,19 +3636,10 @@ describe("HostRuntimeStore", () => { }); it("uses the advertised hostname when adding a relay host from a pairing offer", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore({ hostname: "mbp" }); + answerHostConfirmations(store, true); - await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.label).toBe("mbp"); @@ -3316,27 +3648,14 @@ describe("HostRuntimeStore", () => { }); it("stores relay TLS from a pairing offer", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore(); + answerHostConfirmations(store, true); - await store.upsertConnectionFromOffer( - makeOffer({ - relay: { - endpoint: "relay.example.com:443", - useTls: true, - }, - }), - "tls relay", - ); + await store + .beginLinkPairing() + .submit( + encodeOfferUrl(makeOffer({ relay: { endpoint: "relay.example.com:443", useTls: true } })), + ); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.connections).toEqual([ @@ -3353,17 +3672,8 @@ describe("HostRuntimeStore", () => { }); it("uses TLS for old pairing URLs that omit relay TLS on port 443", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", - }, - }); + const store = createPairingStore(); + answerHostConfirmations(store, true); const oldPairingUrl = encodeOfferUrl({ v: 2, serverId: "srv_offer", @@ -3371,7 +3681,7 @@ describe("HostRuntimeStore", () => { relay: { endpoint: "relay.paseo.sh:443" }, }); - await store.upsertConnectionFromOfferUrl(oldPairingUrl, "old relay"); + await store.importConnectionLink(oldPairingUrl, "openProject"); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.connections).toEqual([ @@ -3387,28 +3697,314 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); - it("preserves the existing host label when re-pairing an existing relay host", async () => { - const store = new HostRuntimeStore({ - deps: { - createClient: () => new FakeDaemonClient() as unknown as DaemonClient, - connectToDaemon: async ({ host }) => ({ - client: makeConnectedProbeClient(5) as unknown as DaemonClient, - serverId: host.serverId, - hostname: host.label ?? null, - }), - getClientId: async () => "cid_test_runtime", + it("probes a pairing link immediately and saves only after admission", async () => { + const store = createPairingStore({ password: "correct-password" }); + answerHostConfirmations(store, true); + const offerUrl = encodeOfferUrl(makeOffer()); + const pairing = store.beginLinkPairing(); + + await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required"); + expect(store.getHosts()).toHaveLength(0); + const result = await pairing.submit(offerUrl, "correct-password"); + expect(result).toMatchObject({ status: "connected", serverId: "srv_offer" }); + expect(store.getHosts()[0]?.password).toBe("correct-password"); + store.syncHosts([]); + }); + + it("asks before a pairing link adds a new host, and Cancel saves and connects nothing", async () => { + const connectAttempts: string[] = []; + const store = createPairingStore({ connectAttempts }); + const requests = answerHostConfirmations(store, false); + const offerUrl = encodeOfferUrl(makeOffer()); + const relayLink = "relay://relay.example:443/srv_relay?key=AAAA&ssl=true"; + + // `#offer=` in the app URL, then a QR scan, then the pair-link and add-host modals. + await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(relayLink)).resolves.toEqual({ + status: "cancelled", + }); + + expect(requests).toEqual([ + ...Array.from({ length: 3 }, () => ({ + id: expect.any(Number), + serverId: "srv_offer", + keyFingerprint: "pkte stof fer", + relayEndpoint: "relay.paseo.sh:443", + kind: "newHost", + })), + { + id: expect.any(Number), + serverId: "srv_relay", + keyFingerprint: "AAAA", + relayEndpoint: "relay.example:443", + kind: "newHost", }, - storage: createMemoryHostRuntimeStorage(), - }); + ]); + expect(store.getHosts()).toEqual([]); + expect(connectAttempts).toEqual([]); + store.syncHosts([]); + }); - await store.upsertRelayConnection({ + it("connects a saved host from a matching pairing link without asking", async () => { + const store = createPairingStore(); + const requests = answerHostConfirmations(store, true); + const offerUrl = encodeOfferUrl(makeOffer()); + await store.beginLinkPairing().submit(offerUrl); + expect(requests).toHaveLength(1); + + await expect(store.importConnectionLink(offerUrl, "openProject")).resolves.toEqual({ + status: "connected", serverId: "srv_offer", - relayEndpoint: "relay.paseo.sh:443", - daemonPublicKeyB64: "pk_test_offer", - label: "Custom name", + }); + await expect(store.importConnectionLink(offerUrl, "hostRoot")).resolves.toEqual({ + status: "connected", + serverId: "srv_offer", + }); + await expect(store.beginLinkPairing().submit(offerUrl)).resolves.toMatchObject({ + status: "connected", }); - await store.upsertConnectionFromOffer(makeOffer(), "mbp"); + expect(requests).toHaveLength(1); + expect(store.getHosts()).toHaveLength(1); + store.syncHosts([]); + }); + + it("asks when a pairing link changes a saved host's key, relay, or TLS setting", async () => { + const store = createPairingStore(); + const requests = answerHostConfirmations(store, (_request, index) => index === 0); + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + const savedHosts = store.getHosts(); + + const changedLinks = [ + makeOffer({ daemonPublicKeyB64: "pk_other_key" }), + makeOffer({ relay: { endpoint: "relay.example:443", useTls: false } }), + makeOffer({ relay: { endpoint: "relay.paseo.sh:443", useTls: true } }), + ].map(encodeOfferUrl); + for (const link of changedLinks) { + await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({ + status: "cancelled", + }); + await expect(store.beginLinkPairing().submit(link)).resolves.toEqual({ + status: "cancelled", + }); + } + + expect(requests.map((request) => request.kind)).toEqual([ + "newHost", + ...Array.from({ length: 6 }, () => "changedConnection"), + ]); + expect(store.getHosts()).toEqual(savedHosts); + store.syncHosts([]); + }); + + it("asks once per pairing flow and link when the host asks for a password", async () => { + const store = createPairingStore({ password: "correct-password" }); + const requests = answerHostConfirmations(store, (_request, index) => index < 2); + const offerUrl = encodeOfferUrl(makeOffer()); + const pairing = store.beginLinkPairing(); + + await expect(pairing.submit(offerUrl)).rejects.toThrow("Password required"); + await expect(pairing.submit(offerUrl, "wrong-password")).rejects.toThrow(); + expect(requests).toHaveLength(1); + + // A new pairing flow for the same link asks again. + await expect(store.beginLinkPairing().submit(offerUrl, "wrong-password")).rejects.toThrow(); + expect(requests).toHaveLength(2); + + await expect(pairing.submit(offerUrl, "correct-password")).resolves.toMatchObject({ + status: "connected", + }); + expect(requests).toHaveLength(2); + expect(store.getHosts()[0]).toMatchObject({ + serverId: "srv_offer", + password: "correct-password", + }); + + // A different link in the same flow asks again. + const otherKeyUrl = encodeOfferUrl(makeOffer({ daemonPublicKeyB64: "pk_other_key" })); + await expect(pairing.submit(otherKeyUrl, "correct-password")).resolves.toEqual({ + status: "cancelled", + }); + expect(requests).toHaveLength(3); + store.syncHosts([]); + }); + + it("continues a scanned link's password retry without asking again", async () => { + const store = createPairingStore({ password: "correct-password" }); + const requests = answerHostConfirmations(store, true); + const link = "relay://relay.example:443/srv_pair?key=AAAA&ssl=true"; + + const outcome = await store.importConnectionLink(link, "hostRoot"); + expect(outcome).toMatchObject({ status: "password_required", link }); + expect(store.getHosts()).toHaveLength(0); + if (outcome.status !== "password_required") throw new Error("expected password_required"); + + await outcome.pairing.submit(outcome.link, "correct-password"); + expect(requests).toHaveLength(1); + expect(store.getHosts()[0]).toMatchObject({ + serverId: "srv_pair", + password: "correct-password", + }); + store.syncHosts([]); + }); + + it("keeps a host confirmation pending until it is answered, and a newer one cancels it", async () => { + const store = createPairingStore(); + const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }), + ); + + const second = store.importConnectionLink( + encodeOfferUrl(makeOffer({ serverId: "srv_second" })), + "openProject", + ); + await expect(first).resolves.toEqual({ status: "cancelled" }); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_second" }), + ); + + store.answerHostConfirmation(requirePendingConfirmation(store).id, true); + await expect(second).resolves.toEqual({ status: "connected", serverId: "srv_second" }); + expect(store.getPendingHostConfirmation()).toBeNull(); + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_second"]); + store.syncHosts([]); + }); + + it("ignores an answer for a host confirmation that a newer one replaced", async () => { + const store = createPairingStore(); + const first = store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"); + await vi.waitFor(() => + expect(store.getPendingHostConfirmation()).toMatchObject({ serverId: "srv_offer" }), + ); + const firstRequest = requirePendingConfirmation(store); + const second = store.importConnectionLink( + encodeOfferUrl(makeOffer({ serverId: "srv_second" })), + "openProject", + ); + await expect(first).resolves.toEqual({ status: "cancelled" }); + + // Connect tapped on the first sheet as the second link arrives. + store.answerHostConfirmation(firstRequest.id, true); + + const secondRequest = requirePendingConfirmation(store); + expect(secondRequest.serverId).toBe("srv_second"); + store.answerHostConfirmation(secondRequest.id, false); + await expect(second).resolves.toEqual({ status: "cancelled" }); + expect(store.getHosts()).toEqual([]); + }); + + it("waits for saved hosts to load before deciding whether a pairing link asks", async () => { + const savedHost = makeHost({ + serverId: "srv_offer", + connections: [ + { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + useTls: false, + daemonPublicKeyB64: "pk_test_offer", + }, + ], + }); + const store = createPairingStore({ + storage: createMemoryHostRuntimeStorage({ + "@paseo:daemon-registry": JSON.stringify([savedHost]), + "@paseo:e2e": "1", + }), + }); + const requests = answerHostConfirmations(store, true); + + await expect( + store.importConnectionLink(encodeOfferUrl(makeOffer()), "openProject"), + ).resolves.toEqual({ status: "connected", serverId: "srv_offer" }); + + expect(requests).toEqual([]); + expect(store.getHosts().map((host) => host.serverId)).toEqual(["srv_offer"]); + store.syncHosts([]); + }); + + it("adds hosts by address and the default localhost without asking", async () => { + const store = createPairingStore({ + serverIdForHost: (host) => + host.connections[0]?.type === "directTcp" ? `srv_${host.connections[0].endpoint}` : "", + }); + const requests = answerHostConfirmations(store, false); + + await store.boot(); + await store.probeAndUpsertDirectConnection({ endpoint: "10.0.0.5:6767" }); + + expect(requests).toEqual([]); + expect(store.getHosts().map((host) => host.serverId)).toEqual([ + "srv_localhost:6767", + "srv_10.0.0.5:6767", + ]); + store.syncHosts([]); + }); + + it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => { + const store = createPairingStore({ + password: " s3cret ", + serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""), + }); + answerHostConfirmations(store, false); + + await store.boot(); + await store.probeAndUpsertRemoteSshConnection({ + host: "deploy@example.com", + password: " s3cret ", + }); + + const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh"); + expect(host?.password).toBe(" s3cret "); + expect(host?.connections[0]).toMatchObject({ + type: "remoteSsh", + host: "deploy@example.com", + }); + + // The fix promises the saved password is sent on every connection: a later + // cycle must authenticate from the stored profile alone. + await store.runProbeCycleNow("srv_ssh"); + await waitForHostOnline(store, "srv_ssh"); + store.syncHosts([]); + }); + + it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => { + const store = createPairingStore({ + password: "s3cret", + serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""), + }); + answerHostConfirmations(store, false); + + await store.boot(); + const failure = await store + .probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" }) + .then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(DaemonAuthenticationError); + expect((failure as DaemonAuthenticationError).reason).toBe("password_required"); + expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined(); + store.syncHosts([]); + }); + + it("preserves the existing host label when re-pairing an existing relay host", async () => { + const store = createPairingStore({ hostname: "mbp" }); + answerHostConfirmations(store, true); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); + await store.renameHost("srv_offer", "Custom name"); + + await store.beginLinkPairing().submit(encodeOfferUrl(makeOffer())); const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer"); expect(pairedHost?.label).toBe("Custom name"); @@ -3455,6 +4051,326 @@ describe("readInitialDaemonConnectionHint", () => { }); describe("HostRuntimeStore initial connection hint bootstrap", () => { + it("starts saved remote-host probes while desktop daemon startup is still pending", async () => { + const host = makeHost({ + serverId: "srv_remote_saved", + connections: [ + { id: "direct:remote.example:6799", type: "directTcp", endpoint: "remote.example:6799" }, + ], + }); + const storage = createMemoryHostRuntimeStorage(); + await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); + let remoteProbeStarted = false; + let finishDesktopStart: (() => void) | undefined; + const desktopStartPending = new Promise((resolve) => { + finishDesktopStart = resolve; + }); + const store = new HostRuntimeStore({ + storage, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + remoteProbeStarted = true; + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }; + }, + getClientId: async () => "cid_remote_saved", + }, + }); + startHostRuntimeBootstrap({ + store, + shouldStartDaemon: true, + daemonStartService: { + startIfEnabled: async () => { + await desktopStartPending; + return { ok: true }; + }, + }, + }); + try { + await vi.waitFor(() => expect(remoteProbeStarted).toBe(true), { timeout: 400 }); + expect(store.isHostRegistryLoaded()).toBe(true); + } finally { + finishDesktopStart?.(); + await store.boot(); + store.syncHosts([]); + } + }); + it("resolves a local credential for a reopened saved desktop host connection", async () => { + const host = makeHost({ + serverId: "srv_desktop_reopened", + connections: [{ id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }], + }); + const storage = createMemoryHostRuntimeStorage(); + await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); + const credentialRequests: string[] = []; + const store = new HostRuntimeStore({ + storage, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host: probeHost, connection }) => { + expect(probeHost.password).toBeUndefined(); + const credential = await readDesktopManagedLocalCredential(connection, async (listen) => { + credentialRequests.push(listen); + return listen === "localhost:6799" ? "local-token" : null; + }); + expect(credential).toBe("local-token"); + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }; + }, + getClientId: async () => "cid_desktop_reopened", + }, + }); + startHostRuntimeBootstrap({ + store, + shouldStartDaemon: true, + daemonStartService: { + startIfEnabled: async () => ({ ok: true }), + }, + }); + await waitForHostOnline(store, host.serverId); + expect(credentialRequests).toContain("localhost:6799"); + store.syncHosts([]); + }); + it("requires a new password and stops reconnecting after a previously connected client is rejected", async () => { + const client = makeConnectedProbeClient(5); + const host = makeHost({ + serverId: "srv_changed_password", + password: "old-password", + connections: [{ id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767" }], + }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => client as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: client as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }), + getClientId: async () => "cid_changed_password", + }, + }); + store.syncHosts([host], { + initialConnectionByServerId: new Map([ + [ + host.serverId, + { + connectionId: host.connections[0]!.id, + existingClient: client as unknown as DaemonClient, + }, + ], + ]), + }); + await waitForHostOnline(store, host.serverId); + client.authFailureReason = "incorrect_password"; + client.setConnectionState({ status: "disconnected", reason: "Incorrect password" }); + await onceHostSnapshotMatches( + store, + host.serverId, + (snapshot) => snapshot?.authFailureReason === "incorrect_password", + ); + expect(store.getSnapshot(host.serverId)?.lastError).toBe("Incorrect password"); + expect(client.reconnectEnabledChanges.at(-1)).toBe(false); + store.syncHosts([]); + }); + + it("tries another connection after the active connection rejects its password", async () => { + const relayClient = makeConnectedProbeClient(5); + const directClient = makeConnectedProbeClient(5); + const host = makeHost({ + serverId: "srv_rejected_relay_with_local_direct", + password: "stale-password", + preferredConnectionId: "relay:remote:443", + connections: [ + { + id: "relay:remote:443", + type: "relay", + relayEndpoint: "remote:443", + daemonPublicKeyB64: "pk_test_offer", + }, + { id: "direct:localhost:6799", type: "directTcp", endpoint: "localhost:6799" }, + ], + }); + const probeAttempts: string[] = []; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ connection }) => { + probeAttempts.push(connection.id); + if (connection.type === "relay") + throw new DaemonAuthenticationError("incorrect_password"); + return { + client: directClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }; + }, + getClientId: async () => "cid_rejected_relay_with_local_direct", + }, + }); + store.syncHosts([host], { + initialConnectionByServerId: new Map([ + [ + host.serverId, + { + connectionId: "relay:remote:443", + existingClient: relayClient as unknown as DaemonClient, + }, + ], + ]), + }); + await waitForHostOnline(store, host.serverId); + relayClient.authFailureReason = "incorrect_password"; + relayClient.setConnectionState({ status: "disconnected", reason: "Incorrect password" }); + await onceHostSnapshotMatches( + store, + host.serverId, + (snapshot) => snapshot?.authFailureReason === "incorrect_password", + ); + await store.runProbeCycleNow(host.serverId); + await onceHostSnapshotMatches( + store, + host.serverId, + (snapshot) => + snapshot?.connectionStatus === "online" && + snapshot.activeConnectionId === "direct:localhost:6799", + ); + expect(probeAttempts).toContain("direct:localhost:6799"); + expect(probeAttempts).not.toContain("relay:remote:443"); + store.syncHosts([]); + }); + + it("keeps five rejected hosts red with independent password errors", async () => { + let probeAttempts = 0; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + probeAttempts += 1; + throw new DaemonAuthenticationError("password_required"); + }, + getClientId: async () => "cid_five_rejected_hosts", + }, + }); + for (let index = 1; index <= 5; index += 1) { + const serverId = `srv_rejected_${index}`; + await store.upsertDirectConnection({ serverId, endpoint: `host-${index}:6767` }); + await onceHostSnapshotMatches( + store, + serverId, + (snapshot) => snapshot?.authFailureReason === "password_required", + ); + } + for (let index = 1; index <= 5; index += 1) { + const snapshot = store.getSnapshot(`srv_rejected_${index}`); + expect(snapshot?.connectionStatus).toBe("error"); + expect(snapshot?.lastError).toBe("Password required"); + } + const attemptsBeforeRetry = probeAttempts; + await store.runProbeCycleNow("srv_rejected_1"); + expect(probeAttempts).toBe(attemptsBeforeRetry); + store.syncHosts([]); + }); + it("imports a deep link without waiting for admission, then leaves a rejected saved host red", async () => { + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + throw new DaemonAuthenticationError("password_required"); + }, + getClientId: async () => "cid_deep_link", + }, + }); + answerHostConfirmations(store, true); + const link = "relay://relay.example:443/srv_deep?key=AAAA&ssl=true"; + await expect(store.importConnectionLink(link, "openProject")).resolves.toEqual({ + status: "connected", + serverId: "srv_deep", + }); + expect(store.getHosts()).toHaveLength(1); + await onceHostSnapshotMatches( + store, + "srv_deep", + (snapshot) => snapshot?.authFailureReason === "password_required", + ); + expect(store.getSnapshot("srv_deep")?.connectionStatus).toBe("error"); + store.syncHosts([]); + }); + it("stops connecting to a host removed while its first probe is pending", async () => { + useHostRuntimeClock(); + const firstProbe = createDeferred(); + let connectCalls = 0; + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => { + connectCalls += 1; + if (connectCalls === 1) await firstProbe.promise; + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_removed_host", + }, + }); + await store.upsertDirectConnection({ serverId: "srv_removed", endpoint: "lan:6767" }); + await vi.waitFor(() => expect(connectCalls).toBe(1)); + + await store.removeHost("srv_removed"); + firstProbe.resolve(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(connectCalls).toBe(1); + expect(store.getSnapshot("srv_removed")).toBeNull(); + expect(useSessionStore.getState().sessions["srv_removed"]).toBeUndefined(); + }); + + it("saves and reconnects a rejected saved host after changing its password", async () => { + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async ({ host }) => { + if (host.password !== "correct-password") + throw new DaemonAuthenticationError("password_required"); + return { + client: makeConnectedProbeClient(5) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label, + }; + }, + getClientId: async () => "cid_test_password_setting", + }, + }); + await store.upsertDirectConnection({ serverId: "srv_test", endpoint: "lan:6767" }); + await onceHostSnapshotMatches( + store, + "srv_test", + (snapshot) => snapshot?.authFailureReason === "password_required", + ); + expect(store.getSnapshot("srv_test")?.connectionStatus).toBe("error"); + await store.setHostPassword("srv_test", "correct-password"); + await waitForHostOnline(store, "srv_test"); + expect(store.getHosts()[0]?.password).toBe("correct-password"); + expect(store.getSnapshot("srv_test")?.authFailureReason).toBeNull(); + await store.setHostPassword("srv_test", ""); + expect(store.getHosts()[0]?.password).toBeUndefined(); + store.syncHosts([]); + }); + it("attempts the explicit initial connection hint before default localhost bootstrap", async () => { const seenProbes: { endpoint: string; useTls?: boolean }[] = []; const store = new HostRuntimeStore({ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fc434365dd..2ecbe60448 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; import { DaemonClient, + DaemonAuthenticationError, type DaemonClientConfig, type ConnectionState, type FetchAgentsOptions, @@ -13,6 +14,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + relayConnectionFromOffer, StoredHostRegistrySchema, type HostConnection, type HostProfile, @@ -23,13 +25,15 @@ import { buildRelayWebSocketUrl, decodeOfferFragmentPayload, normalizeHostPort, + parseRelayConnectionUri, shouldUseTlsForDefaultHostedRelay, } from "@/utils/daemon-endpoints"; import { resolveAppVersion } from "@/utils/app-version"; import { ConnectionOfferSchema, type ConnectionOffer } from "@getpaseo/protocol/connection-offer"; +import { HostConfirmations, type HostConfirmationRequest } from "./host-confirmation"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; -import { connectToDaemon } from "@/utils/test-daemon-connection"; +import { connectToDaemon, getConnectionAuthFailureReason } from "@/utils/test-daemon-connection"; import { getOrCreateClientId } from "@/utils/client-id"; import { z } from "zod"; import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; @@ -43,9 +47,17 @@ import { createDesktopDaemonTransportFactory, } from "@/desktop/daemon/desktop-daemon-transport"; import { getDesktopHost } from "@/desktop/host"; +import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { useSessionStore } from "@/stores/session-store"; +import { + useSessionStore, + toDaemonServerInfo, + type Agent, + type WorkspaceDescriptor, + type ProjectDescriptor, +} from "@/stores/session-store"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; @@ -76,8 +88,30 @@ import { revokePushNotifications } from "@/push-notifications"; import { createAppWebSocketFactory } from "./websocket-factory"; export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; +export type PairingNavigationTarget = "openProject" | "hostRoot" | "hostSettings"; export type HostRegistryStatus = "loading" | "ready"; +/** One pairing flow started with `HostRuntimeStore.beginLinkPairing`. */ +export interface LinkPairing { + /** Probes the linked host and saves it. Connection and password errors throw. */ + submit(link: string, password?: string): Promise; +} + +export type LinkPairingResult = + | { status: "connected"; profile: HostProfile; serverId: string; hostname: string | null } + | { status: "cancelled" }; + +/** A pairing link the user confirmed whose host then asked for a password. */ +export interface PasswordRequiredPairing { + link: string; + pairing: LinkPairing; +} + +export type ConnectionLinkImport = + | { status: "connected"; serverId: string } + | ({ status: "password_required" } & PasswordRequiredPairing) + | { status: "cancelled" }; + export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } | { type: "directSocket"; endpoint: string; display: "socket" } @@ -100,6 +134,7 @@ export interface HostRuntimeSnapshot { connectionStatus: HostRuntimeConnectionStatus; client: DaemonClient | null; lastError: string | null; + authFailureReason?: "password_required" | "incorrect_password" | null; lastOnlineAt: string | null; agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; agentDirectoryError: string | null; @@ -495,8 +530,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps { } : undefined; const appCapabilities = { - [CLIENT_CAPS.selectiveAgentTimeline]: true, - [CLIENT_CAPS.timelineReplacementInvalidation]: true, ...browserAutomationCapabilities, }; @@ -512,6 +545,9 @@ function createDefaultDeps(): HostRuntimeControllerDeps { runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, + providerSnapshots: "wire", + ...(host.password ? { password: host.password } : {}), + localCredential: () => readDesktopManagedLocalCredential(connection), } satisfies Omit; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ @@ -545,7 +581,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps { url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false, }), - ...(connection.password ? { password: connection.password } : {}), }); } return new DaemonClient({ @@ -565,6 +600,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps { connectToDaemon: ({ host, connection, timeoutMs }) => connectToDaemon(connection, { ...(host.serverId ? { serverId: host.serverId } : {}), + ...(host.password ? { password: host.password } : {}), + localCredential: () => readDesktopManagedLocalCredential(connection), ...(timeoutMs !== undefined ? { timeoutMs } : {}), capabilities: appCapabilities, trace: nativePerformanceTrace, @@ -612,6 +649,7 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; + private readonly authRejectedConnectionIds = new Set(); constructor(input: { host: HostProfile; @@ -662,8 +700,15 @@ export class HostRuntimeController { connectionId: options.initialConnection.connectionId, existingClient: options.initialConnection.existingClient, }); + if (!this.started) { + return; + } } await this.runProbeCycleNow(); + // stop() can run while startup is pending; a stopped controller never probes again. + if (!this.started) { + return; + } if (options?.autoProbe !== false) { this.probeIntervalHandle = setInterval(() => { void this.runProbeCycleNow(); @@ -700,6 +745,12 @@ export class HostRuntimeController { } async updateHost(host: HostProfile): Promise { + const passwordChanged = this.host.password !== host.password; + if (passwordChanged) { + this.probeRequestVersion += 1; + this.authRejectedConnectionIds.clear(); + this.updateSnapshot({ authFailureReason: null }); + } const activeConnectionId = this.snapshot.activeConnectionId; const previousActiveConnection = findConnectionById(this.host, activeConnectionId); this.host = host; @@ -709,16 +760,18 @@ export class HostRuntimeController { activeConnectionId && previousActiveConnection && nextActiveConnection && - !equal(previousActiveConnection, nextActiveConnection) + (!equal(previousActiveConnection, nextActiveConnection) || passwordChanged) ) { this.connectionLastProbedAt.delete(activeConnectionId); await this.switchToConnection({ connectionId: activeConnectionId }); } + if (passwordChanged) this.connectionLastProbedAt.clear(); + if (passwordChanged) await this.probeCycleInFlight; await this.runProbeCycleNow(); } - ensureConnected(): void { - this.activeClient?.ensureConnected(); + ensureConnected(options?: { verify?: boolean }): void { + this.activeClient?.ensureConnected(options); } markAgentDirectorySyncLoading(): void { @@ -803,6 +856,7 @@ export class HostRuntimeController { const hasActiveOnlineConnection = isOnline && activeConnectionId !== null; const connectionsToProbe = this.host.connections.filter((connection) => { + if (this.authRejectedConnectionIds.has(connection.id)) return false; const lastProbed = this.connectionLastProbedAt.get(connection.id); if (lastProbed == null) { return true; @@ -838,6 +892,7 @@ export class HostRuntimeController { this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); let remaining = connectionsToProbe.length; + let probeAuthFailure: "password_required" | "incorrect_password" | null = null; let activationLock: Promise | null = null; const publishProbeState = (): void => { @@ -888,11 +943,18 @@ export class HostRuntimeController { connectionId: nextConnectionId, expectedProbeVersion: requestVersion, }); + } else if (probeAuthFailure) { + const message = new DaemonAuthenticationError(probeAuthFailure).message; + this.applyConnectionEvent({ type: "connect_failed", message }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + authFailureReason: probeAuthFailure, + }); } return; } - if (activeProbe?.status === "unavailable") { + if (this.isConnectionUnavailable(currentActiveConnectionId, activeProbe)) { const nextConnectionId = selectBestConnection({ candidates: buildConnectionCandidates(this.host), probeByConnectionId, @@ -970,10 +1032,7 @@ export class HostRuntimeController { let shouldCloseClient = false; try { const activeClient = - this.snapshot.connectionStatus === "online" && - this.snapshot.activeConnectionId === connection.id - ? this.snapshot.client - : null; + this.snapshot.activeConnectionId === connection.id ? this.snapshot.client : null; if (activeClient) { connectedClient = activeClient; @@ -1028,8 +1087,13 @@ export class HostRuntimeController { latencyMs: rttMs, }); publishProbeState(); - } catch { + } catch (error) { if (this.isCurrentProbeRequest(requestVersion)) { + const authFailure = getConnectionAuthFailureReason(error); + if (authFailure) { + this.authRejectedConnectionIds.add(connection.id); + probeAuthFailure = authFailure; + } probeByConnectionId.set(connection.id, { status: "unavailable", latencyMs: null, @@ -1047,6 +1111,13 @@ export class HostRuntimeController { }); } + private isConnectionUnavailable( + connectionId: string, + probe: ConnectionProbeState | null | undefined, + ): boolean { + return probe?.status === "unavailable" || this.authRejectedConnectionIds.has(connectionId); + } + private updateSnapshot(patch: HostRuntimeSnapshotPatch): void { const preservedPatch: HostRuntimeSnapshotPatch = { ...patch }; let hasChanged = this.host.serverId !== this.snapshot.serverId; @@ -1226,9 +1297,6 @@ export class HostRuntimeController { } const nextGeneration = this.snapshot.clientGeneration + 1; - if (existingClient) { - existingClient.setReconnectEnabled(true); - } const client = existingClient ?? this.deps.createClient({ @@ -1237,6 +1305,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); + client.setReconnectEnabled(true); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1244,56 +1313,75 @@ export class HostRuntimeController { } this.activeClient = client; - this.unsubscribeClientHandlers = - this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; this.applyConnectionEvent({ type: "select_connection", connectionId: connection.id, connection: toActiveConnection(connection), }); - this.snapshot = { - ...this.snapshot, - serverId: this.host.serverId, + this.snapshot = { ...this.snapshot, clientGeneration: nextGeneration }; + this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - client, - clientGeneration: nextGeneration, - }; - for (const listener of this.listeners) { - listener(); - } - - this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - this.applyConnectionEvent({ - type: "client_state", - state, - lastError: client.lastError, - }); - const patch: HostRuntimeSnapshotPatch = { - ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), - ...this.buildAgentDirectoryStatusPatch(), - }; - this.updateSnapshot(patch); + client: null, }); - try { - if (!existingClient) { - await client.connect(); - } - } catch (error) { - if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { - return; - } - const message = toErrorMessage(error); - this.applyConnectionEvent({ - type: "connect_failed", - message, - }); + const failConnection = async (error: unknown) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + const authFailure = getConnectionAuthFailureReason(error); + if (authFailure) this.authRejectedConnectionIds.add(connection.id); + this.unsubscribeClientStatus?.(); + this.unsubscribeClientStatus = null; + this.unsubscribeClientHandlers?.(); + this.unsubscribeClientHandlers = null; + client.setReconnectEnabled(false); + this.applyConnectionEvent({ type: "connect_failed", message: toErrorMessage(error) }); this.updateSnapshot({ ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + client: null, + authFailureReason: authFailure, }); + if (authFailure) { + const probes = new Map(this.snapshot.probeByConnectionId); + probes.set(connection.id, { status: "unavailable", latencyMs: null }); + this.updateSnapshot({ probeByConnectionId: probes }); + for (const candidate of this.host.connections) { + if (!this.authRejectedConnectionIds.has(candidate.id)) { + this.connectionLastProbedAt.delete(candidate.id); + } + } + } + try { + await client.close(); + } catch { + /* Preserve the compatibility/connection error. */ + } + if ( + authFailure && + this.host.connections.some((candidate) => !this.authRejectedConnectionIds.has(candidate.id)) + ) { + void this.runProbeCycleNow(); + } + }; + try { + if (!existingClient) await client.connect(); + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + this.unsubscribeClientHandlers = + this.deps.mountClientHandlers?.({ client, host: this.host, connection }) ?? null; + this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) return; + if (client.authFailureReason) { + void failConnection(new DaemonAuthenticationError(client.authFailureReason)); + return; + } + this.applyConnectionEvent({ type: "client_state", state, lastError: client.lastError }); + this.updateSnapshot({ + ...toSnapshotConnectionPatch(this.connectionMachineState, this.connectionEpoch), + ...this.buildAgentDirectoryStatusPatch(), + client, + authFailureReason: null, + }); + }); + } catch (error) { + await failConnection(error); } } @@ -1350,6 +1438,26 @@ function readConfiguredLocalDaemonOverride(): string | null { return value && value.length > 0 ? value : null; } +function isSameLinkedConnection(left: ConnectionOffer, right: ConnectionOffer): boolean { + return ( + left.serverId === right.serverId && + equal(relayConnectionFromOffer(left), relayConnectionFromOffer(right)) + ); +} + +function parseOfferConnectionUrl(input: string): { offer: ConnectionOffer; password?: string } { + if (input.trim().startsWith("relay://") || input.includes("#connect=")) { + return parseRelayConnectionUri(input); + } + const marker = "#offer="; + const idx = input.indexOf(marker); + if (idx === -1) throw new Error("Missing #offer= fragment"); + const encoded = input.slice(idx + marker.length).trim(); + if (!encoded) throw new Error("Offer payload is empty"); + const payload = decodeOfferFragmentPayload(encoded); + return { offer: ConnectionOfferSchema.parse(payload) }; +} + export function hasConfiguredLocalDaemonOverride(): boolean { return readConfiguredLocalDaemonOverride() !== null; } @@ -1394,9 +1502,12 @@ export class HostRuntimeStore { private connectionStatusStartedAtByServer = new Map(); private queuedAgentDrainInFlight = new Set(); private directorySyncByServer = new Map(); + private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; + private registryLoad: Promise | null = null; + private readonly hostConfirmations = new HostConfirmations(); private storage: HostRuntimeStorage; private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; @@ -1447,8 +1558,7 @@ export class HostRuntimeStore { private async runBoot(): Promise { const override = readConfiguredLocalDaemonOverride(); - await this.loadFromStorage(); - this.markHostRegistryLoaded(); + await this.loadRegistry(); let isE2E: string | null = null; try { @@ -1481,6 +1591,13 @@ export class HostRuntimeStore { } } + private loadRegistry(): Promise { + if (!this.registryLoad) { + this.registryLoad = this.loadFromStorage().then(() => this.markHostRegistryLoaded()); + } + return this.registryLoad; + } + private async loadFromStorage(): Promise { let shouldPersistHosts = false; let profiles: HostProfile[] = []; @@ -1499,6 +1616,14 @@ export class HostRuntimeStore { normalizedProfiles.length = 0; break; } + // COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 with stored-password migration. + if ( + entry.connections.some( + (connection) => connection.type === "directTcp" && connection.password, + ) + ) { + shouldPersistHosts = true; + } normalizedProfiles.push(profile); } profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); @@ -1511,6 +1636,12 @@ export class HostRuntimeStore { projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); + for (const profile of profiles) { + void this.directorySyncByServer + .get(profile.serverId) + ?.restoreCachedDirectory() + .catch(() => undefined); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -1715,12 +1846,12 @@ export class HostRuntimeStore { return this.upsertHostConnection({ serverId: input.serverId, label: input.label, + password, connection: { id: `direct:${endpoint}`, type: "directTcp", endpoint, useTls: input.useTls ?? false, - ...(password ? { password } : {}), }, existingClient: input.existingClient, }); @@ -1728,6 +1859,7 @@ export class HostRuntimeStore { async probeAndUpsertConnection(input: { connection: HostConnection; + password?: string; label?: string; timeoutMs?: number; }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { @@ -1736,6 +1868,7 @@ export class HostRuntimeStore { } const probeHost: HostProfile = { serverId: "", + ...(input.password ? { password: input.password } : {}), label: input.label ?? input.connection.id, appearance: defaultHostAppearance(), lifecycle: {}, @@ -1752,6 +1885,7 @@ export class HostRuntimeStore { const profile = await this.upsertHostConnection({ serverId, label: input.label ?? hostname ?? undefined, + password: input.password, connection: input.connection, existingClient: client, }); @@ -1768,12 +1902,12 @@ export class HostRuntimeStore { const password = input.password?.trim(); return this.probeAndUpsertConnection({ label: input.label, + password, connection: { id: `direct:${endpoint}`, type: "directTcp", endpoint, useTls: input.useTls ?? false, - ...(password ? { password } : {}), }, }); } @@ -1782,20 +1916,25 @@ export class HostRuntimeStore { host: string; sshPort?: number; daemonPort?: number; + password?: string; label?: string; }): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { return this.probeAndUpsertConnection({ label: input.label, + // The daemon hashes and compares the password verbatim (`paseo daemon + // set-password` keeps what it reads), so whitespace is significant here. + password: input.password ? input.password : undefined, connection: createRemoteSshHostConnection(input), }); } - async upsertRelayConnection(input: { + private async upsertRelayConnection(input: { serverId: string; relayEndpoint: string; useTls?: boolean; daemonPublicKeyB64: string; label?: string; + password?: string; }): Promise { const relayEndpoint = normalizeHostPort(input.relayEndpoint); const useTls = input.useTls ?? false; @@ -1804,7 +1943,7 @@ export class HostRuntimeStore { throw new Error("daemonPublicKeyB64 is required"); } const explicitUseTls = input.useTls !== undefined; - return this.upsertHostConnection({ + const profile = await this.upsertHostConnection({ serverId: input.serverId, label: input.label, connection: { @@ -1815,36 +1954,110 @@ export class HostRuntimeStore { daemonPublicKeyB64, }, }); + if (input.password) { + await this.setHostPassword(profile.serverId, input.password); + return this.hosts.find((host) => host.serverId === profile.serverId) ?? profile; + } + return profile; } - async upsertConnectionFromOffer(offer: ConnectionOffer, label?: string): Promise { - // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. - const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + // A pairing link (a URL, a QR code, or a pasted link) asks the user to + // confirm when it adds a new host or changes a saved host's key, relay, or + // TLS setting. The answer needs the saved hosts, so it waits for them to load. + private async confirmLink(offer: ConnectionOffer): Promise { + await this.loadRegistry(); + return this.hostConfirmations.confirmLink(offer, this.hosts); + } + + getPendingHostConfirmation(): HostConfirmationRequest | null { + return this.hostConfirmations.getPending(); + } + + subscribeHostConfirmation(listener: () => void): () => void { + return this.hostConfirmations.subscribe(listener); + } + + answerHostConfirmation(requestId: number, approved: boolean): void { + this.hostConfirmations.answer(requestId, approved); + } + + /** + * Starts one pairing flow, such as one open pairing modal. Once the user + * confirms a link, submitting the same link again in this flow (the + * password retry) does not ask again. A new flow asks again. + */ + beginLinkPairing(): LinkPairing { + let approvedOffer: ConnectionOffer | null = null; + return { + submit: async (link, password) => { + const { offer, password: linkPassword } = parseOfferConnectionUrl(link); + if (!approvedOffer || !isSameLinkedConnection(approvedOffer, offer)) { + if (!(await this.confirmLink(offer))) return { status: "cancelled" }; + approvedOffer = offer; + } + const result = await this.probeAndUpsertOffer(offer, password ?? linkPassword); + return { status: "connected", ...result }; + }, + }; + } + + async importConnectionLink( + url: string, + target: PairingNavigationTarget, + ): Promise { + if (target === "openProject") { + const { offer, password } = parseOfferConnectionUrl(url); + if (!(await this.confirmLink(offer))) return { status: "cancelled" }; + const profile = await this.upsertConnectionFromOffer(offer, undefined, password); + return { status: "connected", serverId: profile.serverId }; + } + const pairing = this.beginLinkPairing(); + try { + const result = await pairing.submit(url); + if (result.status === "cancelled") return result; + return { status: "connected", serverId: result.serverId }; + } catch (error) { + if (!getConnectionAuthFailureReason(error)) throw error; + return { status: "password_required", link: url, pairing }; + } + } + + private async upsertConnectionFromOffer( + offer: ConnectionOffer, + label?: string, + password?: string, + ): Promise { + const connection = relayConnectionFromOffer(offer); return this.upsertRelayConnection({ serverId: offer.serverId, - relayEndpoint: offer.relay.endpoint, - useTls, - daemonPublicKeyB64: offer.daemonPublicKeyB64, + relayEndpoint: connection.relayEndpoint, + useTls: connection.useTls, + daemonPublicKeyB64: connection.daemonPublicKeyB64, label, + password, }); } - async upsertConnectionFromOfferUrl( - offerUrlOrFragment: string, - label?: string, - ): Promise { - const marker = "#offer="; - const idx = offerUrlOrFragment.indexOf(marker); - if (idx === -1) { - throw new Error("Missing #offer= fragment"); - } - const encoded = offerUrlOrFragment.slice(idx + marker.length).trim(); - if (!encoded) { - throw new Error("Offer payload is empty"); - } - const payload = decodeOfferFragmentPayload(encoded); - const offer = ConnectionOfferSchema.parse(payload); - return this.upsertConnectionFromOffer(offer, label); + private async probeAndUpsertOffer( + offer: ConnectionOffer, + password: string | undefined, + ): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> { + const connection = relayConnectionFromOffer(offer); + const probeHost: HostProfile = { + serverId: offer.serverId, + ...(password ? { password } : {}), + label: offer.serverId, + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [connection], + preferredConnectionId: connection.id, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + }; + const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection }); + await client.close().catch(() => undefined); + const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password); + return { profile, serverId: offer.serverId, hostname }; } async upsertConnectionFromListen(input: { @@ -1884,6 +2097,13 @@ export class HostRuntimeStore { await this.updateHost(serverId, (host) => ({ ...host, label })); } + async setHostPassword(serverId: string, password: string): Promise { + await this.updateHost(serverId, (host) => { + const { password: _previous, ...rest } = host; + return password ? { ...rest, password } : rest; + }); + } + async setHostColor(serverId: string, color: HostColor): Promise { await this.updateHostAppearance(serverId, (host) => ({ ...host, @@ -1961,6 +2181,7 @@ export class HostRuntimeStore { private async upsertHostConnection(input: { serverId: string; label?: string; + password?: string; connection: HostConnection; existingClient?: DaemonClient; }): Promise { @@ -1970,6 +2191,7 @@ export class HostRuntimeStore { serverId: input.serverId, label: input.label, connection: input.connection, + password: input.password, now, }); this.setHostsAndSync(next, { @@ -2117,6 +2339,13 @@ export class HostRuntimeStore { const sessionStore = useSessionStore.getState(); sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration); sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration); + // A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can + // change its version and features) only reaches the store here. The client clears it while + // disconnected; keep the last known value until the next handshake. + const serverInfo = snapshot.client.getLastServerInfoMessage(); + if (serverInfo) { + sessionStore.updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo)); + } } private clearHostReplica(serverId: string): void { @@ -2197,6 +2426,11 @@ export class HostRuntimeStore { }), encodeImages, submission: createMessageSubmissionWriter(serverId), + // The queue drains when the agent goes idle, but the agent can start a turn of its own + // in that same moment (a wake-up after background work). Steering folds the message + // into that turn instead of interrupting it; with no turn running, the daemon starts + // a normal one. + activeTurnBehavior: "steer", }); }, }) @@ -2215,6 +2449,56 @@ export class HostRuntimeStore { }); } + applyAgentTurnLiveness( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition); + } + + beginAgentCancellation(serverId: string, agentId: string): number { + const requestId = ++this.nextCancellationRequestId; + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId }); + return requestId; + } + + settleAgentCancellation(serverId: string, agentId: string, requestId: number): void { + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId }); + } + + acceptAgentSnapshot(serverId: string, agent: Agent): Agent { + return this.requireDirectory(serverId).acceptAgent(agent); + } + + archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void { + this.requireDirectory(serverId).archiveAgent(agentId, archivedAt); + } + + restoreAgentSnapshot(serverId: string, agentId: string, agent: Agent | undefined): void { + const directory = this.requireDirectory(serverId); + if (agent) directory.acceptAgent(agent); + else directory.removeAgent(agentId); + } + + acceptWorkspaceSnapshots(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void { + this.requireDirectory(serverId).acceptWorkspaces(workspaces); + } + + acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void { + this.requireDirectory(serverId).acceptProject(project); + } + + removeWorkspaceSnapshot(serverId: string, workspaceId: string): void { + this.requireDirectory(serverId).removeWorkspace(workspaceId); + } + + private requireDirectory(serverId: string): DirectorySync { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory; + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2268,12 +2552,23 @@ export class HostRuntimeStore { return earliestServerId; } - ensureConnectedAll(): void { + ensureConnectedAll(options?: { verify?: boolean }): void { for (const controller of this.controllers.values()) { - controller.ensureConnected(); + controller.ensureConnected(options); } } + setAppVisible(visible: boolean): void { + // Keep normal reconnect backoff running while hidden, for as long as the OS + // lets us execute. Foregrounding bypasses that backoff without closing healthy sockets. + if (!visible) { + void this.replicaCache.flush(); + return; + } + + this.ensureConnectedAll({ verify: true }); + } + runProbeCycleNow(serverId?: string): Promise { if (serverId) { return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); @@ -2329,14 +2624,20 @@ export class HostRuntimeStore { await replica.prepare(agentId); } - fetchAgentTimeline( + async fetchAgentTimeline( serverId: string, agentId: string, request: Parameters[1], ): Promise>> { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - return directory.fetchTimeline(agentId, request); + const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync; + const page = await directory.fetchTimeline(agentId, request); + if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) { + owner.flushStreamAgent(agentId); + owner.applyTimelineResponse(page); + } + return page; } createViewedTimelineOwner( @@ -2437,21 +2738,22 @@ export function useHostRuntimeConnectionStatuses( serverIds: readonly string[], ): ReadonlyMap { const store = getHostRuntimeStore(); - const version = useSyncExternalStore( + // The snapshot is the statuses themselves, joined into a string so React compares by + // value. A version counter read only for reactivity is dropped by the React Compiler. + const readStatuses = () => + serverIds + .map((serverId) => store.getSnapshot(serverId)?.connectionStatus ?? "connecting") + .join("\n"); + const statuses = useSyncExternalStore( (onStoreChange) => store.subscribeAll(onStoreChange), - () => store.getVersion(), - () => store.getVersion(), + readStatuses, + readStatuses, ); return useMemo(() => { - // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. - void version; - const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ - serverId, - store.getSnapshot(serverId)?.connectionStatus ?? "connecting", - ]); - return new Map(entries); - }, [serverIds, store, version]); + const values = statuses.split("\n") as HostRuntimeConnectionStatus[]; + return new Map(serverIds.map((serverId, index) => [serverId, values[index]])); + }, [serverIds, statuses]); } export function useHostRuntimeLastError(serverId: string): string | null { @@ -2510,6 +2812,29 @@ export function useHostRegistryLoaded(): boolean { ); } +export function useHostConfirmation(): { + pending: HostConfirmationRequest | null; + answer: (approved: boolean) => void; +} { + const store = getHostRuntimeStore(); + const pending = useSyncExternalStore( + (onStoreChange) => store.subscribeHostConfirmation(onStoreChange), + () => store.getPendingHostConfirmation(), + () => store.getPendingHostConfirmation(), + ); + // The answer is bound to the request this render shows, so a tap never + // answers a request that replaced it. + return useMemo( + () => ({ + pending, + answer: (approved: boolean) => { + if (pending) store.answerHostConfirmation(pending.id, approved); + }, + }), + [pending, store], + ); +} + export interface HostMutations { upsertDirectConnection: (input: { serverId: string; @@ -2528,20 +2853,10 @@ export interface HostMutations { host: string; sshPort?: number; daemonPort?: number; + password?: string; label?: string; }) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>; - upsertRelayConnection: (input: { - serverId: string; - relayEndpoint: string; - useTls?: boolean; - daemonPublicKeyB64: string; - label?: string; - }) => Promise; - upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise; - upsertConnectionFromOfferUrl: ( - offerUrlOrFragment: string, - label?: string, - ) => Promise; + beginLinkPairing: () => LinkPairing; renameHost: (serverId: string, label: string) => Promise; setHostColor: (serverId: string, color: HostColor) => Promise; setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise; @@ -2556,9 +2871,7 @@ export function useHostMutations(): HostMutations { upsertDirectConnection: (input) => store.upsertDirectConnection(input), probeAndUpsertDirectConnection: (input) => store.probeAndUpsertDirectConnection(input), probeAndUpsertRemoteSshConnection: (input) => store.probeAndUpsertRemoteSshConnection(input), - upsertRelayConnection: (input) => store.upsertRelayConnection(input), - upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label), - upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label), + beginLinkPairing: () => store.beginLinkPairing(), renameHost: (serverId, label) => store.renameHost(serverId, label), setHostColor: (serverId, color) => store.setHostColor(serverId, color), setHostBadgeDisplay: (serverId, badgeDisplay) => diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 56e4546507..76e9e66464 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -7,7 +7,8 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { ReplicaCache, type DirectoryCheckpoint } from "."; +import { ReplicaCache } from "."; +import type { DirectoryCheckpoint } from "@/runtime/replica-cache"; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; @@ -31,8 +32,11 @@ class MemoryStorage implements ReplicaRowStore { writes = 0; cleanups = 0; nextWriteFailure: Error | null = null; + persistentWriteFailure: Error | null = null; readGate: Promise | null = null; onRead: (() => void) | null = null; + /** Throws once a read goes past this count, so a non-terminating read loop fails the test. */ + readLimit: number | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; @@ -46,6 +50,11 @@ class MemoryStorage implements ReplicaRowStore { ids?: readonly string[], ): Promise { this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) }); + if (this.readLimit !== null && this.reads.length > this.readLimit) { + throw new Error( + `replica cache read ${this.reads.length} passed the limit of ${this.readLimit}`, + ); + } this.onRead?.(); await this.readGate; const acceptedKinds = new Set(kinds); @@ -70,6 +79,7 @@ class MemoryStorage implements ReplicaRowStore { async apply(changes: ReplicaRowChanges): Promise { this.writes += 1; + if (this.persistentWriteFailure) throw this.persistentWriteFailure; if (this.nextWriteFailure) { const error = this.nextWriteFailure; this.nextWriteFailure = null; @@ -109,6 +119,12 @@ function createCache(storage: MemoryStorage, maxBytes?: number): ReplicaCache { return cache; } +function requireTimelineRow(storage: MemoryStorage): ReplicaRow { + const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); + if (!row) throw new Error("timeline row was not written"); + return row; +} + function agent(id = "agent-1"): Agent { return { ...normalizeAgentSnapshot( @@ -200,7 +216,81 @@ function timeline(text = "Cached") { }; } +function commitDirectory( + cache: ReplicaCache, + serverId: string, + value: ReturnType, +): void { + cache.commitDirectoryMutations( + serverId, + [ + ...Array.from(value.agents.values(), (cachedAgent) => ({ + kind: "agent" as const, + type: "upsert" as const, + id: cachedAgent.id, + value: cachedAgent, + })), + ...Array.from(value.workspaces.values(), (workspace) => ({ + kind: "workspace" as const, + type: "upsert" as const, + id: workspace.id, + value: workspace, + })), + ...Array.from(value.projects.values(), (project) => ({ + kind: "project" as const, + type: "upsert" as const, + id: project.projectId, + value: project, + })), + ], + value.checkpoint, + ); +} + +function deleteDirectory(cache: ReplicaCache, serverId: string): void { + cache.commitDirectoryMutations(serverId, [ + { kind: "agent", type: "delete", id: "agent-1" }, + { kind: "workspace", type: "delete", id: "workspace-1" }, + { kind: "project", type: "delete", id: "project-1" }, + ]); +} + describe("ReplicaCache", () => { + it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory(); + writer.replaceDirectoryBaseline(SERVER_ID, baseline); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + await storage.apply({ + deletes: [], + upserts: [ + { + serverId: SERVER_ID, + kind: "checkpoint", + id: "singleton", + payload: JSON.stringify(baseline.checkpoint), + }, + ], + }); + + const reader = createCache(storage); + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.checkpoint).toBeUndefined(); + expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]); + expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]); + expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline()); + expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]); + + reader.replaceDirectoryBaseline(SERVER_ID, baseline); + await reader.flush(); + expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual( + baseline.checkpoint, + ); + }); + it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); @@ -214,7 +304,7 @@ describe("ReplicaCache", () => { it("round-trips explicit directory and timeline commits", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -229,17 +319,145 @@ describe("ReplicaCache", () => { expect(restoredTimeline).toEqual(timeline()); }); + it("preserves pending timeline updates across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Latest visible reply")); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual( + timeline("Latest visible reply"), + ); + }); + + it("preserves clearing a timeline across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [], range: null }); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual({ + ...timeline(), + items: [], + range: null, + hasOlder: false, + }); + }); + + it("serializes and writes only keyed directory mutations", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const changed = agent("changed-agent"); + let titleReads = 0; + Object.defineProperty(changed, "title", { + configurable: true, + enumerable: true, + get: () => { + titleReads += 1; + return "Changed agent"; + }, + }); + + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: changed.id, value: changed }], + { agents: { generation: "g", afterSeq: 13 } }, + ); + + expect(titleReads).toBe(0); + await cache.flush(); + expect(titleReads).toBe(1); + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "agent", id: "changed-agent" }, + { kind: "checkpoint", id: "singleton" }, + ]); + expect(storage.changes[0]?.deletes).toEqual([]); + }); + + it("round-trips identified and anonymous open turns without changing protocol status", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const identified = agent("identified"); + identified.turn = { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }; + const anonymous = agent("anonymous"); + anonymous.turn = { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }; + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: identified.id, value: identified }, + { kind: "agent", type: "upsert", id: anonymous.id, value: anonymous }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("identified")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }, + }); + expect(restored.agents.get("anonymous")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }); + }); + + it("coalesces timeline values before serialization", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + let textReads = 0; + const changedTimeline = timeline(); + Object.defineProperty(changedTimeline.items[0], "text", { + configurable: true, + enumerable: true, + get: () => { + textReads += 1; + return "Latest"; + }, + }); + + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + cache.commitTimeline(SERVER_ID, "agent-1", changedTimeline); + + expect(textReads).toBe(0); + await cache.flush(); + expect(textReads).toBe(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "timeline", id: "agent-1" }, + ]); + }); + it("never reads directory rows older than an accepted deferred deletion", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); @@ -249,23 +467,47 @@ describe("ReplicaCache", () => { it("fails closed when an accepted deletion cannot be persisted before a read", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); storage.nextWriteFailure = new Error("disk busy"); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); }); + it("gives up a read instead of retrying while the store keeps rejecting writes", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + storage.persistentWriteFailure = new Error("QuotaExceededError"); + storage.reads.length = 0; + storage.readLimit = 5; + + deleteDirectory(cache, SERVER_ID); + + expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); + expect(storage.reads.length).toBeLessThanOrEqual(1); + }); + + it("still reads a host whose rows are stored when another host's write is rejected", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage, noLegacyCleanup); + cache.setHosts([SERVER_ID, "other-host"]); + commitDirectory(cache, SERVER_ID, directory()); + await cache.flush(); + storage.persistentWriteFailure = new Error("QuotaExceededError"); + + commitDirectory(cache, "other-host", directory()); + + expect((await cache.readWorkspace(SERVER_ID, "workspace-1"))?.workspace.id).toBe("workspace-1"); + }); + it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); const started = deferred(); const release = deferred(); @@ -274,11 +516,7 @@ describe("ReplicaCache", () => { const reading = cache.readAgent(SERVER_ID, "agent-1"); await started.promise; - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); release.resolve(); expect(await reading).toBeUndefined(); @@ -341,8 +579,7 @@ describe("ReplicaCache", () => { hasOlder: true, }); await writer.flush(); - const row = [...storage.rows.values()].find((candidate) => candidate.kind === "timeline"); - if (!row) throw new Error("timeline row was not written"); + const row = requireTimelineRow(storage); const payload = JSON.parse(row.payload) as { items: Array> }; delete payload.items[0]?.pluginItemId; storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { @@ -353,10 +590,26 @@ describe("ReplicaCache", () => { expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); }); + it("refetches old cached Markdown fragments instead of offering them as whole messages", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + const row = requireTimelineRow(storage); + const payload = JSON.parse(row.payload) as { items: Array> }; + Object.assign(payload.items[0]!, { blockGroupId: "message-1", blockIndex: 0 }); + storage.rows.set(`${row.serverId}:${row.kind}:${row.id}`, { + ...row, + payload: JSON.stringify(payload), + }); + + expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toBeUndefined(); + }); + it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -372,7 +625,7 @@ describe("ReplicaCache", () => { it("reads one requested workspace and its project without scanning the directory", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); await writer.flush(); const reader = createCache(storage); @@ -422,9 +675,9 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage, noLegacyCleanup); writer.setHosts([SERVER_ID, otherServerId]); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); - writer.commitDirectory(otherServerId, directory()); + commitDirectory(writer, otherServerId, directory()); await writer.flush(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { serverId: SERVER_ID, @@ -438,7 +691,7 @@ describe("ReplicaCache", () => { ); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 }); cache.setHosts([SERVER_ID, otherServerId]); - cache.commitDirectory(otherServerId, directory()); + commitDirectory(cache, otherServerId, directory()); await cache.flush(); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); @@ -451,7 +704,8 @@ describe("ReplicaCache", () => { it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -480,7 +734,8 @@ describe("ReplicaCache", () => { it("drops only the cursor whose cached entity baseline is corrupt", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -514,7 +769,7 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.changes).toHaveLength(1); @@ -548,7 +803,7 @@ describe("ReplicaCache", () => { await cache.flush(); storage.onRead = () => { storage.onRead = null; - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); }; expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ @@ -561,13 +816,23 @@ describe("ReplicaCache", () => { const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); cache.setHosts([SERVER_ID, "other-host"]); const cachedDirectory = directory(); - cache.commitDirectory(SERVER_ID, cachedDirectory); + commitDirectory(cache, SERVER_ID, cachedDirectory); await cache.flush(); - cache.commitDirectory("other-host", cachedDirectory); + commitDirectory(cache, "other-host", cachedDirectory); await cache.flush(); expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); - cache.commitDirectory(SERVER_ID, cachedDirectory); + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: "agent-1", value: agent() }], + { agents: { generation: "new-generation", afterSeq: 99 } }, + ); + await cache.flush(); + + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + expect((await cache.readDirectory(SERVER_ID)).checkpoint).toBeUndefined(); + + cache.replaceDirectoryBaseline(SERVER_ID, cachedDirectory); await cache.flush(); expect( @@ -588,9 +853,42 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.readAgent(SERVER_ID, "missing"); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.cleanups).toBe(1); }); }); + +it("retains semantic agent messages across disk-cache reconstruction", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const value = timeline(); + value.items = [ + { + kind: "tool_call", + id: "delivery", + timestamp: new Date(1), + timelineCursor: { epoch: "epoch-1", seq: 12 }, + payload: { + source: "agent", + data: { + provider: "codex", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + agentMessage: { + event: "finished", + sender: { id: "remote::worker", title: "Reviewer" }, + text: "Done", + }, + }, + }, + }, + ]; + writer.commitTimeline(SERVER_ID, "agent-1", value); + await writer.flush(); + expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value); +}); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 8029a2506b..3fc8458551 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -30,7 +30,45 @@ import { type ReplicaRowStore, } from "./row-store"; -const PERSIST_DELAY_MS = 5_000; +export type DirectoryReplicaMutation = + | { kind: "agent"; type: "upsert"; id: string; value: Agent } + | { kind: "agent"; type: "delete"; id: string } + | { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor } + | { kind: "workspace"; type: "delete"; id: string } + | { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor } + | { kind: "project"; type: "delete"; id: string }; + +export interface CachedDirectory { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + +export interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +export interface DirectoryCheckpoint { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; +} + +export interface CachedTimeline { + agentId: string; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +const PERSIST_DELAY_MS = 1_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -86,8 +124,7 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ kind: z.literal("assistant_message"), messageId: z.string().optional(), text: z.string(), - blockGroupId: z.string().optional(), - blockIndex: z.number().int().nonnegative().optional(), + // Reject old caches containing display fragments; refetch the complete source text. }), z.strictObject({ ...TimelineItemBaseShape, @@ -104,8 +141,9 @@ const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ }), z.strictObject({ ...TimelineItemBaseShape, - kind: z.literal("activity_log"), - activityType: z.enum(["system", "info", "success", "error"]), + kind: z.literal("notification"), + sourceType: z.enum(["error", "notification"]), + level: z.enum(["info", "warning", "error"]), message: z.string(), }), z.strictObject({ @@ -216,6 +254,16 @@ const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({ snapshot: StoredAgentSnapshotSchema, + turn: z + .discriminatedUnion("phase", [ + z.strictObject({ phase: z.literal("idle") }), + z.strictObject({ + phase: z.literal("open"), + turnId: z.string().nullable(), + startedAt: IsoDateSchema.nullable(), + }), + ]) + .optional(), projectPlacement: StoredProjectPlacementSchema.nullable(), lastActivityAt: IsoDateSchema, }); @@ -312,28 +360,12 @@ interface ReplicaCacheOptions { clearLegacyCache?: () => Promise; } -export interface CachedDirectory { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; -} - -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - -export interface DirectoryCursor { - generation: string; - afterSeq: number; -} - -export interface DirectoryCheckpoint { - projects?: DirectoryCursor; - workspaces?: DirectoryCursor; - agents?: DirectoryCursor; -} +type StructuredReplicaUpsert = + | { serverId: string; kind: "agent"; id: string; value: Agent } + | { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor } + | { serverId: string; kind: "project"; id: string; value: ProjectDescriptor } + | { serverId: string; kind: "timeline"; id: string; value: CachedTimeline } + | { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint }; const DirectoryCursorSchema = z.strictObject({ generation: z.string(), @@ -346,11 +378,19 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -export interface CachedTimeline { - agentId: string; - items: StreamItem[]; - range: AgentTimelineCursorState | null; - hasOlder: boolean; +// Old checkpoints could describe a baseline that an overlapping refresh had discarded. +// Revalidate directory contents once without evicting cached rows or timelines. +const StoredDirectoryCheckpointSchema = z.strictObject({ + version: z.literal(1), + cursors: DirectoryCheckpointSchema, +}); + +function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) { + return { version: 1 as const, cursors }; +} + +function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint { + return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors; } function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { @@ -381,6 +421,7 @@ function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall { name: data.name, detail: data.detail, ...(data.metadata ? { metadata: data.metadata } : {}), + ...(data.agentMessage ? { agentMessage: data.agentMessage } : {}), }; switch (data.status) { case "running": @@ -409,8 +450,6 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; @@ -422,11 +461,12 @@ function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { items: item.items, activity: item.activity, }; - case "activity_log": + case "notification": return { ...base, kind: item.kind, - activityType: item.activityType, + sourceType: item.sourceType, + level: item.level, message: item.message, }; case "compaction": @@ -500,8 +540,6 @@ function deserializeBuiltinTimelineItem( kind: item.kind, ...(item.messageId ? { messageId: item.messageId } : {}), text: item.text, - ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), - ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), }; case "thought": return { ...base, kind: item.kind, text: item.text, status: item.status }; @@ -513,11 +551,12 @@ function deserializeBuiltinTimelineItem( items: item.items, activity: item.activity, }; - case "activity_log": + case "notification": return { ...base, kind: item.kind, - activityType: item.activityType, + sourceType: item.sourceType, + level: item.level, message: item.message, }; case "compaction": @@ -546,6 +585,7 @@ function deserializeBuiltinTimelineItem( error: tool.error, detail: tool.detail, ...(tool.metadata ? { metadata: tool.metadata } : {}), + ...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}), }, }, }; @@ -557,6 +597,15 @@ function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement" return agent.projectPlacement ?? null; } +function serializeAgentTurn(agent: Agent): NonNullable { + if (agent.turn.phase === "idle") return { phase: "idle" }; + return { + phase: "open", + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -569,11 +618,11 @@ function serializeAgent(agent: Agent): StoredAgent { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - ...(agent.activeTurn?.turnId + ...(agent.turn.phase === "open" && agent.turn.turnId ? { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, } : {}), @@ -611,14 +660,27 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, + turn: serializeAgentTurn(agent), projectPlacement: serializeProjectPlacement(agent), lastActivityAt: agent.lastActivityAt.toISOString(), }; } function deserializeAgent(serverId: string, stored: StoredAgent): Agent { + const normalized = normalizeAgentSnapshot(stored.snapshot, serverId); + let turn = normalized.turn; + if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null }; + if (stored.turn?.phase === "open") { + turn = { + phase: "open", + turnId: stored.turn.turnId, + startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null, + cancellationRequestId: null, + }; + } return { - ...normalizeAgentSnapshot(stored.snapshot, serverId), + ...normalized, + turn, lastActivityAt: new Date(stored.lastActivityAt), projectPlacement: stored.projectPlacement, }; @@ -681,8 +743,6 @@ function isTimelineItemStoredLosslessly(item: StreamItem): boolean { switch (item.kind) { case "user_message": return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0; - case "activity_log": - return item.metadata === undefined; case "tool_call": return item.payload.source === "agent"; default: @@ -772,8 +832,10 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges extends ReplicaRowChanges { - directoryReplacements: Map>; +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; } function applyDirectoryRow( @@ -804,7 +866,7 @@ function applyDirectoryRow( if (row.id !== REPLICA_SINGLETON_ROW_ID) { throw new Error("Replica checkpoint row id mismatch"); } - result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload); + result.checkpoint = deserializeDirectoryCheckpoint(row.payload); return; default: return; @@ -824,9 +886,10 @@ export class ReplicaCache { private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); + private pendingUpserts = new Map(); private pendingDeletes = new Map(); - private pendingDirectoryReplacements = new Map>(); + private pendingBaselines = new Set(); + private readonly invalidatedHosts = new Set(); private readonly maxBytes: number; private totalBytes = 0; private persistTimer: ReturnType | null = null; @@ -937,7 +1000,11 @@ export class ReplicaCache { try { await this.prepareStore(); while (this.activeServerIds.has(serverId)) { - await this.flush(); + // A read may only answer from rows the store already holds, so it waits for this host's + // accepted commits to land. A store that rejects them will keep rejecting them: fail closed + // rather than re-attempt the write on every pass. A write rejected for another host leaves + // this host's stored rows readable. + if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return []; const revision = this.hostRevisions.get(serverId) ?? 0; const rows = await this.rowStore.read(serverId, kinds, ids); if (this.canReadHostRevision(serverId, revision)) return rows; @@ -965,7 +1032,7 @@ export class ReplicaCache { let checkpoint: DirectoryCheckpoint | undefined; if (checkpointRow) { try { - checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); + checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload); checkpoint = { ...checkpoint }; delete checkpoint[invalidEntity]; } catch { @@ -981,7 +1048,7 @@ export class ReplicaCache { serverId: row.serverId, kind: "checkpoint", id: REPLICA_SINGLETON_ROW_ID, - payload: JSON.stringify(checkpoint), + payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)), }, ] : [], @@ -1007,7 +1074,7 @@ export class ReplicaCache { serverId, kind: "checkpoint", id: REPLICA_SINGLETON_ROW_ID, - payload: JSON.stringify(checkpoint), + payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)), }, ] : [], @@ -1018,39 +1085,65 @@ export class ReplicaCache { }); } - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void { if (!this.activeServerIds.has(serverId)) return; + if (this.invalidatedHosts.has(serverId)) return; this.advanceHostRevision(serverId); - this.clearPendingDirectoryChanges(serverId); - const desiredRows: ReplicaRow[] = []; - for (const agent of directory.agents.values()) { - desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent))); + for (const mutation of mutations) { + if (mutation.type === "delete") { + this.queueEntityDelete(serverId, mutation.kind, mutation.id); + } else { + this.queueUpsert({ + serverId, + kind: mutation.kind, + id: mutation.id, + value: mutation.value, + } as StructuredReplicaUpsert); + } } - for (const workspace of directory.workspaces.values()) { - desiredRows.push( - this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)), - ); + if (checkpoint) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); } - for (const project of directory.projects.values()) { - desiredRows.push( - this.entityRow(serverId, "project", project.projectId, serializeProject(project)), - ); + this.schedulePersist(); + } + + replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { + if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); + // Replacing the directory must not discard independently accepted timeline changes. + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); + } + this.pendingBaselines.add(serverId); + for (const [id, value] of directory.agents) { + this.queueUpsert({ serverId, kind: "agent", id, value }); + } + for (const [id, value] of directory.workspaces) { + this.queueUpsert({ serverId, kind: "workspace", id, value }); + } + for (const [id, value] of directory.projects) { + this.queueUpsert({ serverId, kind: "project", id, value }); } if (directory.checkpoint) { - desiredRows.push( - this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint), - ); + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: directory.checkpoint, + }); } - for (const row of desiredRows) this.queueUpsert(row); - this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row)))); this.schedulePersist(); } @@ -1058,9 +1151,7 @@ export class ReplicaCache { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); this.advanceHostRevision(serverId); - const stored = serializeTimeline(timeline); - if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored); - else this.queueEntityDelete(serverId, "timeline", agentId); + this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1074,6 +1165,7 @@ export class ReplicaCache { for (const serverId of removed) { this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); + this.invalidatedHosts.delete(serverId); this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } @@ -1102,20 +1194,26 @@ export class ReplicaCache { } async flush(): Promise { - await this.persist(); + await this.syncPending(); + } + + /** Resolves to whether every pending change reached the store. */ + private async syncPending(): Promise { + const persisted = await this.persist(); await this.writeQueue.catch(() => undefined); + return persisted; } private async flushPending(): Promise { await this.persist(); } - private async persist(): Promise { + private async persist(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - if (!this.hasPendingChanges()) return; + if (!this.hasPendingChanges()) return true; const write = this.writeQueue .catch(() => undefined) .then(async () => { @@ -1123,55 +1221,35 @@ export class ReplicaCache { try { await this.prepareStore(); await this.ensureStoredIndex(); - const changes = this.materializeDirectoryReplacements(pending); - const boundedChanges = await this.fitChangesToBudget(changes); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { await this.rowStore.apply(boundedChanges); this.applyStoredChanges(boundedChanges); } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } } catch { this.restorePendingChanges(pending); if (this.hasPendingChanges()) this.schedulePersist(); + return false; } - return undefined; + return true; }); - this.writeQueue = write; - await write; - } - - private queueEntityUpsert( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): void { - this.queueUpsert(this.entityRow(serverId, kind, id, value)); - } - - private entityRow( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): ReplicaRow { - return { serverId, kind, id, payload: JSON.stringify(value) }; - } - - private clearPendingDirectoryChanges(serverId: string): void { - const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline"; - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key); - } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key); - } + // The queue only sequences writes; every consumer decides for itself what a failure means. + this.writeQueue = write.then( + () => undefined, + () => undefined, + ); + return write; } private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { this.queueDelete({ serverId, kind, id }); } - private queueUpsert(row: ReplicaRow): void { + private queueUpsert(row: StructuredReplicaUpsert): void { const key = pendingRowKey(row); this.pendingDeletes.delete(key); this.pendingUpserts.set(key, row); @@ -1185,9 +1263,7 @@ export class ReplicaCache { private hasPendingChanges(): boolean { return ( - this.pendingUpserts.size > 0 || - this.pendingDeletes.size > 0 || - this.pendingDirectoryReplacements.size > 0 + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 ); } @@ -1200,7 +1276,7 @@ export class ReplicaCache { } private hasPendingHostChanges(serverId: string): boolean { - if (this.pendingDirectoryReplacements.has(serverId)) return true; + if (this.pendingBaselines.has(serverId)) return true; for (const row of this.pendingUpserts.values()) { if (row.serverId === serverId) return true; } @@ -1218,31 +1294,66 @@ export class ReplicaCache { const changes = { upserts: [...this.pendingUpserts.values()], deletes: [...this.pendingDeletes.values()], - directoryReplacements: this.pendingDirectoryReplacements, + baselines: this.pendingBaselines, }; this.pendingUpserts = new Map(); this.pendingDeletes = new Map(); - this.pendingDirectoryReplacements = new Map(); + this.pendingBaselines = new Set(); return changes; } - private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges { + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); - for (const [serverId, desiredRows] of pending.directoryReplacements) { + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { + const replacementKeys = new Set( + upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), + ); for (const row of this.storedRows.get(serverId)?.values() ?? []) { - if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) { + if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) { deletes.set(pendingRowKey(row), row); } } } - return { upserts: pending.upserts, deletes: [...deletes.values()] }; + return { upserts, deletes: [...deletes.values()] }; + } + + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = serializeDirectoryCheckpoint(upsert.value); + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; } private restorePendingChanges(changes: PendingReplicaChanges): void { - for (const [serverId, desiredRows] of changes.directoryReplacements) { - if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) { - this.pendingDirectoryReplacements.set(serverId, desiredRows); - } + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); } for (const key of changes.deletes) { const pendingKey = pendingRowKey(key); @@ -1305,7 +1416,9 @@ export class ReplicaCache { this.hostWriteOrder.set(serverId, true); } - private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { + private async fitChangesToBudget( + changes: ReplicaRowChanges, + ): Promise<{ changes: ReplicaRowChanges; evicted: Set }> { const touchedServerIds = new Set(); for (const key of changes.deletes) touchedServerIds.add(key.serverId); for (const row of changes.upserts) touchedServerIds.add(row.serverId); @@ -1341,15 +1454,17 @@ export class ReplicaCache { projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); + this.invalidatedHosts.add(serverId); if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); - // The next accepted owner commit must rebuild the complete host replica. Retaining the - // directory replacement marker ensures its checkpoint and rows stay atomic. } return { - upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), - deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + changes: { + upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), + deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + }, + evicted, }; } @@ -1361,7 +1476,7 @@ export class ReplicaCache { } private dropPendingHostChanges(serverId: string): void { - this.pendingDirectoryReplacements.delete(serverId); + this.pendingBaselines.delete(serverId); for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId) this.pendingUpserts.delete(key); } @@ -1372,18 +1487,16 @@ export class ReplicaCache { private renamePendingHostChanges(oldServerId: string, newServerId: string): void { const changes = this.drainPendingChanges(); - for (const [serverId, desiredRows] of changes.directoryReplacements) { - this.pendingDirectoryReplacements.set( - serverId === oldServerId ? newServerId : serverId, - desiredRows, - ); - } for (const key of changes.deletes) { this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } for (const row of changes.upserts) { this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); + } + if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } private prepareStore(): Promise { diff --git a/packages/app/src/runtime/subscription-fixture.ts b/packages/app/src/runtime/subscription-fixture.ts new file mode 100644 index 0000000000..c6eb9f78fe --- /dev/null +++ b/packages/app/src/runtime/subscription-fixture.ts @@ -0,0 +1,43 @@ +import type { OwnedSubscription, SubscriptionObserver } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +/** Public observation dependency for the directory/host runtime tests. */ +export function subscriptionFixture( + snapshot: Promise, + listen: (receive: (message: SessionOutboundMessage) => void) => () => void, +): OwnedSubscription { + const subscriptionId = crypto.randomUUID(); + const observers = new Set>(); + const updates: SessionOutboundMessage[] = []; + let current: (T & { subscriptionId: string }) | undefined; + let released = false; + const stop = listen((message) => { + if (!current) updates.push(message); + else for (const observer of observers) observer.update(message); + }); + const ready = snapshot.then((value) => { + current = { ...value, subscriptionId }; + if (!released) { + for (const observer of observers) { + observer.snapshot(current); + for (const message of updates) observer.update(message); + } + } + return current; + }); + return { + subscriptionId, + ready, + subscribe(observer) { + if (released) return () => {}; + observers.add(observer); + if (current) observer.snapshot(current); + return () => observers.delete(observer); + }, + async release() { + released = true; + observers.clear(); + stop(); + }, + }; +} diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 4a889f9ef1..d7d2a115d0 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -338,19 +338,16 @@ function isSelectedModelValidForProviders(input: { function normalizeInitialValues(input: { snapshot: ScheduleFormSnapshot; - selectedServerId: string | null; }): FormInitialValues | undefined { const config = newAgentConfig(input.snapshot.schedule); if (!config) { return undefined; } return { - serverId: input.selectedServerId, provider: config.provider, model: config.model ?? null, modeId: config.modeId ?? null, thinkingOptionId: config.thinkingOptionId ?? null, - workingDir: config.cwd, }; } @@ -711,24 +708,20 @@ function buildInitialState(snapshot: ScheduleFormSnapshot): ScheduleFormState { function toFormState(state: ScheduleFormState): FormState { return { - serverId: state.selectedServerId, provider: state.selectedProvider, modeId: state.selectedMode, model: state.selectedModel, thinkingOptionId: state.selectedThinkingOptionId, - workingDir: state.workingDir, }; } function applyResolvedFormState(state: ScheduleFormState, form: FormState): ScheduleFormState { return { ...state, - selectedServerId: form.serverId, selectedProvider: form.provider, selectedMode: form.modeId, selectedModel: form.model, selectedThinkingOptionId: form.thinkingOptionId, - workingDir: form.workingDir, }; } @@ -846,7 +839,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo const listeners = new Set<() => void>(); const initialValues = normalizeInitialValues({ snapshot, - selectedServerId: resolveInitialServerId(snapshot), }); let closed = false; let hosts = snapshot.hosts; @@ -1017,11 +1009,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (closed || state.selectedServerId === serverId) { return; } - userModified = { - ...userModified, - serverId: true, - workingDir: true, - }; publish( clearProviderSelection({ ...state, @@ -1046,7 +1033,6 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (!providerScopeChanged && state.selectedProjectOptionId === target.optionId) { return; } - userModified = { ...userModified, serverId: true, workingDir: true }; const nextState = { ...state, selectedServerId: target.serverId, diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index 739738e0f9..a6c1e81845 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; import { type BranchPickerDetail, @@ -340,3 +341,48 @@ describe("defaultBasePickerItem", () => { expect(defaultBasePickerItem({ currentBranch: null })).toBeNull(); }); }); + +describe("starting-ref search", () => { + it.each(["gitlab", "github"] as const)( + "keeps a %s change request returned for a body-only match", + (forge) => { + const item: ForgeSearchItem = { + ...prItem, + forge, + number: 3896, + title: "fix(config): send environment credentials only to trusted hosts", + body: "This mirrors the fix shipped for CVE-2024-53858.", + headRefName: "8545-duo-test", + }; + const { options } = buildPickerOptionData({ + branchDetails: [], + prItems: [item], + baseItem: null, + }); + const visible = buildVisibleComboboxOptions({ + options, + searchQuery: "2024", + searchable: true, + allowCustomValue: false, + customValuePrefix: "Use", + }); + expect(visible.map((option) => option.label)).toEqual([ + `${forge === "gitlab" ? "!" : "#"}3896 fix(config): send environment credentials only to trusted hosts`, + ]); + const selected = buildPickerOptionData({ + branchDetails: [], + prItems: [], + baseItem: { kind: "github-pr", item }, + }); + expect( + buildVisibleComboboxOptions({ + options: selected.options, + searchQuery: "2024", + searchable: true, + allowCustomValue: false, + customValuePrefix: "Use", + }), + ).toEqual(visible); + }, + ); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index 0cb0680605..d02c7a0bf2 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -264,7 +264,10 @@ export function buildPickerOptionData(input: { itemById.set(id, { kind: "github-pr", item: pr }); const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0; const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000); - timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp }); + timedOptions.push({ + option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined }, + timestamp, + }); } const baseItem = input.baseItem; @@ -279,7 +282,13 @@ export function buildPickerOptionData(input: { const labeledItem = disambiguate(baseItem, timedOptions); itemById.set(selectedOptionId, labeledItem); timedOptions.push({ - option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) }, + option: { + id: selectedOptionId, + label: pickerItemLabel(labeledItem), + ...(labeledItem.kind === "github-pr" + ? { searchText: labeledItem.item.body ?? undefined } + : {}), + }, // The base sorts first: it is what a workspace is created from unless you pick something. timestamp: Number.POSITIVE_INFINITY, }); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 947190ccd0..1cf8d76f23 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,16 +1,22 @@ +import type { + CreateAgentRequestOptions, + CreateWorkspaceRequestOptions, +} from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; +import { encodeImages } from "@/utils/encode-images"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import type { ReactElement, RefObject } from "react"; +import type { ReactElement, ReactNode, RefObject } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native"; +import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; -import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; +import { ComposerDock } from "@/composer/dock"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { resolveComposerAttachmentSubmitFormat, @@ -25,9 +31,8 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; -import { ScreenHeader } from "@/components/headers/screen-header"; -import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; @@ -53,7 +58,7 @@ import { navigateToWorkspace, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; @@ -62,13 +67,11 @@ import { useWorkspaceDraftSubmissionStore, type PendingWorkspaceDraftSetup, } from "@/stores/workspace-draft-submission-store"; -import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { @@ -88,7 +91,7 @@ import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-att import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; -import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; @@ -118,6 +121,15 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { ImportSessionButton } from "./new-workspace/import-session-button"; +import { useImportSession } from "@/hooks/use-import-session"; +import { + buildTerminalsQueryKey, + type ListTerminalsPayload, + upsertCreatedTerminalPayload, +} from "./workspace/terminals/state"; +import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; +import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; const ThemedFolderPlus = withUnistyles(FolderPlus); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -675,8 +687,13 @@ function IsolationPickerTrigger({ // Wraps a single argument control in the mobile vertical stack. On desktop the // controls are laid out in one horizontal row, so no per-control wrapper is used. +// Decorative padding must not block the dock background from iOS hit testing. function FormRow({ children }: { children: React.ReactNode }) { - return {children}; + return ( + + {children} + + ); } interface WorkspaceIsolationState { @@ -726,13 +743,6 @@ function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string { : t("newWorkspace.isolation.local"); } -function getContentStyle(input: { isCompact: boolean; insetBottom: number }) { - if (input.isCompact) { - return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }]; - } - return [styles.content, styles.contentCentered]; -} - function normalizeBranchDetails( data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined, ): BranchPickerDetail[] { @@ -742,10 +752,23 @@ function normalizeBranchDetails( return names.map((name) => ({ name, committerDate: 0 })); } +/** + * "background" means the user left the New workspace screen mid-creation, so nothing navigated + * and the screen — if still mounted under another route — has to drop its pending state itself. + */ +type SubmitOutcome = "navigated" | "background"; + interface SubmitDraftInput { + clearConsumedDraft: () => void; + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; initialSetup?: WorkspaceDraftTabSetup; workspaceId: string; workspaceDirectory: string; @@ -754,6 +777,8 @@ interface SubmitDraftInput { provider: AgentProvider; composerState: NewWorkspaceComposerState; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; } type NewWorkspaceComposerState = NonNullable< @@ -770,37 +795,22 @@ interface WorkspaceDraftSubmissionConfig { target: WorkspaceTabTarget; } -async function createAndMergeWorkspace(input: { - client: NonNullable>; - createInput: Parameters< - NonNullable>["createPaseoWorktree"] - >[0]; - mergeWorkspaces: ( - serverId: string, - workspaces: ReturnType[], - ) => void; - serverId: string; - createFailedMessage: string; -}): Promise> { - const payload = await input.client.createPaseoWorktree(input.createInput); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? input.createFailedMessage); - } - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - const workspaceForInitialMerge = input.createInput.firstAgentContext - ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } - : normalizedWorkspace; - input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; +interface WorkspaceCreationResult { + workspace: ReturnType; + agent?: AgentSnapshotPayload; } async function createMultiplicityWorkspace(input: { + idempotencyKey: string; + worktreeSlug: string; client: NonNullable>; isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; prompt: string; attachments: AgentAttachment[]; mergeWorkspaces: ( @@ -809,7 +819,7 @@ async function createMultiplicityWorkspace(input: { ) => void; serverId: string; createFailedMessage: string; -}): Promise> { +}): Promise { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; @@ -818,12 +828,15 @@ async function createMultiplicityWorkspace(input: { attachments: input.attachments, }); const payload = await input.client.createWorkspace({ + idempotencyKey: input.idempotencyKey, + agent: input.agent, + onEvent: input.onEvent, source: isWorktree ? { kind: "worktree", cwd: input.sourceDirectory, projectId, - worktreeSlug: createNameId(), + worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, } : { @@ -841,7 +854,7 @@ async function createMultiplicityWorkspace(input: { ? { ...normalizedWorkspace, status: "running" as const, statusEnteredAt: new Date() } : normalizedWorkspace; input.mergeWorkspaces(input.serverId, [workspaceForInitialMerge]); - return normalizedWorkspace; + return { workspace: normalizedWorkspace, agent: payload.agent }; } interface CreateChatAgentInput { @@ -853,11 +866,17 @@ interface CreateChatAgentInput { prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; - }) => Promise>; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; + }) => Promise; serverId: string; + draftKey: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; draftId?: string; + draftContextScopeKey: string | null; supportsForgeSearch: boolean; + resolveClient: () => DaemonClient; + isStillOnCreateScreen: () => boolean; labels: { composerStateRequired: string; selectModel: string; @@ -900,26 +919,33 @@ function buildWorkspaceDraftSetupForCreatedWorkspace(input: { } function buildComposerInitialValues(input: { - workingDir: string | undefined; initialSetup?: WorkspaceDraftTabSetup | null; }): CreateAgentInitialValues | undefined { if (input.initialSetup) { return { - workingDir: input.workingDir ?? input.initialSetup.cwd, provider: input.initialSetup.provider, modeId: input.initialSetup.modeId, model: input.initialSetup.model, thinkingOptionId: input.initialSetup.thinkingOptionId, }; } - if (input.workingDir) { - return { workingDir: input.workingDir }; - } return undefined; } -async function runCreateChatAgent(input: CreateChatAgentInput): Promise { +const pendingWorkspaceSubmissions = new Map>(); +function runCreateChatAgent(input: CreateChatAgentInput): Promise { + const key = JSON.stringify([input.serverId, input.draftId]); + const pending = pendingWorkspaceSubmissions.get(key); + if (pending) return pending; + const submission = Promise.resolve().then(() => createWorkspaceChatAgent(input)); + pendingWorkspaceSubmissions.set(key, submission); + void submission.finally(() => pendingWorkspaceSubmissions.delete(key)).catch(() => undefined); + return submission; +} + +async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise { const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input; + const clearConsumedDraft = captureWorkspaceDraftCleanup(input); const { text, attachments, cwd } = payload; if (!composerState) { throw new Error(input.labels.composerStateRequired); @@ -935,48 +961,101 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise { format: attachmentSubmitFormat, }); const workspaceNamingAttachments = getWorkspaceNamingAttachments(reviewAttachments); - const ensuredWorkspace = await ensureWorkspace({ - cwd, - prompt: text, - attachments: workspaceNamingAttachments, - withInitialAgent: true, - }); - const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ - forkDraftSetup: input.forkDraftSetup, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, - provider, - composerState, - }); - submitWorkspaceDraft({ - serverId, - clearDraft, - draftId: input.draftId, - initialSetup, - workspaceId: ensuredWorkspace.id, - workspaceDirectory: ensuredWorkspace.workspaceDirectory, - text, - attachments, - provider, - composerState, - supportsForgeSearch: input.supportsForgeSearch, + const wirePayload = splitComposerAttachmentsForSubmit(attachments, { + format: attachmentSubmitFormat, }); + const images = await encodeImages(wirePayload.images); + let navigated = false; + let outcome: SubmitOutcome = "background"; + const initialAgent: NonNullable = { + config: { + provider, + cwd, + modeId: composerState.selectedMode || undefined, + model: composerState.effectiveModelId || undefined, + thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, + }, + initialPrompt: text, + clientMessageId: `${input.draftId}:initial-message`, + images: images?.length ? images : undefined, + attachments: wirePayload.attachments?.length ? wirePayload.attachments : undefined, + }; + const execute = async (requestedAgent = initialAgent): Promise => { + const { agent } = await ensureWorkspace({ + cwd, + prompt: text, + attachments: workspaceNamingAttachments, + withInitialAgent: true, + agent: requestedAgent, + onEvent: (snapshot) => { + if (!snapshot.workspace || navigated) return; + navigated = true; + if (!input.isStillOnCreateScreen()) return; + const workspace = normalizeWorkspaceDescriptor(snapshot.workspace); + getHostRuntimeStore().acceptWorkspaceSnapshots(serverId, [ + { ...workspace, status: "running" }, + ]); + const initialSetup = buildWorkspaceDraftSetupForCreatedWorkspace({ + forkDraftSetup: input.forkDraftSetup, + workspaceDirectory: workspace.workspaceDirectory, + provider, + composerState, + }); + outcome = submitWorkspaceDraft({ + clearConsumedDraft, + draftKey: input.draftKey, + draftContextScopeKey: input.draftContextScopeKey, + resolveClient: input.resolveClient, + isStillOnCreateScreen: input.isStillOnCreateScreen, + serverId, + clearDraft, + draftId: input.draftId, + initialSetup, + workspaceId: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, + text, + attachments, + provider, + composerState, + supportsForgeSearch: input.supportsForgeSearch, + agentCreation, + }); + }, + }); + if (!agent) throw new Error("Workspace creation returned no agent"); + return agent; + }; + const agentCreation = { + result: Promise.resolve().then(() => execute()), + retry: (request: CreateAgentRequestOptions) => + execute({ + ...initialAgent, + config: { ...request.config!, cwd }, + initialPrompt: request.initialPrompt ?? "", + clientMessageId: initialAgent.clientMessageId, + images: request.images, + attachments: request.attachments, + }), + }; + await agentCreation.result; + if (outcome === "background") clearConsumedDraft(); + return outcome; } function buildComposerConfig(input: { serverId: string; - isConnected: boolean; workspaceDirectory: string | null; sourceDirectory: string | null; initialSetup?: WorkspaceDraftTabSetup | null; }): Parameters[0]["composer"] { - const { serverId, isConnected, workspaceDirectory, sourceDirectory, initialSetup } = input; + const { serverId, workspaceDirectory, sourceDirectory, initialSetup } = input; const workingDir = workspaceDirectory || sourceDirectory || undefined; return { initialServerId: serverId || null, - initialValues: buildComposerInitialValues({ workingDir, initialSetup }), + initialValues: buildComposerInitialValues({ initialSetup }), initialFeatureValues: initialSetup?.featureValues, isVisible: true, - onlineServerIds: isConnected && serverId ? [serverId] : [], lockedWorkingDir: workingDir, }; } @@ -1023,7 +1102,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: { }; } -function submitWorkspaceDraft(input: SubmitDraftInput): void { +function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome { const { serverId, clearDraft, @@ -1037,7 +1116,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { initialSetup, } = input; const draftId = draftIdInput?.trim() || generateDraftId(); - const clientMessageId = generateMessageId(); + const clientMessageId = `${draftId}:initial-message`; const timestamp = Date.now(); const wirePayload = splitComposerAttachmentsForSubmit(attachments, { format: resolveComposerAttachmentSubmitFormat({ @@ -1051,7 +1130,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { composerState, initialSetup, }); - useCreateFlowStore.getState().setPending({ + const started = useCreateFlowStore.getState().trySetPending({ serverId, draftId, workspaceId, @@ -1062,6 +1141,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); + if (!started) return "navigated"; useWorkspaceDraftSubmissionStore.getState().setPending({ serverId, workspaceId, @@ -1077,6 +1157,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), ...(submission.featureValues ? { featureValues: submission.featureValues } : {}), allowEmptyText: true, + agentCreation: input.agentCreation, }); clearDraft("sent"); navigateToWorkspace({ @@ -1084,6 +1165,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void { workspaceId, target: submission.target, }); + return "navigated"; } function useNewWorkspaceHostSelector(input: { @@ -1516,23 +1598,27 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ); return isCompact ? ( - + {projectControl} {hostControl ? {hostControl} : null} {isolationControl ? {isolationControl} : null} {baseControl ? {baseControl} : null} {launchControl} {/* Keep fixed stack height without separating the visible controls. */} - {isolationControl ? null : } - {baseControl ? null : } + {isolationControl ? null : } + {baseControl ? null : } ) : ( - + {projectControl} {hostControl} {isolationControl} {baseControl} - + {launchControl} ); @@ -1548,10 +1634,14 @@ export function NewWorkspaceScreen({ const queryClient = useQueryClient(); const { theme } = useUnistyles(); const { t } = useTranslation(); - const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const { allHosts, selectedServerId, @@ -1572,10 +1662,14 @@ export function NewWorkspaceScreen({ // COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97 const supportsWorkspaceMultiplicity = useHostFeature(selectedServerId, "workspaceMultiplicity"); const supportsForgeSearch = useHostFeature(selectedServerId, "forgeSearch"); + const [creationIdentity] = useState(() => ({ + draftId: draftId ?? generateDraftId(), + worktreeSlug: createNameId(), + })); const [errorMessage, setErrorMessage] = useState(null); - const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [creationResult, setCreationResult] = useState< + WorkspaceCreationResult | { workspace: null } + >({ workspace: null }); const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); @@ -1588,6 +1682,7 @@ export function NewWorkspaceScreen({ const isolationPickerAnchorRef = useRef(null); const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); + const isStillOnCreateScreen = useNewWorkspaceScreenPresence(); // Launch target: what the composer submits to (chat agent, or a terminal // profile). Mirrors useWorkspaceIsolation's pattern below: the derived @@ -1620,8 +1715,15 @@ export function NewWorkspaceScreen({ terminalSubmitLabel, launchFocusKey, } = useTerminalComposerState({ launchTarget, terminalProfiles, terminalPromptText }); + const terminalTextSource = useMemo( + () => ({ + getSnapshot: () => terminalComposerValue, + subscribe: (_listener: () => void) => () => {}, + }), + [terminalComposerValue], + ); const terminalTextReplacement = useMemo( - () => ({ key: launchFocusKey, text: terminalComposerValue }), + () => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }), [launchFocusKey, terminalComposerValue], ); @@ -1631,7 +1733,7 @@ export function NewWorkspaceScreen({ return () => clearTimeout(timer); }, [pickerSearchQuery]); - const workspace = createdWorkspace; + const { workspace } = creationResult; const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1669,7 +1771,6 @@ export function NewWorkspaceScreen({ draftKey, composer: buildComposerConfig({ serverId: selectedServerId, - isConnected, workspaceDirectory: workspace?.workspaceDirectory ?? null, sourceDirectory: selectedSourceDirectory, initialSetup: forkDraftSetup?.setup, @@ -1694,11 +1795,12 @@ export function NewWorkspaceScreen({ }, []); const withConnectedClient = useCallback(() => { - if (!client || !isConnected) { + const connectedClient = getHostRuntimeStore().getClient(selectedServerId); + if (!connectedClient?.isConnected) { throw new Error(t("newWorkspace.errors.hostDisconnected")); } - return client; - }, [client, isConnected, t]); + return connectedClient; + }, [selectedServerId, t]); const clientReady = isConnected && Boolean(client); const hasSelectedSourceDirectory = selectedSourceDirectory !== null; @@ -1932,45 +2034,17 @@ export function NewWorkspaceScreen({ setProjectPickerOpen(nextOpen); }, []); - const buildCreateWorktreeInput = useCallback( - (input: { - cwd: string; - prompt: string; - attachments: AgentAttachment[]; - checkoutRequest: PickerCheckoutRequest | undefined; - }): CreatePaseoWorktreeInput => { - if (!selectedProject) { - throw new Error("Choose a project"); - } - if (!selectedSourceDirectory) { - throw new Error("Choose a host for this project"); - } - const firstAgentContext = buildFirstAgentContext(input); - const hostProjectId = getHostProjectId(selectedProject, selectedServerId); - if (!hostProjectId) { - throw new Error("Project is not available on the selected host"); - } - - return { - cwd: selectedSourceDirectory, - projectId: hostProjectId, - worktreeSlug: createNameId(), - ...(firstAgentContext ? { firstAgentContext } : {}), - ...input.checkoutRequest, - }; - }, - [selectedProject, selectedServerId, selectedSourceDirectory], - ); - const ensureWorkspace = useCallback( async (input: { cwd: string; prompt: string; attachments: AgentAttachment[]; withInitialAgent: boolean; + agent?: CreateWorkspaceRequestOptions["agent"]; + onEvent?: (snapshot: CreationSnapshot) => void; }) => { - if (createdWorkspace) { - return createdWorkspace; + if (creationResult.workspace) { + return creationResult; } if (!selectedProject) { throw new Error("Choose a project"); @@ -1993,33 +2067,29 @@ export function NewWorkspaceScreen({ selectedItem ?? defaultBasePickerItem(checkoutStatusForCreate), ) : undefined; - const normalizedWorkspace = supportsWorkspaceMultiplicity - ? await createMultiplicityWorkspace({ - client: connectedClient, - isolation: effectiveIsolation, - project: selectedProject, - sourceDirectory: selectedSourceDirectory, - checkoutRequest, - withInitialAgent: input.withInitialAgent, - prompt: input.prompt, - attachments: input.attachments, - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }) - : await createAndMergeWorkspace({ - client: connectedClient, - createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), - mergeWorkspaces, - serverId: selectedServerId, - createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), - }); - setCreatedWorkspace(normalizedWorkspace); + const normalizedWorkspace = await createMultiplicityWorkspace({ + idempotencyKey: creationIdentity.draftId, + worktreeSlug: creationIdentity.worktreeSlug, + client: connectedClient, + isolation: createsWorktree ? "worktree" : "local", + project: selectedProject, + sourceDirectory: selectedSourceDirectory, + checkoutRequest, + withInitialAgent: input.withInitialAgent, + prompt: input.prompt, + attachments: input.attachments, + agent: input.agent, + onEvent: input.onEvent, + mergeWorkspaces, + serverId: selectedServerId, + createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), + }); + setCreationResult(normalizedWorkspace); return normalizedWorkspace; }, [ - buildCreateWorktreeInput, - createdWorkspace, + creationIdentity, + creationResult, effectiveIsolation, mergeWorkspaces, queryClient, @@ -2041,31 +2111,49 @@ export function NewWorkspaceScreen({ await updateFormPreferences({ launchTarget }); if (isEmptyWorkspaceSubmission(payload)) { setPendingAction("empty"); + let outcome: SubmitOutcome = "background"; await runCreateEmptyWorkspace({ payload, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, serverId: selectedServerId, - navigate: (targetServerId, workspaceId) => - navigateToWorkspace({ serverId: targetServerId, workspaceId }), + navigate: (targetServerId, workspaceId) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId }); + }, }); + // Nothing navigated, so this screen may still be mounted under another route. Release + // the pending lock it would otherwise keep forever. + if (outcome === "background") { + setPendingAction(null); + } return; } setPendingAction("chat"); - await runCreateChatAgent({ + const outcome = await runCreateChatAgent({ payload, composerState, forkDraftSetup, ensureWorkspace, serverId: selectedServerId, clearDraft: chatDraft.clear, - draftId, + draftKey, + draftId: creationIdentity.draftId, + draftContextScopeKey, supportsForgeSearch, + resolveClient: withConnectedClient, + isStillOnCreateScreen, labels: { composerStateRequired: t("newWorkspace.errors.composerStateRequired"), selectModel: t("newWorkspace.errors.selectModel"), }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2075,16 +2163,20 @@ export function NewWorkspaceScreen({ }, [ composerState, - draftId, + draftContextScopeKey, + creationIdentity, chatDraft.clear, + draftKey, ensureWorkspace, forkDraftSetup, + isStillOnCreateScreen, launchTarget, selectedServerId, supportsForgeSearch, t, toast, updateFormPreferences, + withConnectedClient, ], ); @@ -2093,12 +2185,13 @@ export function NewWorkspaceScreen({ setErrorMessage(null); await updateFormPreferences({ launchTarget }); setPendingAction("terminal"); + let outcome: SubmitOutcome = "background"; await runCreateTerminalWorkspace({ cwd: selectedSourceDirectory ?? "", prompt: terminalPromptText, profile: selectedTerminalProfile, profileName: selectedTerminalProfile?.name, - ensureWorkspace, + ensureWorkspace: async (request) => (await ensureWorkspace(request)).workspace, createTerminal: async (input) => { const connectedClient = withConnectedClient(); const createdTerminal = await connectedClient.createTerminal( @@ -2107,18 +2200,39 @@ export function NewWorkspaceScreen({ undefined, { command: input.command, args: input.args, workspaceId: input.workspaceId }, ); - if (!createdTerminal.terminal) { + const terminal = createdTerminal.terminal; + if (!terminal) { throw new Error(createdTerminal.error ?? t("newWorkspace.errors.createWorktreeFailed")); } - return { terminalId: createdTerminal.terminal.id }; + queryClient.setQueryData( + buildTerminalsQueryKey(selectedServerId, input.workspaceDirectory, input.workspaceId), + (current) => + upsertCreatedTerminalPayload({ + current, + terminal, + workspaceDirectory: input.workspaceDirectory, + }), + ); + return { terminalId: terminal.id }; }, sendTerminalInput: (terminalId, data) => { withConnectedClient().sendTerminalInput(terminalId, { type: "input", data }); }, serverId: selectedServerId, - navigate: (targetServerId, workspaceId, target) => - navigateToWorkspace({ serverId: targetServerId, workspaceId, target }), + // The terminal is spawned and fed its command before this runs, so skipping the + // navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone + // terminals when the workspace is next visited. + navigate: (targetServerId, workspaceId, target) => { + if (!isStillOnCreateScreen()) { + return; + } + outcome = "navigated"; + navigateToWorkspace({ serverId: targetServerId, workspaceId, target }); + }, }); + if (outcome === "background") { + setPendingAction(null); + } } catch (error) { const message = toErrorMessage(error); setPendingAction(null); @@ -2127,7 +2241,9 @@ export function NewWorkspaceScreen({ } }, [ ensureWorkspace, + isStillOnCreateScreen, launchTarget, + queryClient, selectedServerId, selectedSourceDirectory, selectedTerminalProfile, @@ -2173,20 +2289,6 @@ export function NewWorkspaceScreen({ ], ); - const contentStyle = useMemo( - () => getContentStyle({ isCompact, insetBottom: insets.bottom }), - [isCompact, insets.bottom], - ); - - const { style: composerKeyboardStyle } = useKeyboardShiftStyle({ - mode: "translate", - }); - - const centeredStyle = useMemo( - () => [animatedStaticStyles.centered, composerKeyboardStyle], - [composerKeyboardStyle], - ); - const agentControlsWithDisabled = useMemo( () => composerState @@ -2265,91 +2367,120 @@ export function NewWorkspaceScreen({ }, }); - const screenHeaderLeft = useMemo(() => , []); + const importSession = useImportSession({ serverId: selectedServerId }); + const composer = isTerminalLaunch ? ( + + ) : ( + + ); return ( - - + + - - - {t("newWorkspace.title")} - - {formStack} - {isTerminalLaunch ? ( - - ) : ( - - )} + + {composer} {errorMessage ? {errorMessage} : null} - + + {importSession.sheet} ); } -const animatedStaticStyles = RNStyleSheet.create({ - centered: { - width: "100%", - maxWidth: MAX_CONTENT_WIDTH, - }, -}); +function NewWorkspaceLayout({ + isCompact, + title, + formStack, + onImportSession, + children, +}: { + isCompact: boolean; + title: string; + formStack: ReactNode; + onImportSession: () => void; + children: ReactNode; +}) { + // At the top of the screen on compact layouts, under the composer otherwise. + const importSessionButton = ; + const setupFields = ( + <> + {isCompact ? {importSessionButton} : null} + + {title} + + {formStack} + + ); + return ( + + {setupFields} + <> + {children} + {isCompact ? null : importSessionButton} + + + ); +} const styles = StyleSheet.create((theme) => ({ container: { @@ -2360,14 +2491,12 @@ const styles = StyleSheet.create((theme) => ({ content: { position: "relative", flex: 1, - alignItems: "center", }, - contentCentered: { - justifyContent: "center", - paddingBottom: HEADER_INNER_HEIGHT + theme.spacing[6], - }, - contentCompact: { - justifyContent: "flex-end", + // Takes the free space above the setup fields, so its button sits at the top of the screen. + // The inset puts the ghost button's icon on the setup rows' icon rail. + compactTopActions: { + flex: 1, + paddingHorizontal: theme.spacing[3], }, composerTitleContainer: { marginBottom: theme.spacing[8], @@ -2391,7 +2520,8 @@ const styles = StyleSheet.create((theme) => ({ formStackDesktop: { flexDirection: "row", alignItems: "center", - marginBottom: theme.spacing[8], + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], // The badge adds its own left padding; offset it so the project icon's left // edge lands exactly on the "New workspace" title's left edge. The trailing // inset mirrors it so the launch chip stops on the composer's inner content diff --git a/packages/app/src/screens/new-workspace/background-handoff.test.ts b/packages/app/src/screens/new-workspace/background-handoff.test.ts new file mode 100644 index 0000000000..a84369fdf4 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; +import { + captureWorkspaceDraftCleanup, + createWorkspaceAgentInBackground, +} from "./background-handoff"; + +const DRAFT_ID = "draft_1"; +const DRAFT_KEY = "new-workspace"; +const SCOPE_KEY = "draft:draft_1"; + +function saveDraft(text: string) { + useDraftStore + .getState() + .saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } }); +} + +function draftText(): string | undefined { + return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text; +} + +function captureCleanup() { + return captureWorkspaceDraftCleanup({ + draftId: DRAFT_ID, + draftKey: DRAFT_KEY, + clearDraft: (lifecycle) => + useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }), + draftContextScopeKey: SCOPE_KEY, + }); +} + +function run(createAgent: () => Promise, clearConsumedDraft = captureCleanup()) { + return createWorkspaceAgentInBackground({ createAgent, clearConsumedDraft }); +} + +beforeEach(() => { + useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} }); + useCreateFlowStore.setState({ pendingByDraftId: {} }); + useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } }); + saveDraft("do the thing"); +}); + +describe("createWorkspaceAgentInBackground", () => { + it("preserves context added to a newer draft without editing its text", async () => { + await run(async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/new-repo", + provider: "codex", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId[DRAFT_ID]?.setup.cwd).toBe( + "/work/new-repo", + ); + }); + + it("creates one agent and clears the consumed draft setup, attachments and input", async () => { + let calls = 0; + useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ + draftId: DRAFT_ID, + setup: { + cwd: "/work/repo", + provider: "claude", + modeId: null, + model: null, + thinkingOptionId: null, + featureValues: {}, + }, + }); + + await run(async () => { + calls += 1; + }); + + expect(calls).toBe(1); + expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({}); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined(); + expect(draftText()).toBe(""); + }); + + it("preserves context changed while workspace creation was pending", async () => { + const clearConsumedDraft = captureCleanup(); + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + await run(async () => undefined, clearConsumedDraft); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + expect(draftText()).toBe("do the thing"); + }); + + it("leaves no pending submission behind, so no draft tab can create a second agent", async () => { + await run(async () => undefined); + + expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({}); + expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({}); + }); + + it("rethrows a failed creation and keeps the prompt for retry", async () => { + const failure = new Error("daemon exploded"); + + await expect( + run(async () => { + throw failure; + }), + ).rejects.toThrow(failure); + + expect(draftText()).toBe("do the thing"); + }); + + it("keeps a prompt typed into the shared draft while creation was in flight", async () => { + await run(async () => { + saveDraft("a different idea"); + }); + + expect(draftText()).toBe("a different idea"); + }); + it("keeps a newer draft written while workspace creation was pending", async () => { + useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment({ + scopeKey: SCOPE_KEY, + attachment: { + kind: "chat_history", + id: "fork-context", + attachment: { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + title: "Chat history", + text: "Preserve this context", + }, + source: { serverId: "local", agentId: "agent-1" }, + }, + }); + const clearConsumedDraft = captureCleanup(); + saveDraft("a newer workspace idea"); + await run(async () => undefined, clearConsumedDraft); + expect(draftText()).toBe("a newer workspace idea"); + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toHaveLength(1); + }); +}); diff --git a/packages/app/src/screens/new-workspace/background-handoff.ts b/packages/app/src/screens/new-workspace/background-handoff.ts new file mode 100644 index 0000000000..598772f1a1 --- /dev/null +++ b/packages/app/src/screens/new-workspace/background-handoff.ts @@ -0,0 +1,54 @@ +import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; + +interface WorkspaceDraftCleanupInput { + draftId?: string; + draftKey: string; + clearDraft: (lifecycle: "sent" | "abandoned") => void; + draftContextScopeKey: string | null; +} + +/** Capture ownership before workspace creation starts, not when its response arrives. */ +export function captureWorkspaceDraftCleanup(input: WorkspaceDraftCleanupInput): () => void { + const draftId = input.draftId?.trim() ?? ""; + const scopeKey = input.draftContextScopeKey ?? ""; + const draftVersion = useDraftStore.getState().drafts[input.draftKey]?.version; + const setup = useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId]; + const attachments = useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]; + + return () => { + // These stores update independently. A context-only edit does not bump the text version. + // Any newer draft state owns the whole draft, including its unchanged context. + if ( + useDraftStore.getState().drafts[input.draftKey]?.version !== draftVersion || + useWorkspaceDraftSubmissionStore.getState().setupByDraftId[draftId] !== setup || + useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey] !== attachments + ) { + return; + } + useWorkspaceDraftSubmissionStore.getState().clearDraftSetup({ draftId }); + if (scopeKey) { + useWorkspaceAttachmentsStore.getState().clearWorkspaceAttachments({ scopeKey }); + } + input.clearDraft("sent"); + }; +} + +/** + * The workspace draft tab normally issues create_agent once it mounts on the destination screen. + * When the user leaves the New workspace screen before creation resolves, that tab never mounts, + * so this path issues the request itself. The agent tab opens on its own when the workspace is + * next visited — `reconcileTabs` auto-opens agents. + * + * The caller must not write a pending entry to the draft-submission or create-flow stores on this + * path: those drive the draft tab's auto-submit, and the daemon does not dedupe create_agent by + * clientMessageId, so a leftover entry would create a second agent when the workspace is opened. + */ +export async function createWorkspaceAgentInBackground(input: { + clearConsumedDraft: () => void; + createAgent: () => Promise; +}): Promise { + await input.createAgent(); + input.clearConsumedDraft(); +} diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx new file mode 100644 index 0000000000..39b55e1b2e --- /dev/null +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -0,0 +1,50 @@ +import { Import } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; + +/** + * The new workspace screen's way into Import session: a ghost button at the top on compact + * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. + */ +export function ImportSessionButton({ + compact, + onPress, +}: { + compact: boolean; + onPress: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compact: { + alignItems: "flex-start", + }, + // The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's + // border lines up with the composer's border. + wide: { + alignItems: "flex-start", + paddingHorizontal: theme.spacing[4], + }, + pill: { + borderRadius: theme.borderRadius.full, + borderColor: theme.colors.borderAccent, + }, +})); diff --git a/packages/app/src/screens/new-workspace/screen-presence.test.ts b/packages/app/src/screens/new-workspace/screen-presence.test.ts new file mode 100644 index 0000000000..7213583c13 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isNewWorkspaceScreenActive } from "./screen-presence"; + +describe("isNewWorkspaceScreenActive", () => { + it("is active while the screen is mounted and the route is /new", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/new" })).toBe(true); + }); + + it("is inactive once the screen has been popped off the stack", () => { + expect(isNewWorkspaceScreenActive({ isMounted: false, pathname: "/new" })).toBe(false); + }); + + it("is inactive while another route sits on top of the screen", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/settings" })).toBe(false); + }); + + it("is inactive on a workspace route", () => { + expect(isNewWorkspaceScreenActive({ isMounted: true, pathname: "/host/workspace/wks_1" })).toBe( + false, + ); + }); +}); diff --git a/packages/app/src/screens/new-workspace/screen-presence.ts b/packages/app/src/screens/new-workspace/screen-presence.ts new file mode 100644 index 0000000000..812138e399 --- /dev/null +++ b/packages/app/src/screens/new-workspace/screen-presence.ts @@ -0,0 +1,41 @@ +import { usePathname } from "expo-router"; +import { useCallback, useEffect, useRef } from "react"; + +const NEW_WORKSPACE_PATHNAME = "/new"; + +export interface NewWorkspaceScreenPresence { + isMounted: boolean; + pathname: string; +} + +/** + * Workspace creation blocks on a slow daemon RPC, so by the time it resolves the user may have + * moved on. Both signals are needed: popping to a workspace unmounts the screen while its last + * observed pathname stays "/new", and pushing a route on top of it keeps it mounted underneath. + */ +export function isNewWorkspaceScreenActive(input: NewWorkspaceScreenPresence): boolean { + return input.isMounted && input.pathname === NEW_WORKSPACE_PATHNAME; +} + +export function useNewWorkspaceScreenPresence(): () => boolean { + const pathname = usePathname(); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + return useCallback( + () => + isNewWorkspaceScreenActive({ + isMounted: isMountedRef.current, + pathname: pathnameRef.current, + }), + [], + ); +} diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 9cbe43d83e..4c340e0f15 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -1,3 +1,4 @@ +import { useHosts, useHostRuntimeLastError } from "@/runtime/host-runtime"; import { useCallback, useEffect, useState, type ComponentType } from "react"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable } from "react-native"; @@ -24,6 +25,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; export function OpenProjectScreen() { const { t } = useTranslation(); + const hosts = useHosts(); const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); const openProjectPicker = useOpenAddProject(); @@ -64,6 +66,9 @@ export function OpenProjectScreen() { + {hosts.map((host) => ( + + ))} + {label}: {error} + + ) : null; +} + interface HomeTileProps { icon: ComponentType<{ size: number; color: string }>; title: string; @@ -178,6 +192,12 @@ const styles = StyleSheet.create((theme) => ({ logo: { marginBottom: theme.spacing[8], }, + hostError: { + color: theme.colors.destructive, + fontSize: theme.fontSize.base, + maxWidth: 452, + textAlign: "center", + }, tiles: { marginTop: { xs: theme.spacing[6], md: theme.spacing[12] }, width: "100%", diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 591c955269..50c81a05fd 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -28,8 +28,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda import { ProjectEditSheet } from "@/components/project-edit-sheet"; import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; -import { SettingsGroup } from "@/screens/settings/settings-group"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsGroup } from "@/components/settings/headings/settings-group"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; @@ -419,7 +419,7 @@ function ReadFailureCallout({ kind, error, onReload }: ReadFailureCalloutProps) }); return ( - + @@ -702,6 +702,7 @@ function ProjectConfigForm({ > {hasUncommittedWorktreeSetupChanges ? ( ({ - theme: { - spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, - iconSize: { sm: 14, md: 20 }, - fontSize: { xs: 11, sm: 13, base: 15 }, - fontWeight: { normal: "400" as const, medium: "500" as const }, - borderRadius: { sm: 4, md: 6, lg: 8, full: 999 }, - opacity: { 50: 0.5 }, - colors: { - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - surfaceSidebarHover: "#1a1a1a", - foreground: "#fff", - foregroundMuted: "#aaa", - border: "#444", - accent: "#0a84ff", - palette: { red: { 300: "#ff6b6b" } }, - }, - }, +const { projectsState, push } = vi.hoisted(() => ({ projectsState: { current: { projects: [], @@ -40,7 +20,8 @@ const { theme, projectsState, push } = vi.hoisted(() => ({ push: vi.fn(), })); -vi.mock("react-native", () => { +vi.mock("react-native", async (importOriginal) => { + const actual = await importOriginal(); const passthrough = ({ children, testID, @@ -87,6 +68,7 @@ vi.mock("react-native", () => { }; return { + ...actual, View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => React.createElement("div", { "data-testid": testID }, children), Text: ({ children }: { children?: React.ReactNode }) => @@ -101,21 +83,15 @@ vi.mock("react-native", () => { }; }); -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => - typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory, - }, - useUnistyles: () => ({ theme }), -})); - -vi.mock("lucide-react-native", () => { +vi.mock("lucide-react-native", async (importOriginal) => { + const actual = await importOriginal(); const icon = (name: string) => { const Icon = () => React.createElement("span", { "data-icon": name }); Icon.displayName = name; return Icon; }; return { + ...actual, ChevronRight: icon("ChevronRight"), MoreVertical: icon("MoreVertical"), ExternalLink: icon("ExternalLink"), @@ -128,19 +104,6 @@ vi.mock("expo-router", () => ({ router: { push }, })); -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string, values?: Record) => { - if (key === "sidebar.project.empty.title") return "No projects yet"; - if (key === "settings.projectList.hostLoadFailed") { - return `Couldn't load projects from host ${values?.hostName}: ${values?.message}`; - } - if (key === "settings.projectList.editProject") return `Edit ${values?.projectName}`; - return key; - }, - }), -})); - vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: ({ size }: { size?: string | number }) => React.createElement("span", { @@ -208,6 +171,7 @@ vi.mock("@/projects/icons", () => ({ useProjectIcons: () => new Map(), })); +import { i18n } from "@/i18n/i18next"; import ProjectsScreen from "./projects-screen"; function workspaceSummary(overrides: Partial = {}): WorkspaceSummary { @@ -275,7 +239,8 @@ describe("ProjectsScreen", () => { let container: HTMLElement | null = null; let root: Root | null = null; - beforeEach(() => { + beforeEach(async () => { + await i18n.changeLanguage("en"); vi.stubGlobal("React", React); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 7cc3294ef5..bcd1e4f740 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -87,7 +87,6 @@ function SessionsScreenContent() { isError, isSearchSupported, isSearchTruncated, - searchMatchesByAgentKey, hostErrors, loadMore, refreshAll, @@ -113,7 +112,7 @@ function SessionsScreenContent() { void refreshAll().finally(() => setIsManualRefresh(false)); }, [refreshAll]); - // `useAgentHistory` owns the order: recency at rest, relevance under a query. + // Searching filters the chronological history without changing its date buckets. const emptyText = resolveEmptyText({ t, isSearching, @@ -130,8 +129,8 @@ function SessionsScreenContent() { const handleClearSearch = useCallback(() => setSearchInput(""), []); const listFooterComponent = useMemo(() => { - // A ranked result set has no next page — reaching a weaker match means - // narrowing the query, so the footer says that instead of offering a button. + // COMPAT(historyPagination): old daemons return a truncated relevance page. + // Added in v0.8.0; remove after 2027-03-16 once the daemon floor supports search pagination. if (isSearchTruncated) { return ( @@ -217,8 +216,7 @@ function SessionsScreenContent() { listFooterComponent={listFooterComponent} showAttentionIndicator={false} showHostColumn - searchMatchesByAgentKey={isSearching ? searchMatchesByAgentKey : undefined} - flat={isSearching} + search={isSearching ? search : undefined} /> ) : null} {importSession.sheet} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5e5e9be474..925607a858 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -8,7 +8,6 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { EditingTextInput as TextInput } from "@/components/ui/text-input"; import { useRouter } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -38,26 +37,27 @@ import { Smartphone, Sparkles, Blocks, - PanelsTopLeft, + Globe, + PanelLeft, + MessageSquare, + ChevronRight, } from "lucide-react-native"; import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; -import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker"; import { HostStatusDot } from "@/components/host-status-dot"; -import { ScreenTitle } from "@/components/headers/screen-title"; -import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { AppearanceSection } from "@/screens/settings/appearance/appearance-section"; -import { LayoutSection } from "@/screens/settings/layout/layout-section"; +import { OpenLocationSection } from "@/screens/settings/open-location/open-location-section"; +import { TerminalSection } from "@/screens/settings/terminal/terminal-section"; +import { ChatSection } from "@/screens/settings/chat/chat-section"; +import { SidebarNavSection } from "@/screens/settings/sidebar/sidebar-nav-section"; +import { SendingSection } from "@/screens/settings/general/sending-section"; import { useAppSettings, useSettings, - parseTerminalScrollbackLines, type AppSettings, - type SendBehavior, - type ServiceUrlBehavior, type Settings as EffectiveSettings, } from "@/hooks/use-settings"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; @@ -71,7 +71,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; import { BackHeader } from "@/components/headers/back-header"; -import { ScreenHeader } from "@/components/headers/screen-header"; +import { PageLayout } from "@/components/page-layout"; import { AddHostMethodModal } from "@/components/add-host-method-modal"; import { AddHostModal } from "@/components/add-host-modal"; import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal"; @@ -91,6 +91,7 @@ import { isElectronRuntime } from "@/desktop/host"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { resolveAppVersion } from "@/utils/app-version"; +import { openChangelog } from "@/changelog"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { settingsStyles } from "@/styles/settings"; import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm"; @@ -112,6 +113,8 @@ import { HostWorkspacesPage, HostTerminalsPage, } from "@/screens/settings/host-page"; +import { PluginSettingsContent } from "@/plugins/settings"; +import { useInstalledPlugins } from "@/plugins/registry"; import { HostPluginsPage } from "@/screens/settings/plugins-page"; import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page"; import ProjectsScreen from "@/screens/projects-screen"; @@ -139,48 +142,88 @@ import { isNative, isWeb } from "@/constants/platform"; interface SidebarSectionItem { id: SettingsSectionSlug; labelKey: string; - icon: ComponentType<{ size: number; color: string }>; + icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>; desktopOnly?: boolean; webOnly?: boolean; + /** The page body, for pages that need nothing from the settings screen. */ + Content?: ComponentType; } const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [ { id: "general", labelKey: "settings.sections.general", icon: Settings }, - { id: "appearance", labelKey: "settings.sections.appearance", icon: Palette }, { - id: "layout", - labelKey: "settings.sections.layout", - icon: PanelsTopLeft, + id: "appearance", + labelKey: "settings.sections.appearance", + icon: Palette, + Content: AppearanceSection, + }, + { + id: "sidebar", + labelKey: "settings.sections.sidebar", + icon: PanelLeft, + Content: SidebarNavSection, + }, + { id: "chat", labelKey: "settings.sections.chat", icon: MessageSquare, Content: ChatSection }, + { + id: "terminal", + labelKey: "settings.sections.terminal", + icon: SquareTerminal, + Content: TerminalSection, + }, + { + id: "browser", + labelKey: "settings.sections.browser", + icon: Globe, desktopOnly: true, + Content: BrowserDataSection, + }, + { + id: "editor", + labelKey: "settings.sections.editor", + icon: Code2, + webOnly: true, + Content: EditorSection, + }, + { + id: "shortcuts", + labelKey: "settings.sections.shortcuts", + icon: Keyboard, + desktopOnly: true, + Content: KeyboardShortcutsSection, }, - { id: "editor", labelKey: "settings.sections.editor", icon: Code2, webOnly: true }, - { id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true }, { id: "integrations", labelKey: "settings.sections.integrations", icon: Puzzle, desktopOnly: true, + Content: IntegrationsSection, }, { id: "notifications", labelKey: "settings.sections.notifications", icon: Bell, desktopOnly: true, + Content: DesktopNotificationsSection, }, { id: "permissions", labelKey: "settings.sections.permissions", icon: Shield, desktopOnly: true, + Content: DesktopPermissionsSection, }, { id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope }, { id: "about", labelKey: "settings.sections.about", icon: Info }, ]; +function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean { + return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb); +} + interface HostSectionItem { id: HostSectionSlug; labelKey: string; - icon: ComponentType<{ size: number; color: string }>; + icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>; } const HOST_SECTION_ITEMS: HostSectionItem[] = [ @@ -231,10 +274,6 @@ function renderHostSettingsContent( // Trigger + sidebar style helpers // --------------------------------------------------------------------------- -function themeTriggerStyle({ pressed }: PressableStateCallbackType) { - return [styles.themeTrigger, pressed && { opacity: 0.85 }]; -} - function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered]; } @@ -247,82 +286,18 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho ]; } -function getSendBehaviorOptions(t: TFunction) { - return [ - { value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") }, - { value: "steer" as const, label: t("settings.general.defaultSend.options.steer") }, - { value: "queue" as const, label: t("settings.general.defaultSend.options.queue") }, - ]; -} - -function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string { - const labels: Record = { - ask: t("settings.general.serviceUrls.options.ask"), - "in-app": t("settings.general.serviceUrls.options.inApp"), - external: t("settings.general.serviceUrls.options.external"), - }; - return labels[value]; -} - function getActiveLocale(language: string | undefined): SupportedLocale { const parsed = parseAppLanguage(language); return parsed && parsed !== "system" ? parsed : "en"; } -const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"]; - // --------------------------------------------------------------------------- // Section components // --------------------------------------------------------------------------- interface GeneralSectionProps { settings: AppSettings; - isDesktopApp: boolean; - handleSendBehaviorChange: (behavior: SendBehavior) => void; - handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void; handleLanguageChange: (language: AppLanguage) => void; - handleTerminalScrollbackLinesChange: (lines: number) => void; -} - -interface ServiceUrlBehaviorMenuItemProps { - value: ServiceUrlBehavior; - label: string; - selected: boolean; - onChange: (value: ServiceUrlBehavior) => void; -} - -interface SendBehaviorMenuItemProps { - value: SendBehavior; - label: string; - selected: boolean; - onChange: (value: SendBehavior) => void; -} - -function SendBehaviorMenuItem({ value, label, selected, onChange }: SendBehaviorMenuItemProps) { - const handleSelect = useCallback(() => { - onChange(value); - }, [onChange, value]); - return ( - - {label} - - ); -} - -function ServiceUrlBehaviorMenuItem({ - value, - label, - selected, - onChange, -}: ServiceUrlBehaviorMenuItemProps) { - const handleSelect = useCallback(() => { - onChange(value); - }, [onChange, value]); - return ( - - {label} - - ); } interface LanguageMenuItemProps { @@ -349,21 +324,9 @@ function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageM ); } -function GeneralSection({ - settings, - isDesktopApp, - handleSendBehaviorChange, - handleServiceUrlBehaviorChange, - handleLanguageChange, - handleTerminalScrollbackLinesChange, -}: GeneralSectionProps) { +function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) { const { t, i18n } = useTranslation(); const activeLocale = getActiveLocale(i18n.language); - const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]); - const selectedSendBehaviorLabel = - sendBehaviorOptions.find((option) => option.value === settings.sendBehavior)?.label ?? - settings.sendBehavior; - const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`; const selectedLanguageOption = LANGUAGE_OPTIONS.find( (option) => option.value === settings.language, ); @@ -374,72 +337,17 @@ function GeneralSection({ t(selectedLanguageOption.labelKey), ) : settings.language; - const [terminalScrollbackValue, setTerminalScrollbackValue] = useState( - String(settings.terminalScrollbackLines), - ); - - const handleTerminalScrollbackChangeText = useCallback((value: string) => { - setTerminalScrollbackValue(value.replace(/[^\d]/g, "")); - }, []); - - const commitTerminalScrollback = useCallback(() => { - const parsed = parseTerminalScrollbackLines(terminalScrollbackValue); - const nextValue = parsed ?? settings.terminalScrollbackLines; - setTerminalScrollbackValue(String(nextValue)); - if (nextValue !== settings.terminalScrollbackLines) { - handleTerminalScrollbackLinesChange(nextValue); - } - }, [ - handleTerminalScrollbackLinesChange, - settings.terminalScrollbackLines, - terminalScrollbackValue, - ]); - - useEffect(() => { - setTerminalScrollbackValue(String(settings.terminalScrollbackLines)); - }, [settings.terminalScrollbackLines]); - return ( - - {t("settings.general.defaultSend.label")} - {t(sendBehaviorDescriptionKey)} - - - - {selectedSendBehaviorLabel} - - - {sendBehaviorOptions.map((option) => ( - - ))} - - - - {t("settings.general.language.label")} {t("settings.general.language.description")} - - {selectedLanguageLabel} + + {selectedLanguageLabel} {LANGUAGE_OPTIONS.map((option) => ( @@ -454,55 +362,6 @@ function GeneralSection({ - {isDesktopApp ? ( - - - {t("settings.general.serviceUrls.label")} - - {t("settings.general.serviceUrls.description")} - - - - - - {getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)} - - - - {SERVICE_URL_BEHAVIOR_VALUES.map((value) => ( - - ))} - - - - ) : null} - - - - {t("settings.general.terminalScrollback.label")} - - - {t("settings.general.terminalScrollback.description")} - - - - ); @@ -604,6 +463,7 @@ function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSection {appVersionText} + {isDesktopApp ? : null} @@ -615,6 +475,33 @@ function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSection ); } +function WhatsNewRow() { + const { t } = useTranslation(); + const { theme } = useUnistyles(); + + return ( + + {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( + <> + + {t("changelog.title")} + {t("settings.about.whatsNewHint")} + + + + )} + + ); +} + function normalizeVersion(version: string | null | undefined): string | null { const trimmed = version?.trim(); if (!trimmed) return null; @@ -856,11 +743,14 @@ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): Hos interface SidebarSectionButtonProps { itemId: SettingsSectionSlug; label: string; - icon: ComponentType<{ size: number; color: string }>; + icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>; isSelected: boolean; onSelect: (section: SettingsSectionSlug) => void; } +// Lighter than lucide's default 2px stroke so the nav reads quieter than the content. +const SIDEBAR_ICON_STROKE_WIDTH = 1.5; + function SidebarSectionButton({ itemId, label, @@ -887,6 +777,7 @@ function SidebarSectionButton({ {label} @@ -898,7 +789,7 @@ function SidebarSectionButton({ interface SidebarHostSectionButtonProps { itemId: HostSectionSlug; label: string; - icon: ComponentType<{ size: number; color: string }>; + icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>; isSelected: boolean; onSelect: (section: HostSectionSlug) => void; } @@ -930,6 +821,7 @@ function SidebarHostSectionButton({ {label} @@ -1049,9 +941,7 @@ function SettingsSidebar({ const hasHosts = sortedHosts.length > 0; const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption(); const isDesktopApp = isElectronRuntime(); - const items = SIDEBAR_SECTION_ITEMS.filter( - (item) => (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb), - ); + const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp)); const insets = useSafeAreaInsets(); const isDesktop = layout === "desktop"; const outerContainerStyle = useMemo( @@ -1066,6 +956,7 @@ function SettingsSidebar({ let selectedHostSection: HostSectionSlug | null = null; if (view.kind === "host") selectedHostSection = view.section; if (view.kind === "project") selectedHostSection = "projects"; + if (view.kind === "plugin") selectedHostSection = "plugins"; const sidebarBody = ( <> @@ -1082,7 +973,6 @@ function SettingsSidebar({ /> ))} - {hasHosts ? ( {t("settings.groups.host")} @@ -1113,7 +1003,11 @@ function SettingsSidebar({ testID="settings-add-host" style={sidebarItemStyle} > - + {t("settings.addHost")} @@ -1126,7 +1020,11 @@ function SettingsSidebar({ testID="settings-enable-built-in-daemon" style={sidebarItemStyle} > - + {t("settings.enableBuiltInDaemon")} @@ -1182,7 +1080,6 @@ export interface SettingsScreenProps { export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) { const router = useRouter(); - const { theme } = useUnistyles(); const { t } = useTranslation(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); @@ -1204,7 +1101,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti const sortedHosts = useSortedHosts(hosts, localServerId); const lastWorkspaceSelection = useLastWorkspaceSelection(); const routedSettingsHostServerId = - view.kind === "host" || view.kind === "project" ? view.serverId : null; + view.kind === "host" || view.kind === "project" || view.kind === "plugin" + ? view.serverId + : null; const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState( routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null, ); @@ -1219,7 +1118,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti // The host the four sections scope to: the host on the active view, otherwise // the picker choice, otherwise the connected local daemon, otherwise the first host. const activeHostServerId = useMemo(() => { - if (view.kind === "host" || view.kind === "project") return view.serverId; + if (view.kind === "host" || view.kind === "project" || view.kind === "plugin") + return view.serverId; return resolveActiveHostServerId({ selectedServerId: selectedSettingsHostServerId, localServerId, @@ -1228,20 +1128,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti }); }, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]); - const handleSendBehaviorChange = useCallback( - (behavior: SendBehavior) => { - void updateSettings({ sendBehavior: behavior }); - }, - [updateSettings], - ); - - const handleServiceUrlBehaviorChange = useCallback( - (behavior: ServiceUrlBehavior) => { - void updateSettings({ serviceUrlBehavior: behavior }); - }, - [updateSettings], - ); - const handleLanguageChange = useCallback( (language: AppLanguage) => { void updateSettings({ language }); @@ -1249,13 +1135,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti [updateSettings], ); - const handleTerminalScrollbackLinesChange = useCallback( - (terminalScrollbackLines: number) => { - void updateSettings({ terminalScrollbackLines }); - }, - [updateSettings], - ); - const handleUseLegacyTerminalRendererChange = useCallback( (useLegacyTerminalRenderer: boolean) => { void updateSettings({ useLegacyTerminalRenderer }); @@ -1425,98 +1304,90 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti returnFromSettings({ kind: "root" }); }, []); - const detailHeader = ((): { - title: string; - Icon: ComponentType<{ size: number; color: string }>; - titleAccessory?: ReactNode; - } | null => { + const installedPlugins = useInstalledPlugins(); + const detailTitle = ((): string | undefined => { + if (view.kind === "plugin") { + const screen = installedPlugins + .find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId) + ?.settingsScreens.find((candidate) => candidate.id === view.screenId); + return `${view.pluginId} · ${screen?.title ?? t("settings.title")}`; + } if (view.kind === "host") { const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section); - if (!item) return null; - return { title: t(item.labelKey), Icon: item.icon }; + return item ? t(item.labelKey) : undefined; } if (view.kind === "section") { const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section); - if (!item) return null; - return { title: t(item.labelKey), Icon: item.icon }; + return item ? t(item.labelKey) : undefined; } if (view.kind === "project") { - return { title: t("settings.projects"), Icon: FolderGit2 }; + return t("settings.projects"); + } + return undefined; + })(); + + const content: ReactNode = (() => { + if (view.kind === "plugin") + return ( + + ); + if (view.kind === "host") { + return renderHostSettingsContent(view, handleHostRemoved); + } + if (view.kind === "project") { + return ( + + ); + } + if (view.kind === "section") { + const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section); + if (!item || !isSectionAvailable(item, isDesktopApp)) return null; + switch (view.section) { + case "general": + return ( + <> + + + {isDesktopApp ? : null} + + ); + case "diagnostics": + return ( + + ); + case "about": + return ( + + ); + default: + return item.Content ? : null; + } } return null; })(); - let content: ReactNode; - if (view.kind === "section" && view.section === "layout") { - content = isDesktopApp ? : null; - } else { - content = (() => { - if (view.kind === "host") { - return renderHostSettingsContent(view, handleHostRemoved); - } - if (view.kind === "project") { - return ( - - ); - } - if (view.kind === "section") { - switch (view.section) { - case "general": - return ( - <> - - {isDesktopApp ? : null} - - ); - case "appearance": - return ; - case "editor": - return isWeb ? : null; - case "shortcuts": - return isDesktopApp ? : null; - case "integrations": - return isDesktopApp ? : null; - case "notifications": - return isDesktopApp ? : null; - case "permissions": - return isDesktopApp ? : null; - case "diagnostics": - return ( - - ); - case "about": - return ( - - ); - } - } - return null; - })(); - } - if (settingsLoading) { return ( @@ -1525,16 +1396,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti ); } - const desktopDetailHeaderLeft = detailHeader ? ( - <> - - - - {detailHeader.title} - {detailHeader.titleAccessory} - - ) : null; - const addHostModals = ( <> - - - {content} - + + {content} + {addHostModals} ); @@ -1624,14 +1480,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti - - - {content} - + + {content} + @@ -1662,13 +1513,6 @@ const styles = StyleSheet.create((theme) => ({ scrollView: { flex: 1, }, - content: { - padding: theme.spacing[4], - paddingTop: theme.spacing[6], - width: "100%", - maxWidth: 720, - alignSelf: "center", - }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, @@ -1689,33 +1533,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], }, - themeTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - }, - themeTriggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - terminalScrollbackInput: { - width: 112, - minHeight: 36, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface2, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - textAlign: "right", - }, placeholder: { flex: 1, alignItems: "center", @@ -1728,7 +1545,7 @@ const styles = StyleSheet.create((theme) => ({ }, })); -const desktopStyles = StyleSheet.create((theme) => ({ +const desktopStyles = StyleSheet.create({ row: { flex: 1, flexDirection: "row", @@ -1736,10 +1553,7 @@ const desktopStyles = StyleSheet.create((theme) => ({ contentPane: { flex: 1, }, - detailLeft: { - gap: theme.spacing[2], - }, -})); +}); const sidebarStyles = StyleSheet.create((theme) => ({ desktopContainer: { @@ -1761,12 +1575,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({ list: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], - gap: theme.spacing[1], + gap: theme.spacing[0.5], }, groupLabel: { fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundExtraMuted, paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], }, @@ -1774,8 +1588,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - minHeight: 36, - paddingVertical: theme.spacing[2], + minHeight: 28, + paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, @@ -1795,8 +1609,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - minHeight: 36, - paddingVertical: theme.spacing[2], + minHeight: 28, + paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, diff --git a/packages/app/src/screens/settings/appearance/appearance-section.tsx b/packages/app/src/screens/settings/appearance/appearance-section.tsx index 5e9d3d1597..6e98819323 100644 --- a/packages/app/src/screens/settings/appearance/appearance-section.tsx +++ b/packages/app/src/screens/settings/appearance/appearance-section.tsx @@ -1,9 +1,9 @@ -import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Text, View, type PressableStateCallbackType } from "react-native"; +import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native"; +import { Monitor, Moon, Sun } from "lucide-react-native"; import { SYNTAX_THEME_OPTIONS, type SyntaxThemeId, @@ -14,12 +14,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Switch } from "@/components/ui/switch"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useContributedThemes } from "@/appearance/provider"; -import { EditingTextInput as TextInput } from "@/components/ui/text-input"; +import { Button } from "@/components/ui/button"; +import { + EditingTextInput as TextInput, + type EditingTextInputHandle, +} from "@/components/ui/text-input"; import { MAX_CODE_FONT_SIZE, MAX_CONTENT_FONT_SIZE, @@ -28,6 +31,8 @@ import { MIN_CONTENT_FONT_SIZE, MIN_UI_BASE_FONT_SIZE, parseClampedFontSize, + parseContentMaxWidth, + resolveContentMaxWidth, sanitizeFontFamily, useAppSettings, type AppSettings, @@ -46,7 +51,6 @@ import { isNative } from "@/constants/platform"; import type { PluginThemeOption } from "@/plugins/themes"; import { settingsStyles } from "@/styles/settings"; import { AppearancePreview } from "./appearance-preview"; -import { SidebarNavSection } from "./sidebar-nav-section"; // --------------------------------------------------------------------------- // Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no @@ -57,7 +61,6 @@ import { SidebarNavSection } from "./sidebar-nav-section"; const ThemedSun = withUnistyles(Sun); const ThemedMoon = withUnistyles(Moon); const ThemedMonitor = withUnistyles(Monitor); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -83,10 +86,6 @@ function sizeDraftToOverride(value: string): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) { - return [styles.trigger, pressed ? styles.triggerPressed : null]; -} - // --------------------------------------------------------------------------- // Theme picker // --------------------------------------------------------------------------- @@ -175,27 +174,30 @@ function ThemeRow({ const selectedLabel = selectedPluginTheme ? selectedPluginTheme.name : getThemeLabel(t, builtInValue); + const leading = useMemo( + () => + selectedPluginTheme ? ( + + ) : ( + + ), + [builtInValue, selectedPluginTheme], + ); return ( {t("settings.appearance.theme.title")} - - {selectedPluginTheme ? ( - - ) : ( - - )} - {selectedLabel} - - - + {selectedLabel} + + {THEME_OPTIONS.map((option, index) => { const previousOption = THEME_OPTIONS[index - 1]; return ( @@ -226,121 +228,6 @@ function ThemeRow({ ); } -interface AutoExpandReasoningRowProps { - value: boolean; - onChange: (value: boolean) => void; -} - -function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) { - const { t } = useTranslation(); - return ( - - - - {t("settings.general.autoExpandReasoning.label")} - - - {t("settings.general.autoExpandReasoning.description")} - - - - - ); -} - -interface ChatOutlineRowProps { - value: boolean; - onChange: (value: boolean) => void; -} - -function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) { - const { t } = useTranslation(); - return ( - - - {t("settings.appearance.chatOutline.title")} - - {t("settings.appearance.chatOutline.description")} - - - - - ); -} - -const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ - "detailed", - "overview", -]; - -function getToolCallDetailLevelLabel( - t: TFunction, - value: AppSettings["toolCallDetailLevel"], -): string { - return t(`settings.general.toolCallDetail.options.${value}`); -} - -interface ToolCallDetailMenuItemProps { - value: AppSettings["toolCallDetailLevel"]; - selected: boolean; - onChange: (value: AppSettings["toolCallDetailLevel"]) => void; -} - -function ToolCallDetailMenuItem({ value, selected, onChange }: ToolCallDetailMenuItemProps) { - const { t } = useTranslation(); - const handleSelect = useCallback(() => onChange(value), [onChange, value]); - return ( - - {getToolCallDetailLevelLabel(t, value)} - - ); -} - -interface ToolCallDetailRowProps { - value: AppSettings["toolCallDetailLevel"]; - onChange: (value: AppSettings["toolCallDetailLevel"]) => void; -} - -function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) { - const { t } = useTranslation(); - const selectedLabel = getToolCallDetailLevelLabel(t, value); - return ( - - - {t("settings.general.toolCallDetail.label")} - - {t("settings.general.toolCallDetail.description")} - - - - - {selectedLabel} - - - - {TOOL_CALL_DETAIL_LEVELS.map((option) => ( - - ))} - - - - ); -} - // --------------------------------------------------------------------------- // Fonts: family text fields + numeric size fields (commit on blur/submit) // --------------------------------------------------------------------------- @@ -445,6 +332,74 @@ function FontSizeRow({ ); } +// --------------------------------------------------------------------------- +// Content width: numeric field (commit on blur/submit) + reset to the default +// --------------------------------------------------------------------------- + +interface ContentWidthRowProps { + value: AppSettings["contentMaxWidth"]; + onChange: (value: AppSettings["contentMaxWidth"]) => void; +} + +function ContentWidthRow({ value, onChange }: ContentWidthRowProps) { + const { t } = useTranslation(); + const width = resolveContentMaxWidth({ contentMaxWidth: value }); + // The field is uncontrolled, so a saved or reset width is written into it directly. + const input = useRef(null); + + useEffect(() => { + input.current?.replaceText(String(width)); + }, [width]); + + const commit = useCallback(() => { + const next = parseContentMaxWidth(input.current?.getText()) ?? width; + input.current?.replaceText(String(next)); + // Typing the width already in effect keeps following the default. + if (next !== width) { + onChange(next); + } + }, [onChange, width]); + + const reset = useCallback(() => { + onChange(null); + }, [onChange]); + + return ( + + + {t("settings.appearance.layout.contentWidth")} + + {t("settings.appearance.layout.contentWidthHint")} + + + + {value === null ? null : ( + + )} + + px + + + ); +} + // --------------------------------------------------------------------------- // Syntax highlight theme picker (commits immediately) // --------------------------------------------------------------------------- @@ -490,15 +445,13 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) { - - {selectedLabel} - - + {selectedLabel} + {SYNTAX_THEME_OPTIONS.map((option) => ( { + void updateSettings({ contentMaxWidth }); + }, + [updateSettings], + ); + const handleSyntaxThemeChange = useCallback( (syntaxTheme: SyntaxThemeId) => { void updateSettings({ syntaxTheme }); @@ -568,27 +528,6 @@ export function AppearanceSection() { [updateSettings], ); - const handleAutoExpandReasoningChange = useCallback( - (autoExpandReasoning: boolean) => { - void updateSettings({ autoExpandReasoning }); - }, - [updateSettings], - ); - - const handleToolCallDetailLevelChange = useCallback( - (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => { - void updateSettings({ toolCallDetailLevel }); - }, - [updateSettings], - ); - - const handleChatOutlineChange = useCallback( - (chatOutlineEnabled: boolean) => { - void updateSettings({ chatOutlineEnabled }); - }, - [updateSettings], - ); - const commitUiFontFamily = useCallback( (value: string) => { const sanitized = sanitizeFontFamily(value); @@ -692,25 +631,6 @@ export function AppearanceSection() { /> - - - - - {!isNative ? ( - - ) : null} - - - {showInterfaceFontFamilyRow ? ( @@ -764,6 +684,14 @@ export function AppearanceSection() { /> + + + + + @@ -789,23 +717,6 @@ const styles = StyleSheet.create((theme) => ({ borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, }, - trigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - triggerPressed: { - opacity: 0.85, - }, - triggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, swatch: { width: ICON_SIZE.md, height: ICON_SIZE.md, @@ -846,6 +757,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, textAlign: "right", }, + widthInput: { + width: 80, + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + textAlign: "right", + }, unit: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, diff --git a/packages/app/src/screens/settings/chat/chat-section.tsx b/packages/app/src/screens/settings/chat/chat-section.tsx new file mode 100644 index 0000000000..8a81d30073 --- /dev/null +++ b/packages/app/src/screens/settings/chat/chat-section.tsx @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { + SettingsCard, + SettingsSection, + SettingsSelect, + SettingsSwitch, +} from "@/components/settings"; +import { isNative } from "@/constants/platform"; +import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; + +const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [ + "detailed", + "overview", +]; + +export function ChatSection() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + + const toolCallDetailOptions = useMemo( + () => + TOOL_CALL_DETAIL_LEVELS.map((value) => ({ + value, + label: t(`settings.general.toolCallDetail.options.${value}`), + })), + [t], + ); + + const changeToolCallDetailLevel = useCallback( + (toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => + void updateSettings({ toolCallDetailLevel }), + [updateSettings], + ); + const changeAutoExpandReasoning = useCallback( + (autoExpandReasoning: boolean) => void updateSettings({ autoExpandReasoning }), + [updateSettings], + ); + const changeChatOutline = useCallback( + (chatOutlineEnabled: boolean) => void updateSettings({ chatOutlineEnabled }), + [updateSettings], + ); + + return ( + + + + + + {isNative ? null : ( + + )} + + + + ); +} diff --git a/packages/app/src/screens/settings/daemon-lifecycle.test.ts b/packages/app/src/screens/settings/daemon-lifecycle.test.ts new file mode 100644 index 0000000000..c50006534a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.test.ts @@ -0,0 +1,98 @@ +import { expect, test } from "vitest"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +test("settings restart completes when a replacement worker is observed without a sampled disconnect", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + }, + }); + expect(pid).toBe(11); +}); + +test("a status permission failure prevents the restart request", async () => { + let restarted = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + throw new Error("Permission denied"); + }, + restartServer: async () => { + restarted = true; + }, + }), + ).rejects.toThrow("Permission denied"); + expect(restarted).toBe(false); +}); + +test("a different responder fails confirmation immediately", async () => { + let serverId = "daemon"; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid: 10, version: "1.0.0", serverId }), + restartServer: async () => { + serverId = "other"; + }, + }), + ).rejects.toThrow("identity changed"); +}); + +test("a lost restart acknowledgment can still confirm the replacement", async () => { + let pid = 10; + await restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + restartServer: async () => { + pid = 11; + throw Object.assign(new Error("Connection lost"), { code: "DAEMON_CONNECTION_LOST" }); + }, + }); + expect(pid).toBe(11); +}); + +test("installation and worker version confirmation are separate outcomes", async () => { + let pid = 10; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version: "1.0.0", serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + return { success: true, error: null, newVersion: "2.0.0" }; + }, + }), + ).rejects.toThrow("Package installed; replacement worker version was not confirmed"); +}); + +test("an installed version is confirmed only in its replacement worker", async () => { + let pid = 10, + version = "1.0.0"; + await expect( + updateDaemonFromSettings("daemon", { + getStatus: async () => ({ pid, version, serverId: "daemon" }), + updateDaemon: async () => { + pid = 11; + version = "2.0.0"; + return { success: true, error: null, newVersion: version }; + }, + }), + ).resolves.toEqual({ workerVersion: "2.0.0" }); +}); + +test("RPC errors mentioning transport are not retried", async () => { + let requested = false; + await expect( + restartDaemonFromSettings("daemon", "settings", { + getStatus: async () => { + if (requested) + throw Object.assign(new Error("Connection policy denied by plugin"), { + code: "permission_denied", + }); + return { pid: 10, version: "1.0.0", serverId: "daemon" }; + }, + restartServer: async () => { + requested = true; + }, + }), + ).rejects.toThrow("Connection policy denied by plugin"); +}); diff --git a/packages/app/src/screens/settings/daemon-lifecycle.ts b/packages/app/src/screens/settings/daemon-lifecycle.ts new file mode 100644 index 0000000000..c4e9d83f5a --- /dev/null +++ b/packages/app/src/screens/settings/daemon-lifecycle.ts @@ -0,0 +1,94 @@ +interface WorkerStatus { + pid: number; + serverId: string; + version: string | null; +} +interface StatusReader { + getStatus: () => Promise; +} + +export interface SettingsDaemonRestartDeps extends StatusReader { + restartServer: (reason: string) => Promise; +} + +/** Acknowledgment starts the wait; only a different ready worker completes it. */ +export async function restartDaemonFromSettings( + hostServerId: string, + reason: string, + deps: SettingsDaemonRestartDeps, +): Promise { + const previous = await readSelectedWorker(hostServerId, deps); + let acknowledged = false; + try { + await deps.restartServer(reason); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + try { + await observeReplacement(previous, deps); + } catch (error) { + throw new Error(`Restart acknowledged: ${acknowledged}. ${String(error)}`, { cause: error }); + } +} + +export async function updateDaemonFromSettings( + hostServerId: string, + deps: StatusReader & { + updateDaemon: () => Promise<{ + success: boolean; + error: string | null; + newVersion: string | null; + }>; + }, +): Promise<{ workerVersion: string }> { + const previous = await readSelectedWorker(hostServerId, deps); + const installed = await deps.updateDaemon(); + if (!installed.success) throw new Error(installed.error ?? "Package installation failed"); + try { + const worker = await observeReplacement(previous, deps); + if (!worker.version || !installed.newVersion || worker.version !== installed.newVersion) { + throw new Error( + `Expected installed version ${installed.newVersion ?? "unknown"}; observed worker ${worker.version}.`, + ); + } + return { workerVersion: worker.version }; + } catch (error) { + throw new Error( + `Package installed; replacement worker version was not confirmed. ${String(error)}`, + { cause: error }, + ); + } +} + +async function readSelectedWorker(serverId: string, deps: StatusReader): Promise { + const worker = await deps.getStatus(); + if (worker.serverId !== serverId) throw new Error("Daemon identity changed"); + return worker; +} + +async function observeReplacement( + previous: WorkerStatus, + deps: StatusReader, +): Promise { + const deadline = Date.now() + 600_000; + while (Date.now() < deadline) { + try { + const current = await readSelectedWorker(previous.serverId, deps); + if (current.pid !== previous.pid) return current; + } catch (error) { + if (!isReconnectFailure(error)) throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error("Replacement worker could not be confirmed. Check daemon status and logs."); +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + ["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"].includes(String(error.code)), + ); +} diff --git a/packages/app/src/screens/settings/daemon-restart.test.ts b/packages/app/src/screens/settings/daemon-restart.test.ts deleted file mode 100644 index 51b6c4d121..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; -import { restartDaemonFromSettings, type SettingsDaemonRestartDeps } from "./daemon-restart"; - -const runningDesktopDaemonStatus: DesktopDaemonStatus = { - serverId: "local-desktop", - status: "running", - listen: null, - hostname: null, - pid: 123, - home: "/tmp/paseo", - version: "1.0.0", - desktopManaged: true, - error: null, -}; - -const desktopSettings = { - daemon: { - manageBuiltInDaemon: true, - }, -}; - -function makeDeps(overrides?: { - isElectron?: boolean; - desktopDaemonStatus?: DesktopDaemonStatus; - desktopSettings?: typeof desktopSettings; - desktopSettingsError?: Error; - restartDesktopDaemon?: () => Promise; - restartServer?: (reason: string) => Promise; -}) { - const calls: string[] = []; - const deps: SettingsDaemonRestartDeps = { - getIsElectron: () => overrides?.isElectron ?? true, - getDesktopDaemonStatus: async () => { - calls.push("desktop-status"); - return overrides?.desktopDaemonStatus ?? runningDesktopDaemonStatus; - }, - getDesktopSettings: async () => { - calls.push("desktop-settings"); - if (overrides?.desktopSettingsError) { - throw overrides.desktopSettingsError; - } - return overrides?.desktopSettings ?? desktopSettings; - }, - restartDesktopDaemon: - overrides?.restartDesktopDaemon ?? - (async () => { - calls.push("desktop-restart"); - return runningDesktopDaemonStatus; - }), - restartServer: - overrides?.restartServer ?? - (async (reason) => { - calls.push(`rpc-restart:${reason}`); - }), - }; - return { calls, deps }; -} - -describe("restartDaemonFromSettings", () => { - it("restarts the local desktop-managed daemon through the desktop bridge", async () => { - const { calls, deps } = makeDeps(); - - await restartDaemonFromSettings(" local-desktop ", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); - - it("restarts remote hosts over the daemon RPC without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("remote-host", "settings_daemon_restart_remote", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_remote"]); - }); - - it("keeps manually managed local daemons on the RPC path without reading desktop settings", async () => { - const { calls, deps } = makeDeps({ - desktopDaemonStatus: { ...runningDesktopDaemonStatus, desktopManaged: false }, - desktopSettingsError: new Error("Unreadable desktop settings."), - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["desktop-status", "rpc-restart:settings_daemon_restart_local"]); - }); - - it("keeps the RPC path when built-in daemon management is disabled", async () => { - const { calls, deps } = makeDeps({ - desktopSettings: { - ...desktopSettings, - daemon: { ...desktopSettings.daemon, manageBuiltInDaemon: false }, - }, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual([ - "desktop-status", - "desktop-settings", - "rpc-restart:settings_daemon_restart_local", - ]); - }); - - it("uses the RPC path outside Electron without reading desktop daemon status or settings", async () => { - const { calls, deps } = makeDeps({ - isElectron: false, - }); - - await restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps); - - expect(calls).toEqual(["rpc-restart:settings_daemon_restart_local"]); - }); - - it("surfaces desktop restart failures without falling back to worker recycle", async () => { - const { calls, deps } = makeDeps({ - restartDesktopDaemon: async () => { - calls.push("desktop-restart"); - throw new Error("Desktop restart failed."); - }, - }); - - await expect( - restartDaemonFromSettings("local-desktop", "settings_daemon_restart_local", deps), - ).rejects.toThrow("Desktop restart failed."); - - expect(calls).toEqual(["desktop-status", "desktop-settings", "desktop-restart"]); - }); -}); diff --git a/packages/app/src/screens/settings/daemon-restart.ts b/packages/app/src/screens/settings/daemon-restart.ts deleted file mode 100644 index 61cce091e2..0000000000 --- a/packages/app/src/screens/settings/daemon-restart.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; - -interface DesktopDaemonRestartStatus { - desktopManaged: boolean; - serverId: string; -} - -interface DesktopDaemonRestartSettings { - daemon: { - manageBuiltInDaemon: boolean; - }; -} - -export interface SettingsDaemonRestartDeps { - getIsElectron: () => boolean; - getDesktopDaemonStatus: () => Promise; - getDesktopSettings: () => Promise; - restartDesktopDaemon: () => Promise; - restartServer: (reason: string) => Promise; -} - -async function isLocalDesktopManagedDaemon( - hostServerId: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (!deps.getIsElectron()) { - return false; - } - - const desktopDaemonStatus = await deps.getDesktopDaemonStatus(); - if (!desktopDaemonStatus.desktopManaged) { - return false; - } - - const normalizedHostServerId = hostServerId.trim(); - const normalizedDesktopServerId = desktopDaemonStatus.serverId.trim(); - - if (normalizedHostServerId.length === 0 || normalizedHostServerId !== normalizedDesktopServerId) { - return false; - } - - const desktopSettings = await deps.getDesktopSettings(); - return desktopSettings.daemon.manageBuiltInDaemon; -} - -export async function restartDaemonFromSettings( - hostServerId: string, - reason: string, - deps: SettingsDaemonRestartDeps, -): Promise { - if (await isLocalDesktopManagedDaemon(hostServerId, deps)) { - await deps.restartDesktopDaemon(); - return; - } - - await deps.restartServer(reason); -} diff --git a/packages/app/src/screens/settings/editor-section.tsx b/packages/app/src/screens/settings/editor-section.tsx index ec926640ee..b9367d81f9 100644 --- a/packages/app/src/screens/settings/editor-section.tsx +++ b/packages/app/src/screens/settings/editor-section.tsx @@ -4,7 +4,7 @@ import { Text, View } from "react-native"; import { Switch } from "@/components/ui/switch"; import { useAppSettings } from "@/hooks/use-settings"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "./settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; export function EditorSection() { const { t } = useTranslation(); diff --git a/packages/app/src/screens/settings/general/sending-section.tsx b/packages/app/src/screens/settings/general/sending-section.tsx new file mode 100644 index 0000000000..85da0fdf4e --- /dev/null +++ b/packages/app/src/screens/settings/general/sending-section.tsx @@ -0,0 +1,36 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings"; +import { useAppSettings, type SendBehavior } from "@/hooks/use-settings"; + +const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"]; + +export function SendingSection() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const options = useMemo( + () => + SEND_BEHAVIORS.map((value) => ({ + value, + label: t(`settings.general.defaultSend.options.${value}`), + })), + [t], + ); + const change = useCallback( + (sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }), + [updateSettings], + ); + return ( + + + + + + ); +} diff --git a/packages/app/src/screens/settings/host-appearance-section.tsx b/packages/app/src/screens/settings/host-appearance-section.tsx index 037be657d4..585be1cfda 100644 --- a/packages/app/src/screens/settings/host-appearance-section.tsx +++ b/packages/app/src/screens/settings/host-appearance-section.tsx @@ -1,19 +1,15 @@ import { useCallback, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { ChevronDown, Pencil } from "lucide-react-native"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { Pencil } from "lucide-react-native"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row"; import { useToast } from "@/contexts/toast-context"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { HOST_BADGE_DISPLAYS, HOST_COLORS, @@ -28,15 +24,10 @@ import { settingsStyles } from "@/styles/settings"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; -const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedPencil = withUnistyles(Pencil); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) { - return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger; -} - function HostRenameButton({ host }: { host: HostProfile }) { const { t } = useTranslation(); const { renameHost } = useHostMutations(); @@ -119,23 +110,22 @@ function ColorMenuItem({ function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) { const { t } = useTranslation(); const selectedLabel = colorLabel(t, color); + const leading = useMemo(() => , [color]); return ( {t("settings.host.appearance.color.label")} - - - {selectedLabel} - - + {selectedLabel} + {HOST_COLORS.map((option) => ( {t("settings.host.appearance.badge.label")} - - {selectedLabel} - - + {selectedLabel} + {HOST_BADGE_DISPLAYS.map((option) => ( ({ - trigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - triggerPressed: { - opacity: 0.85, - }, - triggerText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, swatch: { width: ICON_SIZE.md, height: ICON_SIZE.md, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 40eeb4fc77..6b308547a1 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -33,16 +33,10 @@ import { ProfileDraft, TerminalProfileEditModal, } from "@/screens/settings/terminal-profile-edit-modal"; -import { getIsElectron } from "@/constants/platform"; -import { - getDesktopDaemonStatus, - restartDesktopDaemon, - startDesktopDaemon, - stopDesktopDaemon, -} from "@/desktop/daemon/desktop-daemon"; +import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; -import { loadDesktopSettings, useDesktopSettings } from "@/desktop/settings/desktop-settings"; +import { useDesktopSettings } from "@/desktop/settings/desktop-settings"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; @@ -56,10 +50,9 @@ import { useHosts, } from "@/runtime/host-runtime"; import { ProvidersSection } from "@/screens/settings/providers-section"; -import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section"; -import { useProviderUsage } from "@/provider-usage/use-provider-usage"; +import { HostUsageSection } from "@/usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useSessionStore } from "@/stores/session-store"; import { settingsStyles } from "@/styles/settings"; import type { HostConnection, HostProfile } from "@/types/host-connection"; @@ -70,10 +63,18 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons import { formatLatency } from "@/utils/latency"; import { ICON_SIZE } from "@/styles/theme"; import type { Theme } from "@/styles/theme"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { BrowserToolsOptInCard } from "./browser-tools-card"; -import { hasDaemonReconnectedAfter, type DaemonConnectionMarker } from "./daemon-reconnect"; -import { restartDaemonFromSettings } from "./daemon-restart"; +import { restartDaemonFromSettings, updateDaemonFromSettings } from "./daemon-lifecycle"; + +const ThemedRestart = withUnistyles(RotateCw); +const ThemedUpdate = withUnistyles(ArrowUpToLine); +const lifecycleIconProps = (theme: Theme) => ({ + size: theme.iconSize.sm, + color: theme.colors.foreground, +}); +const restartIcon = ; +const updateIcon = ; const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedArrowDown = withUnistyles(ArrowDown); @@ -89,7 +90,7 @@ interface DynamicProviderIconProps { } function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { - const Icon = getProviderIcon(iconKey); + const Icon = useProviderIcon(iconKey); return ; } @@ -224,12 +225,23 @@ function HostStatusBadges({ serverId }: { serverId: string }) { } function HostConnectionError({ serverId }: { serverId: string }) { + const { t } = useTranslation(); const snapshot = useHostRuntimeSnapshot(serverId); const lastError = snapshot?.lastError ?? null; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; if (!connectionError) return null; - return {connectionError}; + return ( + + + + ); } export function HostConnectionsPage({ serverId }: { serverId: string }) { @@ -330,10 +342,6 @@ export function HostProvidersPage({ serverId }: { serverId: string }) { export function HostUsagePage({ serverId }: { serverId: string }) { const host = useHostProfile(serverId); - const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(serverId); - const handleRefresh = useCallback(() => { - void refreshProviderUsage(); - }, [refreshProviderUsage]); if (!host) { return ; @@ -341,7 +349,7 @@ export function HostUsagePage({ serverId }: { serverId: string }) { return ( - + ); } @@ -362,13 +370,8 @@ export function HostSettingsPage({ return ( - - - {host.label} - - - + @@ -553,14 +556,8 @@ function ConnectionRow({ ); } -const delay = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - function RestartDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -578,68 +575,20 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { [host.serverId, runtime], ); - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - const waitForDaemonRestart = useCallback( - async (restartRequest: Promise) => { - const disconnectTimeoutMs = 30000; - const reconnectTimeoutMs = 30000; - const requestFailureDisconnectGraceMs = 2000; - const disconnectedPromise = isHostConnected() - ? waitForCondition(() => !isHostConnected(), disconnectTimeoutMs) - : Promise.resolve(true); - const restartResult = await restartRequest.then( - () => ({ status: "accepted" as const }), - async (error) => ({ - status: "rejected" as const, - error, - disconnectedAfterFailure: await waitForCondition( - () => !isHostConnected(), - requestFailureDisconnectGraceMs, - 100, - ), - }), - ); - if (!isMountedRef.current) return; - - if (restartResult.status === "rejected" && !restartResult.disconnectedAfterFailure) { - console.error(`[HostPage] Failed to restart daemon ${host.label}`, restartResult.error); - setIsRestarting(false); + async (request: Promise) => { + try { + await request; + } catch (error) { Alert.alert( t("settings.host.daemon.restart.requestFailedTitle"), - t("settings.host.daemon.restart.requestFailedMessage"), + error instanceof Error ? error.message : String(error), ); - return; - } - - const disconnected = - restartResult.status === "rejected" - ? restartResult.disconnectedAfterFailure - : await disconnectedPromise; - const reconnected = - disconnected && (await waitForCondition(() => isHostConnected(), reconnectTimeoutMs)); - if (isMountedRef.current) { - setIsRestarting(false); - if (!reconnected) { - Alert.alert( - t("settings.host.daemon.restart.unableToReconnectTitle"), - t("settings.host.daemon.restart.unableToReconnectMessage", { name: host.label }), - ); - } + } finally { + if (isMountedRef.current) setIsRestarting(false); } }, - [host.label, isHostConnected, t, waitForCondition], + [t], ); const handleRestart = useCallback(() => { @@ -672,11 +621,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { host.serverId, `settings_daemon_restart_${host.serverId}`, { - getIsElectron, - getDesktopDaemonStatus, - getDesktopSettings: loadDesktopSettings, - restartDesktopDaemon, restartServer: (reason) => daemonClient.restartServer(reason), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus()), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), }, ); void waitForDaemonRestart(restartRequest); @@ -691,11 +641,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }); }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); - const restartIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); - return ( @@ -722,12 +667,12 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { type DaemonUpdateState = | { status: "idle" } + | { status: "complete"; workerVersion: string } | { status: "updating"; phase: string } | { status: "failed"; title: string; message: string }; function UpdateDaemonCard({ host }: { host: HostProfile }) { const { t } = useTranslation(); - const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); const runtime = getHostRuntimeStore(); @@ -759,55 +704,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), [host.serverId, runtime], ); - const hasReconnectedAfter = useCallback( - (startMarker: DaemonConnectionMarker | null) => - hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), - [host.serverId, runtime], - ); - - const waitForCondition = useCallback( - async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!isMountedRef.current) return false; - if (predicate()) return true; - await delay(intervalMs); - } - return predicate(); - }, - [], - ); - - const waitForDaemonRestart = useCallback( - async (startMarker: DaemonConnectionMarker | null) => { - const disconnectTimeoutMs = 15000; - const reconnectTimeoutMs = 120000; // 2 minutes — npm update + restart can take a while - if (!hasReconnectedAfter(startMarker) && isHostConnected()) { - await waitForCondition( - () => !isHostConnected() || hasReconnectedAfter(startMarker), - disconnectTimeoutMs, - ); - } - const reconnected = - hasReconnectedAfter(startMarker) || - (await waitForCondition(() => hasReconnectedAfter(startMarker), reconnectTimeoutMs)); - if (isMountedRef.current) { - if (!reconnected) { - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.unableToReconnectTitle"), - message: t("settings.host.daemon.update.unableToReconnectMessage", { - name: host.label, - }), - }); - return; - } - setUpdateState({ status: "idle" }); - } - }, - [hasReconnectedAfter, host.label, isHostConnected, t, waitForCondition], - ); - const handleUpdate = useCallback(() => { if (!daemonClient) { setUpdateState({ @@ -835,13 +731,6 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .then((confirmed) => { if (!confirmed || !isMountedRef.current) return; - const startSnapshot = runtime.getSnapshot(host.serverId); - const startMarker = startSnapshot - ? { - clientGeneration: startSnapshot.clientGeneration, - lastOnlineAt: startSnapshot.lastOnlineAt, - } - : null; setUpdateState({ status: "updating", phase: t("settings.host.daemon.update.phaseStarting"), @@ -875,24 +764,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }); unsubscribeRef.current = unsubscribe; - void daemonClient - .updateDaemon(requestId) - .then((response) => { + void updateDaemonFromSettings(host.serverId, { + updateDaemon: () => daemonClient.updateDaemon(requestId), + getStatus: async () => ({ + ...(await daemonClient.getDaemonStatus()), + serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "", + version: daemonClient.getLastServerInfoMessage()?.version ?? null, + }), + }) + .then(({ workerVersion }) => { unsubscribeRef.current = null; unsubscribe(); - if (!response.success) { - if (!isMountedRef.current) return undefined; - setUpdateState({ - status: "failed", - title: t("settings.host.daemon.update.requestFailedTitle"), - message: t("settings.host.daemon.update.requestFailedMessage", { - error: response.error ?? "Unknown error", - }), - }); - return undefined; - } - // Update succeeded — wait for daemon to restart and reconnect - void waitForDaemonRestart(startMarker); + if (isMountedRef.current) setUpdateState({ status: "complete", workerVersion }); return undefined; }) .catch((error) => { @@ -919,15 +802,10 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { message: t("settings.host.daemon.update.dialogFailedMessage"), }); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); - - const updateIcon = useMemo( - () => , - [theme.iconSize.sm, theme.colors.foreground], - ); + }, [daemonClient, host.label, host.serverId, isHostConnected, t]); const shouldShowUpdate = hasVersionMismatch && (supportsSelfUpdate || desktopManaged); - if (!shouldShowUpdate) { + if (!shouldShowUpdate && updateState.status !== "complete") { return null; } @@ -956,9 +834,20 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { {buttonLabel} + {updateState.status === "complete" ? ( + + ) : null} {updateState.status === "failed" ? ( ( () => ({ - title: isLocalDaemon + title: stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmTitle") : t("settings.host.daemon.remove.title"), }), - [isLocalDaemon, t], + [stopsOwnedDaemon, t], ); const destructiveTextStyle = useMemo( @@ -1292,7 +1182,7 @@ function RemoveHostSection({ try { await updateSettings({ daemon: { manageBuiltInDaemon: false } }); didDisableDaemonManagement = true; - if (daemonStatus?.status === "running" && daemonStatus.desktopManaged) { + if (daemonStatus?.status === "running" && daemonStatus.ownedByDesktop) { setStatus(await stopDesktopDaemon("host_remove")); didStopDaemon = true; } @@ -1360,7 +1250,7 @@ function RemoveHostSection({ : t("settings.host.daemon.remove.title")} - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localHint") : t("settings.host.daemon.remove.hint")} @@ -1386,7 +1276,7 @@ function RemoveHostSection({ testID="remove-host-confirm-modal" > - {isLocalDaemon + {stopsOwnedDaemon ? t("settings.host.daemon.remove.localConfirmMessage") : t("settings.host.daemon.remove.confirmMessage", { name: host.label })} @@ -1813,18 +1703,6 @@ const styles = StyleSheet.create((theme) => ({ marginHorizontal: theme.spacing[4], marginBottom: theme.spacing[4], }, - daemonHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - marginBottom: theme.spacing[4], - }, - daemonHeaderLabel: { - flexShrink: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, identityBadges: { flexDirection: "row", alignItems: "center", @@ -1855,10 +1733,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flexShrink: 1, }, - errorText: { - color: theme.colors.palette.red[300], - fontSize: theme.fontSize.sm, - marginBottom: theme.spacing[2], + connectionError: { + marginBottom: theme.spacing[6], }, connectionLatency: { fontSize: theme.fontSize.base, diff --git a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx index bba32cfa9d..3798a5f9bd 100644 --- a/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx +++ b/packages/app/src/screens/settings/keyboard-shortcuts-section.tsx @@ -6,7 +6,7 @@ import { MoreHorizontal, Pencil, Undo2, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { settingsStyles } from "@/styles/settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -377,8 +377,7 @@ export function KeyboardShortcutsSection() { event.preventDefault(); event.stopPropagation(); - const key = event.key ?? ""; - if (key === "Backspace") { + if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) { setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current)); return; } diff --git a/packages/app/src/screens/settings/layout/layout-section.tsx b/packages/app/src/screens/settings/layout/layout-section.tsx deleted file mode 100644 index f15c451fbc..0000000000 --- a/packages/app/src/screens/settings/layout/layout-section.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Fragment, useCallback } from "react"; -import { Text, View, type PressableStateCallbackType } from "react-native"; -import { useTranslation } from "react-i18next"; -import { StyleSheet } from "react-native-unistyles"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; -import { DropdownTrigger } from "@/components/ui/dropdown-trigger"; -import { - useAppSettings, - type OpenInSidePanePreferences, - type PullRequestOpenLocation, -} from "@/hooks/use-settings"; -import { SettingsSection } from "@/screens/settings/settings-section"; -import { settingsStyles } from "@/styles/settings"; - -const SOURCES = [ - "explorerFiles", - "diffs", - "chatFiles", - "diffFiles", - "subagents", -] as const satisfies readonly (keyof OpenInSidePanePreferences)[]; - -type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests"; - -function destinationTriggerStyle({ - pressed, - open, -}: PressableStateCallbackType & { open?: boolean }) { - return [styles.destinationTrigger, (pressed || open) && styles.destinationTriggerActive]; -} - -function LayoutPreferenceRow({ - source, - destination, - first, - allowExplorer, - onDestinationChange, -}: { - source: LayoutPreferenceSource; - destination: PullRequestOpenLocation; - first: boolean; - allowExplorer?: boolean; - onDestinationChange: ( - source: LayoutPreferenceSource, - destination: PullRequestOpenLocation, - ) => void; -}) { - const { t } = useTranslation(); - const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`); - const selectMain = useCallback( - () => onDestinationChange(source, "main"), - [onDestinationChange, source], - ); - const selectSide = useCallback( - () => onDestinationChange(source, "side"), - [onDestinationChange, source], - ); - const selectExplorer = useCallback( - () => onDestinationChange(source, "explorer"), - [onDestinationChange, source], - ); - const label = t(`settings.layout.openInSidePane.sources.${source}.label`); - return ( - - - {label} - - - - {destinationLabel} - - - - {t("settings.layout.openInSidePane.destinations.main")} - - - {t("settings.layout.openInSidePane.destinations.side")} - - {allowExplorer ? ( - - {t("settings.layout.openInSidePane.destinations.explorer")} - - ) : null} - - - - ); -} - -export function LayoutSection() { - const { t } = useTranslation(); - const { settings, updateSettings } = useAppSettings(); - const handleDestinationChange = useCallback( - (source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => { - if (source === "pullRequests") { - void updateSettings({ pullRequestOpenLocation: destination }); - return; - } - void updateSettings({ - openInSidePane: { ...settings.openInSidePane, [source]: destination === "side" }, - }); - }, - [settings.openInSidePane, updateSettings], - ); - return ( - - - {SOURCES.map((source, index) => ( - - - - ))} - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - destinationTrigger: { - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - destinationTriggerActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - destinationLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/settings/metadata-generation-page.tsx b/packages/app/src/screens/settings/metadata-generation-page.tsx index decf4bf6ee..be137726ba 100644 --- a/packages/app/src/screens/settings/metadata-generation-page.tsx +++ b/packages/app/src/screens/settings/metadata-generation-page.tsx @@ -10,7 +10,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { settingsStyles } from "@/styles/settings"; const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation"; diff --git a/packages/app/src/screens/settings/open-location/open-location-section.tsx b/packages/app/src/screens/settings/open-location/open-location-section.tsx new file mode 100644 index 0000000000..fddc3e1f4b --- /dev/null +++ b/packages/app/src/screens/settings/open-location/open-location-section.tsx @@ -0,0 +1,123 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsSection, SettingsCard, SettingsSelect } from "@/components/settings"; +import { + useAppSettings, + type OpenInSidePanePreferences, + type PullRequestOpenLocation, + type ServiceUrlBehavior, +} from "@/hooks/use-settings"; + +const SOURCES = [ + "explorerFiles", + "diffs", + "chatFiles", + "diffFiles", + "subagents", +] as const satisfies readonly (keyof OpenInSidePanePreferences)[]; + +const SERVICE_URL_BEHAVIORS: readonly ServiceUrlBehavior[] = ["ask", "in-app", "external"]; + +const SERVICE_URL_LABEL_KEYS: Record = { + ask: "settings.general.serviceUrls.options.ask", + "in-app": "settings.general.serviceUrls.options.inApp", + external: "settings.general.serviceUrls.options.external", +}; + +type OpenLocationSource = keyof OpenInSidePanePreferences | "pullRequests"; + +function OpenLocationRow({ + source, + destination, + allowExplorer, + onDestinationChange, +}: { + source: OpenLocationSource; + destination: PullRequestOpenLocation; + allowExplorer?: boolean; + onDestinationChange(source: OpenLocationSource, destination: PullRequestOpenLocation): void; +}) { + const { t } = useTranslation(); + const options = useMemo(() => { + const destinations = allowExplorer + ? (["main", "side", "explorer"] as const) + : (["main", "side"] as const); + return destinations.map((value) => ({ + value, + label: t(`settings.layout.openInSidePane.destinations.${value}`), + })); + }, [allowExplorer, t]); + const change = useCallback( + (value: PullRequestOpenLocation) => onDestinationChange(source, value), + [source, onDestinationChange], + ); + return ( + + ); +} + +function ServiceUrlRow() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const options = useMemo( + () => + SERVICE_URL_BEHAVIORS.map((value) => ({ value, label: t(SERVICE_URL_LABEL_KEYS[value]) })), + [t], + ); + const change = useCallback( + (serviceUrlBehavior: ServiceUrlBehavior) => void updateSettings({ serviceUrlBehavior }), + [updateSettings], + ); + return ( + + ); +} + +/** Where things open: files, diffs, subagents, pull requests, and script URLs. Desktop only. */ +export function OpenLocationSection() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const handleDestinationChange = useCallback( + (source: OpenLocationSource, destination: PullRequestOpenLocation) => { + if (source === "pullRequests") { + void updateSettings({ pullRequestOpenLocation: destination }); + return; + } + void updateSettings({ + openInSidePane: { ...settings.openInSidePane, [source]: destination === "side" }, + }); + }, + [settings.openInSidePane, updateSettings], + ); + return ( + + + {SOURCES.map((source) => ( + + ))} + + + + + ); +} diff --git a/packages/app/src/screens/settings/plugin-install-form-model.test.ts b/packages/app/src/screens/settings/plugin-install-form-model.test.ts new file mode 100644 index 0000000000..3ee48f43d7 --- /dev/null +++ b/packages/app/src/screens/settings/plugin-install-form-model.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { openPluginInstallForm } from "./plugin-install-form-model"; + +describe("plugin install form", () => { + it("keeps the edited source in the submission until explicitly reset", () => { + const model = openPluginInstallForm(); + + model.setSource("owner/review"); + + expect(model.getSubmission()).toEqual({ source: "owner/review" }); + + model.reset(); + expect(model.getState()).toEqual({ + source: "", + canSubmit: false, + resetKey: 1, + }); + }); + + it("trims the submitted source", () => { + const model = openPluginInstallForm(); + model.setSource(" /plugins/review "); + expect(model.getSubmission()).toEqual({ source: "/plugins/review" }); + }); +}); diff --git a/packages/app/src/screens/settings/plugin-install-form-model.ts b/packages/app/src/screens/settings/plugin-install-form-model.ts new file mode 100644 index 0000000000..82fbfa12e2 --- /dev/null +++ b/packages/app/src/screens/settings/plugin-install-form-model.ts @@ -0,0 +1,53 @@ +export interface PluginInstallFormState { + source: string; + canSubmit: boolean; + resetKey: number; +} + +export interface PluginInstallSubmission { + source: string; +} + +export interface PluginInstallFormModel { + getState(): PluginInstallFormState; + subscribe(listener: () => void): () => void; + close(): void; + setSource(value: string): void; + getSubmission(): PluginInstallSubmission; + reset(): void; +} + +export function openPluginInstallForm(): PluginInstallFormModel { + const listeners = new Set<() => void>(); + let state: PluginInstallFormState = { + source: "", + canSubmit: false, + resetKey: 0, + }; + + const publish = (next: Pick) => { + state = { ...next, canSubmit: next.source.trim().length > 0 }; + for (const listener of listeners) listener(); + }; + + return { + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + close() { + listeners.clear(); + }, + setSource(source) { + publish({ ...state, source }); + }, + getSubmission() { + const source = state.source.trim(); + return { source }; + }, + reset() { + publish({ source: "", resetKey: state.resetKey + 1 }); + }, + }; +} diff --git a/packages/app/src/screens/settings/plugins-page.test.tsx b/packages/app/src/screens/settings/plugins-page.test.tsx index 65992960da..0ede3f803b 100644 --- a/packages/app/src/screens/settings/plugins-page.test.tsx +++ b/packages/app/src/screens/settings/plugins-page.test.tsx @@ -1,3 +1,4 @@ +import { subscriptionFixture } from "@/runtime/subscription-fixture"; /** * @vitest-environment jsdom */ @@ -25,8 +26,10 @@ vi.mock("@/runtime/host-runtime", () => ({ })); vi.mock("@/runtime/host-features", () => ({ - useHostFeature: (_serverId: string, feature: string) => - feature === "pluginLogs" ? runtime.logsSupported : runtime.supported, + useHostFeature: (_serverId: string, feature: string) => { + if (feature === "pluginLogs") return runtime.logsSupported; + return runtime.supported; + }, })); vi.mock("@/components/adaptive-modal-sheet", async () => { @@ -58,6 +61,11 @@ vi.mock("@/components/adaptive-modal-sheet", async () => { vi.mock("react-native-reanimated", () => ({ default: { View: "div" }, + Keyframe: class { + duration() { + return this; + } + }, Easing: { ease: "ease", inOut: (value: unknown) => value }, interpolateColor: (value: number, _input: number[], output: string[]) => value >= 1 ? output[1] : output[0], @@ -83,27 +91,32 @@ vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), })); -function never(): Promise { - return new Promise(() => undefined); -} - function plugin(enabled = true): PluginListItem { return { id: "example", + description: "Reviews changes before merge", path: "/plugins/example", enabled, status: enabled ? "running" : "disabled", }; } +async function selectPluginAction(action: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: "Actions for example" })); + fireEvent.click(await screen.findByRole("menuitem", { name: action })); +} + function createClient() { return { - on: vi.fn(() => () => undefined), + observeEvents: () => + subscriptionFixture( + Promise.resolve({ events: ["status.plugin_catalog_changed"] }), + () => () => {}, + ), getDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), patchDaemonConfig: vi.fn(async () => ({ config: { pluginsEnabled: true } })), listPlugins: vi.fn(async (): Promise => []), - inspectDirectoryPlugin: vi.fn(async () => ({ id: "example" })), - installDirectoryPlugin: vi.fn(async () => plugin()), + installPluginSource: vi.fn(async () => plugin()), reloadPlugin: vi.fn(async () => plugin()), enablePlugin: vi.fn(async () => plugin()), disablePlugin: vi.fn(async () => plugin(false)), @@ -170,36 +183,16 @@ describe("HostPluginsPage", () => { }); it.each([ - ["reloadPlugin", "Reload", "Reloading…", true], - ["disablePlugin", "Disable", "Disabling…", true], - ["enablePlugin", "Enable", "Enabling…", false], - ["removePlugin", "Remove", "Removing…", true], - ] as const)("renders %s pending on its plugin row", async (method, trigger, pending, enabled) => { + ["reloadPlugin", "Reload"], + ["removePlugin", "Remove"], + ] as const)("runs %s from the plugin actions menu", async (method, action) => { const client = createClient(); - client.listPlugins.mockResolvedValue([plugin(enabled)]); - client[method].mockImplementation(() => never()); + client.listPlugins.mockResolvedValue([plugin()]); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: trigger })); + await selectPluginAction(action); - const pendingControl = await screen.findByRole("button", { name: pending }); - expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); - expect(client[method]).toHaveBeenCalledTimes(1); - }); - - it("renders install pending through the real form and mutation", async () => { - const client = createClient(); - client.installDirectoryPlugin.mockImplementation(() => never()); - renderPage(client); - - fireEvent.change(screen.getByLabelText("Plugin directory"), { - target: { value: "/plugins/example" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Install directory" })); - - const pendingControl = await screen.findByRole("button", { name: "Installing…" }); - expect(pendingControl.getAttribute("aria-disabled")).toBe("true"); - expect(client.installDirectoryPlugin).toHaveBeenCalledWith("/plugins/example", undefined); + await waitFor(() => expect(client[method]).toHaveBeenCalledTimes(1)); }); it("hides the logs action when the host does not advertise support", async () => { @@ -209,7 +202,8 @@ describe("HostPluginsPage", () => { renderPage(client); await screen.findByText("example"); - expect(screen.queryByRole("button", { name: "Logs" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Actions for example" })); + expect(screen.queryByRole("menuitem", { name: "Logs" })).toBeNull(); }); it("opens readable stdout and stderr logs and refreshes them", async () => { @@ -231,7 +225,7 @@ describe("HostPluginsPage", () => { ]); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: "Logs" })); + await selectPluginAction("Logs"); expect(await screen.findByRole("dialog")).toBeDefined(); expect(screen.getByText("Logs: example")).toBeDefined(); @@ -250,7 +244,7 @@ describe("HostPluginsPage", () => { client.getPluginLogs.mockRejectedValueOnce(new Error("logs exploded")); renderPage(client); - fireEvent.click(await screen.findByRole("button", { name: "Logs" })); + await selectPluginAction("Logs"); expect(await screen.findByText("Unable to load plugin logs")).toBeDefined(); expect(screen.getByText("logs exploded")).toBeDefined(); diff --git a/packages/app/src/screens/settings/plugins-page.tsx b/packages/app/src/screens/settings/plugins-page.tsx index 8ff4ccc27e..f3bdab3fad 100644 --- a/packages/app/src/screens/settings/plugins-page.tsx +++ b/packages/app/src/screens/settings/plugins-page.tsx @@ -1,26 +1,39 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference"; +import { PluginSettingsMenuItems } from "@/plugins/settings"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; +import { MoreHorizontal, Trash2 } from "lucide-react-native"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { SettingsCard, SettingsRow } from "@/components/settings"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { ExternalLink } from "@/components/ui/external-link"; import { Field, FormTextInput } from "@/components/ui/form-field"; import { StatusBadge } from "@/components/ui/status-badge"; import { Switch } from "@/components/ui/switch"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useFetchQuery } from "@/data/query"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useHostFeature } from "@/runtime/host-features"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { resolvePluginPageState } from "@/screens/settings/plugins-page-state"; +import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model"; import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry"; import { settingsStyles } from "@/styles/settings"; import { confirmDialog } from "@/utils/confirm-dialog"; const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const; +const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources"; type PluginRowAction = "reload" | "enable" | "disable" | "remove"; function errorMessage(error: unknown): string { @@ -34,9 +47,9 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine return undefined; } -function PluginRow({ +function PluginActionsMenu({ plugin, - clientError, + serverId, pending, pendingAction, onAction, @@ -44,7 +57,7 @@ function PluginRow({ supportsLogs, }: { plugin: PluginListItem; - clientError?: string; + serverId: string; pending: boolean; pendingAction?: PluginRowAction; onAction(action: PluginRowAction, plugin: PluginListItem): void; @@ -53,55 +66,128 @@ function PluginRow({ }) { const { t } = useTranslation(); const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]); - const toggle = useCallback( - () => onAction(plugin.enabled ? "disable" : "enable", plugin), - [onAction, plugin], - ); const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]); const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]); + const removeIcon = useMemo(() => , []); + const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id }); + + return ( + + + + + + + {supportsLogs ? ( + + {t("settings.plugins.logs.action")} + + ) : null} + + {t("settings.plugins.actions.reload")} + + + {t("settings.plugins.actions.remove")} + + + + ); +} + +function PluginRow({ + plugin, + serverId, + clientError, + pending, + pendingAction, + onAction, + onOpenLogs, + supportsLogs, +}: { + plugin: PluginListItem; + serverId: string; + clientError?: string; + pending: boolean; + pendingAction?: PluginRowAction; + onAction(action: PluginRowAction, plugin: PluginListItem): void; + onOpenLogs(pluginId: string): void; + supportsLogs: boolean; +}) { + const { t } = useTranslation(); + const toggle = useCallback( + (enabled: boolean) => onAction(enabled ? "enable" : "disable", plugin), + [onAction, plugin], + ); const status = clientError ? "failed" : plugin.status; let badgeVariant: "success" | "error" | "muted" = "muted"; if (status === "running") badgeVariant = "success"; else if (status === "failed") badgeVariant = "error"; const statusLabel = t(`settings.plugins.status.${status}`); - let toggleLabel = t( - plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + const statusBadge = useMemo( + () => , + [badgeVariant, statusLabel], ); - if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling"); - else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling"); - return ( - - - - {plugin.id} - + const hint = useMemo( + () => + plugin.description || plugin.installation ? ( + + {plugin.description ? ( + {plugin.description} + ) : null} + {plugin.installation ? ( + {formatPluginInstallation(plugin.installation)} + ) : null} - {plugin.path} - {clientError || plugin.error ? ( - {clientError ?? plugin.error} - ) : null} + ) : undefined, + [plugin.description, plugin.installation], + ); + const toggleLabel = `${plugin.id}: ${t( + plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable", + )}`; + return ( + + + + - - {supportsLogs ? ( - - ) : null} - - - - - + ); } @@ -144,6 +230,7 @@ function PluginLogsSheet({ if (logs.isError) { content = ( pluginQueryKey(serverId), [serverId]); - const [directory, setDirectory] = useState(""); - const [pluginId, setPluginId] = useState(""); - const [directoryResetKey, setDirectoryResetKey] = useState(0); - const [pluginIdResetKey, setPluginIdResetKey] = useState(0); + const [installForm] = useState(openPluginInstallForm); + const installState = useSyncExternalStore( + installForm.subscribe, + installForm.getState, + installForm.getState, + ); + const sourceDocsLink = useMemo( + () => ( + + ), + [t], + ); const [logsPluginId, setLogsPluginId] = useState(null); const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>( null, @@ -216,11 +317,24 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { await refreshQueue.current; }, [client, refetchPlugins]); useEffect(() => { - if (!client) return; - return client.on("status", (message) => { - if (message.payload.status === "plugin_catalog_changed") void refresh(); + return () => installForm.close(); + }, [installForm]); + useEffect(() => { + if (!client || !connected || !supported) return; + const observation = client.observeEvents(["status.plugin_catalog_changed"]); + observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "status" && message.payload.status === "plugin_catalog_changed") + void refresh(); + }, }); - }, [client, refresh]); + return () => { + void observation + .release() + .catch((error) => console.warn("[Plugins] Failed to release settings feed", error)); + }; + }, [client, connected, supported, refresh]); const mutation = useMutation({ mutationFn: async (operation: { action: string; @@ -234,33 +348,18 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { onError: (error) => setFeedback({ kind: "error", message: errorMessage(error) }), }); const install = useCallback(() => { - const path = directory.trim(); - if (!client || !path) return; + if (!client || !sourceInstallSupported || !installState.canSubmit) return; + const input = installForm.getSubmission(); setFeedback(null); mutation.mutate({ action: "install", run: async () => { - const installed = await client.installDirectoryPlugin(path, pluginId.trim() || undefined); - setDirectory(""); - setPluginId(""); - setDirectoryResetKey((key) => key + 1); - setPluginIdResetKey((key) => key + 1); + const installed = await client.installPluginSource(input); + installForm.reset(); return t("settings.plugins.feedback.installed", { id: installed.id }); }, }); - }, [client, directory, mutation, pluginId, t]); - const prefillManifestId = useCallback(() => { - const path = directory.trim(); - if (!client || !path || pluginId.trim()) return; - void client - .inspectDirectoryPlugin(path) - .then(({ id }) => { - setPluginId(id); - setPluginIdResetKey((key) => key + 1); - return undefined; - }) - .catch(() => undefined); - }, [client, directory, pluginId]); + }, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]); const action = useCallback( (name: PluginRowAction, plugin: PluginListItem) => { if (!client) return; @@ -314,6 +413,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { if (pageState === "offline") { return ( ; + return ; } - let catalogContent = ; + let catalogContent = ; if (pageState === "error") { catalogContent = ( + {plugins.data?.length ? ( plugins.data.map((plugin) => { const clientError = pluginRegistry.getEvaluationError(serverId, plugin.id); @@ -347,6 +448,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { return ( {t("settings.plugins.states.empty")} )} - + ); } @@ -370,6 +472,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) { - - - - - - - - - + {sourceInstallSupported ? ( + + + + + + + ) : ( + + )} {feedback ? ( ({ - install: { padding: theme.spacing[4], gap: theme.spacing[3] }, - pluginRow: { - padding: theme.spacing[4], - gap: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, + pluginDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + marginTop: theme.spacing[1], }, - pluginTitle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, - actions: { flexDirection: "row", flexWrap: "wrap", gap: theme.spacing[2] }, - error: { color: theme.colors.statusDanger, fontSize: theme.fontSize.sm }, + pluginSource: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + marginTop: theme.spacing[1], + }, + install: { padding: theme.spacing[4], gap: theme.spacing[3] }, + pluginControls: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + menuButton: { padding: theme.spacing[1], borderRadius: theme.borderRadius.sm }, + menuIcon: { color: theme.colors.foregroundMuted }, + dangerIcon: { color: theme.colors.statusDanger }, empty: { padding: theme.spacing[4], alignItems: "center" }, logsState: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 849ea62cbc..77aaa4d8ec 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -166,7 +166,7 @@ vi.mock("@/components/ui/loading-spinner", () => ({ LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }), })); -vi.mock("@/screens/settings/settings-info-tip", () => ({ +vi.mock("@/components/settings/headings/settings-info-tip", () => ({ SettingsInfoTip: () => null, })); @@ -233,7 +233,7 @@ vi.mock("@/components/ui/dropdown-menu", () => ({ })); vi.mock("@/components/provider-icons", () => ({ - getProviderIcon: (provider: string) => () => + useProviderIcon: (provider: string) => () => React.createElement("span", { "data-icon": `provider-${provider}` }), })); diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 5c44244e32..4fb0a43278 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -22,7 +22,7 @@ import { type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { @@ -31,7 +31,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { SettingsSection } from "@/screens/settings/settings-section"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; @@ -77,6 +77,7 @@ function getProviderStatus( } interface ProviderRowProps { + serverId: string; def: ProviderDefinition; entry: ProviderEntry; enabled: boolean; @@ -167,6 +168,7 @@ function ProviderActionsMenu({ } function ProviderRow({ + serverId, def, entry, enabled, @@ -181,7 +183,7 @@ function ProviderRow({ const { t } = useTranslation(); const { theme } = useUnistyles(); const isCompact = useIsCompactFormFactor(); - const ProviderIcon = getProviderIcon(def.id); + const ProviderIcon = useProviderIcon(def.id, serverId); const providerError = enabled && entry.status === "error" && @@ -326,7 +328,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const isConnected = useHostRuntimeIsConnected(serverId); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); - const { patchConfig } = useDaemonConfig(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [removingProviderId, setRemovingProviderId] = useState(null); @@ -336,6 +338,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; + /** Custom providers are deletable only when config.json holds their `extends` definition. */ + const isConfigOwnedProvider = useCallback( + (providerId: string) => Boolean(config?.providers?.[providerId]?.extends), + [config], + ); + const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); @@ -436,12 +444,17 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { return ( ; const moveDownIcon = ; -const BUILTIN_ICONS: Record = { +const BUILTIN_ICONS: Record = { "new-workspace": Plus, history: History, search: Search, schedules: CalendarClock, + usage: Gauge, }; +/** Plugin items register no icon, so they share this one; a legacy `addSidebarItem` keeps its own. */ +const PLUGIN_ICON = Blocks; + function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { return ; } @@ -50,7 +57,8 @@ function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) { const ThemedNavIcon = withUnistyles(NavIcon); function navItemIcon(item: SidebarNavItem): LucideIcon { - return item.kind === "builtin" ? BUILTIN_ICONS[item.id] : resolvePluginIcon(item.group.icon); + if (item.kind === "builtin") return BUILTIN_ICONS[item.id]; + return item.group.kind === "legacy" ? resolvePluginIcon(item.group.icon) : PLUGIN_ICON; } function navItemLabel(t: TFunction, item: SidebarNavItem): string { @@ -134,15 +142,26 @@ function SidebarNavRow({ ); } -export function SidebarNavSection(): ReactElement { +const SECTION_COPY = { + header: { + title: "settings.appearance.sidebar.header.title", + info: "settings.appearance.sidebar.header.description", + }, + footer: { + title: "settings.appearance.sidebar.footer.title", + info: "settings.appearance.sidebar.footer.description", + }, +} as const satisfies Record; + +function SidebarItemsCard({ section }: { section: SidebarSection }): ReactElement { const { t } = useTranslation(); - const { items, setVisible, move } = useSidebarNavItems(); + const { items, setVisible, move } = useSidebarNavItems(section); return ( {items.map((item, index) => ( @@ -160,6 +179,16 @@ export function SidebarNavSection(): ReactElement { ); } +/** Settings > Sidebar: one card per section. The footer's bottom line is fixed and not listed. */ +export function SidebarNavSection(): ReactElement { + return ( + <> + + + + ); +} + const styles = StyleSheet.create((theme) => ({ row: { gap: theme.spacing[2], diff --git a/packages/app/src/screens/settings/terminal/terminal-section.tsx b/packages/app/src/screens/settings/terminal/terminal-section.tsx new file mode 100644 index 0000000000..4193e2e874 --- /dev/null +++ b/packages/app/src/screens/settings/terminal/terminal-section.tsx @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { SettingsSection } from "@/components/settings"; +import { FormTextInput } from "@/components/ui/form-field"; +import { parseTerminalScrollbackLines, useAppSettings } from "@/hooks/use-settings"; +import { settingsStyles } from "@/styles/settings"; + +export function TerminalSection() { + const { t } = useTranslation(); + const { settings, updateSettings } = useAppSettings(); + const [scrollbackValue, setScrollbackValue] = useState(String(settings.terminalScrollbackLines)); + + const handleChangeText = useCallback((value: string) => { + setScrollbackValue(value.replace(/[^\d]/g, "")); + }, []); + + const commitScrollback = useCallback(() => { + const nextValue = + parseTerminalScrollbackLines(scrollbackValue) ?? settings.terminalScrollbackLines; + setScrollbackValue(String(nextValue)); + if (nextValue !== settings.terminalScrollbackLines) { + void updateSettings({ terminalScrollbackLines: nextValue }); + } + }, [scrollbackValue, settings.terminalScrollbackLines, updateSettings]); + + useEffect(() => { + setScrollbackValue(String(settings.terminalScrollbackLines)); + }, [settings.terminalScrollbackLines]); + + return ( + + + + + + {t("settings.general.terminalScrollback.label")} + + + {t("settings.general.terminalScrollback.description")} + + + + + + + ); +} + +const styles = StyleSheet.create({ + scrollbackInput: { + width: 112, + textAlign: "right", + }, +}); diff --git a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx new file mode 100644 index 0000000000..2a7c4a6a57 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx @@ -0,0 +1,72 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { CompactHeader } from "@/components/headers/compact-header"; +import { + EXPLORER_TOGGLE_KEYS, + WorkspaceExplorerToggle, +} from "@/screens/workspace/workspace-explorer-toggle"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; + +export { MobileWorkspaceTabSwitcher } from "./tab-switcher"; + +interface CompactWorkspaceHeaderProps { + serverId: string; + isLoading: boolean; + title: string; + subtitle: string; + isSubtitleDistinct: boolean; + onToggleExplorer: () => void; + explorerToggleLabel: string; + explorerToggleAccessibilityState: { expanded: boolean }; + /** Tab switcher and workspace menu, before the explorer toggle. */ + children: ReactNode; +} + +/** The compact header with the workspace's project row and controls. */ +export function CompactWorkspaceHeader({ + isLoading, + title, + subtitle, + isSubtitleDistinct, + onToggleExplorer, + explorerToggleLabel, + explorerToggleAccessibilityState, + children, + serverId, +}: CompactWorkspaceHeaderProps) { + const { t } = useTranslation(); + const projectRow = useMemo( + () => ( + + ), + [subtitle, isSubtitleDistinct, serverId], + ); + const actions = ( + <> + {children} + + + ); + + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx new file mode 100644 index 0000000000..970e77aaa9 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -0,0 +1,501 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { Keyboard, Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { SquarePen, SquareTerminal } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabOptionRow, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; +import { + buildWorkspaceTabMenuEntries, + type WorkspaceTabMenuLabels, +} from "@/screens/workspace/workspace-tab-menu"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free. +const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const; + +function getFallbackTabOptionLabel( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + setup: string; + terminal: string; + browser: string; + agent: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.setup; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + return labels.agent; +} + +function getFallbackTabOptionDescription( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + workspaceSetup: string; + agent: string; + terminal: string; + browser: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.workspaceSetup; + } + if (tab.target.kind === "agent") { + return labels.agent; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "plugin") { + return tab.target.panelId; + } + return tab.target.path; +} + +interface MobileWorkspaceTabSwitcherProps { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + normalizedServerId: string; + normalizedWorkspaceId: string; + onSelectSwitcherTab: (key: string) => void; + createTerminalDisabled: boolean; + onCreateDraftTab: () => void; + onCreateTerminal: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +} + +function MobileWorkspaceTabOption({ + tab, + tabIndex, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + selected, + active, + onPress, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: { + tab: WorkspaceTabDescriptor; + tabIndex: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + selected: boolean; + active: boolean; + onPress: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +}) { + const { t } = useTranslation(); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); + const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: tabIndex, + tabCount, + menuTestIDBase, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsBefore: onCloseTabsAbove, + onCloseTabsAfter: onCloseTabsBelow, + onCloseOtherTabs, + labels: tabMenuLabels, + }); + + const fallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); + const trailingAccessory = useMemo( + () => ( + + ), + [menuTestIDBase, fallbackLabel, menuEntries], + ); + + const renderPresentation = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [selected, active, onPress, trailingAccessory], + ); + + return ( + + {renderPresentation} + + ); +} + +export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ + tabs, + activeTabKey, + normalizedServerId, + normalizedWorkspaceId, + onSelectSwitcherTab, + createTerminalDisabled, + onCreateDraftTab, + onCreateTerminal, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: MobileWorkspaceTabSwitcherProps) { + const { t } = useTranslation(); + const tabFallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + + const tabSwitcherOptions = useMemo( + () => + tabs.map((tab) => ({ + id: tab.key, + label: getFallbackTabOptionLabel(tab, tabFallbackLabels), + description: getFallbackTabOptionDescription(tab, tabFallbackLabels), + })), + [tabFallbackLabels, tabs], + ); + + const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const tabIndexByKey = useMemo(() => { + const map = new Map(); + tabs.forEach((tab, index) => { + map.set(tab.key, index); + }); + return map; + }, [tabs]); + + const handleOpenSwitcher = useCallback(() => { + Keyboard.dismiss(); + setIsOpen(true); + }, []); + + const handleNewAgent = useCallback(() => { + setIsOpen(false); + onCreateDraftTab(); + }, [onCreateDraftTab]); + + const handleNewTerminal = useCallback(() => { + setIsOpen(false); + onCreateTerminal(); + }, [onCreateTerminal]); + + const footer = useMemo( + () => ( + + + + + ), + [createTerminalDisabled, handleNewAgent, handleNewTerminal, t], + ); + + const renderTabOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const tabIndex = tabIndexByKey.get(tab.key) ?? -1; + if (tabIndex < 0) { + return ; + } + return ( + + ); + }, + [ + tabByKey, + tabIndexByKey, + tabs.length, + normalizedServerId, + normalizedWorkspaceId, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, + ], + ); + + return ( + <> + + + {tabs.length} + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + tabSwitcherFooter: { + flexDirection: "row", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[3], + }, + tabSwitcherFooterButton: { + flex: 1, + }, + // Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px). + tabCountBadge: { + minWidth: 16, + height: 16, + paddingHorizontal: 2, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.foregroundMuted, + }, + // Android pads text above the ascender by default, which pushes the digit below center. + tabCountText: { + fontSize: 10, + lineHeight: 12, + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx b/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx deleted file mode 100644 index bda9f8af5c..0000000000 --- a/packages/app/src/screens/workspace/explorer-sidebar-tab-rail.tsx +++ /dev/null @@ -1,470 +0,0 @@ -import { useCallback, useMemo, useState, type ComponentType, type ReactNode } from "react"; -import { Text, View } from "react-native"; -import { ArrowLeftToLine, Plus, X } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { useTranslation } from "react-i18next"; -import Animated from "react-native-reanimated"; -import { SortableInlineList } from "@/components/sortable-inline-list"; -import type { - DraggableListDragHandleProps, - DraggableRenderItemInfo, -} from "@/components/draggable-list.types"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { titlebarDragSurfaceStyle } from "@/components/desktop/titlebar-drag-region"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; -import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome"; -import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; -import { - WorkspaceTabIcon, - WorkspaceTabPresentationResolver, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - useWorkspaceTabLaunchCatalog, - type WorkspaceTabLaunchItem, -} from "@/workspace-tabs/launcher"; -import { panelSupportsHost } from "@/panels/panel-manifest"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { panelTargetSupportsHost } from "@/plugins/workspace-panels/locations"; -import type { Theme } from "@/styles/theme"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; -import { - HorizontalScrollBoundaryShades, - useHorizontalScrollBoundary, -} from "@/components/ui/horizontal-scroll-boundary"; - -const TAB_GAP = 4; -const TAB_DROP_INDICATOR_WIDTH = 4; - -const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - -interface ExplorerSidebarTabRailProps { - paneId: string; - tabs: WorkspaceDesktopTabRowItem[]; - normalizedServerId: string; - normalizedWorkspaceId: string; - activeDragTabId: string | null; - tabDropPreviewIndex: number | null; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; - onReorderTabs: (tabs: WorkspaceTabDescriptor[]) => void; - trailingAccessory?: ReactNode; -} - -function tabKey(item: WorkspaceDesktopTabRowItem): string { - return `${item.tab.key}:${item.tab.kind}`; -} - -function resolveExplorerSidebarTabBackdrop(): SurfaceBackdrop { - return "surfaceSidebar"; -} - -function ExplorerSidebarTab({ - item, - isDragging, - dragHandleProps, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - normalizedServerId, - normalizedWorkspaceId, -}: { - item: WorkspaceDesktopTabRowItem; - isDragging: boolean; - dragHandleProps?: DraggableListDragHandleProps; - onNavigateTab: (tabId: string) => void; - onCloseTab: (tabId: string) => Promise | void; - onMoveTabToMain: (tabId: string) => void; - normalizedServerId: string; - normalizedWorkspaceId: string; -}) { - const { t } = useTranslation(); - const [hovered, setHovered] = useState(false); - const handlePress = useCallback( - () => onNavigateTab(item.tab.tabId), - [item.tab.tabId, onNavigateTab], - ); - const handleHoverIn = useCallback(() => setHovered(true), []); - const handleHoverOut = useCallback(() => setHovered(false), []); - const handleClose = useCallback(() => { - void onCloseTab(item.tab.tabId); - }, [item.tab.tabId, onCloseTab]); - const handleMoveToMain = useCallback( - () => onMoveTabToMain(item.tab.tabId), - [item.tab.tabId, onMoveTabToMain], - ); - const canMoveToMain = panelTargetSupportsHost(normalizedServerId, item.tab.target, "main"); - const moveToMainLeading = useMemo( - () => , - [], - ); - const closeLeading = useMemo(() => , []); - const accessibilityState = useMemo(() => ({ selected: item.isActive }), [item.isActive]); - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - - - - - - {presentation.label} - - - - - {presentation.tooltip} - - - - {canMoveToMain ? ( - - {t("workspace.tabs.menu.moveToMain")} - - ) : null} - {canMoveToMain ? : null} - - {t("workspace.tabs.menu.close")} - - - - ), - [ - accessibilityState, - dragHandleProps, - handleHoverIn, - handleHoverOut, - handleClose, - handleMoveToMain, - handlePress, - hovered, - isDragging, - item, - canMoveToMain, - closeLeading, - moveToMainLeading, - t, - ], - ); - - return ( - - {renderPresentation} - - ); -} - -function CatalogIcon({ - Icon, - color = "", -}: { - Icon: ComponentType; - color?: string; -}) { - return ; -} - -const ThemedCatalogIcon = withUnistyles(CatalogIcon); -const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); -const ThemedPlus = withUnistyles(Plus); -const ThemedX = withUnistyles(X); - -function ExplorerSidebarConfigurationItem({ - item, - paneId, - tab, - onCloseTab, -}: { - item: WorkspaceTabLaunchItem; - paneId: string; - tab: WorkspaceTabDescriptor | null; - onCloseTab: (tabId: string) => Promise | void; -}) { - const leading = useMemo(() => { - return item.Icon ? : null; - }, [item.Icon]); - const handleSelect = useCallback(() => { - if (tab) { - void onCloseTab(tab.tabId); - return; - } - item.launch({ kind: "open", paneId }); - }, [item, onCloseTab, paneId, tab]); - - return ( - - {item.label} - - ); -} - -function catalogItemMatchesTab(item: WorkspaceTabLaunchItem, tab: WorkspaceTabDescriptor): boolean { - return item.panelKind === tab.target.kind; -} - -export function ExplorerSidebarTabRail({ - paneId, - tabs, - normalizedServerId, - normalizedWorkspaceId, - activeDragTabId, - tabDropPreviewIndex, - onNavigateTab, - onCloseTab, - onCreateNewTab, - onMoveTabToMain, - onReorderTabs, - trailingAccessory, -}: ExplorerSidebarTabRailProps) { - const scrollBoundary = useHorizontalScrollBoundary(); - const { t } = useTranslation(); - const groups = useWorkspaceTabLaunchCatalog({ - serverId: normalizedServerId, - purpose: "supporting", - host: "explorer", - }); - const singletonConfigurationItems = useMemo( - () => - (groups.find((group) => group.id === "tabs")?.items ?? []).filter( - (item) => !panelSupportsHost(item.panelKind, "main"), - ), - [groups], - ); - const newTabLeading = useMemo(() => , []); - const handleDragEnd = useCallback( - (nextTabs: WorkspaceDesktopTabRowItem[]) => onReorderTabs(nextTabs.map((item) => item.tab)), - [onReorderTabs], - ); - const getTabDragData = useCallback( - (item: WorkspaceDesktopTabRowItem) => ({ - kind: "workspace-tab" as const, - paneId, - tabId: item.tab.tabId, - }), - [paneId], - ); - const renderTab = useCallback( - ({ - item, - index, - dragHandleProps, - isActive, - }: DraggableRenderItemInfo) => { - const showBefore = activeDragTabId !== null && tabDropPreviewIndex === index; - const showAfter = - activeDragTabId !== null && - tabDropPreviewIndex === tabs.length && - index === tabs.length - 1; - return ( - - {showBefore ? : null} - - {showAfter ? : null} - - ); - }, - [ - activeDragTabId, - normalizedServerId, - normalizedWorkspaceId, - onNavigateTab, - onCloseTab, - onMoveTabToMain, - tabDropPreviewIndex, - tabs.length, - ], - ); - - return ( - - - - - - - - - {trailingAccessory ? ( - {trailingAccessory} - ) : null} - - - - {t("workspace.tabs.actions.newTab")} - - - {singletonConfigurationItems.map((item) => ( - catalogItemMatchesTab(item, tab))?.tab ?? null} - onCloseTab={onCloseTab} - /> - ))} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - track: { - minWidth: 0, - height: WORKSPACE_SECONDARY_HEADER_HEIGHT, - backgroundColor: theme.colors.surfaceSidebar, - flexDirection: "row", - alignItems: "center", - }, - scrollContainer: { - flex: 1, - minWidth: 0, - alignSelf: "stretch", - }, - scrollContent: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: 4, - }, - trailingAccessory: { - marginRight: 4, - }, - tabSlot: { - position: "relative", - marginHorizontal: TAB_GAP / 2, - }, - tab: { - height: HEADER_CONTROL_HEIGHT, - maxWidth: 180, - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.md, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - userSelect: "none", - }, - tabHovered: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabActive: { - backgroundColor: theme.colors.interactionHighlight, - }, - tabLabel: { - minWidth: 0, - flexShrink: 1, - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, - userSelect: "none", - }, - tabLabelActive: { - color: theme.colors.foreground, - }, - tabDragging: { - opacity: 0.3, - }, - dropIndicator: { - position: "absolute", - top: theme.spacing[0.5], - bottom: theme.spacing[0.5], - width: TAB_DROP_INDICATOR_WIDTH, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, - zIndex: 10, - pointerEvents: "none", - }, - dropIndicatorBefore: { - left: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - dropIndicatorAfter: { - right: -TAB_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, - }, - tooltipText: { - color: theme.colors.popoverForeground, - fontSize: theme.fontSize.base, - }, -})); diff --git a/packages/app/src/screens/workspace/explorer-sidebar.tsx b/packages/app/src/screens/workspace/explorer-sidebar.tsx index 389d37c821..175be3695b 100644 --- a/packages/app/src/screens/workspace/explorer-sidebar.tsx +++ b/packages/app/src/screens/workspace/explorer-sidebar.tsx @@ -4,17 +4,35 @@ import { StyleSheet } from "react-native-unistyles"; import { RetainedPanel } from "@/components/retained-panel"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import type { TabDropPreview } from "@/components/split-container-tab-drop-preview"; -import { ExplorerSidebarTabRail } from "@/screens/workspace/explorer-sidebar-tab-rail"; import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; -import type { WorkspaceDesktopTabRowItem } from "@/screens/workspace/workspace-desktop-tabs-row"; +import { + WorkspaceDesktopTabsRow, + type WorkspaceDesktopTabRowItem, + type WorkspaceDesktopTabsRowProps, +} from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SplitPane } from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window"; -interface ExplorerSidebarDockProps { +interface ExplorerSidebarDockProps extends Pick< + WorkspaceDesktopTabsRowProps, + | "setHoveredCloseTabKey" + | "onCopyResumeCommand" + | "onCopyAgentId" + | "onCopyTerminalId" + | "onCopyFilePath" + | "onReloadAgent" + | "onRenameTab" + | "onCreateNewTab" + | "onExitFocusMode" +> { + hoveredCloseTabKey: string | null; + onCloseTabsToLeft: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseTabsToRight: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; + onCloseOtherTabs: (tabId: string, tabs: WorkspaceTabDescriptor[]) => Promise | void; pane: SplitPane; uiTabs: WorkspaceTab[]; normalizedServerId: string; @@ -25,8 +43,6 @@ interface ExplorerSidebarDockProps { tabDropPreview: TabDropPreview | null; onSelectTab: (paneId: string, tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; - onCreateNewTab: () => void; - onMoveTabToMain: (tabId: string) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; buildPaneContentModel: (input: { paneId: string; @@ -48,7 +64,18 @@ export function ExplorerSidebarDock({ onSelectTab, onCloseTab, onCreateNewTab, - onMoveTabToMain, + hoveredCloseTabKey, + setHoveredCloseTabKey, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTabsToLeft, + onCloseTabsToRight, + onCloseOtherTabs, + onExitFocusMode, onReorderTabsInPane, buildPaneContentModel, headerAction, @@ -61,10 +88,10 @@ export function ExplorerSidebarDock({ tabs.map((tab) => ({ tab, isActive: tab.tabId === activeTabId, - isCloseHovered: false, + isCloseHovered: hoveredCloseTabKey === tab.key, isClosingTab: closingTabIds.has(tab.tabId), })), - [activeTabId, closingTabIds, tabs], + [activeTabId, closingTabIds, hoveredCloseTabKey, tabs], ); const handleSelectTab = useCallback( (tabId: string) => onSelectTab(pane.id, tabId), @@ -80,28 +107,59 @@ export function ExplorerSidebarDock({ [onReorderTabsInPane, pane.id], ); + const handleCloseTabsToLeft = useCallback( + (tabId: string) => onCloseTabsToLeft(tabId, tabs), + [onCloseTabsToLeft, tabs], + ); + const handleCloseTabsToRight = useCallback( + (tabId: string) => onCloseTabsToRight(tabId, tabs), + [onCloseTabsToRight, tabs], + ); + const handleCloseOtherTabs = useCallback( + (tabId: string) => onCloseOtherTabs(tabId, tabs), + [onCloseOtherTabs, tabs], + ); + return ( - + + + + {headerAction ? {headerAction} : null} @@ -131,9 +189,18 @@ const styles = StyleSheet.create((theme) => ({ }, tabRail: { position: "relative", + flexDirection: "row", + alignItems: "center", flexShrink: 0, backgroundColor: theme.colors.surfaceSidebar, }, + tabRow: { + flex: 1, + minWidth: 0, + }, + headerAction: { + marginRight: theme.spacing[1], + }, tabRailDivider: { position: "absolute", right: 0, diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index b462cf0cd5..f1806ede34 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -63,12 +63,15 @@ import { type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import type { PaneHost } from "@/panels/panel-manifest"; +import type { WorkspaceTabLaunchPurpose } from "@/workspace-tabs/launcher"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; -import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago"; +import { useCompactTimeAgo } from "@/hooks/use-time-ago"; +import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; @@ -88,7 +91,7 @@ const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; const PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING = 2; const PANE_SPLIT_ACTIONS_OUTER_MARGIN = - paneContentToolbarTrailingPadding(false) - PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING; + paneContentToolbarTrailingPadding(false, "glyph") - PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING; const PANE_SPLIT_ACTIONS_RESERVED_WIDTH = smallIconButtonChromeFrameSize(false) + PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING * 2 + @@ -106,7 +109,7 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MODIFIED_DOT_SIZE = 8; -const TAB_MIN_WIDTH = 96; +const TAB_MIN_WIDTH = 64; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; @@ -148,12 +151,6 @@ function formatAgentTooltipTitle(singleLineTitle: string): string { return `${singleLineTitle.slice(0, AGENT_TOOLTIP_TITLE_MAX_LENGTH - 1).trimEnd()}…`; } -function formatAgentTooltipActivity(compactActivity: string): string { - if (compactActivity === "now") return "just now"; - if (/^\d/.test(compactActivity)) return `${compactActivity} ago`; - return compactActivity; -} - function AgentTabTooltipBody({ serverId, agentId, @@ -169,7 +166,7 @@ function AgentTabTooltipBody({ return state.agentLastActivity.get(agentId) ?? agent?.lastActivityAt ?? null; }); const compactActivity = useCompactTimeAgo(lastActivityAt); - const activity = formatAgentTooltipActivity(compactActivity); + const activity = formatCompactTimeAgoAsProse(compactActivity); return ( @@ -214,7 +211,12 @@ function TabLabelMeasurement({ ); } +type PanePanelKinds = readonly WorkspaceTabDescriptor["kind"][]; + interface WorkspaceNewTabButtonProps { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; serverId: string; paneId?: string; shortcutKeys: ShortcutKey[][] | null; @@ -222,6 +224,9 @@ interface WorkspaceNewTabButtonProps { } function WorkspaceNewTabButton({ + panePanelKinds, + host, + launchPurpose, serverId, paneId, shortcutKeys, @@ -242,8 +247,9 @@ function WorkspaceNewTabButton({ @@ -253,6 +259,9 @@ function WorkspaceNewTabButton({ } function WorkspacePaneToolbarActions({ + panePanelKinds, + host, + launchPurpose, showNewTabButton, showSplitActions, showMaximizeAction, @@ -264,6 +273,9 @@ function WorkspacePaneToolbarActions({ onSplitDown, onTogglePaneMaximized, }: { + panePanelKinds: PanePanelKinds; + host: PaneHost; + launchPurpose: WorkspaceTabLaunchPurpose; showNewTabButton: boolean; showSplitActions: boolean; showMaximizeAction: boolean; @@ -302,6 +314,9 @@ function WorkspacePaneToolbarActions({ {showNewTabButton ? ( width === right[index]); } -interface WorkspaceDesktopTabsRowProps { +export interface WorkspaceDesktopTabsRowProps { + host?: PaneHost; + launchPurpose?: WorkspaceTabLaunchPurpose; paneId?: string; isFocused?: boolean; + ownsKeyboardShortcuts?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; @@ -740,6 +758,7 @@ function TabChip({ dragHandleProps: DraggableListDragHandleProps | undefined; }) { const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; + const { t } = useTranslation(); const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), ); @@ -838,9 +857,8 @@ function TabChip({ {...(dragHandleProps?.listeners as object | undefined)} testID={`workspace-tab-${testIdentity}`} triggerRef={dragHandleProps?.setActivatorNodeRef as unknown as undefined} - enabledOnMobile={false} style={tabChipStyle} - onPressIn={handleNavigateTab} + onPressIn={isWeb ? handleNavigateTab : undefined} onPress={handleNavigateTab} accessibilityRole="button" accessibilityLabel={accessibilityLabel} @@ -889,6 +907,8 @@ function TabChip({ tabs.map(({ tab }) => tab.kind), [tabs]); + const fallbackTabLabels = useMemo( () => ({ newTab: t("workspace.tabs.actions.newTab"), @@ -1206,14 +1231,14 @@ function ResolvedWorkspaceDesktopTabsRow({ const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { - if (!isFocused) return false; + if (!ownsKeyboardShortcuts) return false; if (action.id === "workspace.tab.menu.open") { createNewTab(); return true; } return false; }, - [createNewTab, isFocused], + [createNewTab, ownsKeyboardShortcuts], ); useKeyboardActionHandler({ @@ -1224,7 +1249,7 @@ function ResolvedWorkspaceDesktopTabsRow({ paneId, }), actions: ["workspace.tab.menu.open"], - enabled: isFocused, + enabled: ownsKeyboardShortcuts, priority: 200, handle: handleNewTabKeyboardAction, }); @@ -1236,7 +1261,8 @@ function ResolvedWorkspaceDesktopTabsRow({ dragHandleProps, isActive, }: DraggableRenderItemInfo) => { - const shouldShowCloseButton = layout.closeButtonPolicy === "all"; + const shouldShowCloseButton = + layout.closeButtonPolicy === "all" && item.presentation.showCloseButton; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; @@ -1363,6 +1389,9 @@ function ResolvedWorkspaceDesktopTabsRow({ /> {!layout.requiresHorizontalScrollFallback ? ( void; label: string; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 1ffd99425f..2fb7176e10 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react"; import { View } from "react-native"; import { useRouter, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; -import { - Copy, - Ellipsis, - Globe, - Import as ImportIcon, - Settings, - SquarePen, -} from "lucide-react-native"; +import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { @@ -24,6 +17,7 @@ import { extraMutedIconColorMapping, iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, } from "@/components/ui/icon-button-chrome"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; @@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); -const ThemedSquarePen = withUnistyles(SquarePen); const ThemedGlobe = withUnistyles(Globe); const ThemedImport = withUnistyles(ImportIcon); const ThemedSettings = withUnistyles(Settings); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const MENU_NEW_AGENT_ICON = ; const MENU_NEW_BROWSER_ICON = ; -const MENU_NEW_TERMINAL_ICON = ; const MENU_IMPORT_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; @@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace normalizedServerId: string; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateBrowser: () => void; } /** - * Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace - * actions. + * Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer; + * the rarer launches stay here alongside the workspace actions. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, showCreateBrowserTab, createTerminalDisabled, - onCreateDraftTab, - onCreateTerminal, onCreateTerminalWithProfile, onCreateBrowser, ...workspaceActions }: WorkspaceHeaderMenuMobileProps) { const { t } = useTranslation(); const router = useRouter(); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); const { config } = useDaemonConfig(normalizedServerId); const profiles = useMemo( () => resolveTerminalProfiles(config?.terminalProfiles), @@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({ - - {t("workspace.header.actions.newAgent")} - {showCreateBrowserTab ? ( {t("workspace.tabs.actions.terminalProfilesMenu")} - - {t("workspace.header.actions.newTerminal")} - {profiles.map((profile) => ( + {showProject ? ( + + {subtitle} + + ) : null} + {showProject && hostBadge ? · : null} + {hostBadge ? : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The compact header stretches this row to its available title width. Within that width, + // the project truncates and HostBadge yields its label while retaining its fixed icon. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + minWidth: 0, + flexShrink: 1, + }, + project: { + color: theme.colors.foregroundMuted, + fontSize: { + xs: theme.fontSize.sm, + md: theme.fontSize.base, + }, + flexShrink: 1, + minWidth: 0, + }, + separator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + flexShrink: 0, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx index a8d7c3a43a..16ca639c09 100644 --- a/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-new-tab-menu.tsx @@ -16,6 +16,7 @@ import { type WorkspaceTabLaunchItem, type WorkspaceTabLaunchPurpose, } from "@/workspace-tabs/launcher"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { PaneHost } from "@/panels/panel-manifest"; import type { PanelIconProps } from "@/panels/panel-registry"; @@ -84,14 +85,22 @@ export function WorkspaceNewTabMenuContent({ serverId, purpose, host, + panePanelKinds, paneId, }: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + panePanelKinds: readonly WorkspaceTabTarget["kind"][]; paneId?: string; }) { - const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose, host }); + const groups = useWorkspaceTabLaunchCatalog({ + serverId, + purpose, + host, + surface: "menu", + panePanelKinds, + }); return ( - + + + diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index 364816114f..f5f8c5e096 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -126,15 +126,21 @@ function ArchivedWorkspaceRecovery({ if (recovery.phase === "failed") { actionLabel = t("common.actions.retry"); } - const description = - recovery.recovery.action === "restore" - ? t("workspace.route.recovery.restoreDescription", { - workspaceName: recovery.recovery.workspaceName, - branch: recovery.recovery.branch, - }) - : t("workspace.route.recovery.unarchiveDescription", { - workspaceName: recovery.recovery.workspaceName, - }); + let description: string; + if (recovery.recovery.action === "restore" && recovery.recovery.branch) { + description = t("workspace.route.recovery.restoreDescription", { + workspaceName: recovery.recovery.workspaceName, + branch: recovery.recovery.branch, + }); + } else if (recovery.recovery.action === "restore") { + description = t("workspace.route.recovery.restoreWithoutBranchDescription", { + workspaceName: recovery.recovery.workspaceName, + }); + } else { + description = t("workspace.route.recovery.unarchiveDescription", { + workspaceName: recovery.recovery.workspaceName, + }); + } return ( diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a27a1ed418..55a186cbab 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; @@ -14,23 +15,26 @@ import { } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; -import { BackHandler, Keyboard, Pressable, Text, View } from "react-native"; +import { BackHandler, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; -import { ChevronDown } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import invariant from "tiny-invariant"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { FloatingHeaderLayout } from "@/components/floating-header"; +import { StreamTopOverlayProvider, supportsStreamTopOverlay } from "@/agent-stream/top-overlay"; +import { + CompactWorkspaceHeader, + MobileWorkspaceTabSwitcher, +} from "@/screens/workspace/compact-workspace-header"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostBadge } from "@/hosts/host-badge"; -import { useHostBadges } from "@/hosts/use-host-badges"; -import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { FloatingPanelPortalHost, FloatingPanelPortalHostNameProvider, @@ -115,30 +119,20 @@ import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, - WorkspaceTabOptionRow, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; +import { WorkspaceTabPresentationResolver } from "@/screens/workspace/workspace-tab-presentation"; import { useWorkspaceTabRename, WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; -import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { - buildWorkspaceTabMenuEntries, - type WorkspaceTabMenuLabels, -} from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceExplorerToggleOwner, - WorkspaceExplorerToggle, + EXPLORER_TOGGLE_KEYS, WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; @@ -189,7 +183,6 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -197,6 +190,7 @@ import { WorkspaceHeaderMenuDesktop, WorkspaceHeaderMenuMobile, } from "@/screens/workspace/workspace-header-menu"; +import { PluginHeaderButtons } from "@/plugins"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, @@ -205,7 +199,7 @@ import { } from "@/workspace/file-open"; import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; -import { useHasPullRequest } from "@/panels/pull-request"; +import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; @@ -245,24 +239,19 @@ function buildWorkspaceFileLocation( } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const GATED_WORKSPACE_HEADER_LEFT = ; - interface WorkspaceScreenProps { serverId: string; workspaceId: string; isRouteFocused?: boolean; recoveryRequested?: boolean; - recoveryAgentId?: string | null; } type WorkspaceScreenContentProps = WorkspaceScreenProps & { isRouteFocused: boolean; recoveryRequested: boolean; - recoveryAgentId: string | null; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -302,187 +291,6 @@ function useSyncWorkspaceActiveBrowser(input: { }, [focusedBrowserId, input.workspaceId]); } -function getFallbackTabOptionLabel( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - setup: string; - terminal: string; - browser: string; - agent: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.setup; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "file") { - return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - return labels.agent; -} - -function getFallbackTabOptionDescription( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - workspaceSetup: string; - agent: string; - terminal: string; - browser: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.workspaceSetup; - } - if (tab.target.kind === "agent") { - return labels.agent; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "provider_subagent") { - return labels.agent; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "plugin") { - return tab.target.panelId; - } - return tab.target.path; -} - -interface MobileWorkspaceTabSwitcherProps { - tabs: WorkspaceTabDescriptor[]; - activeTabKey: string; - activeTab: WorkspaceTabDescriptor | null; - tabSwitcherOptions: ComboboxOption[]; - tabByKey: Map; - normalizedServerId: string; - normalizedWorkspaceId: string; - onSelectSwitcherTab: (key: string) => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -} - -function MobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor | null; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - if (!activeTab) { - return null; - } - - return ( - - ); -} - -function ResolvedMobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - const { t } = useTranslation(); - return ( - - {(presentation) => ( - <> - - - - - - {presentation.titleState === "loading" - ? t("workspace.tabs.loading") - : presentation.label} - - - )} - - ); -} - function WorkspaceDocumentTitleEffect({ label, titleState, @@ -505,283 +313,13 @@ function WorkspaceDocumentTitleEffect({ return null; } -function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { - return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; -} - -function MobileWorkspaceTabOption({ - tab, - tabIndex, - tabCount, - normalizedServerId, - normalizedWorkspaceId, - selected, - active, - onPress, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: { - tab: WorkspaceTabDescriptor; - tabIndex: number; - tabCount: number; - normalizedServerId: string; - normalizedWorkspaceId: string; - selected: boolean; - active: boolean; - onPress: () => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -}) { - const { t } = useTranslation(); - const tabMenuLabels = useMemo( - () => ({ - copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), - copyAgentId: t("workspace.tabs.menu.copyAgentId"), - copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), - copyFilePath: t("workspace.tabs.menu.copyFilePath"), - rename: t("workspace.tabs.menu.rename"), - closeAbove: t("workspace.tabs.menu.closeAbove"), - closeBelow: t("workspace.tabs.menu.closeBelow"), - closeLeft: t("workspace.tabs.menu.closeLeft"), - closeRight: t("workspace.tabs.menu.closeRight"), - closeOthers: t("workspace.tabs.menu.closeOthers"), - reloadAgent: t("workspace.tabs.menu.reloadAgent"), - reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), - close: t("workspace.tabs.menu.close"), - }), - [t], - ); - const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "mobile", - tab, - index: tabIndex, - tabCount, - menuTestIDBase, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsBefore: onCloseTabsAbove, - onCloseTabsAfter: onCloseTabsBelow, - onCloseOtherTabs, - labels: tabMenuLabels, - }); - - const fallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); - const trailingAccessory = useMemo( - () => ( - - ), - [menuTestIDBase, fallbackLabel, menuEntries], - ); - - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - ), - [selected, active, onPress, trailingAccessory], - ); - - return ( - - {renderPresentation} - - ); -} - -const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ - tabs, - activeTabKey, - activeTab, - tabSwitcherOptions, - tabByKey, - normalizedServerId, - normalizedWorkspaceId, - onSelectSwitcherTab, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: MobileWorkspaceTabSwitcherProps) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const anchorRef = useRef(null); - const tabIndexByKey = useMemo(() => { - const map = new Map(); - tabs.forEach((tab, index) => { - map.set(tab.key, index); - }); - return map; - }, [tabs]); - - const handleOpenSwitcher = useCallback(() => { - Keyboard.dismiss(); - setIsOpen(true); - }, []); - - const renderTabOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - const tab = tabByKey.get(option.id); - if (!tab) { - return ; - } - const tabIndex = tabIndexByKey.get(tab.key) ?? -1; - if (tabIndex < 0) { - return ; - } - return ( - - ); - }, - [ - tabByKey, - tabIndexByKey, - tabs.length, - normalizedServerId, - normalizedWorkspaceId, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, - ], - ); - - return ( - - - {({ pressed }) => ( - <> - - - - - - )} - - - - - ); -}); - interface MobileMountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; isWorkspaceFocused: boolean; isPaneFocused: boolean; paneId: string | null; + onFocusPane: (paneId: string) => void; buildPaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -794,6 +332,7 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ isWorkspaceFocused, isPaneFocused, paneId, + onFocusPane, buildPaneContentModel, }: MobileMountedTabSlotProps) { const content = useMemo( @@ -804,15 +343,21 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({ }), [buildPaneContentModel, paneId, tabDescriptor], ); + const handleTouch = useCallback(() => { + if (!isPaneFocused && paneId) onFocusPane(paneId); + return false; + }, [isPaneFocused, onFocusPane, paneId]); return ( - + + + ); @@ -850,7 +395,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenProps) { const navigationFocused = useIsFocused(); useEffect(() => { @@ -865,7 +409,6 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({ workspaceId={workspaceId} isRouteFocused={isRouteFocused ?? navigationFocused} recoveryRequested={recoveryRequested ?? false} - recoveryAgentId={recoveryAgentId ?? null} /> ); }); @@ -897,47 +440,6 @@ function useCloseTabs(): UseCloseTabsResult { return { closingTabIds, closeTab }; } -/** - * Which project the workspace belongs to, and which machine it runs on. - * - * Compact gets both, on their own line under the workspace name: this header is the only thing on - * screen that says where the workspace lives, because the sidebar that normally carries the host - * badge is closed. It still follows the host's own badge setting, so a purely local setup stays - * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit - * side by side, and kept on compact, where the line exists for the host anyway. - */ -function WorkspaceHeaderProjectRow({ - subtitle, - isSubtitleDistinct, - serverId, -}: { - subtitle: string; - isSubtitleDistinct: boolean; - serverId: string; -}) { - const isCompact = useIsCompactFormFactor(); - const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; - const showProject = isSubtitleDistinct || isCompact; - if (!showProject && !hostBadge) { - return null; - } - return ( - - {showProject ? ( - - {subtitle} - - ) : null} - {showProject && hostBadge ? · : null} - {hostBadge ? : null} - - ); -} - interface WorkspaceHeaderTitleBarProps { isLoading: boolean; title: string; @@ -945,26 +447,13 @@ interface WorkspaceHeaderTitleBarProps { isSubtitleDistinct: boolean; currentBranchName: string | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceScripts: WorkspaceDescriptor["scripts"]; - liveTerminalIds: string[]; showWorkspaceSetup: boolean; - showCreateBrowserTab: boolean; - isMobile: boolean; - createTerminalDisabled: boolean; importAgentDisabled: boolean; copyPathDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; - onScriptTerminalStarted: (terminalId: string) => void; - onViewScriptTerminal: (terminalId: string) => void; - onOpenUrlInBrowserTab: (url: string) => void; } function WorkspaceHeaderTitleBar({ @@ -974,26 +463,13 @@ function WorkspaceHeaderTitleBar({ isSubtitleDistinct, currentBranchName, normalizedServerId, - normalizedWorkspaceId, - workspaceScripts, - liveTerminalIds, showWorkspaceSetup, - showCreateBrowserTab, - isMobile, - createTerminalDisabled, importAgentDisabled, copyPathDisabled, - onCreateDraftTab, - onCreateTerminal, - onCreateTerminalWithProfile, - onCreateBrowser, onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, - onScriptTerminalStarted, - onViewScriptTerminal, - onOpenUrlInBrowserTab, }: WorkspaceHeaderTitleBarProps) { return ( @@ -1012,49 +488,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1079,6 +522,8 @@ interface RenderWorkspaceContentInput { focusedPaneTabDescriptorMap: Map; isRouteFocused: boolean; focusedPaneId: string | null; + paneFocusSuspended: boolean; + onFocusPane: (paneId: string) => void; buildMobilePaneContentModel: (input: { paneId: string | null; tab: WorkspaceTabDescriptor; @@ -1095,6 +540,8 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended, + onFocusPane, buildMobilePaneContentModel, } = input; @@ -1134,8 +581,9 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React tabDescriptor={tabDescriptor} isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId} isWorkspaceFocused={isRouteFocused} - isPaneFocused={tabId === activeTabDescriptor.tabId} + isPaneFocused={!paneFocusSuspended && tabId === activeTabDescriptor.tabId} paneId={focusedPaneId} + onFocusPane={onFocusPane} buildPaneContentModel={buildMobilePaneContentModel} /> ); @@ -1283,7 +731,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1306,14 +754,20 @@ function WorkspaceContentProviders({ function WorkspacePanelContent({ launcher, content, + tabIds, + workspaceKey, }: { + workspaceKey: string; launcher: NewTabLauncher; content: ReactNode; + tabIds: readonly string[]; }) { return ( - - {content} - + + + {content} + + ); } @@ -1365,6 +819,10 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -1525,7 +983,6 @@ function WorkspaceScreenContent({ workspaceId, isRouteFocused, recoveryRequested, - recoveryAgentId, }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); @@ -1651,7 +1108,6 @@ function WorkspaceScreenContent({ const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - agentId: recoveryAgentId, enabled: shouldInspectWorkspaceRecovery( hasHydratedWorkspaces, workspaceDescriptor, @@ -1752,10 +1208,16 @@ function WorkspaceScreenContent({ workspace: workspaceDescriptor, checkoutState: workspaceHeaderCheckoutState, }); + const canDetectPullRequestTab = canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile); const hasPullRequest = useHasPullRequest({ serverId: normalizedServerId, cwd: workspaceDirectory, - enabled: canDetectPullRequest(isRouteFocused, isGitCheckout, isMobile), + enabled: canDetectPullRequestTab, + }); + usePullRequestAutoAdd({ + workspaceKey: persistenceKey, + hasPullRequest, + enabled: canDetectPullRequestTab, }); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); @@ -1799,6 +1261,9 @@ function WorkspaceScreenContent({ const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const unfocusedPaneId = useWorkspaceLayoutStore((state) => + persistenceKey ? state.focusRestorationByWorkspace[persistenceKey]?.restorePaneId : undefined, + ); const explorerSidebarPaneId = useWorkspaceLayoutStore((state) => persistenceKey ? selectExplorerSidebarPaneId(state, persistenceKey) : null, ); @@ -1898,8 +1363,9 @@ function WorkspaceScreenContent({ deriveWorkspacePaneState({ layout: workspaceLayout, tabs: uiTabs, + paneId: workspaceLayout?.focusedPaneId ?? unfocusedPaneId, }), - [uiTabs, workspaceLayout], + [uiTabs, workspaceLayout, unfocusedPaneId], ); const viewedTimelineSync = useSessionStore( (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, @@ -2282,14 +1748,6 @@ function WorkspaceScreenContent({ terminalsQueryKey, }); - const tabByKey = useMemo(() => { - const map = new Map(); - for (const tab of tabs) { - map.set(tab.key, tab); - } - return map; - }, [tabs]); - const allTabDescriptorsById = useMemo(() => { const map = new Map(); for (const tab of uiTabs) { @@ -2335,32 +1793,6 @@ function WorkspaceScreenContent({ : t("workspace.tabs.explorerSidebar.open"); const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]); - const tabFallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - - const tabSwitcherOptions = useMemo( - () => - tabs.map((tab) => ({ - id: tab.key, - label: getFallbackTabOptionLabel(tab, tabFallbackLabels), - description: getFallbackTabOptionDescription(tab, tabFallbackLabels), - })), - [tabFallbackLabels, tabs], - ); - const handleCreateDraftTab = useCallback( (input?: { paneId?: string }) => { openWorkspaceDraftTab({ paneId: input?.paneId }); @@ -3621,6 +3053,12 @@ function WorkspaceScreenContent({ }, [buildPaneContentModel], ); + const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { + if (!persistenceKey || paneFocusSuppressedRef.current) { + return; + } + focusWorkspacePane(persistenceKey, paneId); + }); const content = renderWorkspaceContent({ isMissingWorkspaceDirectory, activeTabDescriptor, @@ -3630,6 +3068,8 @@ function WorkspaceScreenContent({ focusedPaneTabDescriptorMap, isRouteFocused, focusedPaneId, + paneFocusSuspended: Boolean(unfocusedPaneId), + onFocusPane: handleFocusPane, buildMobilePaneContentModel, }); @@ -3655,13 +3095,6 @@ function WorkspaceScreenContent({ [activeTabDescriptor?.tabId, closingTabIds, hoveredCloseTabKey, tabs], ); - const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) { - if (!persistenceKey || paneFocusSuppressedRef.current) { - return; - } - focusWorkspacePane(persistenceKey, paneId); - }); - const handleSplitPane = useCallback( function handleSplitPane(input: { tabId: string; @@ -3739,7 +3172,8 @@ function WorkspaceScreenContent({ const headerRight = useMemo( () => ( - {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( + + {workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( <> ) : null} - {isMobile ? ( - - ) : null} ), [ - isMobile, workspaceDescriptor, normalizedServerId, normalizedWorkspaceId, @@ -3872,26 +3295,13 @@ function WorkspaceScreenContent({ isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct} currentBranchName={currentBranchName} normalizedServerId={normalizedServerId} - normalizedWorkspaceId={normalizedWorkspaceId} - workspaceScripts={workspaceScripts} - liveTerminalIds={liveTerminalIds} showWorkspaceSetup={showWorkspaceSetup} - showCreateBrowserTab={showCreateBrowserTab} - isMobile={isMobile} - createTerminalDisabled={createTerminalDisabled} importAgentDisabled={!canOpenImportSheet} copyPathDisabled={!workspaceDirectory} - onCreateDraftTab={handleCreateDraftTab} - onCreateTerminal={handleCreateTerminal} - onCreateTerminalWithProfile={handleCreateTerminalWithProfile} - onCreateBrowser={handleCreateBrowserTab} onOpenImportSheet={openImportSheet} onCopyWorkspacePath={handleCopyWorkspacePath} onCopyBranchName={handleCopyBranchName} onOpenSetupTab={handleOpenSetupTab} - onScriptTerminalStarted={handleScriptTerminalStarted} - onViewScriptTerminal={handleViewScriptTerminal} - onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab} /> } @@ -3900,33 +3310,110 @@ function WorkspaceScreenContent({ ) : null, [ canOpenImportSheet, - createTerminalDisabled, currentBranchName, handleCopyBranchName, handleCopyWorkspacePath, - handleCreateBrowserTab, - handleCreateDraftTab, - handleCreateTerminal, - handleCreateTerminalWithProfile, handleOpenSetupTab, - handleOpenUrlInBrowserTab, - handleScriptTerminalStarted, - handleViewScriptTerminal, headerRight, - isMobile, isWorkspaceHeaderLoading, - liveTerminalIds, normalizedServerId, - normalizedWorkspaceId, openImportSheet, - showCreateBrowserTab, showScreenHeader, showWorkspaceSetup, workspaceDirectory, workspaceHeaderSubtitle, workspaceHeaderTitle, isWorkspaceHeaderSubtitleDistinct, - workspaceScripts, + ], + ); + const compactWorkspaceHeader = useMemo( + () => + isMobile ? ( + + + + + ) : null, + [ + activeTabKey, + canOpenImportSheet, + createTerminalDisabled, + currentBranchName, + explorerSidebarToggleAccessibilityState, + explorerSidebarToggleLabel, + handleCloseOtherTabs, + handleCloseTabById, + handleCloseTabsToLeft, + handleCloseTabsToRight, + handleCopyAgentId, + handleCopyBranchName, + handleCopyFilePath, + handleCopyResumeCommand, + handleCopyTerminalId, + handleCopyWorkspacePath, + handleCreateBrowserTab, + handleCreateDraftTab, + handleCreateTerminal, + handleCreateTerminalWithProfile, + handleOpenSetupTab, + handleReloadAgent, + handleRenameTab, + handleSelectSwitcherTab, + handleToggleExplorerSidebar, + isMobile, + isWorkspaceHeaderLoading, + isWorkspaceHeaderSubtitleDistinct, + normalizedServerId, + normalizedWorkspaceId, + openImportSheet, + showCreateBrowserTab, + showWorkspaceSetup, + tabs, + workspaceDirectory, + workspaceHeaderSubtitle, + workspaceHeaderTitle, ], ); const desktopSplitContent = useMemo(() => { @@ -4011,37 +3498,29 @@ function WorkspaceScreenContent({ const workspacePanelContent = ( tab.tabId)} content={isMobile ? content : desktopContent} /> ); - const workspaceCenterColumn = ( + const workspaceCenterColumn = isMobile ? ( + + + {(topOverlayInset) => ( + + {workspacePanelContent} + + )} + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - {isMobile ? ( - - ) : null} - {shouldRenderDesktopPaneFallback ? ( ({ md: "row", }, alignItems: { - xs: "flex-start", + xs: "stretch", md: "center", }, justifyContent: "flex-start", @@ -4170,30 +3649,6 @@ const styles = StyleSheet.create((theme) => ({ md: theme.spacing[2], }, }, - // No width cap. A percentage cap resolves against the title group, whose own width comes from - // this row's content, so it clips the project name while there is still room beside it. - // `flexShrink` on both this row and the title already gives up space only when there is none. - headerProjectRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - minWidth: 0, - flexShrink: 1, - }, - headerProjectTitle: { - color: theme.colors.foregroundMuted, - fontSize: { - xs: theme.fontSize.sm, - md: theme.fontSize.base, - }, - flexShrink: 1, - minWidth: 0, - }, - headerProjectSeparator: { - color: theme.colors.foregroundExtraMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, headerTitleSkeleton: { width: 220, maxWidth: "100%", @@ -4252,37 +3707,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, newTabTooltipShortcut: {}, - mobileTabsRow: { - backgroundColor: theme.colors.surface0, - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - switcherTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2] + theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - switcherTriggerPressed: { - backgroundColor: theme.colors.surface1, - }, - switcherTriggerLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flex: 1, - minWidth: 0, - }, - switcherTriggerIcon: { - flexShrink: 0, - }, - switcherTriggerText: { - minWidth: 0, - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, headerMenuProfileIconWrapper: { width: 16, height: 16, @@ -4397,5 +3821,3 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", }, })); - -const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 3ccf39da35..b2b226e645 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -9,7 +9,7 @@ const metrics = { actionsReservedWidth: 120, rowPaddingHorizontal: 8, tabGap: 4, - minTabWidth: 96, + minTabWidth: 64, maxTabWidth: 160, tabIconWidth: 14, tabContentGap: 4, @@ -29,7 +29,20 @@ describe("computeWorkspaceTabLayout", () => { expect(result.requiresHorizontalScrollFallback).toBe(false); expect(result.items).toHaveLength(3); expect(result.items.every((item) => item.showLabel)).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 104, 96]); + expect(result.items.map((item) => item.width)).toEqual([90, 104, 83]); + }); + + it("lets short labels use their content width below the old 96px floor", () => { + const result = computeWorkspaceTabLayout({ + viewportWidth: 300, + tabLabelWidths: [29, 52], + metrics: { ...metrics, actionsReservedWidth: 36 }, + }); + expect(result.items).toEqual([ + { width: 64, showLabel: true }, + { width: 86, showLabel: true }, + ]); + expect(result.requiresHorizontalScrollFallback).toBe(false); }); it("sizes a single tab between the minimum and maximum from its content", () => { @@ -52,7 +65,7 @@ describe("computeWorkspaceTabLayout", () => { expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([117, 103, 96]); + expect(result.items.map((item) => item.width)).toEqual([132, 102, 82]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); @@ -75,27 +88,27 @@ describe("computeWorkspaceTabLayout", () => { it("keeps every tab at the clickable minimum at the exact fit boundary", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 532, + viewportWidth: 404, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(false); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); it("uses horizontal scroll rather than shrinking below the clickable minimum", () => { const result = computeWorkspaceTabLayout({ - viewportWidth: 531, + viewportWidth: 403, tabLabelWidths: [98, 98, 98, 98], metrics, }); expect(result.closeButtonPolicy).toBe("all"); expect(result.requiresHorizontalScrollFallback).toBe(true); - expect(result.items.map((item) => item.width)).toEqual([96, 96, 96, 96]); + expect(result.items.map((item) => item.width)).toEqual([64, 64, 64, 64]); expect(result.items.every((item) => item.showLabel)).toBe(true); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index 07cd56e4fb..8b499429f0 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -26,6 +26,7 @@ export interface WorkspaceTabPresentation { subtitle: string; tooltip: string; modified: boolean; + showCloseButton: boolean; titleState: "ready" | "loading"; icon: React.ComponentType; statusBucket: SidebarStateBucket | null; @@ -90,6 +91,7 @@ function WorkspaceTabPresentationResolverInner({ subtitle: descriptor.subtitle, tooltip: descriptor.tooltip, modified: attributes.modified, + showCloseButton: registration.showCloseButton, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, @@ -104,6 +106,7 @@ function WorkspaceTabPresentationResolverInner({ tab.key, tab.kind, attributes.modified, + registration.showCloseButton, ], ); diff --git a/packages/app/src/sidebar-nav/model.test.ts b/packages/app/src/sidebar-nav/model.test.ts index fa1ef6763c..f7e7ba0933 100644 --- a/packages/app/src/sidebar-nav/model.test.ts +++ b/packages/app/src/sidebar-nav/model.test.ts @@ -12,11 +12,11 @@ import { function group(pluginId: string, contributionId: string): PluginSidebarGroup { return { + kind: "item", key: `${pluginId}/sidebar/${contributionId}`, pluginId, contributionId, title: contributionId, - icon: "puzzle", targets: [], }; } @@ -32,7 +32,11 @@ function summarize(items: readonly SidebarNavItem[]): SidebarNavPreference[] { describe("resolveSidebarNavItems", () => { it("yields builtins then plugins, all visible, when nothing is stored", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban, notes], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban, notes], + preferences: [], + }); expect(summarize(items)).toEqual([ { key: "new-workspace", visible: true }, @@ -53,6 +57,7 @@ describe("resolveSidebarNavItems", () => { it("keeps the stored order and appends newly available items as visible", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [notes, kanban], preferences: [ { key: kanbanKey, visible: false }, @@ -73,6 +78,7 @@ describe("resolveSidebarNavItems", () => { it("skips keys that are unknown or not currently available", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [], preferences: [ { key: notesKey, visible: false }, @@ -91,6 +97,7 @@ describe("resolveSidebarNavItems", () => { it("lets the first of duplicate keys win", () => { const items = resolveSidebarNavItems({ + section: "header", pluginGroups: [], preferences: [ { key: "history", visible: false }, @@ -107,9 +114,23 @@ describe("resolveSidebarNavItems", () => { }); }); +describe("resolveSidebarNavItems for the Usage item", () => { + it("hides the Usage item until it is turned on", () => { + const resolve = (preferences: SidebarNavPreference[]) => + summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences })); + + expect(resolve([])).toEqual([{ key: "usage", visible: false }]); + expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]); + }); +}); + describe("setSidebarNavItemVisible", () => { it("toggles one item and writes the full resolved order", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "search", visible: false, previous: [] }); @@ -127,7 +148,11 @@ describe("setSidebarNavItemVisible", () => { { key: notesKey, visible: false }, { key: "history", visible: true }, ]; - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: previous, + }); const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous }); @@ -148,7 +173,11 @@ describe("setSidebarNavItemVisible", () => { { key: "search", visible: true }, { key: "schedules", visible: true }, ]; - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: previous, + }); const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous }); @@ -159,13 +188,23 @@ describe("setSidebarNavItemVisible", () => { { key: "search", visible: true }, { key: "schedules", visible: true }, ]); - expect(summarize(resolveSidebarNavItems({ pluginGroups: [notes], preferences: next }))).toEqual( - next, - ); + expect( + summarize( + resolveSidebarNavItems({ + section: "header", + pluginGroups: [notes], + preferences: next, + }), + ), + ).toEqual(next); }); it("returns the normalized list unchanged for an unknown key", () => { - const items = resolveSidebarNavItems({ pluginGroups: [], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [], + preferences: [], + }); const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] }); @@ -174,7 +213,11 @@ describe("setSidebarNavItemVisible", () => { }); describe("moveSidebarNavItem", () => { - const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] }); + const items = resolveSidebarNavItems({ + section: "header", + pluginGroups: [kanban], + preferences: [], + }); it("moves an item up", () => { const next = moveSidebarNavItem({ items, key: "search", direction: "up", previous: [] }); @@ -241,3 +284,79 @@ describe("builtinSidebarNavShortcutAction", () => { expect(builtinSidebarNavShortcutAction("schedules")).toBeNull(); }); }); + +describe("footer section", () => { + const sync = group("sync", "status"); + const syncKey = pluginSidebarNavKey(sync); + + it("resolves the Usage item first, then plugin rows", () => { + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: [], + }); + + expect(summarize(items)).toEqual([ + { key: "usage", visible: false }, + { key: syncKey, visible: true }, + ]); + }); + + it("ignores header built-ins stored in footer preferences", () => { + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [], + preferences: [{ key: "history", visible: false }], + }); + + expect(summarize(items)).toEqual([{ key: "usage", visible: false }]); + }); + + it("ignores the footer icon buttons, which are fixed and not items", () => { + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: [ + { key: "add-project", visible: false }, + { key: "hosts", visible: false }, + { key: "import", visible: false }, + { key: "help", visible: false }, + { key: syncKey, visible: true }, + { key: "usage", visible: false }, + ], + }); + + expect(summarize(items)).toEqual([ + { key: syncKey, visible: true }, + { key: "usage", visible: false }, + ]); + }); + + it("moves and shows footer rows while keeping an unavailable plugin's entry", () => { + const notesPreference = { key: notesKey, visible: false }; + const previous: SidebarNavPreference[] = [notesPreference]; + const items = resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: previous, + }); + + const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous }); + const shown = setSidebarNavItemVisible({ + items: resolveSidebarNavItems({ + section: "footer", + pluginGroups: [sync], + preferences: moved, + }), + key: "usage", + visible: true, + previous: moved, + }); + + expect(shown).toEqual([ + notesPreference, + { key: syncKey, visible: true }, + { key: "usage", visible: true }, + ]); + }); +}); diff --git a/packages/app/src/sidebar-nav/model.ts b/packages/app/src/sidebar-nav/model.ts index f3dbd33520..6b98821e93 100644 --- a/packages/app/src/sidebar-nav/model.ts +++ b/packages/app/src/sidebar-nav/model.ts @@ -1,7 +1,20 @@ import type { PluginSidebarGroup } from "@/plugins/sidebar-groups"; +import type { PluginSidebarSection } from "@/plugins/types"; -export const BUILTIN_SIDEBAR_NAV_IDS = ["new-workspace", "history", "search", "schedules"] as const; -export type BuiltinSidebarNavId = (typeof BUILTIN_SIDEBAR_NAV_IDS)[number]; +export type SidebarSection = PluginSidebarSection; + +/** + * Each section's built-in items in their default order. The footer's bottom line + * (Add project and the Hosts, Help and support, Settings icons) is fixed. + */ +export const BUILTIN_SIDEBAR_ITEM_IDS = { + header: ["new-workspace", "history", "search", "schedules"], + footer: ["usage"], +} as const satisfies Record; + +export type BuiltinSidebarItemId
= + (typeof BUILTIN_SIDEBAR_ITEM_IDS)[Section][number]; +export type BuiltinSidebarNavId = BuiltinSidebarItemId<"header">; /** Persisted shape. Array order is the display order. */ export interface SidebarNavPreference { @@ -9,10 +22,10 @@ export interface SidebarNavPreference { visible: boolean; } -export interface BuiltinSidebarNavItem { +export interface BuiltinSidebarNavItem
{ kind: "builtin"; - key: BuiltinSidebarNavId; - id: BuiltinSidebarNavId; + key: BuiltinSidebarItemId
; + id: BuiltinSidebarItemId
; visible: boolean; } @@ -23,16 +36,19 @@ export interface PluginSidebarNavItem { visible: boolean; } -export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem; +export type SidebarNavItem
= + | BuiltinSidebarNavItem
+ | PluginSidebarNavItem; -const BUILTIN_LABEL_KEYS: Record = { +const BUILTIN_LABEL_KEYS: Record = { "new-workspace": "sidebar.actions.newWorkspace", history: "sidebar.sections.sessions", search: "sidebar.sections.search", schedules: "sidebar.sections.schedules", + usage: "sidebar.footer.usage", }; -export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string { +export function builtinSidebarNavLabelKey(id: BuiltinSidebarItemId): string { return BUILTIN_LABEL_KEYS[id]; } @@ -41,35 +57,50 @@ export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string { * Both the sidebar row and the Appearance settings row read the badge from here so the * two never disagree about which shortcut belongs to which item. */ -const BUILTIN_SHORTCUT_ACTIONS: Record = { +const BUILTIN_SHORTCUT_ACTIONS: Record = { "new-workspace": "new-workspace", history: null, search: "toggle-command-center", schedules: null, + usage: null, }; -export function builtinSidebarNavShortcutAction(id: BuiltinSidebarNavId): string | null { +export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): string | null { return BUILTIN_SHORTCUT_ACTIONS[id]; } +/** Builtins that start hidden until the user turns them on: the Usage summary is opt-in. */ +const HIDDEN_BY_DEFAULT: ReadonlySet = new Set(["usage"]); + +function builtinVisibleByDefault(id: BuiltinSidebarItemId): boolean { + return !HIDDEN_BY_DEFAULT.has(id); +} + export function pluginSidebarNavKey( group: Pick, ): string { return `plugin:${group.pluginId}:${group.contributionId}`; } -function isBuiltinSidebarNavId(key: string): key is BuiltinSidebarNavId { - return (BUILTIN_SIDEBAR_NAV_IDS as readonly string[]).includes(key); +function isBuiltinSidebarItemId
( + section: Section, + key: string, +): key is BuiltinSidebarItemId
{ + const ids: readonly string[] = BUILTIN_SIDEBAR_ITEM_IDS[section]; + return ids.includes(key); } -export function resolveSidebarNavItems(input: { +export function resolveSidebarNavItems
(input: { + section: Section; pluginGroups: readonly PluginSidebarGroup[]; preferences: readonly SidebarNavPreference[]; -}): SidebarNavItem[] { +}): SidebarNavItem
[] { + const builtinIds: readonly BuiltinSidebarItemId
[] = + BUILTIN_SIDEBAR_ITEM_IDS[input.section]; const groupsByKey = new Map( input.pluginGroups.map((group) => [pluginSidebarNavKey(group), group] as const), ); - const items: SidebarNavItem[] = []; + const items: SidebarNavItem
[] = []; const placed = new Set(); for (const preference of input.preferences) { @@ -78,7 +109,7 @@ export function resolveSidebarNavItems(input: { if (group) { placed.add(preference.key); items.push({ kind: "plugin", key: preference.key, group, visible: preference.visible }); - } else if (isBuiltinSidebarNavId(preference.key)) { + } else if (isBuiltinSidebarItemId(input.section, preference.key)) { placed.add(preference.key); items.push({ kind: "builtin", @@ -89,9 +120,14 @@ export function resolveSidebarNavItems(input: { } } - for (const id of BUILTIN_SIDEBAR_NAV_IDS) { + for (const id of builtinIds) { if (placed.has(id)) continue; - items.push({ kind: "builtin", key: id, id, visible: true }); + items.push({ + kind: "builtin", + key: id, + id, + visible: builtinVisibleByDefault(id), + }); } for (const [key, group] of groupsByKey) { if (placed.has(key)) continue; diff --git a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts index 634b9f96e7..dcebf670df 100644 --- a/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts +++ b/packages/app/src/sidebar-nav/use-sidebar-nav-items.ts @@ -1,70 +1,74 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; +import type { AppSettings } from "@/hooks/use-settings/storage"; import { useInstalledPlugins } from "@/plugins/registry"; -import { groupPluginSidebarContributions } from "@/plugins/sidebar-groups"; +import { groupPluginSidebarItems } from "@/plugins/sidebar-groups"; import { moveSidebarNavItem, resolveSidebarNavItems, setSidebarNavItemVisible, type SidebarNavItem, + type SidebarSection, } from "./model"; -export interface UseSidebarNavItemsReturn { - /** Every top-level item in display order, hidden ones included. */ - items: SidebarNavItem[]; +const PREFERENCE_FIELDS = { + header: "sidebarNavItems", + footer: "sidebarFooterItems", +} as const satisfies Record; + +export interface UseSidebarNavItemsReturn
{ + /** Every item in the section in display order, hidden ones included. */ + items: SidebarNavItem
[]; setVisible: (key: string, visible: boolean) => void; move: (key: string, direction: "up" | "down") => void; } -export function useSidebarNavItems(): UseSidebarNavItemsReturn { +export function useSidebarNavItems
( + section: Section, +): UseSidebarNavItemsReturn
{ const plugins = useInstalledPlugins(); const { settings, updateSettings } = useAppSettings(); - const preferences = settings.sidebarNavItems; - const pluginGroups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]); + const field = PREFERENCE_FIELDS[section]; + const preferences = settings[field]; + const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]); const items = useMemo( - () => - resolveSidebarNavItems({ - pluginGroups, - preferences, - }), - [pluginGroups, preferences], + () => resolveSidebarNavItems({ section, pluginGroups, preferences }), + [pluginGroups, preferences, section], ); const setVisible = useCallback( (key: string, visible: boolean) => { void updateSettings((current) => { - const previous = current.sidebarNavItems; - const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous }); + const previous = current[field]; + const currentItems = resolveSidebarNavItems({ + section, + pluginGroups, + preferences: previous, + }); return { - sidebarNavItems: setSidebarNavItemVisible({ - items: currentItems, - key, - visible, - previous, - }), + [field]: setSidebarNavItemVisible({ items: currentItems, key, visible, previous }), }; }); }, - [pluginGroups, updateSettings], + [field, pluginGroups, section, updateSettings], ); const move = useCallback( (key: string, direction: "up" | "down") => { void updateSettings((current) => { - const previous = current.sidebarNavItems; - const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous }); + const previous = current[field]; + const currentItems = resolveSidebarNavItems({ + section, + pluginGroups, + preferences: previous, + }); return { - sidebarNavItems: moveSidebarNavItem({ - items: currentItems, - key, - direction, - previous, - }), + [field]: moveSidebarNavItem({ items: currentItems, key, direction, previous }), }; }); }, - [pluginGroups, updateSettings], + [field, pluginGroups, section, updateSettings], ); return { items, setVisible, move }; diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts index c1b4ec8d77..4b5d73bd15 100644 --- a/packages/app/src/stores/create-flow-store.test.ts +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -6,6 +6,33 @@ describe("create-flow-store", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); + it("accepts a retry after failure while rejecting repeated active or completed submissions", () => { + const store = useCreateFlowStore.getState(); + const submission = { + draftId: "draft-retry", + serverId: "server-1", + workspaceId: "workspace-1", + agentId: null, + clientMessageId: "draft-retry:initial-message", + text: "hello", + timestamp: 1, + }; + expect(store.trySetPending(submission)).toBe(true); + expect(store.trySetPending(submission)).toBe(false); + store.markLifecycle({ + draftId: submission.draftId, + lifecycle: "abandoned", + errorMessage: "Disconnected", + }); + expect(store.trySetPending(submission)).toBe(true); + expect(useCreateFlowStore.getState().pendingByDraftId[submission.draftId]).toEqual({ + ...submission, + lifecycle: "active", + }); + store.markLifecycle({ draftId: submission.draftId, lifecycle: "sent" }); + expect(store.trySetPending(submission)).toBe(false); + }); + it("tracks lifecycle transitions explicitly", () => { const store = useCreateFlowStore.getState(); store.setPending({ diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 2056e42c0f..95a85137a5 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -13,13 +13,14 @@ export interface PendingCreateAttempt { text: string; timestamp: number; lifecycle: CreateFlowLifecycleState; + errorMessage?: string; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } export function isActiveCreateFlowForDraft(input: { pending: PendingCreateAttempt | null | undefined; - serverId: string; + serverId: string | null; draftId: string | null | undefined; }): boolean { const draftId = input.draftId?.trim(); @@ -34,15 +35,20 @@ export function isActiveCreateFlowForDraft(input: { interface CreateFlowState { pendingByDraftId: Record; setPending: (pending: Omit) => void; + trySetPending: (pending: Omit) => boolean; updateAgentId: (input: { draftId: string; agentId: string }) => void; - markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void; + markLifecycle: (input: { + draftId: string; + lifecycle: CreateFlowLifecycleState; + errorMessage?: string; + }) => void; rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: (input: { draftId: string }) => void; clearByAgent: (input: { serverId: string; agentId: string }) => void; clearAll: () => void; } -export const useCreateFlowStore = create((set) => ({ +export const useCreateFlowStore = create((set, get) => ({ pendingByDraftId: {}, setPending: (pending) => set((state) => ({ @@ -54,6 +60,12 @@ export const useCreateFlowStore = create((set) => ({ }, }, })), + trySetPending: (pending) => { + const existing = get().pendingByDraftId[pending.draftId]; + if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false; + get().setPending(pending); + return true; + }, updateAgentId: ({ draftId, agentId }) => set((state) => { const current = state.pendingByDraftId[draftId]; @@ -67,7 +79,7 @@ export const useCreateFlowStore = create((set) => ({ }, }; }), - markLifecycle: ({ draftId, lifecycle }) => + markLifecycle: ({ draftId, lifecycle, errorMessage }) => set((state) => { const current = state.pendingByDraftId[draftId]; if (!current || current.lifecycle === lifecycle) { @@ -76,7 +88,7 @@ export const useCreateFlowStore = create((set) => ({ return { pendingByDraftId: { ...state.pendingByDraftId, - [draftId]: { ...current, lifecycle }, + [draftId]: { ...current, lifecycle, errorMessage }, }, }; }), diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 4b89ee98c0..9836d3dfec 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -14,6 +14,7 @@ import { useSessionStore, type SessionState } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { applyClearDraftRecord, + editDraftRecordText, collectReferencedAttachmentIdsFromState, DRAFT_STORE_VERSION, isAttachmentMetadata, @@ -42,6 +43,7 @@ interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; hydrateDraftInput: (input: { draftKey: string }) => Promise; saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; + editDraftText: (input: { draftKey: string; text: string }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { draftKey: string; @@ -316,6 +318,14 @@ export const useDraftStore = create()( scheduleAttachmentGc(); }, + editDraftText: ({ draftKey, text }) => { + set((state) => { + const previous = state.drafts[draftKey]; + const next = editDraftRecordText(previous, text, Date.now()); + return next === previous ? state : { drafts: { ...state.drafts, [draftKey]: next } }; + }); + }, + markDraftLifecycle: ({ draftKey, lifecycle }) => { set((state) => { const existing = state.drafts[draftKey]; diff --git a/packages/app/src/stores/draft-store/state.test.ts b/packages/app/src/stores/draft-store/state.test.ts index a71ea96668..f157a89d67 100644 --- a/packages/app/src/stores/draft-store/state.test.ts +++ b/packages/app/src/stores/draft-store/state.test.ts @@ -1,7 +1,33 @@ import { describe, expect, it } from "vitest"; -import { applyClearDraftRecord, pruneFinalizedDraftRecords, toDraftInputIfReady } from "./state"; +import { + applyClearDraftRecord, + pruneFinalizedDraftRecords, + toDraftInputIfReady, + editDraftRecordText, + type DraftRecord, +} from "./state"; describe("draft-store lifecycle", () => { + it("edits text without invalidating attachment subscribers", () => { + const draft: DraftRecord = { + input: { + text: "hello", + attachments: [ + { kind: "workspace_file", path: "README.md", selection: { kind: "whole_file" } }, + ], + }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }; + const edited = editDraftRecordText(draft, "hello\n", 2); + expect(edited.input.text).toBe("hello\n"); + expect(edited.input.attachments).toBe(draft.input.attachments); + expect(edited.version).toBe(2); + expect(editDraftRecordText(edited, "hello\n", 3)).toBe(edited); + expect(editDraftRecordText(edited, "", 3).lifecycle).toBe("active"); + expect(editDraftRecordText(undefined, "", 3).lifecycle).toBe("abandoned"); + }); it("prunes finalized tombstones after TTL", () => { const nowMs = 1_000_000; const drafts = { diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index 5d9fecd52f..4d88e507b4 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -32,6 +32,21 @@ export interface DraftRecord { version: number; } +export function editDraftRecordText( + record: DraftRecord | undefined, + text: string, + now: number, +): DraftRecord { + if (record?.lifecycle === "active" && record.input.text === text) return record; + const attachments = record?.lifecycle === "active" ? record.input.attachments : []; + return { + input: { text, attachments }, + lifecycle: text.length > 0 || attachments.length > 0 ? "active" : "abandoned", + updatedAt: now, + version: (record?.version ?? 0) + 1, + }; +} + export interface DraftStoreState { drafts: Record; createModalDraft: DraftRecord | null; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 3475f4779c..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -8,9 +8,57 @@ import { selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, + type Agent, type WorkspaceDescriptor, } from "./session-store"; import type { StreamItem } from "../types/stream"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function createTestAgent(agentId: string): Agent { + return { + serverId: "test-server", + id: agentId, + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(0), + updatedAt: new Date(0), + lastUserMessageAt: null, + lastActivityAt: new Date(0), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + model: null, + parentAgentId: null, + labels: {}, + }; +} + +function applyTestTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): void { + useSessionStore.getState().setAgents(serverId, (agents) => { + const agent = agents.get(agentId) ?? createTestAgent(agentId); + const transitions = Array.isArray(transition) ? transition : [transition]; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + const next = new Map(agents); + next.set(agentId, { ...agent, status: turn.phase === "open" ? "running" : "idle", turn }); + return next; + }); +} function createWorkspace( input: Partial & Pick, @@ -222,22 +270,25 @@ describe("agent timeline state", () => { it("stores turn liveness transitions without duplicating their policy", () => { initializeTestSession(); const store = useSessionStore.getState(); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt: null }, }); - expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ phase: "open", turnId: "turn-1", startedAt: null, cancellationRequestId: null, }); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: null, }); - expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ + phase: "idle", + cancellationRequestId: null, + }); }); }); @@ -267,27 +318,27 @@ describe("message submission ordering", () => { return; } if (step === "stream-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_open", turn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-idle") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: null, }); return; } - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_close", turnId: "turn-1", }); @@ -322,7 +373,7 @@ describe("message submission ordering", () => { store.setAgentStreamState("test-server", agentId, { acknowledgedClientMessageIds: [clientMessageId], }); - store.applyAgentTurnLiveness("test-server", agentId, [ + applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, ]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c2328a5b62..a741f99ddb 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -2,7 +2,7 @@ import equal from "fast-deep-equal"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; +import type { ViewedTimelineOwner } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { appendSubmittedUserMessage, @@ -54,12 +54,10 @@ import { type WorkspaceAgentActivity, } from "@/utils/workspace-agent-activity"; import { - applyTurnLivenessTransition, resolveTurnPresentation, TURN_LIVENESS_IDLE, type TurnLiveness, type TurnPresentation, - type TurnLivenessTransition, } from "@/timeline/turn-liveness"; export interface AgentRuntimeInfo { @@ -76,7 +74,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; - activeTurn: { turnId: string | null; startedAt: Date | null } | null; + turn: TurnLiveness; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -115,6 +113,7 @@ export interface WorkspaceDescriptor { worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; + background?: boolean; name: string; title?: string | null; pinnedAt?: string | null; @@ -152,6 +151,7 @@ export function normalizeWorkspaceDescriptor( worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, + background: payload.background ?? false, name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, @@ -287,6 +287,19 @@ export interface DaemonServerInfo { features?: ServerInfoStatusPayload["features"]; } +export function toDaemonServerInfo(serverInfo: ServerInfoStatusPayload): DaemonServerInfo { + return { + serverId: serverInfo.serverId, + hostname: serverInfo.hostname ?? null, + version: serverInfo.version ?? null, + ...(serverInfo.desktopManaged !== undefined + ? { desktopManaged: serverInfo.desktopManaged } + : {}), + ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), + ...(serverInfo.features ? { features: serverInfo.features } : {}), + }; +} + export interface AgentTimelineCursorState { epoch: string; startSeq: number; @@ -332,7 +345,9 @@ export function selectAgentTurnPresentation( agentId: string, ): TurnPresentation { return resolveTurnPresentation( - session?.agentTurnLiveness.get(agentId) ?? TURN_LIVENESS_IDLE, + session?.agents.get(agentId)?.turn ?? + session?.agentDetails.get(agentId)?.turn ?? + TURN_LIVENESS_IDLE, getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, ); } @@ -366,7 +381,7 @@ export interface SessionState { // Daemon client (immutable reference) client: DaemonClient | null; clientGeneration: number; - viewedTimelineSync: ViewedTimelineUiBridge | null; + viewedTimelineSync: ViewedTimelineOwner | null; // Server metadata (from server_info handshake) serverInfo: DaemonServerInfo | null; @@ -387,7 +402,6 @@ export interface SessionState { agentStreamTail: Map; agentStreamHead: Map; agentTasks: Map; - agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; @@ -443,7 +457,7 @@ interface SessionStoreActions { clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void; - setViewedTimelineSync: (serverId: string, sync: ViewedTimelineUiBridge | null) => void; + setViewedTimelineSync: (serverId: string, sync: ViewedTimelineOwner | null) => void; updateSessionServerInfo: (serverId: string, info: DaemonServerInfo) => void; // Audio state @@ -476,14 +490,6 @@ interface SessionStoreActions { taskSnapshot?: TodoEntry[]; }, ) => void; - applyAgentTurnLiveness: ( - serverId: string, - agentId: string, - transition: TurnLivenessTransition | readonly TurnLivenessTransition[], - ) => void; - beginAgentCancellation: (serverId: string, agentId: string) => number; - settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; - clearAgentTurnLiveness: (serverId: string) => void; beginAgentMessageSubmission: ( serverId: string, agentId: string, @@ -644,7 +650,6 @@ function createInitialSessionState( agentStreamTail: new Map(), agentStreamHead: new Map(), agentTasks: new Map(), - agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), @@ -711,7 +716,6 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { - let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -1058,57 +1062,6 @@ export const useSessionStore = create()( }); }, - applyAgentTurnLiveness: (serverId, agentId, transition) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const agentTurnLiveness = applyTurnLivenessTransition( - session.agentTurnLiveness, - agentId, - transition, - ); - if (agentTurnLiveness === session.agentTurnLiveness) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness }, - }, - }; - }); - }, - - beginAgentCancellation: (serverId, agentId) => { - nextCancellationRequestId += 1; - const requestId = nextCancellationRequestId; - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_started", - requestId, - }); - return requestId; - }, - - settleAgentCancellation: (serverId, agentId, requestId) => { - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_settled", - requestId, - }); - }, - - clearAgentTurnLiveness: (serverId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session || session.agentTurnLiveness.size === 0) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness: new Map() }, - }, - }; - }); - }, - beginAgentMessageSubmission: (serverId, agentId, message) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1924,6 +1877,7 @@ export const useSessionStore = create()( serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt, cwd: agent.cwd, provider: agent.provider, diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index c58931ca69..a68ca206f9 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { migrateSidebarOrderState } from "./sidebar-order-store"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + computeSidebarOrderUpdates, + type SidebarProjectEntry, +} from "@/hooks/sidebar-workspaces-view-model"; +import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { it("prefixes legacy per-server workspace order with the source server id", () => { @@ -31,3 +35,71 @@ describe("migrateSidebarOrderState", () => { expect(migrated.pinnedWorkspaceOrder).toEqual(["host-a:one", "host-b:two"]); }); }); + +// A directory whose name ends in a space produces a view key that ends in a +// space. Trimming it on write used to make the sidebar's reconcile effect loop +// forever, crashing the app with React error #185 (see #4880). +describe("sidebar order keys that end in whitespace", () => { + const PROJECT_KEY = "host:srv-1:/home/u/Reklamation "; + + beforeEach(() => { + useSidebarOrderStore.setState({ + projectOrder: [], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: {}, + }); + }); + + it("stores a project key exactly as given", () => { + useSidebarOrderStore.getState().setProjectOrder([PROJECT_KEY]); + + expect(useSidebarOrderStore.getState().projectOrder).toEqual([PROJECT_KEY]); + }); + + it("scopes a workspace order under the project key the sidebar indexes by", () => { + useSidebarOrderStore.getState().setWorkspaceOrder(PROJECT_KEY, ["srv-1:main"]); + + // use-sidebar-workspaces-list reads workspaceOrderByProject by view key + // directly, so the scope has to be stored under that same key. + expect(useSidebarOrderStore.getState().workspaceOrderByProject[PROJECT_KEY]).toEqual([ + "srv-1:main", + ]); + expect(useSidebarOrderStore.getState().getWorkspaceOrder(PROJECT_KEY)).toEqual(["srv-1:main"]); + }); + + it("settles the sidebar order reconcile instead of rewriting it forever", () => { + const projects: SidebarProjectEntry[] = [ + { + viewKey: PROJECT_KEY, + projectName: "Reklamation ", + projectKind: "non_git", + iconWorkingDir: "/home/u/Reklamation ", + hosts: [], + workspaces: [], + }, + ]; + const runPass = () => + computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: useSidebarOrderStore.getState().projectOrder, + getWorkspaceOrder: (key) => useSidebarOrderStore.getState().getWorkspaceOrder(key), + }); + + // First pass adds the newly visible project. + const first = runPass(); + expect(first.projectOrder).toEqual([PROJECT_KEY]); + useSidebarOrderStore.getState().setProjectOrder(first.projectOrder ?? []); + + // Second pass must find nothing left to do; otherwise the effect that + // applies these updates re-runs on every render. + expect(runPass().projectOrder).toBeNull(); + }); + + it("still trims keys when migrating persisted state", () => { + const migrated = migrateSidebarOrderState({ + projectOrder: [" host-a:one ", "host-a:one"], + }); + + expect(migrated.projectOrder).toEqual(["host-a:one"]); + }); +}); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 27e7a190c8..10831480f8 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -38,6 +38,8 @@ interface SidebarWorkspaceOrderScope { projectViewKey: string; } +// Trims each key. Only for persisted state read at migration time, where a +// stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { const seen = new Set(); const normalized: string[] = []; @@ -54,6 +56,28 @@ function normalizeKeys(keys: string[]): string[] { return normalized; } +/** + * Drops blank keys and duplicates but keeps each key exactly as given. View + * keys embed a project's path, so a directory whose name ends in a space + * produces a key that ends in a space. Trimming it stores a key that can never + * match the one the sidebar looks up, so the caller sees its key as missing, + * writes it again, and the effect that reconciles the order never settles. + */ +function dedupeKeys(keys: string[]): string[] { + const seen = new Set(); + const deduped: string[] = []; + + for (const key of keys) { + if (!key.trim() || seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(key); + } + + return deduped; +} + function normalizeWorkspaceOrderByProject( workspaceOrderByProject: Record | undefined, ): Record { @@ -134,27 +158,22 @@ export const useSidebarOrderStore = create()( workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { - const normalized = normalizeKeys(keys); - set({ projectOrder: normalized }); + set({ projectOrder: dedupeKeys(keys) }); }, getPinnedWorkspaceOrder: () => get().pinnedWorkspaceOrder, setPinnedWorkspaceOrder: (keys) => { - const normalized = normalizeKeys(keys); - set({ pinnedWorkspaceOrder: normalized }); + set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, getWorkspaceOrder: (projectViewKey) => { - const scope = projectViewKey.trim(); - if (!scope) return []; - return get().workspaceOrderByProject[scope] ?? []; + if (!projectViewKey.trim()) return []; + return get().workspaceOrderByProject[projectViewKey] ?? []; }, setWorkspaceOrder: (projectViewKey, keys) => { - const scope = projectViewKey.trim(); - if (!scope) return; - const normalized = normalizeKeys(keys); + if (!projectViewKey.trim()) return; set((state) => ({ workspaceOrderByProject: { ...state.workspaceOrderByProject, - [scope]: normalized, + [projectViewKey]: dedupeKeys(keys), }, })); }, diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index b9df715fa2..e21c8f8a15 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { StateStorage } from "zustand/middleware"; import { createSidebarViewStorage, @@ -8,14 +8,6 @@ import { useSidebarViewStore, } from "./sidebar-view-store"; -vi.mock("@react-native-async-storage/async-storage", () => ({ - default: { - getItem: vi.fn().mockResolvedValue(null), - setItem: vi.fn().mockResolvedValue(undefined), - removeItem: vi.fn().mockResolvedValue(undefined), - }, -})); - interface MemoryStorage extends StateStorage> { reads: string[]; } @@ -40,6 +32,7 @@ function createMemoryStorage(entries: Record): MemoryStor describe("sidebar view store", () => { beforeEach(() => { useSidebarViewStore.setState({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], @@ -92,6 +85,7 @@ describe("sidebar view store", () => { }, }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: [], projectFilters: [], @@ -106,6 +100,7 @@ describe("sidebar view store", () => { hostFilter: "host-a", }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: ["host-a"], projectFilters: [], @@ -120,6 +115,7 @@ describe("sidebar view store", () => { hostFilters: ["host-a", "host-b"], }), ).toEqual({ + showBackground: false, groupMode: "status", hostFilters: ["host-a", "host-b"], projectFilters: [], @@ -230,6 +226,7 @@ describe("sidebar view store", () => { projectFilters: ["project-a", "project-b"], }), ).toEqual({ + showBackground: false, groupMode: "project", hostFilters: ["host-a"], projectFilters: ["project-a", "project-b"], @@ -239,6 +236,7 @@ describe("sidebar view store", () => { it("never keeps project filters from state the schema rejects", () => { expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 19f559c89d..0e12c304a8 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status"; const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view"; const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode"; -const SIDEBAR_VIEW_STORE_VERSION = 6; +const SIDEBAR_VIEW_STORE_VERSION = 7; /** * The key standing for "this workspace carries no labels at all". @@ -47,6 +47,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] { interface SidebarViewStoreState { groupMode: SidebarGroupMode; + showBackground: boolean; // Empty means "all hosts". A non-empty list pins the sidebar to those hosts. hostFilters: string[]; /** @@ -61,6 +62,7 @@ interface SidebarViewStoreState { */ projectFilters: string[]; labelFilter: SidebarLabelFilter; + toggleBackground: () => void; setGroupMode: (mode: SidebarGroupMode) => void; toggleHostFilter: (serverId: string) => void; clearHostFilters: () => void; @@ -74,6 +76,7 @@ interface SidebarViewStoreState { interface SidebarViewPersistedState { groupMode: SidebarGroupMode; + showBackground: boolean; hostFilters: string[]; projectFilters: string[]; labelFilter: SidebarLabelFilter; @@ -84,6 +87,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ + showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(), hostFilter: z.string().nullable().optional(), @@ -122,6 +126,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer const result = SidebarViewPersistedStateSchema.safeParse(persistedState); if (!result.success) { return { + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], @@ -133,6 +138,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { return { + showBackground: false, groupMode: legacyGroupMode, hostFilters: [], projectFilters: [], @@ -141,6 +147,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer } return { + showBackground: state.showBackground ?? false, groupMode: state.groupMode === "status" ? "status" : "project", hostFilters: readHostFilters(state), projectFilters: state.projectFilters ?? [], @@ -178,10 +185,12 @@ export function createSidebarViewStorage( export const useSidebarViewStore = create()( persist( (set) => ({ + showBackground: false, groupMode: "project", hostFilters: [], projectFilters: [], labelFilter: emptyLabelFilter(), + toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })), setGroupMode: (mode) => set({ groupMode: mode }), toggleHostFilter: (serverId) => set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })), @@ -228,6 +237,7 @@ export const useSidebarViewStore = create()( SidebarViewPersistedStateSchema, ), partialize: (state) => ({ + showBackground: state.showBackground ?? false, groupMode: state.groupMode, hostFilters: state.hostFilters, projectFilters: state.projectFilters, diff --git a/packages/app/src/stores/workspace-draft-submission-store.test.ts b/packages/app/src/stores/workspace-draft-submission-store.test.ts new file mode 100644 index 0000000000..73e1a40899 --- /dev/null +++ b/packages/app/src/stores/workspace-draft-submission-store.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { transformFileSync } from "@babel/core"; +import { expect, test } from "vitest"; +import type { useWorkspaceDraftSubmissionStore } from "./workspace-draft-submission-store"; + +// Exercise the emitted app code: plain TypeScript execution misses Expo's omit bug. +function loadProductionStore() { + const filename = fileURLToPath(new URL("./workspace-draft-submission-store.ts", import.meta.url)); + const caller = { name: "metro", platform: "web", isDev: false, supportsStaticESM: false }; + const result = transformFileSync(filename, { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + envName: "production", + caller, + }); + if (!result?.code) throw new Error("Expo produced no store code"); + const module = { + exports: {} as { useWorkspaceDraftSubmissionStore: typeof useWorkspaceDraftSubmissionStore }, + }; + new Function("require", "module", "exports", result.code)( + createRequire(import.meta.url), + module, + module.exports, + ); + return module.exports.useWorkspaceDraftSubmissionStore; +} + +test("the production app consumes a draft once and leaves other drafts pending", () => { + const store = loadProductionStore(); + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-1", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + } as const; + store.getState().setPending({ ...submission, attachments: [] }); + store.getState().setPending({ ...submission, attachments: [], draftId: "draft-2" }); + expect(store.getState().consumePending({ ...submission, workspaceId: "wrong" })).toBeNull(); + expect(store.getState().consumePending(submission)).toEqual(submission); + expect(store.getState().consumePending(submission)).toBeNull(); + expect(Object.keys(store.getState().pendingByDraftId)).toEqual(["draft-2"]); +}); + +test("consuming draft presentation retains its running creation through remount and retry", () => { + const store = loadProductionStore(); + const result = new Promise(() => {}); + const creation = { result, retry: () => result }; + const submission = { + serverId: "server", + workspaceId: "workspace", + draftId: "draft-one", + text: "hello", + attachments: [], + cwd: "/repo", + provider: "codex", + clientMessageId: "message", + timestamp: 1, + agentCreation: creation, + }; + store.getState().setPending(submission); + store.getState().consumePending(submission); + expect(store.getState().pendingByDraftId[submission.draftId]).toBeUndefined(); + expect(store.getState().creationByDraftId[submission.draftId]).toBe(creation); + store.getState().clearDraftSetup({ draftId: submission.draftId }); + expect(store.getState().creationByDraftId[submission.draftId]).toBeUndefined(); +}); diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 2dbd27f2a5..639701d740 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -1,9 +1,16 @@ +import type { CreateAgentRequestOptions } from "@getpaseo/client/internal/daemon-client"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model"; export interface PendingWorkspaceDraftSubmission { + /** Already-running creation. Mounting the draft only observes its result. */ + agentCreation?: { + result: Promise; + retry: (input: CreateAgentRequestOptions) => Promise; + }; serverId: string; workspaceId: string; draftId: string; @@ -28,6 +35,7 @@ export interface PendingWorkspaceDraftSetup { interface WorkspaceDraftSubmissionState { pendingByDraftId: Record; setupByDraftId: Record; + creationByDraftId: Record>; setPending: (submission: PendingWorkspaceDraftSubmission) => void; setDraftSetup: (input: { draftId: string; @@ -61,8 +69,12 @@ export const useWorkspaceDraftSubmissionStore = create ({ pendingByDraftId: {}, setupByDraftId: {}, + creationByDraftId: {}, setPending: (submission) => set((state) => ({ + creationByDraftId: submission.agentCreation + ? { ...state.creationByDraftId, [submission.draftId]: submission.agentCreation } + : state.creationByDraftId, pendingByDraftId: { ...state.pendingByDraftId, [submission.draftId]: submission, @@ -82,9 +94,10 @@ export const useWorkspaceDraftSubmissionStore = create { - if (!state.setupByDraftId[normalizedDraftId]) return state; + const creationByDraftId = { ...state.creationByDraftId }; + delete creationByDraftId[normalizedDraftId]; const { [normalizedDraftId]: _removed, ...setupByDraftId } = state.setupByDraftId; - return { setupByDraftId }; + return { setupByDraftId, creationByDraftId }; }); }, consumePending: (input) => { @@ -96,8 +109,10 @@ export const useWorkspaceDraftSubmissionStore = create { createTabId: () => string; state?: JsonValue; } @@ -161,6 +170,7 @@ interface RetargetTabInLayoutResult { } interface ConvertDraftToAgentInLayoutInput { + explorerSidebarPaneId: string | null; layout: WorkspaceLayout; tabId: string; agentId: string; @@ -179,7 +189,7 @@ interface ReorderFocusedPaneTabsInLayoutInput { interface CloseTabInLayoutInput { layout: WorkspaceLayout; tabId: string; - preserveEmptyPaneId?: string | null; + explorerSidebarPaneId: string | null; } interface ClosePaneInLayoutInput { @@ -252,7 +262,6 @@ interface ReorderPaneTabsInLayoutInput { export interface WorkspaceTabReconcileState { layout: WorkspaceLayout; pinnedAgentIds?: ReadonlySet | null; - pendingAgentIds?: ReadonlySet | null; hiddenAgentIds?: ReadonlySet | null; explorerSidebarPaneId: string | null; } @@ -262,7 +271,6 @@ export interface WorkspaceTabSnapshot { terminalsHydrated: boolean; activeAgentIds: Iterable; autoOpenAgentIds: Iterable; - knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; hasActivePendingTerminalCreate?: boolean; @@ -846,7 +854,11 @@ function insertTabIntoPane( } else if (existingIndex >= 0) { nextTabs = node.pane.tabs.map((tab, index) => (index === existingIndex ? input.tab : tab)); } else { - nextTabs = [...node.pane.tabs, input.tab]; + nextTabs = [...node.pane.tabs]; + const afterIndex = nextTabs.findIndex( + (tab) => tab.tabId === input.insertionPosition?.afterTabId, + ); + nextTabs.splice(afterIndex >= 0 ? afterIndex + 1 : nextTabs.length, 0, input.tab); } return { kind: "pane", @@ -1159,14 +1171,54 @@ function restoreEmptyPanesInNode( }); } -export function restoreEmptyPanesInLayout( +function findOrdinaryPane( + root: SplitNodeInternal, + explorerSidebarPaneId: string | null, +): SplitPaneInternal | null { + if (root.kind === "pane") return root.pane.id === explorerSidebarPaneId ? null : root.pane; + for (const child of root.group.children) { + const pane = findOrdinaryPane(child, explorerSidebarPaneId); + if (pane) return pane; + } + return null; +} + +/** Restores the workspace canvas and empty tabs without changing Explorer or saved content. */ +export function restoreWorkspaceLayout( layout: WorkspaceLayout, - explorerSidebarPaneId: string | null = null, + explorerSidebarPaneId: string | null, + createNodeId = defaultWorkspaceLayoutIds.createNodeId, ): WorkspaceLayout { const normalized = normalizeLayout(layout); + let root = asInternalNode(normalized.root); + let focusedPaneId = normalized.focusedPaneId; + if (!collectAllPanes(root).some((pane) => pane.id !== explorerSidebarPaneId)) { + const ordinaryPane = findOrdinaryPane(root, explorerSidebarPaneId); + if (ordinaryPane) { + root = updatePaneInTree(root, { + paneId: ordinaryPane.id, + updater: (pane) => ({ ...pane, hidden: false }), + }); + focusedPaneId = ordinaryPane.id; + } else { + // Explorer may have a generated persisted id; its role, not its spelling, + // determines whether it can serve as the workspace canvas. + focusedPaneId = DEFAULT_PANE_ID; + if (explorerSidebarPaneId === DEFAULT_PANE_ID) { + focusedPaneId = createNodeId("pane"); + } + root = createGroupNode({ + id: createNodeId("group"), + direction: "horizontal", + children: [createPaneNode({ id: focusedPaneId, tabs: [] }), root], + sizes: [0.78, 0.22], + }); + } + } return { ...normalized, - root: restoreEmptyPanesInNode(asInternalNode(normalized.root), explorerSidebarPaneId), + focusedPaneId, + root: restoreEmptyPanesInNode(root, explorerSidebarPaneId), }; } @@ -1338,6 +1390,7 @@ function insertNewTabIntoPane( root: insertTabIntoPane(layout.root, { paneId: targetPane.id, tab: nextTab, + insertionPosition: input.insertionPosition, focusTabId: input.focus ? tabId : preservedFocusTabId, }), focusedPaneId: input.focus ? targetPane.id : layout.focusedPaneId, @@ -1472,8 +1525,11 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout return null; } const preserveEmptyPaneId = - input.preserveEmptyPaneId ?? - (pane.id === DEFAULT_PANE_ID || pane.id === EXPLORER_SIDEBAR_PANE_ID ? pane.id : null); + pane.id === DEFAULT_PANE_ID || + pane.id === input.explorerSidebarPaneId || + isLastVisibleOrdinaryPane(input.layout, pane.id, input.explorerSidebarPaneId) + ? pane.id + : null; const closeSuccessorTabId = getCloseSuccessorTabId({ pane, @@ -1516,6 +1572,17 @@ export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout return nextLayoutWithParentMap; } +function isLastVisibleOrdinaryPane( + layout: WorkspaceLayout, + paneId: string, + explorerSidebarPaneId: string | null | undefined, +): boolean { + const ordinaryPanes = collectAllPanes(layout.root).filter( + (pane) => pane.id !== explorerSidebarPaneId, + ); + return ordinaryPanes.length === 1 && ordinaryPanes[0].id === paneId; +} + /** * Whether dismissing this pane would do anything — removing it, or hiding it if it * is the Explorer sidebar. A workspace always has somewhere to look, so the last pane the @@ -1535,10 +1602,9 @@ export function canDismissPaneInLayout( if (!pane || pane.hidden === true) { return false; } - const ordinaryPanes = collectAllPanes(layout.root).filter( - (candidate) => candidate.id !== explorerSidebarPaneId, - ); - return paneId === explorerSidebarPaneId ? ordinaryPanes.length > 0 : ordinaryPanes.length > 1; + return paneId === explorerSidebarPaneId + ? collectAllPanes(layout.root).some((candidate) => candidate.id !== explorerSidebarPaneId) + : !isLastVisibleOrdinaryPane(layout, paneId, explorerSidebarPaneId); } /** @@ -1555,14 +1621,7 @@ export function closePaneInLayout(input: ClosePaneInLayoutInput): WorkspaceLayou if (!panePath) { return null; } - const visibleOrdinaryPaneIds = listPaneIds(layout.root).filter( - (paneId) => paneId !== input.explorerSidebarPaneId, - ); - if ( - input.paneId !== input.explorerSidebarPaneId && - visibleOrdinaryPaneIds.length <= 1 && - visibleOrdinaryPaneIds.includes(input.paneId) - ) { + if (isLastVisibleOrdinaryPane(input.layout, input.paneId, input.explorerSidebarPaneId)) { return null; } @@ -1645,6 +1704,7 @@ export function retargetTabInLayout( closeTabInLayout({ layout: input.layout, tabId: input.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? input.layout; return { layout: @@ -1808,6 +1868,7 @@ export function convertDraftToAgentInLayout( closeTabInLayout({ layout: input.layout, tabId: input.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? input.layout; return { layout: @@ -2238,17 +2299,10 @@ interface EntityTabGroup { function applyPinnedAndHidden(input: { baseAgentIds: Set; pinnedAgentIds: Set; - pendingAgentIds: Set; hiddenAgentIds: Set; - knownAgentIds: Set; }): Set { - const { baseAgentIds, pinnedAgentIds, pendingAgentIds, hiddenAgentIds, knownAgentIds } = input; - const result = new Set(baseAgentIds); - for (const agentId of pinnedAgentIds) { - if (knownAgentIds.has(agentId) || pendingAgentIds.has(agentId)) { - result.add(agentId); - } - } + const { baseAgentIds, pinnedAgentIds, hiddenAgentIds } = input; + const result = new Set([...baseAgentIds, ...pinnedAgentIds]); for (const agentId of hiddenAgentIds) { result.delete(agentId); } @@ -2284,6 +2338,7 @@ function collapseStaleEntityTabs(input: { snapshot: WorkspaceTabSnapshot; visibleAgentIds: Set; knownTerminalIds: Set; + explorerSidebarPaneId: string | null; }): WorkspaceLayout { const { snapshot, visibleAgentIds, knownTerminalIds } = input; let nextLayout = input.layout; @@ -2293,6 +2348,7 @@ function collapseStaleEntityTabs(input: { closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? nextLayout; } if ( @@ -2304,6 +2360,7 @@ function collapseStaleEntityTabs(input: { closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: input.explorerSidebarPaneId, }) ?? nextLayout; } } @@ -2369,43 +2426,6 @@ function addMissingEntityTabs(input: { return nextLayout; } -function seedDraftForEmptyWorkspace(input: { - layout: WorkspaceLayout; - snapshot: WorkspaceTabSnapshot; - activeAgentIds: Set; - knownTerminalIds: Set; - explorerSidebarPaneId: string | null; -}): WorkspaceLayout { - const ready = input.snapshot.agentsHydrated && input.snapshot.terminalsHydrated; - const creatingContent = - input.snapshot.hasActivePendingDraftCreate === true || - input.snapshot.hasActivePendingTerminalCreate === true; - const hasWorkspaceEntities = input.activeAgentIds.size > 0 || input.knownTerminalIds.size > 0; - const explorerTabIds = new Set( - input.explorerSidebarPaneId - ? (findPaneById(input.layout.root, input.explorerSidebarPaneId)?.tabIds ?? []) - : [], - ); - const hasContentTab = collectAllTabs(input.layout.root).some( - (tab) => tab.target.kind !== "new_tab" && !explorerTabIds.has(tab.tabId), - ); - if (!ready || creatingContent || hasWorkspaceEntities || hasContentTab) { - return input.layout; - } - - const draftId = generateDraftId(); - return ( - createTabInLayout({ - layout: input.layout, - target: { kind: "draft", draftId }, - now: Date.now(), - placement: FOCUSED_PANE_PLACEMENT, - explorerSidebarPaneId: input.explorerSidebarPaneId, - createTabId: () => draftId, - })?.layout ?? input.layout - ); -} - export function reconcileWorkspaceTabs( state: WorkspaceTabReconcileState, snapshot: WorkspaceTabSnapshot, @@ -2414,12 +2434,17 @@ export function reconcileWorkspaceTabs( const originalFocusedTabId = findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null; let reconciledFocusedTabId = originalFocusedTabId; - const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); - const pendingAgentIds = new Set(state.pendingAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); - const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); + // An explicit open owns the tab until the agent joins the active directory. + // From then on it follows the normal server archive lifecycle. Detail/cache + // hydration never decides whether the user's target is allowed to stay open. + const pinnedAgentIds = new Set( + [...(state.pinnedAgentIds ?? [])].filter( + (agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId), + ), + ); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) @@ -2427,16 +2452,12 @@ export function reconcileWorkspaceTabs( const visibleAgentIds = applyPinnedAndHidden({ baseAgentIds: activeAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const autoOpenSet = applyPinnedAndHidden({ baseAgentIds: autoOpenAgentIds, pinnedAgentIds, - pendingAgentIds, hiddenAgentIds, - knownAgentIds, }); const initialTabs = collectAllTabs(nextLayout.root); @@ -2473,6 +2494,7 @@ export function reconcileWorkspaceTabs( closeTabInLayout({ layout: nextLayout, tabId: tab.tabId, + explorerSidebarPaneId: state.explorerSidebarPaneId, }) ?? nextLayout; } } @@ -2482,6 +2504,7 @@ export function reconcileWorkspaceTabs( snapshot, visibleAgentIds, knownTerminalIds, + explorerSidebarPaneId: state.explorerSidebarPaneId, }); nextLayout = addMissingEntityTabs({ @@ -2494,14 +2517,6 @@ export function reconcileWorkspaceTabs( explorerSidebarPaneId: state.explorerSidebarPaneId, }); - nextLayout = seedDraftForEmptyWorkspace({ - layout: nextLayout, - snapshot, - activeAgentIds, - knownTerminalIds, - explorerSidebarPaneId: state.explorerSidebarPaneId, - }); - if (reconciledFocusedTabId) { nextLayout = focusTabInLayout({ @@ -2513,5 +2528,6 @@ export function reconcileWorkspaceTabs( return { ...state, layout: nextLayout, + pinnedAgentIds, }; } diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts new file mode 100644 index 0000000000..c105d40a06 --- /dev/null +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; +import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; + +const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), +}); +const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("new_tab") }), + z.strictObject({ + kind: z.literal("draft"), + draftId: z.string(), + setup: WorkspaceDraftTabSetupStorageSchema.optional(), + }), + z.strictObject({ kind: z.literal("agent"), agentId: z.string() }), + z.strictObject({ + kind: z.literal("provider_subagent"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), + z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), + z.strictObject({ kind: z.literal("changes_tree") }), + z.strictObject({ kind: z.literal("files") }), + z.strictObject({ kind: z.literal("pull_request") }), + z.strictObject({ + kind: z.literal("file"), + path: z.string(), + lineStart: z.number().int().positive().optional(), + lineEnd: z.number().int().positive().optional(), + }), + z.strictObject({ + kind: z.literal("working_diff"), + focusPath: z.string().optional(), + focusRequestId: z.number().optional(), + // COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them. + mode: z.enum(["uncommitted", "base"]).optional(), + baseRef: z.string().nullable().optional(), + ignoreWhitespace: z.boolean().optional(), + }), + z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), + z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), + z.discriminatedUnion("context", [ + z.strictObject({ + kind: z.literal("plugin"), + pluginId: z.string(), + panelId: z.string(), + context: z.literal("workspace"), + }), + z.strictObject({ + kind: z.literal("plugin"), + pluginId: z.string(), + panelId: z.string(), + context: z.literal("agent"), + agentId: z.string(), + }), + ]), +]); +const WorkspaceTabStorageSchema = z.strictObject({ + tabId: z.string(), + target: WorkspaceTabTargetStorageSchema, + createdAt: z.number(), + state: z.json().optional(), +}); +const SplitNodeStorageSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("pane"), + pane: z.strictObject({ + id: z.string(), + tabIds: z.array(z.string()), + focusedTabId: z.string().nullable(), + tabs: z.array(WorkspaceTabStorageSchema).optional(), + hidden: z.boolean().optional(), + }), + }), + z.strictObject({ + kind: z.literal("group"), + group: z.strictObject({ + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(SplitNodeStorageSchema), + sizes: z.array(z.number()), + }), + }), + ]), +); +const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject({ + root: SplitNodeStorageSchema, + focusedPaneId: z.string().nullable(), + parentTabIdByTabId: z.record(z.string(), z.string()).optional(), +}); +export const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + pinnedAgentIdsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), + splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), + explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(), + // COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25. + explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(), + // COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel. + sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(), + // The persisted keys keep their pre-rename spelling: the schema is strict, so a + // rename here would fail every existing blob and wipe the layout it describes. + explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), + pullRequestTabAutoOpenedByWorkspace: z.record(z.string(), z.literal(true)).optional(), + // COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted + // and ignored so upgrading does not discard the layout. Remove after 2027-08-20. + acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(), +}); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index e53f9c1911..d1a4768cb5 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -16,6 +16,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { }); import AsyncStorage from "@react-native-async-storage/async-storage"; +import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { defaultChangesState, type ChangesState } from "@/panels/changes/state"; import { defaultFileState, type FileState } from "@/panels/file/state"; @@ -24,6 +25,7 @@ import { collectAllPanes, collectAllTabs, createWorkspaceLayoutStore, + observeOpenWorkspaceAgentIds, createDefaultLayout, createWorkspaceLayoutWithExplorerSidebar, findPaneById, @@ -73,6 +75,92 @@ function createDeterministicWorkspaceLayoutIds() { const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds(); const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds); +it("observes open chats across unmounted workspaces until their tabs close", () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {} }); + const received: string[][] = []; + const stop = observeOpenWorkspaceAgentIds("server-1", (ids) => received.push(ids), store); + const first = store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server-1:workspace-b", + target: { kind: "agent", agentId: "agent-b" }, + intent: "background", + }); + store.getState().openTab({ + workspaceKey: "server-2:workspace-a", + target: { kind: "agent", agentId: "other-host-agent" }, + intent: "reveal", + }); + expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"]]); + if (!first) throw new Error("Expected an open agent tab"); + store.getState().closeTab("server-1:workspace-a", first); + expect(received.at(-1)).toEqual(["agent-b"]); + stop(); + store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "after-dispose" }, + intent: "reveal", + }); + expect(received).toEqual([[], ["agent-a"], ["agent-a", "agent-b"], ["agent-b"]]); +}); + +it("feeds restored layout to timeline sync as a release signal, not a subscription source", async () => { + const store = createWorkspaceLayoutStore(workspaceLayoutIds); + store.setState({ layoutByWorkspace: {} }); + // Launch: layout rehydrates tabs the user opened in earlier sessions. + const restored = store.getState().openTab({ + workspaceKey: "server-1:workspace-a", + target: { kind: "agent", agentId: "agent-a" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server-1:workspace-b", + target: { kind: "agent", agentId: "agent-b" }, + intent: "background", + }); + + const observed: string[][] = []; + const sync = createViewedTimelineSync({ + replaceDemandedAgentIds: () => undefined, + prepare: async () => undefined, + observe: (agentIds) => { + observed.push(agentIds); + return { ready: Promise.resolve(), release: async () => undefined }; + }, + readCursor: () => undefined, + fetchPage: async () => ({ hasNewer: false, endCursor: null }), + fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), + onCatchUpEnded: () => undefined, + reportError: () => undefined, + schedule: () => () => undefined, + }); + // The edge session-context.tsx owns. Pointing it at subscription instead would resume every + // agent behind a restored tab on the daemon. + const stop = observeOpenWorkspaceAgentIds( + "server-1", + (agentIds) => sync.replaceOpenTabAgentIds(agentIds), + store, + ); + + sync.setConnected(true); + expect(observed).toEqual([]); + + sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + await vi.waitFor(() => expect(observed).toEqual([["agent-a"]])); + + if (!restored) throw new Error("Expected an open agent tab"); + store.getState().closeTab("server-1:workspace-a", restored); + sync.replaceVisibleAgentIds("workspace", []); + await vi.waitFor(() => expect(observed.at(-1)).toEqual([])); + + stop(); + sync.dispose(); +}); + function useWorkspaceLayoutIds(...values: string[]) { workspaceLayoutIds.useValues(values); } @@ -450,7 +538,7 @@ describe("workspace-layout-store version 2 migration", () => { }); }); - it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => { + it("writes version 2 preserving the existing Explorer key", async () => { await persistVersionOneLayout("v0.6"); const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); @@ -463,6 +551,8 @@ describe("workspace-layout-store version 2 migration", () => { "explorerPaneIdByWorkspace", "explorerSidebarWidthByWorkspace", "layoutByWorkspace", + "pinnedAgentIdsByWorkspace", + "pullRequestTabAutoOpenedByWorkspace", "sidePaneIdByWorkspace", "splitSizesByWorkspace", ]); @@ -1084,6 +1174,118 @@ describe("workspace-layout-store actions", () => { expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before); }); + it.each(["hideExplorerSidebar", "showExplorerSidebar"] as const)( + "retains the last ordinary split after %s", + (setExplorerVisibility) => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "draft", draftId: "draft-origin" }, + intent: "new", + }) as string; + store[setExplorerVisibility](workspaceKey); + const splitPaneId = store.splitPaneEmpty(workspaceKey, { + targetPaneId: "main", + position: "right", + }) as string; + store.moveTabToPane(workspaceKey, tabId, splitPaneId); + expect( + findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main"), + ).toBeNull(); + + store.closeTab(workspaceKey, tabId); + + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + expect( + collectAllPanes(layout.root) + .filter((pane) => pane.id !== "explorer") + .map((pane) => pane.id), + ).toEqual([splitPaneId]); + expect(findPaneById(layout.root, splitPaneId)?.tabIds).toHaveLength(1); + expect(layout.focusedPaneId).toBe(splitPaneId); + }, + ); + + it.each(["explorer", "pane_generated_explorer"])( + "restores an ordinary pane beside saved hidden %s without losing tabs", + async (explorerId) => { + const workspaceKey = createWorkspaceKey(); + const saved = createPane({ + id: explorerId, + hidden: true, + tabIds: ["files", "draft_saved"], + targetsByTabId: { files: { kind: "files" } }, + stateByTabId: { draft_saved: { savedContent: "preserve me" } }, + }); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + version: 2, + state: { + layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } }, + explorerPaneIdByWorkspace: { [workspaceKey]: explorerId }, + }, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + const state = restored.getState(); + const layout = state.layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); + expect(layout.focusedPaneId).toBe("main"); + expect(findPaneById(layout.root, explorerId)).toEqual( + saved.kind === "pane" ? saved.pane : null, + ); + expect(state.explorerSidebarPaneIdByWorkspace[workspaceKey]).toBe(explorerId); + const snapshot = { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }; + state.reconcileTabs(workspaceKey, snapshot); + const afterFirstReconcile = restored.getState().layoutByWorkspace[workspaceKey]; + state.reconcileTabs(workspaceKey, snapshot); + expect(restored.getState().layoutByWorkspace[workspaceKey]).toEqual(afterFirstReconcile); + expect( + collectAllTabs(afterFirstReconcile.root).filter((tab) => tab.target.kind === "draft"), + ).toHaveLength(1); + expect( + collectAllTabs(afterFirstReconcile.root).find((tab) => tab.tabId === "draft_saved")?.state, + ).toEqual({ savedContent: "preserve me" }); + }, + ); + + it("reveals a saved hidden ordinary pane without replacing its content", async () => { + const workspaceKey = createWorkspaceKey(); + const saved = createPane({ + id: "pane_saved_ordinary", + hidden: true, + tabIds: ["draft_saved"], + stateByTabId: { draft_saved: { savedContent: "preserve me" } }, + }); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + version: 2, + state: { + layoutByWorkspace: { [workspaceKey]: { root: saved, focusedPaneId: null } }, + }, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + const layout = restored.getState().layoutByWorkspace[workspaceKey]; + expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["pane_saved_ordinary"]); + expect(layout.focusedPaneId).toBe("pane_saved_ordinary"); + expect(findPaneById(layout.root, "pane_saved_ordinary")?.tabIds).toEqual(["draft_saved"]); + expect(collectAllTabs(layout.root).find((tab) => tab.tabId === "draft_saved")?.state).toEqual({ + savedContent: "preserve me", + }); + }); + it("migrates legacy layouts to include the hidden registered explorer pane", async () => { const legacyLayout = createDefaultLayout(); await AsyncStorage.setItem( @@ -1483,7 +1685,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: true, @@ -1497,7 +1698,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["terminal-1"], standaloneTerminalIds: ["terminal-1"], }); @@ -1580,7 +1780,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1", "agent-2"], autoOpenAgentIds: ["agent-1", "agent-2"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: [], }); @@ -2943,9 +3142,11 @@ describe("workspace-layout-store actions", () => { expect(persisted).toEqual({ layoutByWorkspace: { [workspaceKey]: layout }, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: currentState.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: {}, + pullRequestTabAutoOpenedByWorkspace: currentState.pullRequestTabAutoOpenedByWorkspace, sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace, }); expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ @@ -3082,6 +3283,13 @@ describe("workspace-layout-store actions", () => { intent: "reveal", }); store.closeTab(workspaceKey, tabId!); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; const mainTab = collectAllTabs(layout.root).find( @@ -3092,7 +3300,7 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true); }); - it("keeps explicitly opened pinned agents in memory per workspace without persisting them", () => { + it("persists explicit agent opens per workspace", () => { const workspaceKey = createWorkspaceKey(); const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, @@ -3131,7 +3339,9 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); - expect(partialize?.(state)).not.toHaveProperty("pinnedAgentIdsByWorkspace"); + expect(partialize?.(state)).toHaveProperty("pinnedAgentIdsByWorkspace", { + [otherWorkspaceKey as string]: ["agent-2"], + }); }); it("keeps hidden agent intents in memory per workspace without persisting them", () => { @@ -3165,7 +3375,9 @@ describe("workspace-layout-store actions", () => { const partialize = workspaceLayoutStore.persist.getOptions().partialize; expect(partialize).toBeTypeOf("function"); expect(partialize?.(state)).toEqual({ + pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, explorerPaneIdByWorkspace: {}, @@ -3258,7 +3470,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: ["term-1"], hasActivePendingDraftCreate: false, }); @@ -3314,7 +3525,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3346,7 +3556,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3362,7 +3571,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["agent-1"], autoOpenAgentIds: ["agent-1"], - knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3372,121 +3580,129 @@ describe("workspace-layout-store actions", () => { expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1"); }); - it("reconcileTabs lands on a draft when the hydrated workspace is empty", () => { - const workspaceKey = createWorkspaceKey(); - - workspaceLayoutStore.getState().reconcileTabs(workspaceKey, { - agentsHydrated: true, - terminalsHydrated: true, - activeAgentIds: [], - autoOpenAgentIds: [], - knownAgentIds: [], - knownTerminalIds: [], - standaloneTerminalIds: [], - hasActivePendingDraftCreate: false, - }); - - const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - const mainTab = collectAllTabs(layout.root).find( - (tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main", - ); - expect(mainTab?.target.kind).toBe("draft"); - expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId); - }); - - it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => { + it("reconcileTabs initializes the New launcher once when the hydrated workspace is empty", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); - workspaceLayoutStore.setState((state) => ({ - layoutByWorkspace: { - ...state.layoutByWorkspace, - [workspaceKey]: { - root: { - kind: "group", - group: { - id: "root", - direction: "horizontal", - sizes: [0.78, 0.22], - children: [ - createPane({ - id: "main", - tabIds: ["new", "draft-origin-agent"], - focusedTabId: "draft-origin-agent", - targetsByTabId: { - new: { kind: "new_tab" }, - "draft-origin-agent": { kind: "agent", agentId: "agent-1" }, - }, - }), - createPane({ - id: "explorer", - tabIds: ["files", "changes_tree", "agent_agent-1"], - focusedTabId: "agent_agent-1", - targetsByTabId: { - files: { kind: "files" }, - changes_tree: { kind: "changes_tree" }, - "agent_agent-1": { kind: "agent", agentId: "agent-1" }, - }, - }), - ], - }, - }, - focusedPaneId: "main", - }, - }, - explorerSidebarPaneIdByWorkspace: { - ...state.explorerSidebarPaneIdByWorkspace, - [workspaceKey]: "explorer", - }, - })); - - store.reconcileTabs(workspaceKey, { - agentsHydrated: false, - terminalsHydrated: false, - activeAgentIds: [], - autoOpenAgentIds: [], - knownAgentIds: [], - knownTerminalIds: [], - standaloneTerminalIds: [], - }); - expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe( - "main", - ); - - workspaceLayoutStore.setState((state) => ({ - layoutByWorkspace: { - ...state.layoutByWorkspace, - [workspaceKey]: { - ...state.layoutByWorkspace[workspaceKey], - focusedPaneId: "explorer", - }, - }, - })); - - const emptySnapshot = { + const snapshot = { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: [], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }; - store.reconcileTabs(workspaceKey, emptySnapshot); - store.reconcileTabs(workspaceKey, emptySnapshot); - const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - store.reconcileTabs(workspaceKey, emptySnapshot); - const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; - expect(afterThirdReconcile).toBe(afterSecondReconcile); - const mainTabs = collectAllTabs(afterThirdReconcile.root).filter( - (tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main", - ); - expect(mainTabs).toEqual([ - expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }), + store.reconcileTabs(workspaceKey, snapshot); + + const initialized = workspaceLayoutStore.getState(); + const layout = initialized.layoutByWorkspace[workspaceKey]; + expect(collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([ + { kind: "new_tab" }, + { kind: "files" }, + { kind: "changes_tree" }, ]); + expect(layout.focusedPaneId).toBe("main"); + + store.reconcileTabs(workspaceKey, snapshot); + + expect(workspaceLayoutStore.getState()).toBe(initialized); }); + it.each([ + ["main", "explorer"], + ["pane_ordinary", "pane_generated_explorer"], + ])( + "reconcileTabs stays stable when startup removes a duplicate from %s beside %s", + (ordinaryPaneId, explorerPaneId) => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + workspaceLayoutStore.setState((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [workspaceKey]: { + root: { + kind: "group", + group: { + id: "root", + direction: "horizontal", + sizes: [0.78, 0.22], + children: [ + createPane({ + id: ordinaryPaneId, + tabIds: ["draft-origin-agent"], + focusedTabId: "draft-origin-agent", + targetsByTabId: { + "draft-origin-agent": { kind: "agent", agentId: "agent-1" }, + }, + }), + createPane({ + id: explorerPaneId, + tabIds: ["files", "changes_tree", "agent_agent-1"], + focusedTabId: "agent_agent-1", + targetsByTabId: { + files: { kind: "files" }, + changes_tree: { kind: "changes_tree" }, + "agent_agent-1": { kind: "agent", agentId: "agent-1" }, + }, + }), + ], + }, + }, + focusedPaneId: ordinaryPaneId, + }, + }, + explorerSidebarPaneIdByWorkspace: { + ...state.explorerSidebarPaneIdByWorkspace, + [workspaceKey]: explorerPaneId, + }, + })); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: false, + terminalsHydrated: false, + activeAgentIds: [], + autoOpenAgentIds: [], + knownTerminalIds: [], + standaloneTerminalIds: [], + }); + expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe( + ordinaryPaneId, + ); + + workspaceLayoutStore.setState((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [workspaceKey]: { + ...state.layoutByWorkspace[workspaceKey], + focusedPaneId: explorerPaneId, + }, + }, + })); + + const emptySnapshot = { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + knownTerminalIds: [], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }; + store.reconcileTabs(workspaceKey, emptySnapshot); + store.reconcileTabs(workspaceKey, emptySnapshot); + const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + store.reconcileTabs(workspaceKey, emptySnapshot); + const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(afterThirdReconcile).toBe(afterSecondReconcile); + const mainTabs = collectAllTabs(afterThirdReconcile.root).filter( + (tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === ordinaryPaneId, + ); + expect(mainTabs).toEqual([expect.objectContaining({ target: { kind: "new_tab" } })]); + }, + ); + it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { const workspaceKey = createWorkspaceKey(); @@ -3495,7 +3711,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3522,7 +3737,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent", "child-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3549,7 +3763,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["parent-agent"], autoOpenAgentIds: ["parent-agent"], - knownAgentIds: ["parent-agent", "child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3571,7 +3784,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["child-agent"], autoOpenAgentIds: [], - knownAgentIds: ["child-agent"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, }); @@ -3609,7 +3821,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], hasActivePendingDraftCreate: false, @@ -3629,7 +3840,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -3675,7 +3885,7 @@ describe("workspace-layout-store actions", () => { expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]); }); - it("keeps an explicitly pinned archived agent before its detail is hydrated", () => { + it("keeps an explicitly opened agent when its detail leaves client memory", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3690,7 +3900,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3706,7 +3915,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3717,13 +3925,11 @@ describe("workspace-layout-store actions", () => { .map((tab) => tab.tabId), ).toEqual(["agent_archived-agent"]); - store.resolvePendingAgent(workspaceKey, "archived-agent"); store.reconcileTabs(workspaceKey, { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3732,7 +3938,7 @@ describe("workspace-layout-store actions", () => { .getWorkspaceTabs(workspaceKey) .filter((tab) => tab.target.kind === "agent") .map((tab) => tab.tabId), - ).toEqual([]); + ).toEqual(["agent_archived-agent"]); store.openTab({ workspaceKey: workspaceKey, @@ -3745,7 +3951,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: [], autoOpenAgentIds: [], - knownAgentIds: [], standaloneTerminalIds: [], }); @@ -3757,6 +3962,57 @@ describe("workspace-layout-store actions", () => { ).toEqual(["agent_archived-agent"]); }); + it("restores the selected historical agent before any server data or cache is available", async () => { + const workspaceKey = createWorkspaceKey(); + workspaceLayoutStore.getState().openTab({ + workspaceKey, + target: { kind: "agent", agentId: "archived-agent" }, + intent: "reveal", + pin: true, + }); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + await restored.persist.rehydrate(); + restored.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + const layout = restored.getState().layoutByWorkspace[workspaceKey]; + expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe( + "agent_archived-agent", + ); + }); + + it("follows server archive after an explicitly opened agent becomes active", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + pin: true, + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], + standaloneTerminalIds: [], + }); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: [], + autoOpenAgentIds: [], + standaloneTerminalIds: [], + }); + expect(store.getWorkspaceTabs(workspaceKey).some((tab) => tab.target.kind === "agent")).toBe( + false, + ); + }); + it("atomically reveals, focuses, and pins an archived agent against reconciliation", () => { const workspaceKey = createWorkspaceKey(); const store = workspaceLayoutStore.getState(); @@ -3783,7 +4039,6 @@ describe("workspace-layout-store actions", () => { terminalsHydrated: true, activeAgentIds: ["first-agent", "second-agent"], autoOpenAgentIds: ["first-agent", "second-agent"], - knownAgentIds: ["first-agent", "second-agent"], standaloneTerminalIds: [], }); @@ -4161,3 +4416,25 @@ describe("workspace-layout-store actions", () => { expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]); }); }); + +it("persists the once-only PR add after closing, and clears it when purging the workspace", async () => { + await AsyncStorage.removeItem("workspace-layout-state"); + const source = createWorkspaceLayoutStore(workspaceLayoutIds); + await source.persist.rehydrate(); + const workspaceKey = "server-1:pr-once"; + const placement = () => ({ placement: { mode: "prefer" as const, paneId: "explorer" } }); + source.getState().autoOpenPullRequestTab(workspaceKey, placement); + source.getState().closeTab(workspaceKey, "pull_request"); + const restored = createWorkspaceLayoutStore(workspaceLayoutIds); + await restored.persist.rehydrate(); + expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBe(true); + expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBeNull(); + expect( + collectAllTabs(restored.getState().layoutByWorkspace[workspaceKey].root).map( + (tab) => tab.target.kind, + ), + ).not.toContain("pull_request"); + restored.getState().purgeWorkspace(workspaceKey); + expect(restored.getState().pullRequestTabAutoOpenedByWorkspace[workspaceKey]).toBeUndefined(); + expect(restored.getState().autoOpenPullRequestTab(workspaceKey, placement)).toBe("pull_request"); +}); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 974b8c6964..5de128b631 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -2,7 +2,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { z } from "zod"; +import type { z } from "zod"; +import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { @@ -36,7 +37,7 @@ import { openTabInLayoutBackground, replaceTabTargetInLayout, revealTargetInLayout, - restoreEmptyPanesInLayout, + restoreWorkspaceLayout, reconcileWorkspaceTabs, removePaneFromTree, removeTabFromTree, @@ -53,6 +54,7 @@ import { type SplitNode, type SplitPane, type WorkspaceTabPlacement, + type WorkspaceTabInsertionPosition, type WorkspaceTabReconcileState, type WorkspaceTabSnapshot, type WorkspaceLayout, @@ -98,6 +100,7 @@ export interface OpenWorkspaceTabInput { /** Keeps an explicitly opened agent visible even when it is archived. */ pin?: boolean; placement?: WorkspaceTabPlacement; + insertionPosition?: WorkspaceTabInsertionPosition; parentTabId?: string; state?: JsonValue; } @@ -107,17 +110,23 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; pinnedAgentIdsByWorkspace: Record>; - pendingAgentIdsByWorkspace: Record>; hiddenAgentIdsByWorkspace: Record>; focusRestorationByWorkspace: Record; explorerSidebarPaneIdByWorkspace: Record; sidePaneIdByWorkspace: Record; + /** Workspaces where PR detection already added its tab once; a closed tab never returns. */ + pullRequestTabAutoOpenedByWorkspace: Record; openTab: (input: OpenWorkspaceTabInput) => string | null; + /** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */ + autoOpenPullRequestTab: ( + workspaceKey: string, + resolvePlacement: () => Pick, + ) => string | null; /** Reveals the Explorer sidebar without selecting a view. Returns its pane id. */ showExplorerSidebar: (workspaceKey: string) => string | null; hideExplorerSidebar: (workspaceKey: string) => void; /** Returns the ordinary right-side workspace pane, creating it when absent. */ - ensureSidePane: (workspaceKey: string) => string | null; + ensureSidePane: (workspaceKey: string, options?: { focus: boolean }) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; selectTabInPane: (workspaceKey: string, paneId: string, tabId: string) => void; @@ -130,7 +139,6 @@ interface WorkspaceLayoutStore { setTabState: (workspaceKey: string, tabId: string, state: JsonValue | undefined) => void; convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null; reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void; - resolvePendingAgent: (workspaceKey: string, agentId: string) => void; reorderTabs: (workspaceKey: string, tabIds: string[]) => void; getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[]; splitPane: ( @@ -176,117 +184,6 @@ interface WorkspaceFocusRestorationState { // Preserve four user-created split levels beneath that bookkeeping node. const MAX_TREE_DEPTH = 5; -const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ - provider: z.string(), - cwd: z.string(), - modeId: z.string().nullable(), - model: z.string().nullable(), - thinkingOptionId: z.string().nullable(), - featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), -}); -const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ - z.strictObject({ kind: z.literal("new_tab") }), - z.strictObject({ - kind: z.literal("draft"), - draftId: z.string(), - setup: WorkspaceDraftTabSetupStorageSchema.optional(), - }), - z.strictObject({ kind: z.literal("agent"), agentId: z.string() }), - z.strictObject({ - kind: z.literal("provider_subagent"), - parentAgentId: z.string(), - subagentId: z.string(), - }), - z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), - z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), - z.strictObject({ kind: z.literal("changes_tree") }), - z.strictObject({ kind: z.literal("files") }), - z.strictObject({ kind: z.literal("pull_request") }), - z.strictObject({ - kind: z.literal("file"), - path: z.string(), - lineStart: z.number().int().positive().optional(), - lineEnd: z.number().int().positive().optional(), - }), - z.strictObject({ - kind: z.literal("working_diff"), - focusPath: z.string().optional(), - focusRequestId: z.number().optional(), - // COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them. - mode: z.enum(["uncommitted", "base"]).optional(), - baseRef: z.string().nullable().optional(), - ignoreWhitespace: z.boolean().optional(), - }), - z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), - z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), - z.discriminatedUnion("context", [ - z.strictObject({ - kind: z.literal("plugin"), - pluginId: z.string(), - panelId: z.string(), - context: z.literal("workspace"), - }), - z.strictObject({ - kind: z.literal("plugin"), - pluginId: z.string(), - panelId: z.string(), - context: z.literal("agent"), - agentId: z.string(), - }), - ]), -]); -const WorkspaceTabStorageSchema = z.strictObject({ - tabId: z.string(), - target: WorkspaceTabTargetStorageSchema, - createdAt: z.number(), - state: z.json().optional(), -}); -const SplitNodeStorageSchema: z.ZodType = z.lazy(() => - z.discriminatedUnion("kind", [ - z.strictObject({ - kind: z.literal("pane"), - pane: z.strictObject({ - id: z.string(), - tabIds: z.array(z.string()), - focusedTabId: z.string().nullable(), - tabs: z.array(WorkspaceTabStorageSchema).optional(), - hidden: z.boolean().optional(), - }), - }), - z.strictObject({ - kind: z.literal("group"), - group: z.strictObject({ - id: z.string(), - direction: z.enum(["horizontal", "vertical"]), - children: z.array(SplitNodeStorageSchema), - sizes: z.array(z.number()), - }), - }), - ]), -); -const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject({ - root: SplitNodeStorageSchema, - focusedPaneId: z.string().nullable(), - parentTabIdByTabId: z.record(z.string(), z.string()).optional(), -}); -const WorkspaceLayoutPersistedStateSchema = z.strictObject({ - layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), - splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), - explorerSidebarWidthByWorkspace: z.record(z.string(), z.number()).optional(), - // COMPAT(explorerSidebarWidth): added in v0.6, remove after 2027-08-25. - explorerSidebarRatioByWorkspace: z.record(z.string(), z.number()).optional(), - // COMPAT(explorerSidebarNaming): accepted from builds that called this dock the Side panel. - sidePanelRatioByWorkspace: z.record(z.string(), z.number()).optional(), - // The persisted keys keep their pre-rename spelling: the schema is strict, so a - // rename here would fail every existing blob and wipe the layout it describes. - explorerPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), - explorerSidebarPaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), - sidePaneIdByWorkspace: z.record(z.string(), z.string().nullable()).optional(), - // COMPAT(pullRequestAutoAdd): PR detection stopped opening a tab in v0.5; accepted - // and ignored so upgrading does not discard the layout. Remove after 2027-08-20. - acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(), -}); - const LEGACY_EXPLORER_SIDEBAR_REFERENCE_WIDTH = 1440; const WORKSPACE_LAYOUT_PERSIST_VERSION = 2; @@ -635,7 +532,7 @@ function ensurePersistedExplorerSidebarPane(input: { paneId: split.paneId, hidden: true, }); - const seededLayout = restoreEmptyPanesInLayout( + const seededLayout = restoreWorkspaceLayout( stripEphemeralTabsFromLayout(hiddenLayout ?? split.layout), split.paneId, ); @@ -760,15 +657,34 @@ export function createWorkspaceLayoutStore( return create()( persist( (set, get) => ({ + autoOpenPullRequestTab: (workspaceKey, resolvePlacement) => { + const key = trimNonEmpty(workspaceKey); + if (!key || get().pullRequestTabAutoOpenedByWorkspace[key]) return null; + const tabId = get().openTab({ + workspaceKey: key, + target: { kind: "pull_request" }, + intent: "background", + ...resolvePlacement(), + }); + if (tabId) { + set((state) => ({ + pullRequestTabAutoOpenedByWorkspace: { + ...state.pullRequestTabAutoOpenedByWorkspace, + [key]: true, + }, + })); + } + return tabId; + }, layoutByWorkspace: {}, splitSizesByWorkspace: {}, explorerSidebarWidthByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, - pendingAgentIdsByWorkspace: {}, hiddenAgentIdsByWorkspace: {}, focusRestorationByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, + pullRequestTabAutoOpenedByWorkspace: {}, openTab: (input) => { const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey); const normalizedTarget = normalizeWorkspaceTabTarget(input.target); @@ -785,6 +701,7 @@ export function createWorkspaceLayoutStore( if (input.intent === "new") { result = createTabInLayout({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), createTabId: createWorkspaceTabInstanceId, @@ -793,12 +710,14 @@ export function createWorkspaceLayoutStore( } else if (input.intent === "background") { result = openTabInLayoutBackground({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), }); } else { result = revealTargetInLayout({ ...placement, + insertionPosition: input.insertionPosition, target: normalizedTarget, now: Date.now(), createTabId: createWorkspaceTabInstanceId, @@ -830,13 +749,6 @@ export function createWorkspaceLayoutStore( normalizedTarget.agentId, ) : state.pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: shouldPinAgent - ? addAgentIdToWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedTarget.agentId, - ) - : state.pendingAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: input.parentTabId @@ -918,7 +830,7 @@ export function createWorkspaceLayoutStore( }; }); }, - ensureSidePane: (workspaceKey) => { + ensureSidePane: (workspaceKey, options) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { return null; @@ -949,7 +861,10 @@ export function createWorkspaceLayoutStore( ...withoutFocusRestoration(state, normalizedWorkspaceKey), layoutByWorkspace: { ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, + [normalizedWorkspaceKey]: + options?.focus === false + ? { ...result.layout, focusedPaneId: layout.focusedPaneId } + : result.layout, }, sidePaneIdByWorkspace: { ...state.sidePaneIdByWorkspace, @@ -1000,14 +915,10 @@ export function createWorkspaceLayoutStore( }, }; } - const preserveEmptyPaneId = - closingPane?.id === "main" || closingPane?.id === explorerSidebarPaneId - ? closingPane.id - : null; const closedLayout = closeTabInLayout({ layout, tabId: normalizedTabId, - preserveEmptyPaneId, + explorerSidebarPaneId, }); const nextLayoutBeforeFocusNormalization = closedLayout && @@ -1165,8 +1076,13 @@ export function createWorkspaceLayoutStore( return null; } + const layout = getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey); const result = convertDraftToAgentInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + layout, + explorerSidebarPaneId: resolveExplorerSidebarPaneId( + layout, + get().explorerSidebarPaneIdByWorkspace[normalizedWorkspaceKey], + ), tabId: normalizedTabId, agentId: normalizedAgentId, }); @@ -1212,7 +1128,6 @@ export function createWorkspaceLayoutStore( { layout: currentLayout, pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, - pendingAgentIds: state.pendingAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, hiddenAgentIds: state.hiddenAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null, explorerSidebarPaneId, }, @@ -1223,11 +1138,26 @@ export function createWorkspaceLayoutStore( explorerSidebarPaneId, currentLayout.focusedPaneId, ); - if (nextLayout === rawLayout) { + let pinnedAgentIdsByWorkspace = state.pinnedAgentIdsByWorkspace; + for (const agentId of state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? []) { + if (!nextState.pinnedAgentIds?.has(agentId)) { + pinnedAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( + pinnedAgentIdsByWorkspace, + normalizedWorkspaceKey, + agentId, + ); + } + } + if ( + state.layoutByWorkspace[normalizedWorkspaceKey] && + nextLayout === rawLayout && + pinnedAgentIdsByWorkspace === state.pinnedAgentIdsByWorkspace + ) { return state; } return { + pinnedAgentIdsByWorkspace, layoutByWorkspace: { ...state.layoutByWorkspace, [normalizedWorkspaceKey]: nextLayout, @@ -1235,25 +1165,6 @@ export function createWorkspaceLayoutStore( }; }); }, - resolvePendingAgent: (workspaceKey, agentId) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - const normalizedAgentId = trimNonEmpty(agentId); - if (!normalizedWorkspaceKey || !normalizedAgentId) { - return; - } - - set((state) => { - const pendingAgentIdsByWorkspace = removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ); - if (pendingAgentIdsByWorkspace === state.pendingAgentIdsByWorkspace) { - return state; - } - return { pendingAgentIdsByWorkspace }; - }); - }, reorderTabs: (workspaceKey, tabIds) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); if (!normalizedWorkspaceKey) { @@ -1415,7 +1326,7 @@ export function createWorkspaceLayoutStore( } const restoredLayout = normalizedToPaneId === explorerSidebarPaneId - ? restoreEmptyPanesInLayout(nextLayout, explorerSidebarPaneId) + ? restoreWorkspaceLayout(nextLayout, explorerSidebarPaneId) : nextLayout; const normalizedNextLayout = keepWorkspaceFocusOutOfExplorerSidebar( restoredLayout, @@ -1676,11 +1587,6 @@ export function createWorkspaceLayoutStore( delete nextPinnedAgentIdsByWorkspace[normalizedWorkspaceKey]; return { pinnedAgentIdsByWorkspace: nextPinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; } @@ -1692,11 +1598,6 @@ export function createWorkspaceLayoutStore( ...state.pinnedAgentIdsByWorkspace, [normalizedWorkspaceKey]: nextPinnedAgentIds, }, - pendingAgentIdsByWorkspace: removeAgentIdFromWorkspaceSet( - state.pendingAgentIdsByWorkspace, - normalizedWorkspaceKey, - normalizedAgentId, - ), }; }); }, @@ -1756,14 +1657,18 @@ export function createWorkspaceLayoutStore( normalizedWorkspaceKey in state.splitSizesByWorkspace || normalizedWorkspaceKey in state.explorerSidebarWidthByWorkspace || normalizedWorkspaceKey in state.pinnedAgentIdsByWorkspace || - normalizedWorkspaceKey in state.pendingAgentIdsByWorkspace || normalizedWorkspaceKey in state.hiddenAgentIdsByWorkspace || normalizedWorkspaceKey in state.focusRestorationByWorkspace || normalizedWorkspaceKey in state.explorerSidebarPaneIdByWorkspace || - normalizedWorkspaceKey in state.sidePaneIdByWorkspace; + normalizedWorkspaceKey in state.sidePaneIdByWorkspace || + normalizedWorkspaceKey in state.pullRequestTabAutoOpenedByWorkspace; if (!hasAny) { return state; } + const { + [normalizedWorkspaceKey]: _autoOpened, + ...pullRequestTabAutoOpenedByWorkspace + } = state.pullRequestTabAutoOpenedByWorkspace; const { [normalizedWorkspaceKey]: _layout, ...layoutByWorkspace } = state.layoutByWorkspace; const { [normalizedWorkspaceKey]: _splits, ...splitSizesByWorkspace } = @@ -1774,8 +1679,6 @@ export function createWorkspaceLayoutStore( } = state.explorerSidebarWidthByWorkspace; const { [normalizedWorkspaceKey]: _pinned, ...pinnedAgentIdsByWorkspace } = state.pinnedAgentIdsByWorkspace; - const { [normalizedWorkspaceKey]: _pending, ...pendingAgentIdsByWorkspace } = - state.pendingAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _hidden, ...hiddenAgentIdsByWorkspace } = state.hiddenAgentIdsByWorkspace; const { [normalizedWorkspaceKey]: _restoration, ...focusRestorationByWorkspace } = @@ -1787,11 +1690,11 @@ export function createWorkspaceLayoutStore( const { [normalizedWorkspaceKey]: _sidePane, ...sidePaneIdByWorkspace } = state.sidePaneIdByWorkspace; return { + pullRequestTabAutoOpenedByWorkspace, layoutByWorkspace, splitSizesByWorkspace, explorerSidebarWidthByWorkspace, pinnedAgentIdsByWorkspace, - pendingAgentIdsByWorkspace, hiddenAgentIdsByWorkspace, focusRestorationByWorkspace, explorerSidebarPaneIdByWorkspace, @@ -1817,10 +1720,17 @@ export function createWorkspaceLayoutStore( } return { layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(state.pinnedAgentIdsByWorkspace).map(([key, agentIds]) => [ + key, + Array.from(agentIds), + ]), + ), splitSizesByWorkspace: state.splitSizesByWorkspace, explorerSidebarWidthByWorkspace: state.explorerSidebarWidthByWorkspace, explorerPaneIdByWorkspace: state.explorerSidebarPaneIdByWorkspace, sidePaneIdByWorkspace: state.sidePaneIdByWorkspace, + pullRequestTabAutoOpenedByWorkspace: state.pullRequestTabAutoOpenedByWorkspace, }; }, merge: (persistedState, currentState) => { @@ -1836,25 +1746,33 @@ export function createWorkspaceLayoutStore( for (const [workspaceKey, persistedLayout] of Object.entries( result.data.layoutByWorkspace, )) { - const strippedLayout = stripEphemeralTabsFromLayout(persistedLayout); + const restoredLayout = restoreWorkspaceLayout( + stripEphemeralTabsFromLayout(persistedLayout), + resolveExplorerSidebarPaneId( + persistedLayout, + explorerSidebarPaneIdByWorkspace[workspaceKey], + ), + ids.createNodeId, + ); const explorerSidebar = ensurePersistedExplorerSidebarPane({ - layout: strippedLayout, + layout: restoredLayout, registeredPaneId: explorerSidebarPaneIdByWorkspace[workspaceKey], ids, }); - if (!explorerSidebar) { - layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout(strippedLayout); - continue; + layoutByWorkspace[workspaceKey] = explorerSidebar?.layout ?? restoredLayout; + if (explorerSidebar) { + explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId; } - layoutByWorkspace[workspaceKey] = restoreEmptyPanesInLayout( - explorerSidebar.layout, - explorerSidebar.paneId, - ); - explorerSidebarPaneIdByWorkspace[workspaceKey] = explorerSidebar.paneId; } return { ...currentState, layoutByWorkspace, + pinnedAgentIdsByWorkspace: Object.fromEntries( + Object.entries(result.data.pinnedAgentIdsByWorkspace ?? {}).map(([key, agentIds]) => [ + key, + new Set(agentIds), + ]), + ), splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, explorerSidebarWidthByWorkspace: result.data.explorerSidebarWidthByWorkspace ?? @@ -1865,6 +1783,8 @@ export function createWorkspaceLayoutStore( ), explorerSidebarPaneIdByWorkspace, sidePaneIdByWorkspace: result.data.sidePaneIdByWorkspace ?? {}, + pullRequestTabAutoOpenedByWorkspace: + result.data.pullRequestTabAutoOpenedByWorkspace ?? {}, }; }, }, @@ -1874,6 +1794,39 @@ export function createWorkspaceLayoutStore( export const useWorkspaceLayoutStore = createWorkspaceLayoutStore(); +/** + * The agent tabs that exist right now, across every workspace of this host, independently + * of which workspace views are mounted. Timeline sync uses this to release a chat when its + * tab closes. It is not a subscription source: this layout is restored from disk at launch, + * so subscribing to it would resume every agent the user has ever opened. + */ +export function observeOpenWorkspaceAgentIds( + serverId: string, + listener: (agentIds: string[]) => void, + store = useWorkspaceLayoutStore, +): () => void { + let previous: string[] | undefined; + const publish = (state: Pick) => { + const ids = new Set(); + for (const [workspaceKey, layout] of Object.entries(state.layoutByWorkspace)) { + if (!workspaceKey.startsWith(`${serverId}:`)) continue; + for (const tab of collectAllTabs(layout.root)) { + if (tab.target.kind === "agent") ids.add(tab.target.agentId); + } + } + const next = [...ids].sort(); + if (previous?.length === next.length && previous.every((id, index) => id === next[index])) + return; + previous = next; + listener(next); + }; + const unsubscribe = store.subscribe((state, before) => { + if (state.layoutByWorkspace !== before.layoutByWorkspace) publish(state); + }); + publish(store.getState()); + return unsubscribe; +} + export function useWorkspaceLayoutStoreHydrated(): boolean { const [hasHydrated, setHasHydrated] = useState(() => useWorkspaceLayoutStore.persist.hasHydrated(), diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index f826d8ffa0..85c2696f57 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -145,6 +145,8 @@ describe("workspace-setup-store", () => { sourceWorkspaceId: "42", displayName: "project", creationMethod: "open_project", + creationId: expect.any(String), + worktreeSlug: expect.any(String), }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index 31e3bfc024..d0205ba27a 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,3 +1,5 @@ +import { createNameId } from "mnemonic-id"; +import { generateDraftId } from "@/stores/draft-keys"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -44,7 +46,9 @@ export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | n } interface WorkspaceSetupStoreState { - pendingWorkspaceSetup: PendingWorkspaceSetup | null; + pendingWorkspaceSetup: + | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) + | null; snapshots: Record; requestedKeys: Set; surfacedFailedSetupKeys: Set; @@ -71,7 +75,13 @@ export const useWorkspaceSetupStore = create()((set, g requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { - set({ pendingWorkspaceSetup: value }); + set({ + pendingWorkspaceSetup: { + ...value, + creationId: generateDraftId(), + worktreeSlug: createNameId(), + }, + }); }, clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 29925efd42..5f04a39bd9 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -37,7 +37,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts new file mode 100644 index 0000000000..5b452bcb93 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,57 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact + * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay + * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * + * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes + * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. + */ + +export type DensityIconSize = keyof typeof ICON_SIZE; +type TextSize = "sm" | "base"; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; +const COMPACT_TEXT_SIZE: Record = { sm: "base", base: "lg" }; + +// Compact layouts end where `md` begins; see `isCompactBreakpoint`. +function byLayout(compact: T, desktop: T) { + return { xs: compact, md: desktop }; +} + +export const density = { + /** A list row such as a sidebar entry. */ + rowHeight: byLayout(44, 36), + /** A row in a tight group, such as the sidebar's navigation rows. */ + tightRowHeight: byLayout(40, 28), + /** The line height of a row's `base` text; slots that align to its first line share it. */ + rowLineHeight: byLayout(22, 20), + /** A square icon-only button that sits in a row or toolbar. */ + iconButton: byLayout(44, 28), +}; + +/** The width or height of a slot that holds an icon of `size`. */ +export function densityIconBox(size: DensityIconSize) { + return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); +} + +export function densityFontSize(theme: Theme, size: TextSize) { + return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]); +} + +export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { + return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; +} + +/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */ +export function densityIconProps

( + size: DensityIconSize, + mapping: (theme: Theme) => P, +) { + return (theme: Theme, rt: { breakpoint?: string }) => ({ + ...mapping(theme), + size: densityIconSize(rt, size), + }); +} diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index 707085e7c9..461d980ecd 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -68,9 +68,9 @@ describe("Pure black theme", () => { }); describe("Sidebar interaction surfaces", () => { - it("keeps Light selection distinct from the sidebar surface", () => { - expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); - expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); + it("keeps Light hover and selection darker than the sidebar surface", () => { + expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3); + expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); }); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 918c83b9ab..8e1e77aac7 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -159,6 +159,20 @@ const darkStatusColors = { statusMerged: "#a890d5", }; +// Status tints — the fill of a status badge. The status color itself at low opacity, so a +// badge takes its state's hue from the same source as its text and never drifts from it. +// Dark surfaces swallow more of the tint, so the dark band runs a little stronger. +function statusTints(colors: typeof lightStatusColors, alphaHex: string) { + return { + statusSuccessTint: `${colors.statusSuccess}${alphaHex}`, + statusDangerTint: `${colors.statusDanger}${alphaHex}`, + statusWarningTint: `${colors.statusWarning}${alphaHex}`, + }; +} + +const lightStatusTints = statusTints(lightStatusColors, "1f"); // 12% +const darkStatusTints = statusTints(darkStatusColors, "29"); // 16% + // Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in // for them. Same four hues and the same generation rule as the status colors above, but its // own band, because a dot is doing a different job than a check icon or a host badge. @@ -253,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) { surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, - surfaceSidebarHover: tint.surface1, - surfaceSidebarSelected: tint.surface3, + surfaceSidebarHover: tint.surface3, + surfaceSidebarSelected: tint.surface4, surfaceWorkspace: tint.surface0, interactionHighlight: "rgba(0, 0, 0, 0.06)", @@ -289,6 +303,7 @@ export function buildLightSemanticColors(tint: LightThemeConfig) { ...lightDiffColors, ...lightStatusColors, + ...lightStatusTints, ...lightStatusDotColors, terminal: { @@ -420,6 +435,7 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) { ...darkDiffColors, ...darkStatusColors, + ...darkStatusTints, ...darkStatusDotColors, terminal: { @@ -581,6 +597,8 @@ export const ICON_SIZE = { lg: 20, } as const; +export const ICON_STROKE_WIDTH = 1.5; + export const FONT_WEIGHT = { normal: "normal" as const, medium: "500" as const, @@ -626,7 +644,10 @@ export const DEFAULT_MONO_FONT_STACK: string = Platform.select({ web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", }); -// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain +// Chat and markdown content column; the appearance updater patches the user's width in. +export const DEFAULT_CONTENT_MAX_WIDTH = 820; + +// `fontSize`, `fontFamily`, `lineHeight`, and `contentMaxWidth` are deliberately widened to plain // `number`/`string` (not narrowed by `as const`) so the appearance updater can patch // them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their // literal types. @@ -635,6 +656,7 @@ interface CommonTheme { fontSize: Record; fontFamily: { ui: string; mono: string }; lineHeight: Record; + contentMaxWidth: number; iconSize: typeof ICON_SIZE; fontWeight: typeof FONT_WEIGHT; borderRadius: typeof BORDER_RADIUS; @@ -647,6 +669,7 @@ const commonTheme: CommonTheme = { fontSize: FONT_SIZE, fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK }, lineHeight: LINE_HEIGHT, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, iconSize: ICON_SIZE, fontWeight: FONT_WEIGHT, borderRadius: BORDER_RADIUS, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 42a3e51ca6..5c1615d96c 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -11,6 +11,10 @@ function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSu description: null, subtitle: null, status, + turn: + status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, requiresAttention: false, createdAt: new Date(), }; diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts index ea863ea37a..01d6880d5b 100644 --- a/packages/app/src/subagents/provider-store.test.ts +++ b/packages/app/src/subagents/provider-store.test.ts @@ -1,5 +1,13 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; -import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; +import { + observeProviderSubagentTimeline, + providerSubagentKey, + refreshProviderSubagents, + resyncProviderSubagents, + useProviderSubagentStore, +} from "./provider-store"; const SERVER_ID = "server-1"; const PARENT_ID = "parent-1"; @@ -10,6 +18,7 @@ afterEach(() => { descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), }); }); @@ -41,6 +50,7 @@ describe("provider subagent client store", () => { item: { type: "assistant_message", text: "New live output." }, }); subagents.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "history-1", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -103,12 +113,12 @@ describe("provider subagent client store", () => { const state = useProviderSubagentStore.getState(); expect(state.descriptors.get(key)?.status).toBe("completed"); expect(state.timelines.get(key)?.head).toEqual([]); - expect(state.timelines.get(key)?.tail).toEqual([ - expect.objectContaining({ - kind: "assistant_message", - text: "Older history.New live output.", - }), - ]); + expect( + state.timelines + .get(key) + ?.tail.map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""), + ).toBe("Older history.New live output."); }); test("removes timelines for children no longer returned by the provider", () => { @@ -325,6 +335,7 @@ describe("provider subagent client store", () => { test("merges bounded older pages and tracks whether more history remains", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "tail-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -347,6 +358,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "older-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -373,8 +385,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.hasOlder).toBe(false); - expect([...timeline!.rows.keys()]).toEqual([2, 1]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 1, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }), ]); }); @@ -382,6 +394,7 @@ describe("provider subagent client store", () => { test("ignores delayed live updates from a stale timeline epoch", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "current-page", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -419,8 +432,8 @@ describe("provider subagent client store", () => { .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); expect(timeline?.epoch).toBe("epoch-current"); - expect([...timeline!.rows.keys()]).toEqual([2]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 2, endSeq: 2 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current output." }), ]); }); @@ -428,6 +441,7 @@ describe("provider subagent client store", () => { test("replaces cached rows with an authoritative tail page after a reconnect gap", () => { const store = useProviderSubagentStore.getState(); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "old-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -450,6 +464,7 @@ describe("provider subagent client store", () => { error: null, }); store.replaceTimeline(SERVER_ID, { + projection: "projected", requestId: "reconnect-tail", parentAgentId: PARENT_ID, subagentId: SUBAGENT_ID, @@ -475,9 +490,337 @@ describe("provider subagent client store", () => { const timeline = useProviderSubagentStore .getState() .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); - expect([...timeline!.rows.keys()]).toEqual([401]); - expect(timeline?.head).toEqual([ + expect(timeline?.cursor).toMatchObject({ startSeq: 401, endSeq: 401 }); + expect([...(timeline?.tail ?? []), ...(timeline?.head ?? [])]).toEqual([ expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }), ]); }); }); + +describe("projected child history", () => { + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + const timestamp = "2026-09-14T00:00:00.000Z"; + const current = () => useProviderSubagentStore.getState().timelines.get(key)!; + const text = () => + [...current().tail, ...current().head] + .map((item) => (item.kind === "assistant_message" ? item.text : "")) + .join(""); + function stream(seq: number, value: string) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }); + } + type Payload = Parameters< + ReturnType["replaceTimeline"] + >[1]; + function response( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ): Payload { + return { + requestId: "r", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + direction, + projection: "projected", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seqEnd, nextSeq: seqEnd + 1 }, + startCursor: { epoch: "e", seq: seqStart }, + endCursor: { epoch: "e", seq: seqEnd }, + hasOlder: false, + hasNewer: false, + error: null, + rows: [ + { + seq: seqEnd, + seqStart, + seqEnd, + sourceSeqRanges: [{ startSeq: seqStart, endSeq: seqEnd }], + timestamp, + item: { type: "assistant_message", messageId: "m", text: value }, + }, + ], + }; + } + function page( + value: string, + seqStart: number, + seqEnd: number, + direction: "tail" | "after" = "tail", + ) { + useProviderSubagentStore + .getState() + .replaceTimeline(SERVER_ID, response(value, seqStart, seqEnd, direction)); + } + test("replaces overlapping fetched text and continues live streaming", () => { + page("AB", 1, 2); + stream(3, "C"); + page("ABCD", 1, 4, "after"); + expect(text()).toBe("ABCD"); + stream(5, "E"); + expect(text()).toBe("ABCDE"); + }); + test("reconciles a tail snapshot racing newer live text", () => { + stream(1, "A"); + stream(2, "B"); + stream(3, "C"); + page("AB", 1, 2); + page("ABC", 1, 3); + expect(text()).toBe("ABC"); + expect(current().needsRefresh).toBe(false); + }); + test("recovers a live sequence gap while observed and stops fetching when closed", async () => { + let calls = 0; + const client = { + async fetchProviderSubagentTimeline() { + calls++; + return calls === 1 ? response("A", 1, 1) : response("ABC", 1, 3); + }, + }; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(text).toBe("ABC"); + expect(calls).toBe(2); + expect(current().needsRefresh).toBe(false); + } finally { + stop(); + } + stream(5, "E"); + await Promise.resolve(); + expect(calls).toBe(2); + expect(errors).toEqual([]); + }); + test("retries a disconnected history read on the next stream update", async () => { + let calls = 0; + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + calls++; + if (calls === 2) throw new DaemonConnectionError("Connection lost"); + return calls === 1 ? response("A", 1, 1) : response("ABCD", 1, 4); + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => current()?.cursor?.endSeq).toBe(1); + stream(3, "C"); + await expect.poll(() => calls).toBe(2); + expect(current().needsRefresh).toBe(true); + stream(4, "D"); + await expect.poll(text).toBe("ABCD"); + expect(current().needsRefresh).toBe(false); + expect(errors).toEqual([]); + } finally { + stop(); + } + }); + test("reports unexpected child history failures", async () => { + const failure = new TypeError("Invalid child timeline response"); + const errors: unknown[] = []; + const stop = observeProviderSubagentTimeline({ + client: { + async fetchProviderSubagentTimeline() { + throw failure; + }, + }, + serverId: SERVER_ID, + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + limit: 100, + reportError: (error) => { + errors.push(error); + }, + }); + try { + await expect.poll(() => errors).toEqual([failure]); + } finally { + stop(); + } + }); + test("keeps a completed tool at its original position after fetching its latest update", () => { + const payload = response("Answer", 1, 5); + payload.rows = [ + { + seq: 5, + seqStart: 1, + seqEnd: 5, + sourceSeqRanges: [ + { startSeq: 1, endSeq: 1 }, + { startSeq: 5, endSeq: 5 }, + ], + timestamp, + item: { + type: "tool_call", + callId: "t", + name: "shell", + status: "completed", + error: null, + detail: { type: "plain_text", label: "work" }, + }, + }, + { + seq: 4, + seqStart: 2, + seqEnd: 4, + sourceSeqRanges: [{ startSeq: 2, endSeq: 4 }], + timestamp, + item: { type: "assistant_message", text: "Answer", messageId: "m" }, + }, + ]; + useProviderSubagentStore.getState().replaceTimeline(SERVER_ID, payload); + expect([...current().tail, ...current().head].map((item) => item.kind)).toEqual([ + "tool_call", + "assistant_message", + ]); + }); + test("retains projected display state rather than cumulative tool snapshots", () => { + for (let seq = 1; seq <= 2000; seq++) { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "e", + seq, + timestamp, + item: { + type: "tool_call", + callId: "tool", + name: "task", + status: "running", + error: null, + detail: { type: "sub_agent", description: "Child", log: "x".repeat(seq * 128) }, + }, + }); + } + expect([...current().tail, ...current().head]).toHaveLength(1); + expect(JSON.stringify(current()).length).toBeLessThan(1_000_000); + expect(current().lastSeq).toBe(2000); + }); +}); + +describe("resyncing after the update feed resubscribes", () => { + function child( + id: string, + parentAgentId: string, + status: ProviderSubagentDescriptorPayload["status"], + ): ProviderSubagentDescriptorPayload { + return { + id, + parentAgentId, + provider: "claude", + title: "Explore", + description: null, + status, + createdAt: "2026-10-07T10:00:00.000Z", + updatedAt: "2026-10-07T10:00:00.000Z", + toolCallId: id, + }; + } + + function fakeDaemon(lists: Record) { + const requested: string[] = []; + return { + requested, + lists, + client: { + listProviderSubagents: async (parentAgentId: string) => { + requested.push(parentAgentId); + return { + requestId: "list", + parentAgentId, + subagents: lists[parentAgentId] ?? [], + error: null, + }; + }, + }, + }; + } + + const status = (parentAgentId: string, subagentId: string) => + useProviderSubagentStore + .getState() + .descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status; + + test("catches up on children that finished or started while updates were not delivered", async () => { + const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] }); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + expect(status(PARENT_ID, "child-1")).toBe("running"); + + // The connection dropped: the daemon finished child-1 and ran child-2 without telling us. + daemon.lists[PARENT_ID] = [ + child("child-1", PARENT_ID, "completed"), + child("child-2", PARENT_ID, "completed"), + ]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + expect(status(PARENT_ID, "child-2")).toBe("completed"); + }); + + test("catches up on a parent whose first list failed while disconnected", async () => { + const daemon = fakeDaemon({}); + const disconnected = { + listProviderSubagents: async () => { + throw new DaemonConnectionError("Transport not connected"); + }, + }; + await expect( + refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID), + ).rejects.toBeInstanceOf(DaemonConnectionError); + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: child("child-1", PARENT_ID, "running"), + }); + + // The connection dropped again: the daemon finished child-1 without telling us. + daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")]; + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(status(PARENT_ID, "child-1")).toBe("completed"); + }); + + test("lists again only the parents this server was showing", async () => { + const daemon = fakeDaemon({}); + await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID); + await refreshProviderSubagents(daemon.client, "server-2", "other-parent"); + daemon.requested.length = 0; + + await resyncProviderSubagents(daemon.client, SERVER_ID); + + expect(daemon.requested).toEqual([PARENT_ID]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts index 15a9eb9b71..fd44dae8d2 100644 --- a/packages/app/src/subagents/provider-store.ts +++ b/packages/app/src/subagents/provider-store.ts @@ -3,36 +3,34 @@ import type { ProviderSubagentDescriptorPayload, SessionOutboundMessage, } from "@getpaseo/protocol/messages"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { create } from "zustand"; import { applyStreamEvent } from "@/types/stream"; +import { + processTimelineResponse, + processAgentStreamEvent, + type TimelineCursor, +} from "@/timeline/session-stream-reducers"; import type { StreamItem } from "@/types/stream"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -type ProviderSubagentTimelineItem = Extract< - Extract["payload"], - { kind: "timeline" } ->["item"]; - -interface ProviderSubagentTimelineRow { - provider: ProviderSubagentDescriptorPayload["provider"]; - item: ProviderSubagentTimelineItem; - timestamp: string; -} - export interface ProviderSubagentTimelineState { tail: StreamItem[]; head: StreamItem[]; epoch: string | null; lastSeq: number; hasOlder: boolean; - rows: Map; + cursor: TimelineCursor | undefined; + needsRefresh: boolean; } interface ProviderSubagentState { descriptors: Map; timelines: Map; hiddenFromTrack: Set; + /** Parents whose list this client asked for, so a resubscribed update feed can catch them up. */ + trackedParents: Set; + trackParent(serverId: string, parentAgentId: string): void; hideFromTrack(serverId: string, parentAgentId: string, subagentIds: readonly string[]): void; replaceList( serverId: string, @@ -89,6 +87,7 @@ export function refreshProviderSubagents( const pending = clientRequests.get(requestKey); if (pending) return pending; + useProviderSubagentStore.getState().trackParent(serverId, parentAgentId); const request = client .listProviderSubagents(parentAgentId) .then((payload) => { @@ -106,13 +105,35 @@ function parentPrefix(serverId: string, parentAgentId: string): string { return `${serverId}\0${parentAgentId}\0`; } +/** + * Updates are not replayed across a reconnect, so a child that finished or started while the + * update feed was down stays stale until its parent is listed again. Call this once the feed has + * resubscribed, so every update after the list is delivered. + */ +export async function resyncProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, +): Promise { + const serverPrefix = `${serverId}\0`; + const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents] + .filter((key) => key.startsWith(serverPrefix)) + .map((key) => key.slice(serverPrefix.length, -1)); + await Promise.all( + parentAgentIds.map((parentAgentId) => + // A parent that was archived meanwhile has nothing left to catch up. + refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined), + ), + ); +} + const EMPTY_TIMELINE: ProviderSubagentTimelineState = { tail: [], head: [], epoch: null, lastSeq: 0, hasOlder: false, - rows: new Map(), + cursor: undefined, + needsRefresh: false, }; function providerSubagentTerminalEvent( @@ -130,72 +151,35 @@ function providerSubagentTerminalEvent( return { type: "turn_completed", provider: subagent.provider }; } -function buildTimelineState( - rows: ProviderSubagentTimelineState["rows"], - epoch: string | null, +function settleTimeline( + timeline: ProviderSubagentTimelineState, descriptor?: ProviderSubagentDescriptorPayload, - hasOlder = false, ): ProviderSubagentTimelineState { - let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] }; - for (const [, row] of [...rows].sort(([left], [right]) => left - right)) { - timeline = applyStreamEvent({ - ...timeline, - event: { type: "timeline", provider: row.provider, item: row.item }, - timestamp: new Date(row.timestamp), - }); - } - const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null; - if (terminalEvent && descriptor) { - timeline = applyStreamEvent({ - ...timeline, - event: terminalEvent, - timestamp: new Date(descriptor.updatedAt), - }); - } + const event = descriptor ? providerSubagentTerminalEvent(descriptor) : null; + if (!event || !descriptor) return timeline; return { ...timeline, - epoch, - lastSeq: rows.size ? Math.max(...rows.keys()) : 0, - hasOlder, - rows, + ...applyStreamEvent({ + tail: timeline.tail, + head: timeline.head, + event, + timestamp: new Date(descriptor.updatedAt), + }), }; } -function buildTimelineResponseRows( - existing: ProviderSubagentTimelineState | undefined, - payload: Extract< - SessionOutboundMessage, - { type: "agent.provider_subagents.timeline.get.response" } - >["payload"], - provider: ProviderSubagentDescriptorPayload["provider"], -): ProviderSubagentTimelineState["rows"] { - const rows = new Map(); - for (const row of payload.rows) { - rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp }); - } - if (payload.reset || existing?.epoch !== payload.epoch) { - return rows; - } - if (payload.direction !== "tail") { - return new Map([...existing.rows, ...rows]); - } - - let nextSeq = payload.rows.length - ? Math.max(...payload.rows.map((row) => row.seq)) + 1 - : payload.window.maxSeq + 1; - for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) { - if (seq < nextSeq) continue; - if (seq !== nextSeq) break; - rows.set(seq, row); - nextSeq += 1; - } - return rows; -} - export const useProviderSubagentStore = create((set) => ({ descriptors: new Map(), timelines: new Map(), hiddenFromTrack: new Set(), + trackedParents: new Set(), + trackParent(serverId, parentAgentId) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + if (state.trackedParents.has(prefix)) return state; + return { trackedParents: new Set(state.trackedParents).add(prefix) }; + }); + }, hideFromTrack(serverId, parentAgentId, subagentIds) { set((state) => { const hiddenFromTrack = new Set(state.hiddenFromTrack); @@ -229,10 +213,7 @@ export const useProviderSubagentStore = create((set) => ( const current = timelines.get(key); const previous = state.descriptors.get(key); if (current && previous?.status !== subagent.status) { - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, subagent)); } } return { descriptors, timelines, hiddenFromTrack }; @@ -257,10 +238,7 @@ export const useProviderSubagentStore = create((set) => ( const current = state.timelines.get(key); if (current && previous?.status !== payload.subagent.status) { timelines = new Map(state.timelines); - timelines.set( - key, - buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), - ); + timelines.set(key, settleTimeline(current, payload.subagent)); } return { descriptors, timelines, hiddenFromTrack }; } @@ -281,30 +259,33 @@ export const useProviderSubagentStore = create((set) => ( if (payload.seq <= current.lastSeq) { return state; } - const rows = new Map(current.rows); - rows.set(payload.seq, { - provider: payload.provider, - item: payload.item, - timestamp: payload.timestamp, - }); - const descriptor = state.descriptors.get(key); - const next = - descriptor && descriptor.status !== "running" - ? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder) - : applyStreamEvent({ - tail: current.tail, - head: current.head, - event: { type: "timeline", provider: payload.provider, item: payload.item }, - timestamp: new Date(payload.timestamp), - }); - const timelines = new Map(state.timelines); - timelines.set(key, { - ...next, + const next = processAgentStreamEvent({ + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + hasAuthoritativeBaseline: current.cursor !== undefined, + event: { type: "timeline", provider: payload.provider, item: payload.item }, + timestamp: new Date(payload.timestamp), + seq: payload.seq, epoch: payload.epoch, - lastSeq: payload.seq, - hasOlder: current.hasOlder, - rows, }); + const timelines = new Map(state.timelines); + timelines.set( + key, + settleTimeline( + { + tail: next.tail, + head: next.head, + epoch: payload.epoch, + lastSeq: payload.seq, + cursor: next.cursor ?? current.cursor, + hasOlder: current.hasOlder, + needsRefresh: + current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, @@ -316,11 +297,115 @@ export const useProviderSubagentStore = create((set) => ( set((state) => { const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); const existing = state.timelines.get(key); - const rows = buildTimelineResponseRows(existing, payload, provider); - const descriptor = state.descriptors.get(key); + const current = existing ?? EMPTY_TIMELINE; + // Normalize optional wire metadata at the child timeline boundary. Legacy + // notices are complete singleton items; history always comes from a projected host. + const entries = payload.rows.map((row) => ({ + ...row, + provider, + seqStart: row.seqStart ?? row.seq, + seqEnd: row.seqEnd ?? row.seq, + })); + const result = processTimelineResponse({ + payload: { + ...payload, + agentId: payload.subagentId, + projection: "projected", + startCursor: + payload.startCursor ?? + (entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null), + endCursor: + payload.endCursor ?? + (entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null), + entries, + }, + currentTail: current.tail, + currentHead: current.head, + currentCursor: current.cursor, + isInitializing: current.cursor === undefined, + hasActiveInitDeferred: current.cursor === undefined, + initRequestDirection: "tail", + sendingClientMessageIds: [], + }); const timelines = new Map(state.timelines); - timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); + timelines.set( + key, + settleTimeline( + { + tail: result.tail, + head: result.head, + epoch: payload.epoch, + lastSeq: + payload.reset || current.epoch !== payload.epoch + ? (result.cursor?.endSeq ?? 0) + : Math.max(current.lastSeq, result.cursor?.endSeq ?? 0), + cursor: result.cursor ?? undefined, + hasOlder: + result.older === "unchanged" ? current.hasOlder : result.older === "available", + needsRefresh: + result.sideEffects.some((effect) => effect.type === "catch_up") && + (payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)), + }, + state.descriptors.get(key), + ), + ); return { timelines }; }); }, })); + +/** Owns child history bootstrap and recovery while a pane observes the child. */ +export function observeProviderSubagentTimeline({ + client, + serverId, + parentAgentId, + subagentId, + limit, + reportError, +}: { + client: Pick; + serverId: string; + parentAgentId: string; + subagentId: string; + limit: number; + reportError: (error: unknown) => void; +}): () => void { + const key = providerSubagentKey(serverId, parentAgentId, subagentId); + let active = true; + let fetching = false; + const refresh = async () => { + if (!active || fetching) return; + fetching = true; + let succeeded = false; + try { + const payload = await client.fetchProviderSubagentTimeline(parentAgentId, subagentId, { + direction: "tail", + limit, + }); + if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + succeeded = true; + } catch (error) { + // A later stream update or reopening the pane retries a disconnected read. + if (!(error instanceof DaemonConnectionError)) throw error; + } finally { + fetching = false; + if ( + succeeded && + active && + useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh + ) + requestRefresh(); + } + }; + const requestRefresh = () => { + void refresh().catch(reportError); + }; + const unsubscribe = useProviderSubagentStore.subscribe((state) => { + if (state.timelines.get(key)?.needsRefresh) requestRefresh(); + }); + requestRefresh(); + return () => { + active = false; + unsubscribe(); + }; +} diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 5229acf88d..6835260ca7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,7 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, @@ -128,6 +128,54 @@ describe("selectSubagentsForParent", () => { expect(useProviderSubagentStore.getState().descriptors.size).toBe(1); }); + it("places nested provider children only beneath their direct provider parent", () => { + const store = useProviderSubagentStore.getState(); + const base = { + parentAgentId: "parent-a", + provider: "claude" as const, + title: "general-purpose", + subtitle: null, + status: "running" as const, + createdAt: "2026-09-04T10:00:00.000Z", + updatedAt: "2026-09-04T10:00:00.000Z", + toolCallId: null, + }; + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { ...base, id: "direct", description: "Direct", parentSubagentId: null }, + }); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + ...base, + id: "nested", + description: "Nested", + parentSubagentId: "direct", + }, + }); + + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent-a" }, + true, + true, + ).map((row) => row.id), + ).toEqual(["direct"]); + expect( + selectProviderSubagentsForParent( + useProviderSubagentStore.getState(), + { + serverId: SERVER_ID, + parentAgentId: "parent-a", + providerParentSubagentId: "direct", + }, + true, + true, + ).map((row) => row.id), + ).toEqual(["nested"]); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), @@ -268,6 +316,7 @@ describe("selectSubagentsForParent", () => { description: null, subtitle: null, status: "running", + turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, }, @@ -282,6 +331,7 @@ describe("selectSubagentsForParent", () => { "status", "subtitle", "title", + "turn", ]); expect(rows[0]).not.toHaveProperty("onOpen"); expect(rows[0]).not.toHaveProperty("model"); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index cc07480b33..3bb3bddcb6 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -15,6 +15,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; } @@ -44,6 +45,8 @@ type ProviderSubagentStoreSnapshot = ReturnType state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, ); + const nestingSupported = useSessionStore( + (state) => + state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true, + ); const providerRows = useStoreWithEqualityFn( useProviderSubagentStore, - (state) => selectProviderSubagentsForParent(state, params, supported), + (state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported), equal, ); const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); @@ -145,9 +161,10 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo }, [client, params.parentAgentId, params.serverId, supported]); return useMemo(() => { + if (params.providerParentSubagentId) return providerRows; if (providerRows.length === 0) return paseoRows; const rows = [...paseoRows, ...providerRows]; rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); return rows; - }, [paseoRows, providerRows]); + }, [params.providerParentSubagentId, paseoRows, providerRows]); } diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 516e2fafb7..df009583be 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -19,6 +19,11 @@ function row( description: null, subtitle: null, status: overrides.status ?? "idle", + turn: + overrides.turn ?? + (overrides.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 1e2ddbb8d8..d95082ca39 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -7,7 +7,10 @@ import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { - if (row.kind === "paseo") return row.status; + if (row.kind === "paseo") { + if (row.turn.phase === "open") return "running"; + return row.status === "running" ? "idle" : row.status; + } return providerSubagentLifecycleStatus(row.status); } diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 31cbd4a70f..601030562f 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,9 +1,9 @@ -import { useCallback, useMemo, type ReactElement } from "react"; +import { useCallback, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { Archive, Unlink } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { getProviderIcon } from "@/components/provider-icons"; +import { useProviderIcon } from "@/components/provider-icons"; import { ComposerTrackActions, ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -13,6 +13,7 @@ import { type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; +import { getPanelManifest } from "@/panels/panel-manifest"; import type { SubagentRow } from "./select"; import type { ArchiveFinishedStatus } from "./use-archive-finished"; import { @@ -30,6 +31,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ }); export interface SubagentsTrackProps { + serverId: string; rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -44,17 +46,20 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" }; /** Leading and action glyphs share one size so rows keep a single icon column. */ const ROW_ICON_SIZE = 14; -function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { +function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation { + const icon = useProviderIcon(row.provider, serverId); const data = buildSubagentRowPresentationData(row); return { ...data, tooltip: data.label, modified: false, - icon: getProviderIcon(row.provider), + showCloseButton: getPanelManifest(data.kind).showCloseButton, + icon, }; } export function SubagentsTrack({ + serverId, rows, onOpenSubagent, onOpenProviderSubagent, @@ -95,6 +100,7 @@ export function SubagentsTrack({ void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; @@ -172,6 +179,7 @@ interface SubagentsTrackRowProps { } function SubagentsTrackRow({ + serverId, row, onOpenSubagent, onOpenProviderSubagent, @@ -180,7 +188,7 @@ function SubagentsTrackRow({ }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); - const presentation = useMemo(() => buildRowPresentation(row), [row]); + const presentation = useRowPresentation(row, serverId); const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { diff --git a/packages/app/src/terminal/find/index.tsx b/packages/app/src/terminal/find/index.tsx new file mode 100644 index 0000000000..c8ecab5a04 --- /dev/null +++ b/packages/app/src/terminal/find/index.tsx @@ -0,0 +1,139 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + type RefObject, +} from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find"; +import { isWeb } from "@/constants/platform"; +import { hasActiveWebOverlay } from "@/lib/overlay-root"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; +import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract"; +import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime"; + +export interface TerminalPaneFindHandle { + open(): void; + update(result: TerminalFindResult): void; + reset(): void; +} + +export const TerminalFind = forwardRef< + TerminalPaneFindHandle, + { + terminal: RefObject; + active: boolean; + focusTerminal(): void; + } +>(function TerminalFind({ terminal, active, focusTerminal }, ref) { + const { t } = useTranslation(); + const widget = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [result, setResult] = useState({ + resultIndex: -1, + resultCount: 0, + limited: false, + placement: "top", + }); + const show = useCallback(() => { + if (!active || !terminal.current?.find) return; + if (!open && query) terminal.current.find.search(query); + setOpen(true); + widget.current?.focus(); + }, [active, open, query, terminal]); + useImperativeHandle( + ref, + () => ({ + open: show, + update: setResult, + reset: () => { + setOpen(false); + setQuery(""); + }, + }), + [show], + ); + useEffect(() => { + if (!isWeb || !active) return; + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event)) + return; + if (isFindShortcut(event, findShortcutPlatform()) && terminal.current?.find) { + event.preventDefault(); + show(); + } + } + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [active, show, terminal]); + const search = useCallback( + (text: string) => { + setQuery(text); + terminal.current?.find?.search(text); + }, + [terminal], + ); + const next = useCallback(() => terminal.current?.find?.search(query, "next"), [query, terminal]); + const previous = useCallback( + () => terminal.current?.find?.search(query, "previous"), + [query, terminal], + ); + const measureWidget = useCallback( + (event: LayoutChangeEvent) => { + const { width, height } = event.nativeEvent.layout; + terminal.current?.find?.setWidgetSize({ width, height }); + }, + [terminal], + ); + const close = useCallback(() => { + terminal.current?.find?.clear(); + setOpen(false); + focusTerminal(); + }, [focusTerminal, terminal]); + if (!open || !terminal.current?.find) return null; + const total = `${result.resultCount}${result.limited ? "+" : ""}`; + let status = ""; + if (query) { + if (result.resultCount === 0) status = t("paneFind.noMatches"); + else if (result.resultIndex < 0) status = t("paneFind.total", { total }); + else status = t("paneFind.position", { current: result.resultIndex + 1, total }); + } + return ( + + + 0} + onQueryChange={search} + onNext={next} + onPrevious={previous} + onClose={close} + /> + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + overlay: { + position: "absolute", + left: 0, + right: 0, + alignItems: "flex-end", + zIndex: 2, + }, + frame: { maxWidth: "100%", padding: theme.spacing[2] }, + top: { top: 0 }, + bottom: { bottom: 0 }, +})); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 58dfd7ff38..b173545abc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -1,4 +1,4 @@ -import { page } from "@vitest/browser/context"; +import { page, userEvent } from "@vitest/browser/context"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; @@ -40,6 +40,7 @@ interface MountedTerminal { sizes: TerminalSize[]; terminalKeys: TerminalKeyRecord[]; inputModeChanges: TerminalInputModeState[]; + openedUrls: string[]; } const mountedTerminals: MountedTerminal[] = []; @@ -72,11 +73,14 @@ function settleMountRefits(): Promise { return new Promise((resolve) => setTimeout(resolve, 2_600)); } -function createTerminalHost(input: { +interface CreateTerminalHostInput { width: number; height: number; scrollback?: number; -}): MountedTerminal { + isMac?: boolean; +} + +function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal { const root = document.createElement("div"); root.style.width = `${input.width}px`; root.style.height = `${input.height}px`; @@ -95,7 +99,10 @@ function createTerminalHost(input: { const inputs: string[] = []; const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; - const runtime = new TerminalEmulatorRuntime(); + const openedUrls: string[] = []; + const runtime = new TerminalEmulatorRuntime( + input.isMac === undefined ? undefined : { isMac: input.isMac }, + ); runtime.setCallbacks({ callbacks: { onInput: (data) => { @@ -110,6 +117,9 @@ function createTerminalHost(input: { onInputModeChange: (state) => { inputModeChanges.push(state); }, + onOpenExternalUrl: (url) => { + openedUrls.push(url); + }, }, }); runtime.mount({ @@ -124,7 +134,16 @@ function createTerminalHost(input: { }, }); - const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges }; + const mounted = { + host, + root, + runtime, + inputs, + sizes, + terminalKeys, + inputModeChanges, + openedUrls, + }; mountedTerminals.push(mounted); return mounted; } @@ -162,33 +181,76 @@ function getBrowserTerminal(): BrowserTerminal { return terminal; } -function dispatchTerminalKey(input: { +interface DispatchTerminalKeyInput { host: HTMLElement; key: string; + keyCode?: number; shiftKey?: boolean; ctrlKey?: boolean; altKey?: boolean; metaKey?: boolean; -}): boolean { +} + +function dispatchTerminalKey(input: DispatchTerminalKeyInput): boolean { const textarea = input.host.querySelector("textarea"); if (!textarea) { throw new Error("Expected xterm textarea to be mounted"); } textarea.focus(); - return textarea.dispatchEvent( - new KeyboardEvent("keydown", { - key: input.key, - shiftKey: input.shiftKey ?? false, - ctrlKey: input.ctrlKey ?? false, - altKey: input.altKey ?? false, - metaKey: input.metaKey ?? false, - bubbles: true, - cancelable: true, - }), - ); + const event = new KeyboardEvent("keydown", { + key: input.key, + shiftKey: input.shiftKey ?? false, + ctrlKey: input.ctrlKey ?? false, + altKey: input.altKey ?? false, + metaKey: input.metaKey ?? false, + bubbles: true, + cancelable: true, + }); + if (input.keyCode !== undefined) { + // Synthetic KeyboardEvents always report keyCode 0; xterm's fallback encoder + // switches on keyCode, so hardware-like events need it filled in. + Object.defineProperty(event, "keyCode", { value: input.keyCode }); + } + return textarea.dispatchEvent(event); +} + +/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ +function hyperlink(input: { url: string; text: string }): string { + return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`; +} + +function writeLines(mounted: MountedTerminal, lines: string[]): Promise { + return new Promise((resolve) => { + mounted.runtime.write({ + data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")), + onCommitted: resolve, + }); + }); +} + +async function clickTerminalLink(input: { + host: HTMLElement; + row: number; + col: number; +}): Promise { + const terminal = getBrowserTerminal(); + const screen = input.host.querySelector(".xterm-screen"); + if (!screen) { + throw new Error("Expected xterm screen to be mounted"); + } + const position = { + x: (input.col + 0.5) * (screen.clientWidth / terminal.cols), + y: (input.row + 0.5) * (screen.clientHeight / terminal.rows), + }; + await userEvent.hover(screen, { position }); + await waitFor({ + predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null, + }); + await userEvent.click(screen, { position }); } afterEach(() => { + vi.restoreAllMocks(); for (const mounted of mountedTerminals.splice(0)) { mounted.runtime.unmount(); mounted.root.remove(); @@ -464,6 +526,78 @@ describe("terminal emulator runtime in a real browser", () => { }); }); + it("translates mac editing shortcuts into shell editing sequences", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39 }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "Backspace", keyCode: 8, metaKey: true }); + await nextFrame(); + + expect(mounted.inputs).toEqual([ + "\x1b[C", // plain arrow keeps xterm's default encoding + "\x01", // cmd+left -> line start + "\x05", // cmd+right -> line end + "\x1bb", // option+left -> word back + "\x1bf", // option+right -> word forward + "\x15", // cmd+backspace -> kill line + ]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it.each([true, false])("keeps pending modifier chips authoritative (isMac=%s)", async (isMac) => { + const mounted = createTerminalHost({ width: 720, height: 360, isMac }); + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + mounted.runtime.setPendingModifiers({ + pendingModifiers: { ctrl: true, alt: false, shift: false }, + }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowLeft", keyCode: 37, altKey: true }); + await nextFrame(); + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([ + { key: "ArrowLeft", ctrl: true, alt: true, shift: false, meta: false }, + ]); + }); + + it("keeps cmd+shift+arrow free for app-level shortcuts", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: true }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ + host: mounted.host, + key: "ArrowRight", + keyCode: 39, + metaKey: true, + shiftKey: true, + }); + await nextFrame(); + + expect(mounted.inputs).toEqual([]); + expect(mounted.terminalKeys).toEqual([]); + }); + + it("does not remap modified arrows on non-mac platforms", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360, isMac: false }); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, metaKey: true }); + dispatchTerminalKey({ host: mounted.host, key: "ArrowRight", keyCode: 39, altKey: true }); + await nextFrame(); + + // Meta+arrow is ignored by xterm; alt+arrow takes xterm's generic encoding. + expect(mounted.inputs).toEqual(["\x1b[1;3C"]); + }); + it.each([ { name: "DA1", bytes: "\x1b[c" }, { name: "DA1-zero", bytes: "\x1b[0c" }, @@ -526,4 +660,23 @@ describe("terminal emulator runtime in a real browser", () => { expect(reset).not.toHaveBeenCalled(); }); + + it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + await writeLines(mounted, [ + hyperlink({ url: "https://example.com/osc8", text: "example" }), + "https://example.com/plain", + ]); + + await clickTerminalLink({ host: mounted.host, row: 1, col: 4 }); + await clickTerminalLink({ host: mounted.host, row: 0, col: 2 }); + + expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]); + expect(confirm).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index d739dcac34..a11cedbfe2 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -192,6 +192,65 @@ describe("terminal-emulator-runtime", () => { vi.useRealTimers(); }); + it.each([ + { isMac: true, ctrlKey: true, metaKey: false, opensFind: false }, + { isMac: true, ctrlKey: false, metaKey: true, opensFind: true }, + { isMac: false, ctrlKey: true, metaKey: false, opensFind: true }, + { isMac: false, ctrlKey: false, metaKey: true, opensFind: false }, + { isMac: true, ctrlKey: false, metaKey: true, shiftKey: true, opensFind: false }, + { isMac: true, ctrlKey: false, metaKey: true, altKey: true, opensFind: false }, + { isMac: false, ctrlKey: true, metaKey: false, shiftKey: true, opensFind: false }, + { isMac: false, ctrlKey: true, metaKey: false, altKey: true, opensFind: false }, + { isMac: true, ctrlKey: true, metaKey: true, opensFind: false }, + { isMac: false, ctrlKey: true, metaKey: true, opensFind: false }, + ])( + "routes Find with isMac=$isMac ctrl=$ctrlKey meta=$metaKey shift=$shiftKey alt=$altKey", + ({ isMac, ctrlKey, metaKey, shiftKey = false, altKey = false, opensFind }) => { + const runtime = new TerminalEmulatorRuntime({ isMac }); + let findRequests = 0; + let prevented = false; + let stopped = false; + runtime.setCallbacks({ + callbacks: { + onFindRequest: () => { + findRequests += 1; + }, + }, + }); + const event = { + type: "keydown", + key: "f", + ctrlKey, + metaKey, + shiftKey, + altKey, + isComposing: false, + preventDefault: () => { + prevented = true; + }, + stopPropagation: () => { + stopped = true; + }, + } as KeyboardEvent; + + let passedToXterm: boolean | undefined; + runtime.attachKeyEventHandler({ + attachCustomKeyEventHandler: (handler) => { + passedToXterm = handler(event); + }, + hasSelection: () => false, + getSelection: () => "", + paste: () => {}, + input: () => {}, + scrollToBottom: () => {}, + }); + expect(passedToXterm).toBe(!opensFind); + expect(findRequests).toBe(opensFind ? 1 : 0); + expect(prevented).toBe(opensFind); + expect(stopped).toBe(opensFind); + }, + ); + it("drains contiguous plain writes without waiting for each commit, gating a clear behind them", () => { const { runtime, terminal, writeCallbacks, writeTexts } = createRuntimeWithTerminal(); const committed: string[] = []; @@ -302,9 +361,10 @@ describe("terminal-emulator-runtime", () => { { kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false }, ]); - // The plain write carries no onCommitted, so it registers no callback; writeCallbacks[0] - // is the barrier gate sentinel. - writeCallbacks[0]?.(); + // Commit the plain write, then the sentinel that gates the snapshot. + writeCallbacks[0](); + expect(inputModeChanges).toHaveLength(1); + writeCallbacks[1](); expect(inputModeChanges).toEqual([ { kittyKeyboardFlags: 7, win32InputMode: false, bracketedPaste: false }, @@ -413,14 +473,15 @@ describe("terminal-emulator-runtime", () => { runtime.write({ data: terminalOutput("output") }); runtime.restoreOutput({ data: terminalOutput("snapshot") }); - // The plain write carries no onCommitted so it registers no callback; writeCallbacks[0] - // is the sentinel gate. suppressInput only flips once the gate resolves the barrier. + // The plain write commits first. Only the following sentinel opens the barrier. expect(readSuppressInput()).toBe(false); - writeCallbacks[0]?.(); + writeCallbacks[0](); + expect(readSuppressInput()).toBe(false); + writeCallbacks[1](); expect(readSuppressInput()).toBe(true); - // writeCallbacks[1] is the barrier's own snapshot write; committing it restores input. - writeCallbacks[1]?.(); + // Committing the barrier's snapshot write restores input. + writeCallbacks[2](); expect(readSuppressInput()).toBe(false); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 56350ef9b7..cc4462ff7b 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs"; -import { Terminal, type ITheme } from "@xterm/xterm"; +import { Terminal, type ITheme, type IMarker } from "@xterm/xterm"; import type { TerminalState } from "@getpaseo/protocol/messages"; import { type TerminalInputModeState, @@ -15,11 +15,13 @@ import { } from "@getpaseo/protocol/terminal-input-mode"; import { type PendingTerminalModifiers, + hasPendingTerminalModifiers, isAppleHandheldPlatform, isTerminalModifierDomKey, mergeTerminalModifiers, normalizeDomTerminalKey, normalizeTerminalTransportKey, + resolveMacTerminalEditingShortcut, shouldInterceptDomTerminalKey, } from "@/utils/terminal-keys"; import { renderTerminalSnapshotToAnsi } from "./terminal-snapshot"; @@ -28,10 +30,27 @@ import { type TerminalLocalFileLinkSource, type TerminalLocalFileLinkTarget, } from "../local-links/terminal-local-link-provider"; +import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut"; +import { isMacUserAgent } from "@/utils/mac-user-agent"; import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font"; export type TerminalOutputData = Uint8Array; +export interface TerminalFindHandle { + setWidgetSize(size: { width: number; height: number }): void; + search(query: string, direction?: "next" | "previous"): void; + clear(): void; +} + +export interface TerminalFindResult { + resultIndex: number; + resultCount: number; + limited: boolean; + placement: "top" | "bottom"; +} + +const FIND_HIGHLIGHT_LIMIT = 20_000; + export interface TerminalEmulatorRuntimeMountInput { root: HTMLDivElement; host: HTMLDivElement; @@ -43,6 +62,8 @@ export interface TerminalEmulatorRuntimeMountInput { } export interface TerminalEmulatorRuntimeCallbacks { + onFindRequest?: () => void; + onFindResult?: (result: TerminalFindResult) => void; onInput?: (data: string) => Promise | void; onResize?: (input: TerminalResizeEvent) => Promise | void; onTerminalKey?: (input: { @@ -123,11 +144,6 @@ declare global { } } -const isMac = - typeof navigator !== "undefined" && - (/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") || - /Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? "")); - const isAppleHandheld = typeof navigator !== "undefined" && isAppleHandheldPlatform({ @@ -165,6 +181,8 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { } export class TerminalEmulatorRuntime { + constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {} + private callbacks: TerminalEmulatorRuntimeCallbacks = {}; private pendingModifiers: PendingTerminalModifiers = { ctrl: false, @@ -173,6 +191,48 @@ export class TerminalEmulatorRuntime { }; private terminal: Terminal | null = null; private fitAddon: FitAddon | null = null; + private searchAddon: SearchAddon | null = null; + private findResult = { resultIndex: -1, resultCount: 0 }; + private findQuery = ""; + private findOutputAnchor: IMarker | undefined; + private findWidgetSize = { width: 0, height: 0 }; + + readonly find: TerminalFindHandle = { + setWidgetSize: (size) => { + this.findWidgetSize = size; + this.emitFindResult(); + }, + search: (query, direction) => { + this.releaseFindOutputAnchor(); + this.findQuery = query; + if (this.terminal && !this.searchAddon) this.loadSearchAddon(this.terminal); + const theme = this.terminal?.options.theme; + const options = { + regex: false, + caseSensitive: false, + incremental: direction === undefined, + decorations: { + matchBorder: theme?.yellow ?? "#b58900", + matchOverviewRuler: theme?.yellow ?? "#b58900", + activeMatchBorder: theme?.blue ?? "#268bd2", + activeMatchColorOverviewRuler: theme?.blue ?? "#268bd2", + }, + }; + if (direction === "next") this.searchAddon?.findNext(query, options); + else this.searchAddon?.findPrevious(query, options); + }, + clear: () => { + this.releaseFindOutputAnchor(); + this.findQuery = ""; + // clearDecorations leaves the addon's pending refresh alive. Disposal + // cancels its timers and listeners without touching terminal selection. + this.searchAddon?.dispose(); + this.searchAddon = null; + this.findResult = { resultIndex: -1, resultCount: 0 }; + this.emitFindResult(); + }, + }; + private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null; private lastSize: { rows: number; cols: number } | null = null; private cleanup: (() => void) | null = null; @@ -215,10 +275,188 @@ export class TerminalEmulatorRuntime { this.pendingModifiers = input.pendingModifiers; } + private loadSearchAddon(terminal: Terminal): void { + const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT }); + terminal.loadAddon(searchAddon); + this.searchAddon = searchAddon; + searchAddon.onDidChangeResults((result) => { + this.findResult = result; + this.emitFindResult(); + }); + } + + private releaseFindOutputAnchor(): void { + this.findOutputAnchor?.dispose(); + this.findOutputAnchor = undefined; + } + + private preserveFindViewport(terminal: Terminal): void { + if (!this.findQuery || this.findResult.resultCount === 0 || this.findOutputAnchor) return; + const buffer = terminal.buffer.active; + this.findOutputAnchor = terminal.registerMarker( + buffer.viewportY - buffer.baseY - buffer.cursorY, + ); + } + + attachKeyEventHandler( + terminal: Pick< + Terminal, + | "attachCustomKeyEventHandler" + | "hasSelection" + | "getSelection" + | "paste" + | "input" + | "scrollToBottom" + >, + ): void { + terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== "keydown" || event.isComposing) { + return true; + } + + if (this.handleFindShortcut(event)) return false; + + if ( + !this.options.isMac && + event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey + ) { + const key = event.key.toLowerCase(); + + // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT + if (key === "c" && terminal.hasSelection()) { + void navigator.clipboard.writeText(terminal.getSelection()); + return false; + } + + // Ctrl+V: paste from clipboard into terminal + if (key === "v") { + event.preventDefault(); + void navigator.clipboard.readText().then((text) => { + if (text) { + terminal.paste(text); + } + return; + }); + return false; + } + + return true; + } + + if (this.maybeSendMacEditingShortcut(terminal, event)) { + return false; + } + + const normalizedKey = normalizeDomTerminalKey(event.key); + if (!normalizedKey || isTerminalModifierDomKey(event.key)) { + return true; + } + + if ( + !shouldInterceptDomTerminalKey({ + key: normalizedKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + pendingModifiers: this.pendingModifiers, + enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), + isAppleHandheld, + }) + ) { + return true; + } + + const modifiers = mergeTerminalModifiers({ + pendingModifiers: this.pendingModifiers, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + }); + this.callbacks.onTerminalKey?.({ + key: normalizeTerminalTransportKey(normalizedKey), + ...modifiers, + }); + + if (hasPendingTerminalModifiers(this.pendingModifiers)) { + this.callbacks.onPendingModifiersConsumed?.(); + } + + event.preventDefault(); + event.stopPropagation(); + return false; + }); + } + + private handleFindShortcut(event: KeyboardEvent): boolean { + if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false; + event.preventDefault(); + event.stopPropagation(); + this.callbacks.onFindRequest(); + return true; + } + + private findWidgetCoversSelection(): boolean { + if (!this.findQuery) return false; + const terminal = this.terminal; + const element = terminal?.element; + const screen = element?.querySelector(".xterm-screen"); + const selection = terminal?.getSelectionPosition(); + if (!terminal || !element || !screen || !selection) return false; + const root = element.getBoundingClientRect(); + const grid = screen.getBoundingClientRect(); + const cellWidth = grid.width / terminal.cols; + const cellHeight = grid.height / terminal.rows; + const firstRow = terminal.buffer.active.viewportY; + const top = grid.top + (selection.start.y - firstRow) * cellHeight; + const bottom = grid.top + (selection.end.y - firstRow + 1) * cellHeight; + // A wrapped selection can occupy the right edge on its intermediate rows. + const rightCol = selection.start.y === selection.end.y ? selection.end.x : terminal.cols; + const right = grid.left + rightCol * cellWidth; + return ( + top < root.top + this.findWidgetSize.height && + bottom > root.top && + right > root.right - this.findWidgetSize.width + ); + } + + private emitFindResult(): void { + const placement = this.findWidgetCoversSelection() ? "bottom" : "top"; + this.callbacks.onFindResult?.({ + ...this.findResult, + limited: this.findResult.resultCount >= FIND_HIGHLIGHT_LIMIT, + placement, + }); + } + getInputModeState(): TerminalInputModeState { return this.inputModeTracker.getState(); } + private maybeSendMacEditingShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + if (!this.options.isMac || hasPendingTerminalModifiers(this.pendingModifiers)) { + return false; + } + const editingShortcutData = resolveMacTerminalEditingShortcut(event); + if (editingShortcutData === null) { + return false; + } + // Routed through terminal.input() so the data takes the same path as typed + // keys: onData -> callbacks.onInput, selection clearing included. + terminal.input(editingShortcutData, true); + terminal.scrollToBottom(); + event.preventDefault(); + event.stopPropagation(); + return true; + } + mount(input: TerminalEmulatorRuntimeMountInput): void { this.unmount(); @@ -227,6 +465,10 @@ export class TerminalEmulatorRuntime { this.inputModeTracker.reset(); this.emitInputModeChange(); + const openExternalLink = (event: MouseEvent, uri: string) => { + event.preventDefault(); + void this.callbacks.onOpenExternalUrl?.(uri); + }; const terminal = new Terminal({ allowProposedApi: true, convertEol: false, @@ -234,6 +476,8 @@ export class TerminalEmulatorRuntime { cursorStyle: "bar", fontFamily: resolveTerminalFontFamily(input.fontFamily), fontSize: resolveTerminalFontSize(input.fontSize), + // OSC 8 hyperlinks; without a handler xterm prompts and calls window.open(). + linkHandler: { activate: openExternalLink }, lineHeight: 1.0, macOptionIsMeta: true, minimumContrastRatio: 1, @@ -250,12 +494,7 @@ export class TerminalEmulatorRuntime { let imageAddon: ImageAddon | null = null; terminal.loadAddon(fitAddon); terminal.loadAddon(unicode11Addon); - terminal.loadAddon( - new WebLinksAddon((event, uri) => { - event.preventDefault(); - void this.callbacks.onOpenExternalUrl?.(uri); - }), - ); + terminal.loadAddon(new WebLinksAddon(openExternalLink)); const localFileLinkProvider = terminal.registerLinkProvider( createTerminalLocalFileLinkProvider(terminal, { resolveLink: async (source) => { @@ -267,7 +506,19 @@ export class TerminalEmulatorRuntime { }, }), ); - terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 })); + this.loadSearchAddon(terminal); + terminal.onWriteParsed(() => { + // Even a bottom viewport is an inspected location while searching. A + // marker follows buffer trimming; output writes remain batched normally. + const anchor = this.findOutputAnchor; + if (anchor && !anchor.isDisposed) terminal.scrollToLine(anchor.line); + // A parser turn may yield with submitted writes still queued. Commit + // callbacks identify completion; plain writes still submit back-to-back. + if (this.pendingWriteCommits.size === 0) this.releaseFindOutputAnchor(); + }); + terminal.onScroll(() => { + if (this.findQuery) this.emitFindResult(); + }); terminal.loadAddon(new ClipboardAddon()); try { terminal.loadAddon(new LigaturesAddon()); @@ -413,75 +664,7 @@ export class TerminalEmulatorRuntime { this.callbacks.onInput?.(data); }); - terminal.attachCustomKeyEventHandler((event) => { - if (event.type !== "keydown" || event.isComposing) { - return true; - } - - if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { - const key = event.key.toLowerCase(); - - // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT - if (key === "c" && terminal.hasSelection()) { - void navigator.clipboard.writeText(terminal.getSelection()); - return false; - } - - // Ctrl+V: paste from clipboard into terminal - if (key === "v") { - event.preventDefault(); - void navigator.clipboard.readText().then((text) => { - if (text) { - terminal.paste(text); - } - return; - }); - return false; - } - - return true; - } - - const normalizedKey = normalizeDomTerminalKey(event.key); - if (!normalizedKey || isTerminalModifierDomKey(event.key)) { - return true; - } - - if ( - !shouldInterceptDomTerminalKey({ - key: normalizedKey, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey, - pendingModifiers: this.pendingModifiers, - enhancedInputActive: this.inputModeTracker.supportsModifiedEnter(), - isAppleHandheld, - }) - ) { - return true; - } - - const modifiers = mergeTerminalModifiers({ - pendingModifiers: this.pendingModifiers, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - altKey: event.altKey, - metaKey: event.metaKey, - }); - this.callbacks.onTerminalKey?.({ - key: normalizeTerminalTransportKey(normalizedKey), - ...modifiers, - }); - - if (this.pendingModifiers.ctrl || this.pendingModifiers.shift || this.pendingModifiers.alt) { - this.callbacks.onPendingModifiersConsumed?.(); - } - - event.preventDefault(); - event.stopPropagation(); - return false; - }); + this.attachKeyEventHandler(terminal); const removeTouchListeners = this.setupTouchScrollHandlers({ root: input.root, @@ -775,6 +958,7 @@ export class TerminalEmulatorRuntime { } unmount(): void { + this.releaseFindOutputAnchor(); this.clearInFlightOutputTimeout(); const inFlightOperation = this.inFlightOutputOperation; this.inFlightOutputOperation = null; @@ -796,6 +980,9 @@ export class TerminalEmulatorRuntime { window.__paseoTerminal = undefined; } this.terminal = null; + this.searchAddon = null; + this.findQuery = ""; + this.findResult = { resultIndex: -1, resultCount: 0 }; this.fitAddon = null; this.fitAndEmitResize = null; this.lastSize = null; @@ -870,6 +1057,7 @@ export class TerminalEmulatorRuntime { } private submitWrite(terminal: Terminal, operation: TerminalOutputOperation): void { + this.preserveFindViewport(terminal); // Synchronous per-write tracking must run in frame order; doing it here in the drain // loop preserves that ordering even though the writes are submitted without waiting. const text = this.inputModeDecoder.decode(operation.data, { stream: true }); @@ -879,19 +1067,11 @@ export class TerminalEmulatorRuntime { } this.hasUngatedWrites = true; const onCommitted = operation.onCommitted; - if (!onCommitted) { - try { - terminal.write(operation.data); - } catch { - // Match existing behavior: a failed write still proceeds with no commit callback. - } - return; - } const commit = () => { if (!this.pendingWriteCommits.delete(commit)) { return; } - onCommitted(); + onCommitted?.(); }; this.pendingWriteCommits.add(commit); try { diff --git a/packages/app/src/terminal/runtime/terminal-find.browser.test.ts b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts new file mode 100644 index 0000000000..8336925338 --- /dev/null +++ b/packages/app/src/terminal/runtime/terminal-find.browser.test.ts @@ -0,0 +1,205 @@ +import "@xterm/xterm/css/xterm.css"; +import { afterEach, expect, test } from "vitest"; +import { + TerminalEmulatorRuntime, + encodeTerminalOutput, + type TerminalFindResult, +} from "./terminal-emulator-runtime"; + +let runtime: TerminalEmulatorRuntime; +let root: HTMLDivElement; +afterEach(() => { + runtime?.unmount(); + root?.remove(); +}); +function mount(options?: { isMac: boolean }) { + root = document.createElement("div"); + Object.assign(root.style, { width: "480px", height: "200px" }); + const host = document.createElement("div"); + Object.assign(host.style, { width: "100%", height: "100%" }); + root.append(host); + document.body.append(root); + runtime = new TerminalEmulatorRuntime(options); + const results: TerminalFindResult[] = []; + runtime.setCallbacks({ + callbacks: { + onFindResult: (result) => { + results.push(result); + }, + }, + }); + runtime.mount({ + root, + host, + initialSnapshot: null, + scrollback: 1000, + theme: { background: "#ffffff", foreground: "#111111" }, + }); + return results; +} +function write(text: string) { + return new Promise((resolve) => + runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }), + ); +} + +test("uses xterm's literal matching across wrapped cells and keeps selection when Find closes", async () => { + const results = mount(); + // Initial font measurement schedules refits through 2 seconds. Settle them + // before choosing an exact column boundary for the wrapped text fixture. + await new Promise((resolve) => setTimeout(resolve, 2600)); + await write("first [a.b] 界\r\n" + "padding\r\n".repeat(30) + "last [A.B] 界"); + runtime.find.search("[a.b] 界"); + expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false }); + runtime.find.search("[a.b] 界", "previous"); + expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 0, placement: "top" }); + runtime.find.search("[a.b] 界", "previous"); + expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1 }); + const selection = window.__paseoTerminal!.getSelection(); + runtime.find.clear(); + expect(window.__paseoTerminal!.getSelection()).toBe(selection); + expect(results.at(-1)).toMatchObject({ resultCount: 0, resultIndex: -1 }); + runtime.find.search("[aXb]"); + expect(results.at(-1)).toMatchObject({ resultCount: 0 }); + const cols = window.__paseoTerminal!.cols; + await write("\r\n" + "x".repeat(cols - 2) + "wrapped-needle"); + runtime.find.search("wrapped-needle"); + await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 }); +}); + +test("reports the addon's highlight limit without disabling navigation beyond the counted matches", async () => { + const results = mount(); + await write(("a ".repeat(25) + "\r\n").repeat(801)); + runtime.find.search("a"); + expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: -1 }); + expect(window.__paseoTerminal!.getSelection()).toBe("a"); + runtime.find.search("a", "next"); + expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: 0 }); +}); + +test("keeps the inspected bottom viewport fixed when output arrives", async () => { + mount(); + await write("padding\r\n".repeat(30) + "inspected needle"); + runtime.find.search("needle"); + const term = window.__paseoTerminal!; + const inspected = term.buffer.active.viewportY; + await write("\r\nnew output".repeat(30)); + await expect.poll(() => term.buffer.active.viewportY).toBe(inspected); + expect(term.getSelection()).toBe("needle"); +}); + +test("searches the active alternate buffer without moving the restored normal viewport", async () => { + const results = mount(); + await write("normal needle\r\n" + "padding\r\n".repeat(30)); + const normalViewport = window.__paseoTerminal!.buffer.active.viewportY; + await write("\x1b[?1049hAlt needle\r\nAlt NEEDLE"); + runtime.find.search("needle"); + expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false }); + expect(window.__paseoTerminal!.getSelection()).toBe("NEEDLE"); + runtime.find.search("needle", "previous"); + expect(window.__paseoTerminal!.getSelection()).toBe("needle"); + await write("\x1b[?1049l"); + await expect.poll(() => window.__paseoTerminal!.buffer.active.viewportY).toBe(normalViewport); + runtime.find.search("needle"); + await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 }); +}); + +test("keeps Find at the top unless its measured frame covers the active match", async () => { + const results = mount(); + await new Promise((resolve) => setTimeout(resolve, 2600)); + runtime.find.setWidgetSize({ width: 356, height: 58 }); + const cols = window.__paseoTerminal!.cols; + await write("left-needle\r\n" + " ".repeat(cols - 12) + "right-needle"); + runtime.find.search("left-needle"); + expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "top" }); + runtime.find.search("right-needle"); + expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "bottom" }); +}); + +test("closing Find cancels pending refreshes without clearing old or new selections", async () => { + mount(); + await write("first needle\r\n" + "padding\r\n".repeat(30) + "last needle"); + const term = window.__paseoTerminal!; + for (const freshSelection of [false, true]) { + runtime.find.search("needle"); + await write("\r\nnew output"); + // onWriteParsed schedules the addon's refresh after the write callback. + await new Promise((resolve) => { + const subscription = term.onWriteParsed(() => { + subscription.dispose(); + resolve(); + }); + runtime.write({ data: encodeTerminalOutput("\rprogress") }); + }); + runtime.find.clear(); + if (freshSelection) term.select(0, 0, 5); + const selection = term.getSelection(); + expect(selection).toBe(freshSelection ? "first" : "needle"); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(term.getSelection()).toBe(selection); + } +}); + +test("preserves inspection through multiple real parser turns of queued output", async () => { + mount(); + await new Promise((resolve) => setTimeout(resolve, 2600)); + await write("padding\r\n".repeat(30) + "needle\r\nprogress"); + runtime.find.search("needle"); + const term = window.__paseoTerminal!; + const inspected = term.buffer.active.viewportY; + const samples: { viewport: number; base: number }[] = []; + const subscription = term.onWriteParsed(() => { + samples.push({ viewport: term.buffer.active.viewportY, base: term.buffer.active.baseY }); + }); + for (let i = 0; i < 150; i++) { + runtime.write({ data: encodeTerminalOutput("\rprogress".repeat(1000)) }); + } + await write("\r\nnew output".repeat(100)); + await new Promise((resolve) => setTimeout(resolve, 350)); + subscription.dispose(); + expect(samples.length).toBeGreaterThan(1); + expect(samples[0].base).toBe(inspected); + expect(samples.at(-1)!.base).toBeGreaterThan(inspected); + expect(term.buffer.active.viewportY).toBe(inspected); + expect(term.getSelection()).toBe("needle"); +}); + +test.each([true, false])("routes Find and shell input through xterm with isMac=%s", (isMac) => { + mount({ isMac }); + const input: string[] = []; + let findRequests = 0; + runtime.setCallbacks({ + callbacks: { + onInput: (data) => { + input.push(data); + }, + onFindRequest: () => { + findRequests += 1; + }, + }, + }); + const textarea = root.querySelector("textarea")!; + textarea.focus(); + const ctrlF = new KeyboardEvent("keydown", { + key: "f", + code: "KeyF", + keyCode: 70, + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(ctrlF); + expect(findRequests).toBe(isMac ? 0 : 1); + expect(input).toEqual(isMac ? ["\x06"] : []); + const metaF = new KeyboardEvent("keydown", { + key: "f", + code: "KeyF", + keyCode: 70, + metaKey: true, + bubbles: true, + cancelable: true, + }); + textarea.dispatchEvent(metaF); + expect(findRequests).toBe(1); + expect(metaF.defaultPrevented).toBe(isMac); +}); 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 ccac3ecc93..388484d79d 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.test.ts @@ -1,3 +1,4 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { describe, expect, it } from "vitest"; import { @@ -36,6 +37,10 @@ type TerminalStreamEvent = | { terminalId: string; type: "restore"; data: Uint8Array }; class FakeTerminalStreamClient implements TerminalStreamControllerClient { + private readonly updates = new Set<(message: SessionOutboundMessage) => void>(); + emitUpdate(message: SessionOutboundMessage): void { + for (const receive of this.updates) receive(message); + } private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); public subscribeCalls: Array<{ terminalId: string; options?: unknown }> = []; public unsubscribeCalls: string[] = []; @@ -47,18 +52,46 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }> = []; public nextSubscribeResults: Array<{ terminalId: string; error?: string | null }> = []; - async subscribeTerminal(terminalId: string, options?: unknown) { + observeTerminal: TerminalStreamControllerClient["observeTerminal"] = ( + terminalId, + receive, + options, + ) => { this.subscribeCalls.push({ terminalId, ...(options ? { options } : {}) }); const result = this.nextSubscribeResults.shift(); - if (!result) { - throw new Error("Missing fake subscribe result"); - } - return result; - } - - unsubscribeTerminal(terminalId: string): void { - this.unsubscribeCalls.push(terminalId); - } + if (!result) throw new Error("Missing fake subscribe result"); + const subscriptionId = `terminal-${this.subscribeCalls.length}`; + const listener = (event: TerminalStreamEvent) => { + if (event.terminalId === terminalId) receive({ ...event, subscriptionId }); + }; + this.listeners.add(listener); + const ready = result.error + ? Promise.reject(new Error(result.error)) + : Promise.resolve({ + terminalId, + subscriptionId, + slot: this.subscribeCalls.length, + error: null, + requestId: subscriptionId, + }); + let released = false; + return { + subscriptionId, + ready, + subscribe: (observer) => { + this.updates.add(observer.update); + return () => { + this.updates.delete(observer.update); + }; + }, + release: async () => { + if (released) return; + released = true; + this.listeners.delete(listener); + this.unsubscribeCalls.push(terminalId); + }, + }; + }; sendTerminalInput( terminalId: string, @@ -72,13 +105,6 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient { }); } - onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void { - this.listeners.add(handler); - return () => { - this.listeners.delete(handler); - }; - } - emit(event: TerminalStreamEvent): void { for (const listener of this.listeners) { listener(event); @@ -92,8 +118,10 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { const restores: Array<{ terminalId: string; data: Uint8Array }> = []; const snapshots: Array<{ terminalId: string; text: string }> = []; const statuses: TerminalStreamControllerStatus[] = []; + const exits: string[] = []; const controller = new TerminalStreamController({ client, + onExit: (terminalId) => exits.push(terminalId), getPreferredSize: () => ({ rows: 24, cols: 80 }), onOutput: (output) => { outputs.push(output); @@ -112,7 +140,7 @@ function createHarness(input?: { client?: FakeTerminalStreamClient }) { }, }); - return { client, controller, outputs, restores, snapshots, statuses }; + return { client, controller, outputs, restores, snapshots, statuses, exits }; } async function flushAsyncWork(): Promise { @@ -239,7 +267,9 @@ describe("terminal-stream-controller", () => { }, }, ]); - expect(client.resizeCalls).toEqual([]); + expect(client.resizeCalls).toEqual([ + { terminalId: "term-1", rows: 24, cols: 80, intent: "claim" }, + ]); expect(harness.restores[0]?.data).toBe(restoreData); expect(harness.restores).toEqual([{ terminalId: "term-1", data: terminalOutput("restored") }]); expect(harness.outputs).toEqual([]); @@ -264,3 +294,28 @@ describe("terminal-stream-controller", () => { }); }); }); + +it("reports observation failure without declaring the PTY exited, then allows reattach", async () => { + const h = createHarness(); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emitUpdate({ + type: "terminal_stream_exit", + payload: { terminalId: "term-1", subscriptionId: "terminal-1", error: "Snapshot read failed" }, + }); + expect(h.statuses.at(-1)).toEqual({ + terminalId: "term-1", + isAttaching: false, + error: "Snapshot read failed", + }); + expect(h.exits).toEqual([]); + expect(h.client.unsubscribeCalls).toEqual(["term-1"]); + h.client.nextSubscribeResults.push({ terminalId: "term-1", error: null }); + h.controller.setTerminal({ terminalId: "term-1" }); + await flushAsyncWork(); + h.client.emit({ terminalId: "term-1", type: "output", data: terminalOutput("STILL ALIVE") }); + expect(h.outputs).toHaveLength(1); + expect(h.statuses.at(-1)?.error).toBe(null); + h.controller.dispose(); +}); diff --git a/packages/app/src/terminal/runtime/terminal-stream-controller.ts b/packages/app/src/terminal/runtime/terminal-stream-controller.ts index 6b008a4ff0..90d54be4d4 100644 --- a/packages/app/src/terminal/runtime/terminal-stream-controller.ts +++ b/packages/app/src/terminal/runtime/terminal-stream-controller.ts @@ -1,29 +1,12 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SubscribeTerminalRequest, TerminalState } from "@getpaseo/protocol/messages"; import type { TerminalOutputData } from "./terminal-emulator-runtime"; import { i18n } from "@/i18n/i18next"; -export interface TerminalStreamControllerClient { - subscribeTerminal: ( - terminalId: string, - options?: { restore?: SubscribeTerminalRequest["restore"] }, - ) => Promise<{ - terminalId: string; - error?: string | null; - }>; - unsubscribeTerminal: (terminalId: string) => void; - sendTerminalInput: ( - terminalId: string, - message: { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" }, - ) => void; - onTerminalStreamEvent: ( - handler: ( - event: - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }, - ) => void, - ) => () => void; -} +export type TerminalStreamControllerClient = Pick< + DaemonClient, + "observeTerminal" | "sendTerminalInput" +>; export interface TerminalStreamControllerSize { rows: number; @@ -41,6 +24,7 @@ export interface TerminalStreamControllerOptions { getPreferredSize: () => TerminalStreamControllerSize | null; onOutput: (input: { terminalId: string; data: TerminalOutputData }) => void; onSnapshot: (input: { terminalId: string; state: TerminalState }) => void; + onExit?: (terminalId: string) => void; onRestore?: (input: { terminalId: string; data: TerminalOutputData }) => void; getRestoreOptions?: () => SubscribeTerminalRequest["restore"] | undefined; onStatusChange?: (status: TerminalStreamControllerStatus) => void; @@ -49,51 +33,76 @@ export interface TerminalStreamControllerOptions { const TERMINAL_EXITED_ERROR = "Terminal exited"; export class TerminalStreamController { - private readonly unsubscribeStreamEvents: () => void; + private subscription: ReturnType | null = null; private terminalId: string | null = null; private disposed = false; - constructor(private readonly options: TerminalStreamControllerOptions) { - this.unsubscribeStreamEvents = this.options.client.onTerminalStreamEvent((event) => { - if (this.disposed || event.terminalId !== this.terminalId) { - return; - } - if (event.type === "snapshot") { - this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); - return; - } - if (event.type === "restore") { - if (event.data.length > 0) { - this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); - } - return; - } + constructor(private readonly options: TerminalStreamControllerOptions) {} + + private receive: Parameters[1] = (event) => { + if (this.disposed || event.terminalId !== this.terminalId) { + return; + } + if (event.type === "snapshot") { + this.options.onSnapshot({ terminalId: event.terminalId, state: event.state }); + return; + } + if (event.type === "restore") { if (event.data.length > 0) { - this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + this.options.onRestore?.({ terminalId: event.terminalId, data: event.data }); } - }); - } + return; + } + if (event.data.length > 0) { + this.options.onOutput({ terminalId: event.terminalId, data: event.data }); + } + }; setTerminal(input: { terminalId: string | null }): void { if (this.disposed || input.terminalId === this.terminalId) { return; } const nextTerminalId = input.terminalId; - const previousTerminalId = this.terminalId; this.terminalId = nextTerminalId; - if (previousTerminalId) { - this.options.client.unsubscribeTerminal(previousTerminalId); - } + void this.subscription?.release().catch(console.error); + this.subscription = null; if (!nextTerminalId) { this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); return; } const restore = this.options.getRestoreOptions?.(); this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: true, error: null }); - void this.options.client - .subscribeTerminal(nextTerminalId, restore ? { restore } : undefined) + let subscription: ReturnType; + try { + const preferredSize = restore?.size ?? this.options.getPreferredSize(); + if (preferredSize) + this.options.client.sendTerminalInput(nextTerminalId, { + type: "resize", + ...preferredSize, + intent: "claim", + }); + subscription = this.options.client.observeTerminal( + nextTerminalId, + this.receive, + restore ? { restore } : undefined, + ); + } catch (error) { + this.failAttach(nextTerminalId, error); + return; + } + this.subscription = subscription; + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "terminal_stream_exit" || this.subscription !== subscription) return; + if (message.payload.error) + this.failAttach(nextTerminalId, new Error(message.payload.error)); + else this.handleTerminalExit({ terminalId: nextTerminalId }); + }, + }); + void subscription.ready .then((payload) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } if (payload.error) { @@ -105,15 +114,6 @@ export class TerminalStreamController { }); return; } - const preferredSize = restore?.size ? null : this.options.getPreferredSize(); - if (preferredSize) { - this.options.client.sendTerminalInput(nextTerminalId, { - type: "resize", - rows: preferredSize.rows, - cols: preferredSize.cols, - intent: "claim", - }); - } this.options.onStatusChange?.({ terminalId: nextTerminalId, isAttaching: false, @@ -122,24 +122,33 @@ export class TerminalStreamController { return; }) .catch((error: unknown) => { - if (this.disposed || this.terminalId !== nextTerminalId) { + if (this.disposed || this.subscription !== subscription) { return; } - this.terminalId = null; - this.options.onStatusChange?.({ - terminalId: nextTerminalId, - isAttaching: false, - error: - error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), - }); + this.failAttach(nextTerminalId, error); }); } + private failAttach(terminalId: string, error: unknown): void { + this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; + this.options.onStatusChange?.({ + terminalId, + isAttaching: false, + error: + error instanceof Error ? error.message : i18n.t("workspace.terminal.unableToSubscribe"), + }); + } + handleTerminalExit(input: { terminalId: string }): void { if (this.disposed || input.terminalId !== this.terminalId) { return; } + this.options.onExit?.(input.terminalId); this.terminalId = null; + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: input.terminalId, isAttaching: false, @@ -152,12 +161,9 @@ export class TerminalStreamController { return; } this.disposed = true; - const terminalId = this.terminalId; this.terminalId = null; - if (terminalId) { - this.options.client.unsubscribeTerminal(terminalId); - } - this.unsubscribeStreamEvents(); + void this.subscription?.release().catch(console.error); + this.subscription = null; this.options.onStatusChange?.({ terminalId: null, isAttaching: false, error: null }); } } diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts index b8541bcfe5..d2ef76fefd 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -2,9 +2,13 @@ import { describe, expect, it } from "vitest"; import { getTerminalVirtualKeyboardControlId, + resolveTerminalVirtualKeyboardRows, + shouldShowTerminalVirtualKeyBar, shouldShowTerminalFloatingCopyAction, shouldShowTerminalPasteAction, TERMINAL_VIRTUAL_KEYBOARD_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, type TerminalVirtualKeyboardControl, } from "./terminal-virtual-keyboard"; @@ -41,6 +45,38 @@ function controlsByType( } describe("terminal virtual keyboard policy", () => { + it("keeps terminal controls on wide native touch screens with either IME state", () => { + expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: false })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: true, isCompact: true })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: true })).toBe(true); + expect(shouldShowTerminalVirtualKeyBar({ isNative: false, isCompact: false })).toBe(false); + }); + + it("selects the same controls in narrow and wide row arrangements", () => { + const ids = (rows: readonly (readonly TerminalVirtualKeyboardControl[])[]) => + rows.flatMap((row) => row.map(getTerminalVirtualKeyboardControlId)); + expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS)].sort()).toEqual( + [...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(), + ); + expect([...ids(TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS)].sort()).toEqual( + [...ids(TERMINAL_VIRTUAL_KEYBOARD_ROWS)].sort(), + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 0 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 515 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 160 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: false, availableWidth: 698 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, + ); + expect(resolveTerminalVirtualKeyboardRows({ isCompact: true, availableWidth: 700 })).toBe( + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + ); + }); it("does not expose redundant Space or Backspace controls", () => { expect(controlIds()).toEqual([ "terminal-key-esc", diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts index f931881bce..3dd244fa95 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -41,6 +41,68 @@ export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ ], ] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; +export const TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS = [ + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, + { type: "modifier", modifier: "ctrl" }, + { type: "modifier", modifier: "alt" }, + { type: "modifier", modifier: "shift" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, + { type: "paste" }, + { type: "keyboardToggle" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, + ], +] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; + +export const TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS = [ + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.tab }, + { type: "modifier", modifier: "ctrl" }, + ], + [ + { type: "modifier", modifier: "alt" }, + { type: "modifier", modifier: "shift" }, + { type: "keyboardToggle" }, + ], + [ + { type: "paste" }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, + ], + [ + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, + { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, + ], +] as const satisfies readonly (readonly TerminalVirtualKeyboardControl[])[]; + +const TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH = 660; +const TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH = 300; + +export function resolveTerminalVirtualKeyboardRows(input: { + isCompact: boolean; + availableWidth: number; +}) { + if (input.availableWidth > 0 && input.availableWidth < TERMINAL_KEY_BAR_THREE_COLUMN_MAX_WIDTH) { + return TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS; + } + return !input.isCompact && input.availableWidth >= TERMINAL_KEY_BAR_SINGLE_ROW_MIN_WIDTH + ? TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS + : TERMINAL_VIRTUAL_KEYBOARD_ROWS; +} + +export function shouldShowTerminalVirtualKeyBar(input: { + isNative: boolean; + isCompact: boolean; +}): boolean { + return input.isNative || input.isCompact; +} + export function getTerminalVirtualKeyboardControlId( control: TerminalVirtualKeyboardControl, ): string { diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index 99abe14e38..50f080b37c 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -1,3 +1,4 @@ +import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime"; import type { ITheme } from "@xterm/xterm"; import xtermCss from "@xterm/xterm/css/xterm.css"; import type { TerminalState } from "@getpaseo/protocol/messages"; @@ -32,6 +33,9 @@ type InboundMessage = | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "paste"; streamKey: string; text: string } | { type: "clear"; streamKey: string } + | { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" } + | { type: "clearFind"; streamKey: string } + | { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } } | { type: "focus"; streamKey: string; forceRefocus?: boolean } | { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } @@ -50,6 +54,8 @@ type OutboundMessage = | { type: "bridgeReady" } | { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "input"; streamKey: string; data: string } + | { type: "findRequest"; streamKey: string } + | { type: "findResult"; streamKey: string; result: TerminalFindResult } | { type: "resize"; streamKey: string; @@ -229,6 +235,18 @@ class TerminalWebViewBridge { this.resolveLocalFileLinkRequest(message.requestId, message.target); return; } + if (message.type === "findWidgetSize") { + this.runtime?.find.setWidgetSize(message.size); + return; + } + if (message.type === "find") { + this.runtime?.find.search(message.query, message.direction); + return; + } + if (message.type === "clearFind") { + this.runtime?.find.clear(); + return; + } this.receiveMounted(message); } @@ -306,6 +324,9 @@ class TerminalWebViewBridge { this.runtime = runtime; runtime.setCallbacks({ callbacks: { + onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }), + onFindResult: (result) => + sendToNative({ type: "findResult", streamKey: message.streamKey, result }), onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), onResize: ({ rows, cols, shouldClaim, forceClaim }) => sendToNative({ diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index b05a65c7a6..c7fc27652e 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts new file mode 100644 index 0000000000..37e83c6ba9 --- /dev/null +++ b/packages/app/src/terminal/webview/terminal-find.browser.test.ts @@ -0,0 +1,73 @@ +import type { Terminal } from "@xterm/xterm"; +import { afterEach, expect, test } from "vitest"; +import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html"; + +interface Message { + type: string; + streamKey?: string; + result?: { resultCount: number; resultIndex: number }; + data?: string; +} +interface TerminalFrameWindow extends Window { + __PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void; + __paseoTerminal?: Terminal; +} +let frame: HTMLIFrameElement; +afterEach(() => frame?.remove()); + +test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => { + const messages: Message[] = []; + frame = document.createElement("iframe"); + frame.style.width = "500px"; + frame.style.height = "300px"; + document.body.append(frame); + const win = frame.contentWindow as TerminalFrameWindow; + Object.assign(win, { + ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) }, + }); + win.document.open(); + win.document.write(terminalEmulatorWebViewHtml); + win.document.close(); + await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true); + const send = (message: object) => + win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message }); + send({ + type: "mount", + initialSnapshot: null, + scrollbackLines: 1000, + theme: { background: "#ffffff", foreground: "#111111" }, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + swipeGesturesEnabled: false, + }); + await expect + .poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned")) + .toBe(true); + send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" }); + await expect + .poll(() => { + const b = win.__paseoTerminal?.buffer.active; + return b + ? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join( + "\n", + ) + : ""; + }) + .toContain("last A.B"); + send({ type: "findWidgetSize", size: { width: 356, height: 58 } }); + send({ type: "find", query: "a.b" }); + await expect + .poll(() => messages.findLast((m) => m.type === "findResult")?.result) + .toMatchObject({ resultCount: 2, resultIndex: 1 }); + send({ type: "find", query: "a.b", direction: "previous" }); + await expect + .poll(() => messages.findLast((m) => m.type === "findResult")?.result) + .toMatchObject({ resultCount: 2, resultIndex: 0 }); + send({ type: "find", streamKey: "stale", query: "absent" }); + expect(win.__paseoTerminal!.getSelection()).toBe("a.b"); + send({ type: "clearFind" }); + await expect + .poll(() => messages.findLast((m) => m.type === "findResult")?.result) + .toMatchObject({ resultCount: 0 }); + expect(messages.filter((m) => m.type === "input")).toEqual([]); + send({ type: "unmount" }); +}); diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts index 23848acd24..d483e01c5d 100644 --- a/packages/app/src/test/seed-session.ts +++ b/packages/app/src/test/seed-session.ts @@ -1,3 +1,5 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { defaultHostAppearance } from "@/hosts/appearance"; import { useSessionStore, type ProjectDescriptor, @@ -39,3 +41,32 @@ export function seedSessionWorkspaces( store.setProjects(serverId, byProjectId.values()); store.setWorkspaces(serverId, workspaces); } + +export function seedSessionHosts(serverIds: readonly string[]): void { + getHostRuntimeStore().syncHosts( + serverIds.map((serverId) => ({ + serverId, + label: serverId, + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + })), + ); +} + +export function seedRuntimeWorkspaces( + serverId: string, + workspaces: Map, +): void { + const runtime = getHostRuntimeStore(); + for (const id of useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? []) { + if (!workspaces.has(id)) runtime.removeWorkspaceSnapshot(serverId, id); + } + for (const workspace of workspaces.values()) { + runtime.acceptProjectSnapshot(serverId, projectFromWorkspace(workspace)); + } + runtime.acceptWorkspaceSnapshots(serverId, [...workspaces.values()]); +} diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index daaadd8c08..fa7521e487 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,7 +1,15 @@ +import { DatabaseSync } from "node:sqlite"; +import { ReplicaCache } from "@/runtime/replica-cache"; +import { + createSqliteReplicaRowStore, + type ReplicaSqliteConnection, + type SqliteValue, +} from "@/runtime/replica-cache/row-store-sqlite"; import { afterEach, describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store"; import type { StreamItem } from "@/types/stream"; import { createTimelineReplica, @@ -44,8 +52,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { replaceDemandedAgentIds: () => undefined, drainQueuedAgentMessage: () => undefined, ports: { - initialDeliveryMode: "legacy", - setSubscription: async () => undefined, + observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), readCursor: () => undefined, fetchPage: async () => ({ hasNewer: false, endCursor: null }), fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), @@ -173,6 +180,59 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("reopens painted live mutations without persisting authoritative coverage", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined = cachedTimeline(); + let pending: CachedTimeline | undefined; + const storage: TimelineReplicaStorage = { + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + pending = timeline; + }, + }; + const first = createOwner(storage); + first.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + + expect(pending).toMatchObject({ range: null, hasOlder: false }); + expect(pending?.items.map((entry) => entry.id)).toEqual(["cached", expect.any(String)]); + durable = pending; + first.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createOwner(storage); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ + status: "painted", + items: [ + expect.objectContaining({ id: "cached" }), + expect.objectContaining({ text: "live" }), + ], + }); + reopened.dispose(); + }); + it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; @@ -383,3 +443,225 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); }); + +describe("create handoff lifetime", () => { + const DRAFT_ID = "draft-1"; + + afterEach(() => useCreateFlowStore.getState().clearAll()); + + function sentCreateHandoff(): void { + useCreateFlowStore.getState().setPending({ + draftId: DRAFT_ID, + serverId: SERVER_ID, + agentId: AGENT_ID, + clientMessageId: "client-message-1", + text: "Create this chat.", + timestamp: 0, + }); + useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" }); + } + + function createHandoff(): PendingCreateAttempt | undefined { + return useCreateFlowStore.getState().pendingByDraftId[DRAFT_ID]; + } + + function syncedTailPage(): Parameters[0] { + return { + requestId: "tail-1", + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: false, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + entries: [], + error: null, + hasNewer: false, + hasOlder: false, + staleCursor: false, + gap: false, + }; + } + + interface HeldCatchUpOwner { + owner: ViewedTimelineOwner; + release: () => void; + } + + async function ownerWithHeldCatchUp(): Promise { + let releaseTailFetch!: () => void; + let tailFetchStarted = false; + const tailFetched = new Promise((resolve) => { + releaseTailFetch = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { readTimeline: async () => undefined, commitTimeline: () => undefined }, + prepareAgent: async () => undefined, + }); + const owner = createViewedTimelineOwner({ + serverId: SERVER_ID, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + observe: () => ({ ready: Promise.resolve(), release: async () => undefined }), + readCursor: () => undefined, + fetchPage: async () => { + tailFetchStarted = true; + await tailFetched; + return { hasNewer: false, endCursor: null }; + }, + fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), + reportError: () => undefined, + schedule: () => () => undefined, + }, + }); + + owner.setConnected(true); + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect.poll(() => tailFetchStarted).toBe(true); + return { owner, release: releaseTailFetch }; + } + + it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + sentCreateHandoff(); + const { owner, release } = await ownerWithHeldCatchUp(); + + owner.applyTimelineResponse(syncedTailPage()); + expect( + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ).toMatchObject({ status: "synced" }); + expect(createHandoff()).toMatchObject({ agentId: AGENT_ID, lifecycle: "sent" }); + + release(); + await expect.poll(() => createHandoff()).toBeUndefined(); + owner.dispose(); + }); + + it("releases the handoff when the chat's tab closes before catch-up completes", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + sentCreateHandoff(); + const { owner, release } = await ownerWithHeldCatchUp(); + + owner.replaceVisibleAgentIds("test", []); + owner.replaceOpenTabAgentIds([]); + + expect(createHandoff()).toBeUndefined(); + release(); + owner.dispose(); + }); +}); + +function createSqliteCache() { + const database = new DatabaseSync(":memory:"); + let beforeRead = async () => {}; + const connection: ReplicaSqliteConnection = { + async exec(sql) { + database.exec(sql); + }, + async run(sql, params = []) { + database.prepare(sql).run(...params); + }, + async all(sql: string, params: readonly SqliteValue[] = []) { + const rows = database.prepare(sql).all(...params) as Row[]; + if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead(); + return rows; + }, + async transaction(operation) { + database.exec("BEGIN IMMEDIATE"); + try { + await operation(connection); + database.exec("COMMIT"); + } catch (error) { + database.exec("ROLLBACK"); + throw error; + } + }, + }; + const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} }); + return { + cache, + database, + holdRead: (operation: () => Promise) => { + beforeRead = operation; + }, + }; +} + +describe("SQLite baseline restoration", () => { + it.each([false, true])( + "keeps display-only cached history under a live event (synced=%s)", + async (synced) => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, { + ...cachedTimeline(), + items: [item("earlier", "earlier", 1), ...cachedTimeline().items], + range: null, + }); + await cache.flush(); + holdRead(async () => { + if (synced) applySynced(AGENT_ID, 8); + else + useSessionStore.getState().setAgentStreamState(SERVER_ID, AGENT_ID, { + head: [item("cached", "cached", 4), item("live", "live", 5)], + }); + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: async () => {}, + }); + await replica.prepare(AGENT_ID); + const session = useSessionStore.getState().sessions[SERVER_ID]; + const timeline = selectAgentTimelineState(session, AGENT_ID); + expect(timeline.status).toBe(synced ? "synced" : "painted"); + expect([ + ...(timeline.status === "cold" ? [] : timeline.items), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ]).toEqual( + synced + ? [item(`network-${AGENT_ID}`, "network", 8)] + : [item("earlier", "earlier", 1), item("cached", "cached", 4), item("live", "live", 5)], + ); + expect(replica.readCursor(AGENT_ID)).toBeUndefined(); + database.close(); + }, + ); + + it("starts the timeline disk read while agent preparation is pending", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const { cache, database, holdRead } = createSqliteCache(); + cache.setHosts([SERVER_ID]); + cache.commitTimeline(SERVER_ID, AGENT_ID, cachedTimeline()); + await cache.flush(); + let readStarted = false; + let release!: () => void; + const agentReady = new Promise((resolve) => { + release = resolve; + }); + holdRead(async () => { + readStarted = true; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: cache, + prepareAgent: () => agentReady, + }); + const preparation = replica.prepare(AGENT_ID); + try { + await expect.poll(() => readStarted, { timeout: 500 }).toBe(true); + } finally { + release(); + await preparation; + database.close(); + } + }); +}); diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index ae2ba9bf53..9e57142846 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -49,6 +49,7 @@ function makeToolCallTimelineEntry( callId: string, status: "running" | "completed", detail: ToolCallDetail, + turnId?: string, ): TimelineResponseEntry { return { seqStart: seq, @@ -62,6 +63,7 @@ function makeToolCallTimelineEntry( detail, error: null, }, + ...(turnId ? { turnId } : {}), timestamp: new Date(1000 + seq).toISOString(), }; } @@ -1899,14 +1901,14 @@ describe("processTimelineResponse", () => { expect(assistants[0]).toMatchObject({ text: "Hello", messageId: "answer-1" }); }); - it("replaces every promoted assistant block when reconciling a projected message", () => { + it("replaces the whole live assistant message when reconciling projected history", () => { const live = processAgentStreamEvents({ events: [makeStreamReducerEvent(makeAssistantTimelineEvent("First paragraph.\n\nSec"), 2)], currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, }); - expect(getAssistantTexts(live.tail)).toHaveLength(1); + expect(getAssistantTexts(live.tail)).toHaveLength(0); expect(getAssistantTexts(live.head)).toHaveLength(1); const result = processTimelineResponse({ @@ -2171,7 +2173,7 @@ describe("processTimelineResponse", () => { [...result.tail, ...result.head] .filter((item) => item.kind === "assistant_message" || item.kind === "user_message") .map((item) => item.text), - ).toEqual(["New prompt", "First paragraph.", "Second paragraph", "Missed answer"]); + ).toEqual(["New prompt", "First paragraph.\n\nSecond paragraph", "Missed answer"]); }); it("does not move a prompt or its live answer around catch-up tool history", () => { @@ -2210,7 +2212,6 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "user_message", "assistant_message", - "assistant_message", "tool_call", ]); }); @@ -2775,7 +2776,7 @@ describe("processTimelineResponse", () => { expect(result.sideEffects).not.toContainEqual(expect.objectContaining({ type: "catch_up" })); }); - it("keeps live assistant blocks ordered when merging a disjoint prompt-jump window", () => { + it("keeps whole live assistant messages ordered when merging a disjoint prompt-jump window", () => { const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -2790,8 +2791,7 @@ describe("processTimelineResponse", () => { currentCursor: undefined, }); expect(getAssistantTexts([...live.tail, ...live.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); const result = processTimelineResponse({ @@ -2813,8 +2813,7 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ - "First paragraph.", - "Second paragraph.", + "First paragraph.\n\nSecond paragraph.", ]); }); @@ -3197,12 +3196,14 @@ describe("processTimelineResponse", () => { it("coalesces tool call lifecycle rows across the older-page prepend boundary", () => { const callId = "toolu_boundary"; + const turnId = "turn-boundary"; const currentTail = hydrateStreamState( [ { event: { type: "timeline", provider: "claude", + turnId, item: makeToolCallTimelineEntry(3, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3230,11 +3231,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + turnId, + ), ], }, }); @@ -3249,7 +3256,7 @@ describe("processTimelineResponse", () => { })), ).toEqual([ { - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${turnId}/${callId}`, callId, status: "completed", detailType: "read", @@ -3358,6 +3365,8 @@ describe("processTimelineResponse", () => { it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; + const olderTurnId = "autonomous-turn-1"; + const currentTurnId = "autonomous-turn-2"; const currentTail = hydrateStreamState( [ { @@ -3368,6 +3377,7 @@ describe("processTimelineResponse", () => { event: { type: "timeline", provider: "claude", + turnId: currentTurnId, item: makeToolCallTimelineEntry(4, callId, "completed", { type: "read", filePath: "/tmp/example.ts", @@ -3395,11 +3405,17 @@ describe("processTimelineResponse", () => { startCursor: { seq: 1 }, endCursor: { seq: 2 }, entries: [ - makeToolCallTimelineEntry(1, callId, "running", { - type: "unknown", - input: { file_path: "/tmp/example.ts" }, - output: null, - }), + makeToolCallTimelineEntry( + 1, + callId, + "running", + { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }, + olderTurnId, + ), makeTimelineEntry(2, "older chunk "), ], }, @@ -3415,7 +3431,7 @@ describe("processTimelineResponse", () => { ).toEqual([ { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${olderTurnId}/${callId}`, status: "running", text: null, }, @@ -3427,7 +3443,7 @@ describe("processTimelineResponse", () => { }, { kind: "tool_call", - id: `agent_tool_${callId}`, + id: `agent_tool_turn:${currentTurnId}/${callId}`, status: "completed", text: null, }, @@ -3961,7 +3977,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts(result.head)).toEqual(["Identified"]); }); - it("keeps a promoted live image block identity when the turn completes", () => { + it("keeps the whole live message identity when the turn completes", () => { const imageMarkdown = "![Architecture](docs/architecture.png)"; const streaming = processAgentStreamEvents({ events: [ @@ -3973,9 +3989,11 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect([streaming.changedTail, streaming.changedHead]).toEqual([true, true]); - expect(getAssistantTexts(streaming.tail)).toEqual(["Introductory paragraph"]); - expect(getAssistantTexts(streaming.head)).toEqual([imageMarkdown]); + expect([streaming.changedTail, streaming.changedHead]).toEqual([false, true]); + expect(getAssistantTexts(streaming.tail)).toEqual([]); + expect(getAssistantTexts(streaming.head)).toEqual([ + `Introductory paragraph\n\n${imageMarkdown}`, + ]); const liveImageId = streaming.head[0]?.id; const completed = processAgentStreamEvent({ @@ -3990,13 +4008,12 @@ describe("processAgentStreamEvents", () => { item.kind === "assistant_message", ); expect(assistantBlocks.map((item) => [item.id, item.text])).toEqual([ - [streaming.tail[0]?.id, "Introductory paragraph"], - [liveImageId, imageMarkdown], + [liveImageId, `Introductory paragraph\n\n${imageMarkdown}`], ]); expect(assistantBlocks.filter((item) => item.id === liveImageId)).toHaveLength(1); }); - it("preserves a live block trailing newline after promoting completed markdown blocks", () => { + it("preserves all newlines in the accumulated live message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -4013,21 +4030,17 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); + expect(result.changedTail).toBe(false); expect(result.changedHead).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.", - }); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "Changed:\n- [timeline-debug.ts]", + text: "Done. I added `[TimelineMerge] ...` logging around the suspicious merge/reconcile paths.\n\nChanged:\n- [timeline-debug.ts]", }); }); - it("does not promote a markdown block that is still inside an open code fence", () => { + it("preserves an open code fence in the whole source message", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Before fence\n\n```ts\nconst a = 1;"), 1), @@ -4038,16 +4051,12 @@ describe("processAgentStreamEvents", () => { currentCursor: undefined, }); - expect(result.changedTail).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "Before fence", - }); + expect(result.changedTail).toBe(false); + expect(result.tail).toHaveLength(0); expect(result.head).toHaveLength(1); expect(result.head[0]).toMatchObject({ kind: "assistant_message", - text: "```ts\nconst a = 1;\n\nconst b = 2;", + text: "Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;", }); }); @@ -4156,8 +4165,7 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ "ABC", imageMarkdown, - "D", - "E", + "D\n\nE", ]); }); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index b04ad00f02..dce9daa4da 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -422,13 +422,7 @@ function mergeTimelineWindow(args: { return cursor?.epoch !== payload.epoch || cursor.seq < startSeq || cursor.seq > endSeq; }); const retainedHead = projected.head; - const reservedItemIds = new Set( - [...retainedTail, ...retainedHead].flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ); + const reservedItemIds = new Set([...retainedTail, ...retainedHead].map((item) => item.id)); const hydrated = hydrateStreamState( toHydratedEvents(timelineUnits.filter((unit) => !projected.reconciledUnits.has(unit))), { @@ -823,51 +817,31 @@ function reconcileOverlappingProjectedAssistant(params: { return { tail: params.tail, head: params.head, reconciled: false }; } - const blockGroupId = match.current.blockGroupId; const messageId = projectedMessageId ?? match.current.messageId; const replacement: AssistantMessageItem = { kind: "assistant_message", - id: blockGroupId ?? match.current.id, + id: match.current.id, ...(messageId !== undefined ? { messageId } : {}), text: projectedText, timestamp: unit.timestamp, timelineCursor: { epoch: params.epoch, seq: unit.seqEnd }, }; - const belongsToBlockGroup = (item: StreamItem) => - blockGroupId !== undefined && - item.kind === "assistant_message" && - item.blockGroupId === blockGroupId; - const removeBlockGroup = (items: StreamItem[]) => - blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items; const replaceMatch = (items: StreamItem[], index: number) => { - if (!blockGroupId) { - const next = [...items]; - next[index] = replacement; - return next; - } - const next: StreamItem[] = []; - let inserted = false; - for (const item of items) { - if (!belongsToBlockGroup(item)) { - next.push(item); - } else if (!inserted) { - next.push(replacement); - inserted = true; - } - } + const next = [...items]; + next[index] = replacement; return next; }; if (headMatch) { return { - tail: removeBlockGroup(params.tail), + tail: params.tail, head: replaceMatch(params.head, headMatch.index), reconciled: true, }; } return { tail: replaceMatch(params.tail, match.index), - head: removeBlockGroup(params.head), + head: params.head, reconciled: true, }; } @@ -1162,13 +1136,7 @@ function applyAcceptedTimelinePage(input: { })), { source: "canonical", - reservedItemIds: new Set( - currentTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ), + reservedItemIds: new Set(currentTail.map((item) => item.id)), }, ); return { diff --git a/packages/app/src/timeline/sync-status.tsx b/packages/app/src/timeline/sync-status.tsx new file mode 100644 index 0000000000..c4d74e400b --- /dev/null +++ b/packages/app/src/timeline/sync-status.tsx @@ -0,0 +1,49 @@ +import type { Theme } from "@/styles/theme"; +import { useMemo } from "react"; +import type { AgentScreenReadySyncState } from "@/hooks/use-agent-screen-state-machine"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import { ToastViewport, type ToastState } from "@/components/toast-host"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; + +const spinnerColor = (theme: Theme) => ({ color: theme.colors.foreground }); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const keepUntilSynchronized = () => {}; + +export function TimelineSyncStatus({ + sync, + toast, + onDismiss, +}: { + sync: AgentScreenReadySyncState | null; + toast: ToastState | null; + onDismiss: () => void; +}) { + const { t } = useTranslation(); + let state: "reconnecting" | "updating" | null = null; + if (sync?.status === "reconnecting") state = "reconnecting"; + else if (sync?.status === "catching_up" && sync.ui === "status") state = "updating"; + const label = state ? t(`agentPanel.states.${state}`) : null; + const syncToast = useMemo( + () => + state && label + ? { + id: state === "reconnecting" ? 1 : 2, + content: label, + nativeMessage: label, + icon: , + variant: "default", + durationMs: null, + testID: `agent-${state}-toast`, + } + : null, + [state, label], + ); + return ( + + ); +} diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 8c7c156e6f..9bf6897bd9 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -1,9 +1,13 @@ import { expect, test, vi } from "vitest"; +import { DaemonClient, type DaemonTransport } from "@getpaseo/client/internal/daemon-client"; +import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan"; import { consumeForcedTimelineTailReplacement, createViewedTimelineSync, type TimelineResponsePayload, + type ViewedTimelineStatus, + type ViewedTimelineSyncPorts, } from "./viewed-timeline-sync"; interface Deferred { @@ -28,6 +32,11 @@ interface MembershipRequest { fail(message: string): void; } +interface TimelineCurrentReport { + agentId: string; + status: ViewedTimelineStatus; +} + interface TimelineFetch { agentId: string; request: ProjectedTimelineForwardFetchPlan; @@ -35,21 +44,111 @@ interface TimelineFetch { fail(message: string): void; } +test("split panes catch up together before hidden open chats start fetching", async () => { + const world = new TimelineWorld(); + world.openedEarlier(["a-hidden"]); + world.sync.replaceVisibleAgentIds("panes", ["y-visible", "z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const left = await world.nextFetch("y-visible"); + const right = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + left.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("y-visible")).toBe("ready")); + world.expectNoPendingFetch(); + right.respond({ hasNewer: false }); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("backgrounding releases hidden catch-ups waiting on a visible chat", async () => { + const world = new TimelineWorld(); + world.openedEarlier(["a-hidden"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + world.sync.setActive(false); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + visible.respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("visible catch-up settles before hidden open chats start fetching", async () => { + const world = new TimelineWorld(); + world.openedEarlier(["a-hidden"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + visible.respond({ hasNewer: false }); + const hidden = await world.nextFetch("a-hidden"); + expect(world.sync.getAgentTimelineStatus("z-visible")).toBe("ready"); + hidden.respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => { + const world = new TimelineWorld(); + world.openedEarlier(["a-hidden"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const visible = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + visible.fail("temporarily unavailable"); + const hidden = await world.nextFetch("a-hidden"); + hidden.respond({ hasNewer: false }); + await world.nextError(); + world.expectNoPendingFetch(); + (await world.nextRetry())(); + (await world.nextFetch("z-visible")).respond({ hasNewer: false }); + world.sync.dispose(); +}); + +test("switching chats promotes the newly visible catch-up while another visible request is pending", async () => { + const world = new TimelineWorld(); + world.openedEarlier(["a-hidden", "b-hidden"]); + world.sync.replaceVisibleAgentIds("pane", ["z-visible"]); + world.sync.setConnected(true); + (await world.nextMembership()).succeed(); + const previous = await world.nextFetch("z-visible"); + world.expectNoPendingFetch(); + world.sync.replaceVisibleAgentIds("pane", ["b-hidden"]); + const current = await world.nextFetch("b-hidden"); + world.expectNoPendingFetch(); + current.respond({ hasNewer: false }); + (await world.nextFetch("a-hidden")).respond({ hasNewer: false }); + previous.respond({ hasNewer: false }); + world.sync.dispose(); +}); + class TimelineWorld { + constructor(private readonly observeMembership?: ViewedTimelineSyncPorts["observe"]) {} readonly errors: string[] = []; + readonly releasedMemberships: string[][] = []; readonly cursors = new Map(); readonly cacheRequests: string[] = []; readonly forcedTimelineTailReplacements = new Set(); + readonly reportedCurrent: TimelineCurrentReport[] = []; cacheGate: Deferred | null = null; readonly sync = createViewedTimelineSync({ replaceDemandedAgentIds: () => undefined, + onCatchUpEnded: (agentId) => { + this.reportedCurrent.push({ + agentId, + status: this.sync.getAgentTimelineStatus(agentId), + }); + }, prepare: async (agentId) => { this.cacheRequests.push(agentId); this.cacheRequestWaiters.shift()?.(agentId); await this.cacheGate?.promise; }, - initialDeliveryMode: "selective", - setSubscription: async (agentIds) => { + observe: (agentIds) => { + if (this.observeMembership) return this.observeMembership(agentIds); const result = deferred(); this.memberships.push({ agentIds, @@ -57,7 +156,12 @@ class TimelineWorld { fail: (message) => result.reject(new Error(message)), }); this.releaseMembershipWaiter(); - return result.promise; + return { + ready: result.promise, + release: async () => { + this.releasedMemberships.push(agentIds); + }, + }; }, readCursor: (agentId) => this.cursors.get(agentId), fetchPage: async (agentId, request) => { @@ -111,6 +215,16 @@ class TimelineWorld { }, }); + show(sourceId: string, agentIds: string[]): void { + this.sync.replaceVisibleAgentIds(sourceId, agentIds); + } + + /** Chats the user opened earlier in this session and has since navigated away from. */ + openedEarlier(agentIds: string[]): void { + this.sync.replaceVisibleAgentIds("earlier", agentIds); + this.sync.replaceVisibleAgentIds("earlier", []); + } + private readonly memberships: MembershipRequest[] = []; private readonly membershipWaiters: Array<(request: MembershipRequest) => void> = []; private readonly fetches: TimelineFetch[] = []; @@ -335,6 +449,60 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); }); +test("reports a chat current only once its parked follow-up page completes", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + const tail = await world.nextFetch("agent-a"); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + tail.respond({ hasNewer: false }); + + const recovery = await world.nextFetch("agent-a"); + expect(world.reportedCurrent).toEqual([]); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + + recovery.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]); + world.sync.dispose(); +}); + +test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => { + const world = new TimelineWorld(); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + + const probe = await world.nextFetch("agent-a"); + probe.respond({ hasNewer: true, seq: 82 }); + + const fallback = await world.nextFetch("agent-a"); + expect(world.reportedCurrent).toEqual([]); + + fallback.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]); + world.sync.dispose(); +}); + +test("stops owing a chat a catch-up when its tab closes mid-fetch", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("pane", ["agent-a"]); + (await world.nextMembership()).succeed(); + await world.nextFetch("agent-a"); + expect(world.reportedCurrent).toEqual([]); + + world.sync.replaceVisibleAgentIds("pane", []); + world.sync.replaceOpenTabAgentIds([]); + + expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "pending" }]); + world.sync.dispose(); +}); + test("unchanged visible-set publication does not cancel paged catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -370,43 +538,169 @@ test("all acknowledged agents begin catch-up independently", async () => { const membership = await world.nextMembership(); membership.succeed(); - const [agentA, agentB] = await Promise.all([ - world.nextFetch("agent-a"), - world.nextFetch("agent-b"), - ]); - agentA.respond({ hasNewer: false }); + const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); + const agentA = await world.nextFetch("agent-a"); + agentA.respond({ hasNewer: false }); expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("an eviction during acknowledgement never catches up the stale hot set", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", [ - "agent-a", - "agent-b", - "agent-c", - "agent-d", - "agent-e", - ]); - const staleMembership = await world.nextMembership(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); - staleMembership.succeed(); - const currentMembership = await world.nextMembership(); - currentMembership.succeed(); - const currentCatchUps = await Promise.all( - ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"].map((agentId) => - world.nextFetch(agentId), - ), - ); - for (const catchUp of currentCatchUps) catchUp.respond({ hasNewer: false }); - - expect({ stale: staleMembership.agentIds, current: currentMembership.agentIds }).toEqual({ - stale: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"], - current: ["agent-a", "agent-b", "agent-c", "agent-d", "agent-f"], +test("an eviction starts and acknowledges B before A returns its late subscription ID", async () => { + const requests: SessionInboundMessage[] = []; + let receive!: (data: unknown, isBinary: boolean) => void; + let open!: () => void; + const reply = (message: SessionOutboundMessage) => + receive(JSON.stringify({ type: "session", message }), false); + const transport: DaemonTransport = { + send(data) { + if (typeof data !== "string") throw new Error("No binary request expected"); + const frame = JSON.parse(data); + if (frame.type === "ping") receive(JSON.stringify({ type: "pong" }), false); + if (frame.type !== "session") return; + requests.push(frame.message); + if (frame.message.type === "subscription.release.request") + reply({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }); + }, + close() {}, + onMessage(handler) { + receive = handler; + return () => {}; + }, + onOpen(handler) { + open = handler; + return () => {}; + }, + onClose() { + return () => {}; + }, + onError() { + return () => {}; + }, + }; + const client = new DaemonClient({ + url: "ws://timeline-fixture", + clientId: "timeline-replacement", + transportFactory: () => transport, + reconnect: { enabled: false }, }); + const world = new TimelineWorld((agentIds) => client.observeTimeline(agentIds)); + const membershipRequests = () => + requests.filter((request) => request.type === "agent.timeline.set_subscription.request"); + try { + const connected = client.connect(); + open(); + reply({ + type: "status", + payload: { + status: "server_info", + serverId: "timeline-fixture", + hostname: null, + version: null, + features: { ownedSubscriptions: true }, + }, + }); + await connected; + world.sync.replaceVisibleAgentIds("workspace", [ + "agent-a", + "agent-b", + "agent-c", + "agent-d", + "agent-e", + ]); + world.sync.setConnected(true); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(1)); + const a = membershipRequests()[0]!; + // Closing the five tabs releases them; opening a sixth chat is what commits the swap. + world.sync.replaceOpenTabAgentIds(["agent-f"]); + world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); + await vi.waitFor(() => expect(membershipRequests()).toHaveLength(2)); + const b = membershipRequests()[1]!; + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: b.requestId, agentIds: b.agentIds, subscriptionId: "server-b" }, + }); + const catchUps = await Promise.all(b.agentIds.map((agentId) => world.nextFetch(agentId))); + for (const catchUp of catchUps) catchUp.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-f")).toBe("ready")); + reply({ + type: "agent.timeline.set_subscription.response", + payload: { requestId: a.requestId, agentIds: a.agentIds, subscriptionId: "server-a-late" }, + }); + await vi.waitFor(() => + expect(requests.filter((request) => request.type === "subscription.release.request")).toEqual( + [expect.objectContaining({ subscriptionId: "server-a-late" })], + ), + ); + expect(a.agentIds).toEqual(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); + expect(b.agentIds).toEqual(["agent-f"]); + world.expectNoPendingFetch(); + expect(world.errors).toEqual([]); + world.sync.dispose(); + await vi.waitFor(() => + expect( + requests + .filter((request) => request.type === "subscription.release.request") + .map((request) => request.subscriptionId), + ).toEqual(["server-a-late", "server-b"]), + ); + } finally { + world.sync.dispose(); + await client.close(); + } +}); + +test("a restored workspace layout subscribes nothing until the user opens a chat", async () => { + const world = new TimelineWorld(); + // Launch. Workspace layout rehydrates from disk carrying a tab for every chat the user + // has ever opened on this host; none of them is open in this session yet. + world.sync.replaceOpenTabAgentIds(["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]); + world.sync.setConnected(true); + world.expectNoPendingMembership(); world.expectNoPendingFetch(); + + // The workspace the app restores into shows one chat. Only that chat is fetched, so only + // that agent is resumed on the daemon. + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(["agent-a"]); + membership.succeed(); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + world.expectNoPendingFetch(); + world.sync.dispose(); +}); + +test("open chats remain subscribed beyond five views and without mounted workspace panes", async () => { + const world = new TimelineWorld(); + const agents = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; + world.openedEarlier(agents); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.sync.setConnected(true); + const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(agents); + membership.succeed(); + for (const agentId of agents) { + (await world.nextFetch(agentId)).respond({ hasNewer: false }); + } + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + + world.sync.replaceVisibleAgentIds("workspace", []); + world.expectNoPendingMembership(); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.expectNoPendingFetch(); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"); + + world.sync.replaceOpenTabAgentIds(agents.slice(0, 5)); + const closed = await world.nextMembership(); + expect(closed.agentIds).toEqual(agents.slice(0, 5)); + closed.succeed(); + world.sync.dispose(); }); test("disconnect cancels paging and reconnect restores membership before fresh catch-up", async () => { @@ -432,25 +726,26 @@ test("disconnect cancels paging and reconnect restores membership before fresh c world.expectNoPendingFetch(); }); -test("navigation while disconnected reconnects only the currently visible agent", async () => { +test("navigation while disconnected keeps both opened chats for reconnect", async () => { const world = new TimelineWorld(); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); world.sync.setConnected(true); const membership = await world.nextMembership(); + expect(membership.agentIds).toEqual(["agent-a", "agent-b"]); membership.succeed(); - const catchUp = await world.nextFetch("agent-b"); - catchUp.respond({ hasNewer: false }); - - expect(membership.agentIds).toEqual(["agent-b"]); + // A dropped socket is not the user closing a chat, so reconnect owes both of them a + // catch-up. The visible one goes first. + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); world.expectNoPendingFetch(); }); -test("overlapping sources deduplicate membership and retain hidden hot agents", async () => { +test("overlapping sources deduplicate membership and retain open hidden chats", async () => { const world = new TimelineWorld(); - world.sync.replaceVisibleAgentIds("left-route", ["agent-a"]); - world.sync.replaceVisibleAgentIds("right-route", ["agent-a", "agent-b"]); + world.show("left-route", ["agent-a"]); + world.show("right-route", ["agent-a", "agent-b"]); world.sync.setConnected(true); const combined = await world.nextMembership(); combined.succeed(); @@ -461,9 +756,9 @@ test("overlapping sources deduplicate membership and retain hidden hot agents", agentA.respond({ hasNewer: false }); agentB.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("left-route", []); + world.show("left-route", []); world.expectNoPendingMembership(); - world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]); + world.show("right-route", ["agent-b"]); expect(combined.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); @@ -699,16 +994,37 @@ test("redeclaring unchanged visibility does not bypass membership backoff", asyn expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"); }); -test("backgrounding preserves the hot membership without catch-up on return", async () => { +test("foreground recovery skips to the latest tail after one missed page", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); + (await world.nextMembership()).succeed(); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); + world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); + + world.sync.setActive(false); + world.sync.setActive(true); + const probe = await world.nextFetch("agent-a"); + expect(probe.request.direction).toBe("after"); + probe.respond({ hasNewer: true, seq: 82 }); + const latest = await world.nextFetch("agent-a"); + expect(latest.request.direction).toBe("tail"); + expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); + latest.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + +test("backgrounding preserves subscriptions and returning verifies the visible chat", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.show("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -721,27 +1037,29 @@ test("backgrounding preserves the hot membership without catch-up on return", as world.sync.setActive(true); world.expectNoPendingMembership(); + expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("pending"); + const resume = await world.nextFetch("agent-b"); + resume.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.expectNoPendingFetch(); }); test("stale membership retry cannot overwrite a newer effective set", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); const failed = await world.nextMembership(); failed.fail("subscription unavailable"); const staleRetry = await world.nextRetry(); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const current = await world.nextMembership(); staleRetry(); current.succeed(); - const [agentA, agentB] = await Promise.all([ - world.nextFetch("agent-a"), - world.nextFetch("agent-b"), - ]); - agentA.respond({ hasNewer: false }); + const agentB = await world.nextFetch("agent-b"); agentB.respond({ hasNewer: false }); + const agentA = await world.nextFetch("agent-a"); + agentA.respond({ hasNewer: false }); expect(current.agentIds).toEqual(["agent-a", "agent-b"]); world.expectNoPendingMembership(); @@ -767,16 +1085,16 @@ test("membership retry cannot run while disconnected", async () => { expect(restored.agentIds).toEqual(["agent-a"]); }); -test("returning to a hot hidden agent stays live after inactivity", async () => { +test("returning to an open hidden agent stays live after inactivity", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); const initialMembership = await world.nextMembership(); initialMembership.succeed(); const agentACatchUp = await world.nextFetch("agent-a"); agentACatchUp.respond({ hasNewer: false }); - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); + world.show("workspace", ["agent-b"]); const expandedMembership = await world.nextMembership(); expandedMembership.succeed(); const agentBCatchUp = await world.nextFetch("agent-b"); @@ -784,48 +1102,13 @@ test("returning to a hot hidden agent stays live after inactivity", async () => await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.elapse(60_000); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + world.show("workspace", ["agent-a"]); world.expectNoPendingMembership(); world.expectNoPendingFetch(); }); -test("the hot set evicts the least-recent hidden agent and promotes revisited agents", async () => { - const world = new TimelineWorld(); - world.sync.setConnected(true); - for (const agentId of ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]) { - world.sync.replaceVisibleAgentIds("workspace", [agentId]); - const membership = await world.nextMembership(); - membership.succeed(); - const catchUp = await world.nextFetch(agentId); - catchUp.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus(agentId)).toBe("ready")); - } - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - world.expectNoPendingMembership(); - world.expectNoPendingFetch(); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-f"]); - const eviction = await world.nextMembership(); - eviction.succeed(); - const agentFCatchUp = await world.nextFetch("agent-f"); - agentFCatchUp.respond({ hasNewer: false }); - expect(eviction.agentIds).toEqual(["agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - world.expectNoPendingMembership(); - world.expectNoPendingFetch(); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const restoreEvicted = await world.nextMembership(); - restoreEvicted.succeed(); - const agentACatchUp = await world.nextFetch("agent-a"); - agentACatchUp.respond({ hasNewer: false }); - expect(restoreEvicted.agentIds).toEqual(["agent-a", "agent-b", "agent-d", "agent-e", "agent-f"]); -}); - -test("visible agents are never evicted when they exceed the hot-set limit", async () => { +test("visible agents stay subscribed even before open-tab membership arrives", async () => { const world = new TimelineWorld(); const visible = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e", "agent-f"]; world.sync.setConnected(true); @@ -837,26 +1120,31 @@ test("visible agents are never evicted when they exceed the hot-set limit", asyn expect(membership.agentIds).toEqual(visible); + // Hiding a pane is not closing its chat, so the sixth stays subscribed. world.sync.replaceVisibleAgentIds("workspace", visible.slice(0, 5)); - const hiddenEviction = await world.nextMembership(); - hiddenEviction.succeed(); - expect(hiddenEviction.agentIds).toEqual(visible.slice(0, 5)); + world.expectNoPendingMembership(); + + // Closing its tab is what releases it. + world.sync.replaceOpenTabAgentIds(visible.slice(0, 5)); + const closed = await world.nextMembership(); + closed.succeed(); + expect(closed.agentIds).toEqual(visible.slice(0, 5)); }); -test("disconnect clears hidden hot agents before reconnecting the visible set", async () => { +test("reconnect restores open demand and reopening a closed chat fetches again", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const agentAMembership = await world.nextMembership(); agentAMembership.succeed(); - const agentACatchUp = await world.nextFetch("agent-a"); - agentACatchUp.respond({ hasNewer: false }); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); + // Close agent-a's tab, then open agent-b. + world.sync.replaceOpenTabAgentIds(["agent-b"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); const agentBMembership = await world.nextMembership(); agentBMembership.succeed(); - const agentBCatchUp = await world.nextFetch("agent-b"); - agentBCatchUp.respond({ hasNewer: false }); + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); world.sync.setConnected(false); @@ -864,88 +1152,24 @@ test("disconnect clears hidden hot agents before reconnecting the visible set", world.sync.setConnected(true); const restored = await world.nextMembership(); restored.succeed(); - const restoredCatchUp = await world.nextFetch("agent-b"); - restoredCatchUp.respond({ hasNewer: false }); - + (await world.nextFetch("agent-b")).respond({ hasNewer: false }); expect(restored.agentIds).toEqual(["agent-b"]); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const reopened = await world.nextMembership(); reopened.succeed(); - const reopenedCatchUp = await world.nextFetch("agent-a"); - reopenedCatchUp.respond({ hasNewer: false }); + (await world.nextFetch("agent-a")).respond({ hasNewer: false }); expect(reopened.agentIds).toEqual(["agent-a", "agent-b"]); }); -test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => { +test("disposing a view releases its pending observation before bootstrap completes", async () => { const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); world.sync.setConnected(true); - - world.expectNoPendingMembership(); - const initial = await world.nextFetch("agent-a"); - initial.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); - const recovery = await world.nextFetch("agent-a"); - recovery.respond({ hasNewer: false }); - - expect(recovery.request).toEqual({ - direction: "after", - cursor: { epoch: "epoch-agent-a", seq: 10 }, - limit: 40, - projection: "projected", - }); -}); - -test("legacy delivery catches up after returning to a view or foreground", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.setConnected(true); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const firstAgentA = await world.nextFetch("agent-a"); - firstAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]); - const agentB = await world.nextFetch("agent-b"); - agentB.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready")); - - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - const secondAgentA = await world.nextFetch("agent-a"); - secondAgentA.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setActive(false); - world.sync.setActive(true); - const foregroundAgentA = await world.nextFetch("agent-a"); - foregroundAgentA.respond({ hasNewer: false }); - - world.expectNoPendingMembership(); -}); - -test("switching from legacy to selective delivery publishes membership and catches up once", async () => { - const world = new TimelineWorld(); - world.sync.setDeliveryMode("legacy"); - world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); - world.sync.setConnected(true); - const legacyCatchUp = await world.nextFetch("agent-a"); - legacyCatchUp.respond({ hasNewer: false }); - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - world.sync.setDeliveryMode("selective"); - expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending"); - const membership = await world.nextMembership(); - membership.succeed(); - const catchUp = await world.nextFetch("agent-a"); - catchUp.respond({ hasNewer: false }); - - await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); - - expect(membership.agentIds).toEqual(["agent-a"]); - world.expectNoPendingMembership(); + const request = await world.nextMembership(); + world.sync.dispose(); + expect(world.releasedMemberships).toEqual([["agent-a"]]); + request.succeed(); + await Promise.resolve(); world.expectNoPendingFetch(); }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d6bd3b2a20..5939feabce 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -42,16 +42,16 @@ async function prepareCachedTimeline(input: { const before = useSessionStore.getState().sessions[input.serverId]; const beforeTimeline = selectAgentTimelineState(before, input.agentId); if (beforeTimeline.status === "synced") return undefined; - const beforeHead = before?.agentStreamHead.get(input.agentId); - await input.prepareAgent(input.agentId); - const stored = await input.storage.readTimeline(input.serverId, input.agentId); + const [stored] = await Promise.all([ + input.storage.readTimeline(input.serverId, input.agentId), + input.prepareAgent(input.agentId), + ]); if (!stored) return undefined; const session = useSessionStore.getState().sessions[input.serverId]; const currentTimeline = selectAgentTimelineState(session, input.agentId); const currentHead = session?.agentStreamHead.get(input.agentId); if (currentTimeline.status === "synced") return undefined; if (!stored.range) { - if (currentHead !== beforeHead) return undefined; if (beforeTimeline.status === "painted") { return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items ? stored @@ -63,18 +63,20 @@ async function prepareCachedTimeline(input: { currentTimeline.status === "painted" ? [...currentTimeline.items, ...(currentHead ?? [])] : (currentHead ?? []); - const replacement = stored.range - ? replaceWithCanonicalStream({ - canonical: stored.items, - previousTail: [], - previousHead: liveItems, - sendingClientMessageIds: getSendingClientMessageIds( - session?.messageSubmissions.get(input.agentId), - ), - preserveContinuity: true, - canonicalCoverage: stored.range, - }) - : { tail: stored.items, head: liveItems, acknowledgedClientMessageIds: [] }; + const replacement = replaceWithCanonicalStream({ + canonical: stored.items, + previousTail: [], + previousHead: liveItems, + sendingClientMessageIds: getSendingClientMessageIds( + session?.messageSubmissions.get(input.agentId), + ), + preserveContinuity: true, + // Display-only rows claim no coverage over the live head. + canonicalCoverage: stored.range ?? { + epoch: liveItems.find((item) => item.timelineCursor)?.timelineCursor?.epoch ?? "", + endSeq: null, + }, + }); useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { items: replacement.tail, head: replacement.head, @@ -141,13 +143,13 @@ class TimelineReplicaOwner implements TimelineReplica { timelineUpdated(agentId: string): void { const session = useSessionStore.getState().sessions[this.serverId]; const timeline = selectAgentTimelineState(session, agentId); - if (timeline.status !== "synced") return; - this.cachedRanges.delete(agentId); + if (timeline.status === "cold") return; + if (timeline.status === "synced") this.cachedRanges.delete(agentId); this.storage.commitTimeline(this.serverId, agentId, { agentId, items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], - range: timeline.range, - hasOlder: timeline.older === "available", + range: timeline.status === "synced" ? timeline.range : null, + hasOlder: timeline.status === "synced" && timeline.older === "available", }); } } @@ -245,12 +247,9 @@ function finalizeProcessedTimeline(input: { clearAgentInitializingFlag(input.serverId, input.agentId); } if (input.synchronized) { - useCreateFlowStore - .getState() - .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); const session = useSessionStore.getState().sessions[input.serverId]; const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.status !== "running") input.drainQueuedAgentMessage(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); } if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); } @@ -306,21 +305,24 @@ function applyAuthoritativeTimelineResponse(input: { } export interface ViewedTimelineSyncPorts { - initialDeliveryMode: TimelineDeliveryMode; prepare(agentId: string): Promise; replaceDemandedAgentIds(agentIds: string[]): void; - setSubscription(agentIds: string[]): Promise; + observe(agentIds: string[]): { ready: Promise; release(): Promise }; readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; fetchPage( agentId: string, request: ProjectedTimelineForwardFetchPlan, ): Promise; fetchLatestTail(agentId: string): Promise; + /** + * The sync no longer owes this chat a catch-up: it reached current, or it left the + * demanded set. Disconnect and backgrounding keep the obligation and do not report here. + */ + onCatchUpEnded(agentId: string): void; reportError(error: unknown): void; schedule(task: () => void, delayMs: number): () => void; } -export type TimelineDeliveryMode = "legacy" | "selective"; export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying"; export interface ViewedTimelineUiBridge { @@ -332,16 +334,21 @@ export interface ViewedTimelineUiBridge { } export interface ViewedTimelineSync extends ViewedTimelineUiBridge { + /** + * The agent tabs that currently exist in workspace layout. This only ever *releases* + * subscriptions: closing a tab drops its chat. It never adds one, because layout is + * restored from disk at launch and those tabs are chats the user opened days ago. + */ + replaceOpenTabAgentIds(agentIds: string[]): void; setActive(active: boolean): void; setConnected(connected: boolean): void; - setDeliveryMode(mode: TimelineDeliveryMode): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; dispose(): void; } export type ViewedTimelineOwnerPorts = Omit< ViewedTimelineSyncPorts, - "prepare" | "replaceDemandedAgentIds" + "prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded" >; export interface ViewedTimelineOwner extends ViewedTimelineSync { @@ -357,11 +364,22 @@ export function createViewedTimelineOwner(input: { drainQueuedAgentMessage: (agentId: string) => void; ports: ViewedTimelineOwnerPorts; }): ViewedTimelineOwner { + const forcedTailReplacements = new Set(); const sync = createViewedTimelineSync({ ...input.ports, + fetchLatestTail: async (agentId) => { + forcedTailReplacements.add(agentId); + try { + return await input.ports.fetchLatestTail(agentId); + } finally { + forcedTailReplacements.delete(agentId); + } + }, prepare: (agentId) => input.replica.prepare(agentId), readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), replaceDemandedAgentIds: input.replaceDemandedAgentIds, + onCatchUpEnded: (agentId) => + useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }), }); const streamQueue = createSessionAgentStreamReducerQueue({ serverId: input.serverId, @@ -372,7 +390,8 @@ export function createViewedTimelineOwner(input: { }); return { ...sync, - applyTimelineResponse(payload) { + applyTimelineResponse(receivedPayload) { + const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements); const accepted = applyAuthoritativeTimelineResponse({ serverId: input.serverId, payload, @@ -397,7 +416,6 @@ export function createViewedTimelineOwner(input: { const RETRY_DELAY_MS = 1_000; const MAX_RETRY_DELAY_MS = 30_000; -const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5; type CatchUpStatus = "running" | "complete" | "error"; @@ -463,7 +481,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const catchUps = new Map(); const catchUpGenerations = new Map(); // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. - // Acknowledgement and tail completion are the only drain points. + // Membership acknowledgement and catch-up completion drain parked requests. const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Map(); @@ -475,46 +493,38 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const listeners = new Set<() => void>(); let active = true; let connected = false; - let deliveryMode = ports.initialDeliveryMode; + let observation: { + handle: ReturnType; + agentIds: string[]; + } | null = null; let disposed = false; let desired: string[] = []; let acknowledged: string[] = []; let membershipGeneration = 0; - let reconciling = false; - let reconcileRequested = false; let membershipNeedsRetry = false; let membershipRetryDelayMs: number | undefined; let cancelMembershipRetry: (() => void) | null = null; - let recentlyViewedAgentIds: string[] = []; + // Chats this session has opened. Visibility is the only way in, so an agent is never + // subscribed — and never resumed on the daemon — until the user actually opens it. + // Membership then survives hiding, workspace switches, backgrounding and reconnect; + // only closing the tab or ending the session takes it out. + let openedAgentIds: string[] = []; const visibleAgentIds = () => normalizeAgentIds([...sources.values()].flat()); - const selectHotAgentIds = (visible: string[]) => { - const visibleSet = new Set(visible); - recentlyViewedAgentIds = [ - ...visible, - ...recentlyViewedAgentIds.filter((agentId) => !visibleSet.has(agentId)), - ]; - const hiddenBudget = Math.max(0, VIEWED_TIMELINE_HOT_AGENT_LIMIT - visible.length); - const desiredAgentIds = normalizeAgentIds([ - ...visible, - ...recentlyViewedAgentIds - .filter((agentId) => !visibleSet.has(agentId)) - .slice(0, hiddenBudget), - ]); - const desiredSet = new Set(desiredAgentIds); - recentlyViewedAgentIds = recentlyViewedAgentIds.filter((agentId) => desiredSet.has(agentId)); - return desiredAgentIds; + const rememberOpenedAgentIds = (agentIds: string[]) => { + if (agentIds.length === 0) return; + const next = normalizeAgentIds([...openedAgentIds, ...agentIds]); + if (sameAgentIds(next, openedAgentIds)) return; + openedAgentIds = next; }; const isAcknowledged = (agentId: string) => acknowledged.includes(agentId); const isDesired = (agentId: string) => desired.includes(agentId); + const canCatchUp = (agentId: string) => + !disposed && connected && isDesired(agentId) && isAcknowledged(agentId); const ownsCatchUp = (agentId: string, generation: number) => - !disposed && - connected && - isDesired(agentId) && - isAcknowledged(agentId) && - catchUps.get(agentId)?.generation === generation; + canCatchUp(agentId) && catchUps.get(agentId)?.generation === generation; const notifyListeners = () => { for (const listener of listeners) listener(); @@ -524,6 +534,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const wasPending = visibilityCatchUpPending.delete(agentId); const hadError = visibilityCatchUpErrors.delete(agentId); const wasRetrying = manualRetries.delete(agentId); + ports.onCatchUpEnded(agentId); if (wasPending || hadError || wasRetrying) notifyListeners(); }; @@ -562,6 +573,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (page.hasNewer && page.endCursor) { if (fallbackToLatestTailOnOverflow) { await ports.fetchLatestTail(agentId); + if (!ownsCatchUp(agentId, generation)) return; catchUps.set(agentId, { generation, status: "complete" }); setVisibilityCatchUpReady(agentId); return; @@ -602,6 +614,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed setVisibilityCatchUpError([agentId], error); ports.reportError(error); } + } finally { + startAcknowledgedCatchUps(); } }; @@ -619,6 +633,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cacheLoads.set(agentId, load); }; + // Existing streams and retries continue; only initial hidden catch-ups wait. + const shouldWaitForVisibleCatchUp = (agentId: string) => { + if (!active || catchUps.has(agentId)) return false; + const visible = visibleAgentIds(); + return ( + !visible.includes(agentId) && + visible.some((id) => { + const status = catchUps.get(id)?.status; + return isDesired(id) && (status === undefined || status === "running"); + }) + ); + }; + const startCatchUp = ( agentId: string, options: { @@ -627,7 +654,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } = {}, ) => { const { request, supersede = false } = options; - if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { + if (!canCatchUp(agentId)) { + if (request) pendingCatchUps.set(agentId, request); + return; + } + if (shouldWaitForVisibleCatchUp(agentId)) { if (request) pendingCatchUps.set(agentId, request); return; } @@ -667,8 +698,17 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }; const startAcknowledgedCatchUps = () => { - for (const agentId of acknowledged) { + const visible = visibleAgentIds(); + const ordered = [ + ...visible.filter(isAcknowledged), + ...acknowledged.filter((id) => !visible.includes(id)), + ]; + for (const agentId of ordered) { const pendingCatchUp = pendingCatchUps.get(agentId); + const current = catchUps.get(agentId); + // Completion of a different chat must not bypass a failed chat's backoff or + // supersede an in-flight tail that owns its parked gap recovery. + if (current && !(current.status === "complete" && pendingCatchUp)) continue; startCatchUp(agentId, { request: pendingCatchUp, supersede: Boolean(pendingCatchUp), @@ -676,78 +716,46 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed } }; - const reconcileLatestMembership = async (): Promise => { - if (disposed || !connected || deliveryMode !== "selective") return; + const reconcileMembership = async (): Promise => { + if (disposed || !connected) return; const generation = membershipGeneration; const requested = desired; - if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return; + if (!membershipNeedsRetry && sameAgentIds(requested, observation?.agentIds ?? acknowledged)) + return; membershipNeedsRetry = false; try { - await ports.setSubscription(requested); + const previous = observation; + const handle = ports.observe(requested); + observation = { handle, agentIds: requested }; + void previous?.handle.release().catch(ports.reportError); + await handle.ready; } catch (error) { + if (disposed || !connected || generation !== membershipGeneration) return; membershipNeedsRetry = true; setVisibilityCatchUpError(requested, error); cancelMembershipRetry?.(); const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); cancelMembershipRetry = ports.schedule(() => { cancelMembershipRetry = null; - if ( - disposed || - !connected || - membershipGeneration !== generation || - !sameAgentIds(desired, requested) - ) { - return; - } + if (disposed || !connected || membershipGeneration !== generation) return; void reconcileMembership(); }, nextRetryDelayMs); membershipRetryDelayMs = nextRetryDelayMs; ports.reportError(error); return; } + if (disposed || !connected || generation !== membershipGeneration) return; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - if (disposed || !connected || deliveryMode !== "selective") return; acknowledged = requested; - if (generation !== membershipGeneration) { - await reconcileLatestMembership(); - return; - } startAcknowledgedCatchUps(); - if (!sameAgentIds(desired, acknowledged)) await reconcileLatestMembership(); - }; - - const reconcileMembership = async () => { - if (reconciling) { - reconcileRequested = true; - return; - } - if (disposed || !connected) return; - reconciling = true; - try { - await reconcileLatestMembership(); - } finally { - reconciling = false; - if (reconcileRequested && !disposed && connected && deliveryMode === "selective") { - reconcileRequested = false; - void reconcileMembership(); - } else if ( - !disposed && - connected && - deliveryMode === "selective" && - !membershipNeedsRetry && - !sameAgentIds(desired, acknowledged) - ) { - void reconcileMembership(); - } - } }; const retryVisibleAgentTimeline = (agentId: string) => { if (!isDesired(agentId) || manualRetries.has(agentId)) return; const catchUp = catchUps.get(agentId); - const membershipRetryable = deliveryMode === "selective" && membershipNeedsRetry && connected; + const membershipRetryable = membershipNeedsRetry && connected; if (catchUp?.status !== "error" && !membershipRetryable) return; manualRetries.add(agentId); notifyListeners(); @@ -759,7 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; void reconcileMembership(); }; @@ -783,12 +790,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed return; } + const released: string[] = []; for (const agentId of desired) { if (!nextDesired.includes(agentId)) { cancelCatchUp(agentId); visibilityCatchUpPending.delete(agentId); visibilityCatchUpErrors.delete(agentId); manualRetries.delete(agentId); + released.push(agentId); } } for (const agentId of nextDesired) { @@ -804,25 +813,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed desired = nextDesired; ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; + for (const agentId of released) ports.onCatchUpEnded(agentId); notifyListeners(); - if (deliveryMode === "legacy") { - acknowledged = connected ? desired : []; - if (connected) startAcknowledgedCatchUps(); - return; - } void reconcileMembership(); }; const publishVisibleMembership = () => { - const visible = visibleAgentIds(); - if (!connected || deliveryMode !== "selective") { - const activeVisible = active ? visible : []; - recentlyViewedAgentIds = activeVisible; - commitDesiredMembership(activeVisible); - return; - } - if (!active) return; - commitDesiredMembership(selectHotAgentIds(visible)); + commitDesiredMembership(normalizeAgentIds([...openedAgentIds, ...visibleAgentIds()])); }; return { @@ -839,61 +836,53 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed getAgentTimelineError(agentId) { return visibilityCatchUpErrors.get(agentId) ?? null; }, + replaceOpenTabAgentIds(agentIds) { + const openTabs = new Set(agentIds); + const next = openedAgentIds.filter((agentId) => openTabs.has(agentId)); + if (sameAgentIds(next, openedAgentIds)) return; + openedAgentIds = next; + publishVisibleMembership(); + }, replaceVisibleAgentIds(sourceId, agentIds) { const normalized = normalizeAgentIds(agentIds); if (normalized.length === 0) sources.delete(sourceId); else sources.set(sourceId, normalized); + rememberOpenedAgentIds(normalized); publishVisibleMembership(); + startAcknowledgedCatchUps(); }, setActive(nextActive) { if (active === nextActive) return; active = nextActive; publishVisibleMembership(); + if (!active) { + startAcknowledgedCatchUps(); + return; + } + for (const agentId of visibleAgentIds()) { + visibilityCatchUpPending.add(agentId); + visibilityCatchUpErrors.delete(agentId); + startCatchUp(agentId, { supersede: true }); + } + notifyListeners(); }, setConnected(nextConnected) { if (connected === nextConnected) return; connected = nextConnected; if (!connected) { - const visible = active ? visibleAgentIds() : []; - recentlyViewedAgentIds = visible; - commitDesiredMembership(visible, { resetCatchUpStatus: true }); + commitDesiredMembership(desired, { resetCatchUpStatus: true }); cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipRetryDelayMs = undefined; + void observation?.handle.release().catch(ports.reportError); + observation = null; acknowledged = []; membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); return; } membershipGeneration += 1; - if (deliveryMode === "legacy") { - acknowledged = desired; - startAcknowledgedCatchUps(); - } else { - void reconcileMembership(); - } - }, - setDeliveryMode(nextMode) { - if (deliveryMode === nextMode) return; - deliveryMode = nextMode; - cancelMembershipRetry?.(); - cancelMembershipRetry = null; - membershipRetryDelayMs = undefined; - membershipNeedsRetry = false; - membershipGeneration += 1; - for (const agentId of desired) cancelCatchUp(agentId); - const visible = active ? visibleAgentIds() : []; - recentlyViewedAgentIds = visible; - desired = visible; - ports.replaceDemandedAgentIds(desired); - visibilityCatchUpPending.clear(); - visibilityCatchUpErrors.clear(); - manualRetries.clear(); - for (const agentId of desired) visibilityCatchUpPending.add(agentId); - acknowledged = deliveryMode === "legacy" && connected ? desired : []; - notifyListeners(); - if (deliveryMode === "selective" && connected) void reconcileMembership(); - else if (connected) startAcknowledgedCatchUps(); + void reconcileMembership(); }, recoverGap(agentId, cursor) { if (!isDesired(agentId)) return; @@ -904,6 +893,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed }, dispose() { disposed = true; + void observation?.handle.release().catch(ports.reportError); + observation = null; cancelMembershipRetry?.(); cancelMembershipRetry = null; membershipNeedsRetry = false; @@ -914,7 +905,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed desired = []; ports.replaceDemandedAgentIds([]); acknowledged = []; - recentlyViewedAgentIds = []; + openedAgentIds = []; loadedCache.clear(); cacheLoads.clear(); visibilityCatchUpPending.clear(); diff --git a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx index 77990b3341..b3b5bf39eb 100644 --- a/packages/app/src/tool-calls/detail-level/overview/sheet.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/sheet.tsx @@ -1,10 +1,6 @@ import { useCallback, useEffect, useRef, type ReactNode } from "react"; import { Pressable, Text, View } from "react-native"; -import { - BottomSheetBackdrop, - BottomSheetScrollView, - type BottomSheetScrollViewMethods, -} from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorhom/bottom-sheet"; import { Wrench, X } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -38,12 +34,7 @@ export function OverviewToolCallGroupSheet({ visible, onClose, }); - const renderBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [], - ); + const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); @@ -64,7 +55,7 @@ export function OverviewToolCallGroupSheet({ enableDynamicSizing={false} onChange={handleSheetChange} onDismiss={handleSheetDismiss} - backdropComponent={renderBackdrop} + backdropOpacity={0.5} enablePanDownToClose > diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index c0e32c3124..dabf87b73d 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -63,6 +63,28 @@ function project(input: { } describe("tool call detail-level projection", () => { + it.each(["detailed", "overview"] as const)( + "keeps pending approval tools out of %s presentation without removing their canonical position", + (level) => { + const pending = toolCall( + "1", + { type: "plan", text: "Ship it" }, + { name: "ExitPlanMode", status: "running" }, + ); + const followUp = { + kind: "user_message", + id: "question", + text: "What about tests?", + timestamp: new Date(2), + } satisfies StreamItem; + const tail = [pending, followUp]; + expect(project({ level, tail }).tail).toEqual([followUp]); + expect(tail).toEqual([pending, followUp]); + const rejected = toolCall("1", { type: "plan", text: "Ship it" }, { name: "plan_approval" }); + expect(project({ level, tail: [rejected, followUp] }).tail).toEqual([rejected, followUp]); + }, + ); + it("passes detailed timelines through without grouping work", () => { const tail = [toolCall("1", { type: "shell", command: "one" })]; const head = [toolCall("2", { type: "shell", command: "two" })]; diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 1740e8500b..5dc1067914 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -20,6 +20,24 @@ export interface ToolCallDetailProjection extends GroupedToolCalls(); +// Approval UI owns pending plan presentation. Retain the canonical tool in the +// stream model so resolving it can reveal a card at its original position. +const visibleItemsCache = new WeakMap(); +function visibleToolCallItems(items: StreamItem[]): StreamItem[] { + const cached = visibleItemsCache.get(items); + if (cached) return cached; + const visible = items.filter((item) => { + if (item.kind !== "tool_call" || item.payload.source !== "agent") return true; + const data = item.payload.data; + return ( + data.name !== "ExitPlanMode" && !(data.name === "plan_approval" && data.status === "running") + ); + }); + const result = visible.length === items.length ? items : visible; + visibleItemsCache.set(items, result); + return result; +} + export function prepareToolCallHistory( level: ToolCallDetailLevel, tail: StreamItem[], @@ -29,7 +47,10 @@ export function prepareToolCallHistory( } return { mode: "overview", - grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }), + grouped: prepareGroupedHistory({ + tail: visibleToolCallItems(tail), + buildGroup: buildOverviewGroup, + }), }; } @@ -42,8 +63,8 @@ export function projectToolCallDetailLevel(input: { }): ToolCallDetailProjection { if (input.level === "detailed") { return { - tail: input.tail, - head: input.head, + tail: visibleToolCallItems(input.tail), + head: visibleToolCallItems(input.head), groupsByHostId: EMPTY_TOOL_CALL_GROUPS, historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS, }; @@ -53,7 +74,7 @@ export function projectToolCallDetailLevel(input: { } return groupLiveToolCalls({ history: input.preparedHistory.grouped, - head: input.head, + head: visibleToolCallItems(input.head), isTurnActive: input.isTurnActive, buildGroup: buildOverviewGroup, }); diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts index 3caf235ace..fde0ba020f 100644 --- a/packages/app/src/tool-calls/presentation.test.ts +++ b/packages/app/src/tool-calls/presentation.test.ts @@ -92,3 +92,42 @@ describe("tool-call presentation", () => { expect(presentation.icon).toBe(fakeIcons.brain); }); }); + +it.each([ + ["message", "Message from QA messenger"], + ["finished", "QA messenger finished"], + ["errored", "QA messenger errored"], + ["permission-required", "QA messenger needs permission"], + ["closed", "QA messenger closed"], +] as const)("presents %s from structured provenance without a second label", (event, heading) => { + const presentation = buildToolCallPresentation({ + toolName: "internal_name_should_not_be_visible", + status: "completed", + error: null, + detail: { type: "plain_text", label: "legacy summary", text: "legacy body" }, + agentMessage: { + event, + sender: { id: "server::sender", title: "QA messenger" }, + text: "The delivered message.", + }, + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe(heading); + expect(presentation.summary).toBeUndefined(); + expect(presentation.detail).toEqual({ + type: "plain_text", + icon: "bot", + text: "From agent: server::sender\n\nThe delivered message.", + }); +}); + +it("uses an opaque sender ID when no title is available", () => { + const presentation = buildToolCallPresentation({ + toolName: "agent_message", + status: "completed", + error: null, + agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" }, + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe("Message from remote::worker"); +}); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index e43aacf771..f25d752624 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,3 +1,6 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; +import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display"; +import type { PlanOutcome } from "@/components/plan-card"; import type { ComponentType } from "react"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { ToolCallDisplayInput } from "@/utils/tool-call-display"; @@ -12,6 +15,7 @@ type ToolCallStatus = "executing" | "running" | "completed" | "failed" | "cancel export type ToolCallPresentationIcon = ComponentType<{ size?: number; color?: string }>; interface BuildToolCallPresentationInput { + agentMessage?: AgentMessage; toolName: string; status: ToolCallStatus; error: unknown; @@ -22,6 +26,7 @@ interface BuildToolCallPresentationInput { } export interface ToolCallPresentation { + detail: ToolCallDetail | undefined; displayName: string; summary?: string; errorText?: string; @@ -31,6 +36,7 @@ export interface ToolCallPresentation { canOpenDetails: boolean; openFilePath: string | null; isPlan: boolean; + planOutcome?: PlanOutcome; } export type ToolCallIconResolver = ( @@ -49,9 +55,13 @@ function displayDetail(detail: ToolCallDetail | undefined): ToolCallDetail { export function buildToolCallPresentation( input: BuildToolCallPresentationInput, ): ToolCallPresentation { - const detailForDisplay = displayDetail(input.detail); + const detail = input.agentMessage + ? buildAgentMessageDisplay(input.agentMessage).detail + : input.detail; + const detailForDisplay = displayDetail(detail); const displayModel = buildToolCallDisplayModel({ name: input.toolName, + agentMessage: input.agentMessage, status: displayStatus(input.status), error: input.error ?? null, detail: detailForDisplay, @@ -59,21 +69,31 @@ export function buildToolCallPresentation( cwd: input.cwd, }); const isLoadingDetails = isPendingToolCallDetail({ - detail: input.detail, + detail, status: input.status, error: input.error, }); - const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(input.detail); + const hasDetails = Boolean(input.error) || hasMeaningfulToolCallDetail(detail); return { + detail, displayName: displayModel.displayName, summary: displayModel.summary, errorText: displayModel.errorText, - icon: input.resolveIcon(input.toolName, input.detail), + icon: input.resolveIcon(input.toolName, detail), isLoadingDetails, hasDetails, canOpenDetails: hasDetails || isLoadingDetails, - openFilePath: extractToolCallFilePath(input.detail), + openFilePath: extractToolCallFilePath(detail), isPlan: input.detail?.type === "plan", + planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined, }; } + +function resolvePlanOutcome(input: BuildToolCallPresentationInput): PlanOutcome | undefined { + if (input.status === "canceled") return "canceled"; + if (input.metadata?.approved === false) return "rejected"; + if (input.metadata?.approved === true) return "approved"; + if (input.status === "running" || input.status === "executing") return "pending"; + return undefined; +} diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 19beea5ecb..8ac1870a6f 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "turn" | "lastActivityAt" | "cwd" | "workspaceId" diff --git a/packages/app/src/types/host-connection.test.ts b/packages/app/src/types/host-connection.test.ts index b7324807b6..a476c32089 100644 --- a/packages/app/src/types/host-connection.test.ts +++ b/packages/app/src/types/host-connection.test.ts @@ -76,6 +76,15 @@ describe("normalizeStoredHostProfile", () => { expect(profile?.connections[0]).not.toHaveProperty("password"); }); + it("moves a stored direct TCP password to the host profile", () => { + const profile = normalizeStoredHostProfile({ + serverId: "srv_legacy", + connections: [{ type: "directTcp", endpoint: "localhost:6767", password: "old-secret" }], + }); + expect(profile?.password).toBe("old-secret"); + expect(profile?.connections[0]).not.toHaveProperty("password"); + }); + it("preserves legacy relay ids when TLS is absent", () => { const profile = normalizeStoredHostProfile({ serverId: "srv_relay", @@ -228,26 +237,28 @@ describe("upsertHostConnectionInProfiles", () => { type: "directTcp", endpoint: "example.test:6767", useTls: false, - password: "old-secret", }; const existing: HostProfile = { ...makeHost("srv_known"), + password: "old-secret", connections: [existingConnection], preferredConnectionId: existingConnection.id, }; const replacement: HostConnection = { ...existingConnection, useTls: true, - password: "new-secret", }; const [profile] = upsertHostConnectionInProfiles({ profiles: [existing], serverId: "srv_known", connection: replacement, + password: "new-secret", }); expect(profile.connections).toEqual([replacement]); + expect("password" in profile.connections[0]!).toBe(false); + expect(profile.password).toBe("new-secret"); expect(profile.preferredConnectionId).toBe(replacement.id); }); }); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 4b6cd6d5a9..0962c02c7c 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -1,10 +1,12 @@ +import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer"; import { normalizeHostPort, normalizeLoopbackToLocalhost, + shouldUseTlsForDefaultHostedRelay, } from "@getpaseo/protocol/daemon-endpoints"; import { DirectTcpHostConnectionSchema, - type DirectTcpHostConnection, + type DirectTcpHostConnection as WireDirectTcpHostConnection, } from "@getpaseo/protocol/host-connection-schema"; import { DEFAULT_SSH_DAEMON_PORT, @@ -18,7 +20,8 @@ import { } from "@/hosts/appearance"; import { z } from "zod"; -export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; +export { DirectTcpHostConnectionSchema }; +export type DirectTcpHostConnection = Omit; export interface DirectSocketHostConnection { id: string; @@ -59,6 +62,7 @@ export type HostLifecycle = Record; export interface HostProfile { serverId: string; + password?: string; label: string; appearance: HostAppearance; lifecycle: HostLifecycle; @@ -125,11 +129,7 @@ function hostConnectionEquals(left: HostConnection, right: HostConnection): bool } if (left.type === "directTcp" && right.type === "directTcp") { - return ( - left.endpoint === right.endpoint && - (left.useTls ?? false) === (right.useTls ?? false) && - left.password === right.password - ); + return left.endpoint === right.endpoint && (left.useTls ?? false) === (right.useTls ?? false); } if (left.type === "directSocket" && right.type === "directSocket") { return left.path === right.path; @@ -186,11 +186,40 @@ function upsertHostConnectionById( return next; } +function hasProfileChanged(input: { + matchingCount: number; + previous: HostProfile; + serverId: string; + password?: string; + createdAt: string; + label: string; + preferredConnectionId: string; + lifecycle: HostLifecycle; + connections: HostConnection[]; +}): boolean { + const { previous, connections } = input; + return ( + input.matchingCount > 1 || + previous.serverId !== input.serverId || + (input.password !== undefined && input.password !== previous.password) || + input.createdAt !== previous.createdAt || + input.label !== previous.label || + input.preferredConnectionId !== previous.preferredConnectionId || + !hostLifecycleEquals(previous.lifecycle, input.lifecycle) || + connections.length !== previous.connections.length || + connections.some((candidate, index) => { + const old = previous.connections[index]; + return !old || !hostConnectionEquals(candidate, old); + }) + ); +} + export function upsertHostConnectionInProfiles(input: { profiles: HostProfile[]; serverId: string; label?: string; connection: HostConnection; + password?: string; now?: string; }): HostProfile[] { const serverId = input.serverId.trim(); @@ -199,13 +228,17 @@ export function upsertHostConnectionInProfiles(input: { } const now = input.now ?? new Date().toISOString(); + const password = input.password; + const normalizedConnection = input.connection; const labelTrimmed = input.label?.trim() ?? ""; const derivedLabel = labelTrimmed || serverId; const existing = input.profiles; const matchingIndexes = existing.reduce((matches, daemon, index) => { if ( daemon.serverId === serverId || - daemon.connections.some((connection) => hostConnectionEquals(connection, input.connection)) + daemon.connections.some((existingConnection) => + hostConnectionEquals(existingConnection, normalizedConnection), + ) ) { matches.push(index); } @@ -215,11 +248,12 @@ export function upsertHostConnectionInProfiles(input: { if (matchingIndexes.length === 0) { const profile: HostProfile = { serverId, + ...(password ? { password } : {}), label: derivedLabel, appearance: defaultHostAppearance(), lifecycle: defaultLifecycle(), - connections: [input.connection], - preferredConnectionId: input.connection.id, + connections: [normalizedConnection], + preferredConnectionId: normalizedConnection.id, createdAt: now, updatedAt: now, }; @@ -230,7 +264,7 @@ export function upsertHostConnectionInProfiles(input: { const prev = matchedProfiles.find((daemon) => daemon.serverId === serverId) ?? matchedProfiles[0]; const nextConnections = upsertHostConnectionById( matchedProfiles.flatMap((daemon) => daemon.connections), - input.connection, + normalizedConnection, ); const nextLifecycle = prev.lifecycle; const nextLabel = prev.label === prev.serverId ? derivedLabel : prev.label; @@ -238,23 +272,22 @@ export function upsertHostConnectionInProfiles(input: { prev.preferredConnectionId && nextConnections.some((connection) => connection.id === prev.preferredConnectionId) ? prev.preferredConnectionId - : input.connection.id; + : normalizedConnection.id; const nextCreatedAt = matchedProfiles.reduce( (earliest, daemon) => (daemon.createdAt < earliest ? daemon.createdAt : earliest), prev.createdAt, ); - const changed = - matchingIndexes.length > 1 || - prev.serverId !== serverId || - nextCreatedAt !== prev.createdAt || - nextLabel !== prev.label || - nextPreferredConnectionId !== prev.preferredConnectionId || - !hostLifecycleEquals(prev.lifecycle, nextLifecycle) || - nextConnections.length !== prev.connections.length || - nextConnections.some((connection, index) => { - const previousConnection = prev.connections[index]; - return !previousConnection || !hostConnectionEquals(connection, previousConnection); - }); + const changed = hasProfileChanged({ + matchingCount: matchingIndexes.length, + previous: prev, + serverId, + password, + createdAt: nextCreatedAt, + label: nextLabel, + preferredConnectionId: nextPreferredConnectionId, + lifecycle: nextLifecycle, + connections: nextConnections, + }); if (!changed) { return existing; @@ -262,6 +295,7 @@ export function upsertHostConnectionInProfiles(input: { const nextProfile: HostProfile = { ...prev, + ...(password ? { password } : {}), serverId, label: nextLabel, lifecycle: nextLifecycle, @@ -322,6 +356,19 @@ export function connectionFromListen(listen: string): HostConnection | null { } } +export function relayConnectionFromOffer(offer: ConnectionOffer): RelayHostConnection { + // COMPAT(oldRelayOfferTls): added in v0.1.73, remove after 2026-11-10. + const useTls = offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint); + const relayEndpoint = normalizeHostPort(offer.relay.endpoint); + return { + id: useTls ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, + type: "relay", + relayEndpoint, + useTls, + daemonPublicKeyB64: offer.daemonPublicKeyB64.trim(), + }; +} + export function createRemoteSshHostConnection(input: { host: string; sshPort?: number; @@ -386,6 +433,7 @@ const StoredHostConnectionSchema = z.discriminatedUnion("type", [ ]); const StoredHostProfileSchema = z.strictObject({ serverId: z.string().trim().min(1), + password: z.string().optional(), label: z.string().optional(), appearance: HostAppearanceSchema.optional(), lifecycle: z.strictObject({}).optional(), @@ -401,13 +449,13 @@ function normalizeStoredConnection(connection: StoredHostConnection): HostConnec if (connection.type === "directTcp") { try { const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); - return DirectTcpHostConnectionSchema.parse({ + const parsed = DirectTcpHostConnectionSchema.parse({ id: `direct:${endpoint}`, type: "directTcp", endpoint, useTls: connection.useTls, - ...(connection.password !== undefined ? { password: connection.password } : {}), }); + return { id: parsed.id, type: parsed.type, endpoint: parsed.endpoint, useTls: parsed.useTls }; } catch { return null; } @@ -459,6 +507,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { } const record = result.data; const serverId = record.serverId; + // COMPAT(connectionPassword): added in v0.9.1, remove after 2027-03-24 once stored direct passwords have migrated. + const legacyPassword = record.connections.find( + (connection) => connection.type === "directTcp" && connection.password, + ); + const password = + record.password ?? (legacyPassword?.type === "directTcp" ? legacyPassword.password : undefined); const connections = record.connections .map((connection) => normalizeStoredConnection(connection)) @@ -478,6 +532,7 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { return { serverId, + ...(password ? { password } : {}), label, appearance: record.appearance ?? defaultHostAppearance(), lifecycle: defaultLifecycle(), diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index e49713f7c1..62e26698db 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -23,6 +23,51 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +it("updates a resolved Claude plan at its proposal position across a follow-up", () => { + const proposal = { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { + type: "tool_call", + callId: "plan-1", + name: "ExitPlanMode", + status: "running", + error: null, + detail: { type: "plan", text: "Ship it" }, + }, + } satisfies AgentStreamEventPayload; + const pending = reduceStreamUpdate([], proposal, new Date(1)); + const steered = reduceStreamUpdate( + pending, + { + type: "timeline", + provider: "claude", + turnId: "turn-1", + item: { type: "user_message", text: "What about tests?", messageId: "question" }, + }, + new Date(2), + ); + const resolved = reduceStreamUpdate( + steered, + { + ...proposal, + item: { + ...proposal.item, + name: "plan_approval", + status: "completed", + metadata: { approved: false }, + }, + }, + new Date(3), + ); + expect(resolved.map((item) => item.kind)).toEqual(["tool_call", "user_message"]); + expect(resolved[0]?.id).toBe(pending[0]?.id); + expect(resolved[0]).toMatchObject({ + payload: { data: { name: "plan_approval", metadata: { approved: false } } }, + }); +}); + describe("plugin timeline rows", () => { it("uses the protocol identity format for stream tool and plugin rows", () => { const tool = { @@ -312,6 +357,7 @@ function reasoningTimeline( function canonicalToolTimeline(params: { provider: AgentProvider; callId: string; + turnId?: string; name: string; status: CanonicalToolStatus; input?: unknown; @@ -350,6 +396,7 @@ function canonicalToolTimeline(params: { return { type: "timeline", provider: params.provider, + ...(params.turnId ? { turnId: params.turnId } : {}), item, }; } @@ -584,6 +631,106 @@ describe("stream reducer tool call idempotency", () => { }); describe("stream reducer canonical tool calls", () => { + it("keeps repeated call ids in different turns as distinct timeline rows", () => { + const callId = "tool-reused-across-turns"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-1", + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId: "autonomous-turn-2", + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + const tools = state.filter(isAgentToolCallItem); + expect(tools.map((tool) => tool.turnId)).toEqual(["autonomous-turn-1", "autonomous-turn-2"]); + expect(new Set(tools.map((tool) => tool.id)).size).toBe(2); + }); + + it("merges lifecycle updates for the same call occurrence", () => { + const callId = "tool-updated-within-turn"; + const turnId = "autonomous-turn-1"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + turnId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_turn:${turnId}/${callId}`, + turnId, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + + it("preserves call-id lifecycle merging for events without turn identity", () => { + const callId = "legacy-unscoped-tool"; + const state = hydrateStreamState([ + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "running", + }), + timestamp: new Date("2025-01-01T09:00:00Z"), + }, + { + event: canonicalToolTimeline({ + provider: "claude", + callId, + name: "Task", + status: "completed", + }), + timestamp: new Date("2025-01-01T09:01:00Z"), + }, + ]); + + expect(state.filter(isAgentToolCallItem)).toEqual([ + expect.objectContaining({ + id: `agent_tool_${callId}`, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("is deterministic for equivalent hydration sequences", () => { const updates = [ { @@ -736,7 +883,7 @@ describe("stream reducer canonical tool calls", () => { expect(new Set(messages.map((message) => message.id)).size).toBe(2); }); - it("keeps every promoted block when an assistant message resumes after a tool", () => { + it("keeps whole messages when an assistant resumes after a tool", () => { const messageId = "msg-promoted-resume"; let tail: StreamItem[] = []; let head: StreamItem[] = []; @@ -779,15 +926,13 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "Before one.", - "Before two.", - "After one.", - "After two.", + "Before one.\n\nBefore two.", + "After one.\n\nAfter two.", ]); expect(new Set(messages.map((message) => message.id)).size).toBe(messages.length); }); - it("keeps the timeline position on every promoted assistant block", () => { + it("keeps the timeline position on the whole assistant message", () => { const timelineCursor = { epoch: "epoch-1", seq: 42 }; const result = applyStreamEvent({ tail: [], @@ -802,13 +947,9 @@ describe("stream reducer canonical tool calls", () => { item.kind === "assistant_message", ); expect(messages.map((message) => message.text)).toEqual([ - "First paragraph.", - "Second paragraph.", - ]); - expect(messages.map((message) => message.timelineCursor)).toEqual([ - timelineCursor, - timelineCursor, + "First paragraph.\n\nSecond paragraph.", ]); + expect(messages.map((message) => message.timelineCursor)).toEqual([timelineCursor]); }); it("preserves old assistant merge behavior when message ids are absent", () => { @@ -2071,3 +2212,121 @@ describe("turn lifecycle events", () => { ); }); }); + +describe("notification timeline items", () => { + it("maps notification items to activity log entries with the matching level", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const state = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "info", message: "Search finished" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "warning", message: "Command blocked" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "error", message: "Turn failed" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "info", message: "Default info" }, + }, + timestamp, + }, + ], + { source: "canonical" }, + ); + + expect( + state.map((item) => + item.kind === "notification" + ? { kind: item.kind, level: item.level, message: item.message } + : { kind: item.kind }, + ), + ).toEqual([ + { kind: "notification", level: "info", message: "Search finished" }, + { kind: "notification", level: "warning", message: "Command blocked" }, + { kind: "notification", level: "error", message: "Turn failed" }, + { kind: "notification", level: "info", message: "Default info" }, + ]); + }); + + it("keeps repeated notifications with the same text in the same millisecond", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const state = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "warning", message: "Command blocked" }, + }, + timestamp, + }, + { + event: { + type: "timeline", + provider: "pi", + item: { type: "notification", level: "error", message: "Command blocked" }, + }, + timestamp, + }, + ], + { source: "canonical" }, + ); + + expect( + state.map((item) => + item.kind === "notification" + ? { kind: item.kind, level: item.level, message: item.message } + : { kind: item.kind }, + ), + ).toEqual([ + { kind: "notification", level: "warning", message: "Command blocked" }, + { kind: "notification", level: "error", message: "Command blocked" }, + ]); + expect(new Set(state.map((item) => item.id)).size).toBe(state.length); + }); +}); + +it("retains agent-message provenance through live delivery and repeated history", () => { + const agentMessage = { + event: "finished" as const, + sender: { id: "remote::worker", title: "Reviewer" }, + text: "Done", + }; + const event = { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + agentMessage, + }, + } satisfies AgentStreamEventPayload; + const live = reduceStreamUpdate([], event, new Date(1)); + const replay = reduceStreamUpdate(live, event, new Date(2)); + expect(replay).toHaveLength(1); + expect(replay[0]).toMatchObject({ payload: { data: { agentMessage } } }); +}); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index cdde4bdf1c..56cf14558b 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentProvider, AgentTimelineItem, @@ -8,7 +9,6 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; -import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; /** * Simple hash function for deterministic ID generation @@ -84,7 +84,7 @@ export type StreamItem = | ThoughtItem | ToolCallItem | TodoListItem - | ActivityLogItem + | NotificationItem | CompactionItem | PluginTimelineStreamItem; @@ -449,7 +449,11 @@ function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): S return null; } if (isAgentToolCallItem(retained)) { - const tailIndex = findExistingTimelineIdentityIndex(tail, retained.payload.data.callId); + const identity = agentToolCallIdentity({ + callId: retained.payload.data.callId, + turnId: retained.turnId, + }); + const tailIndex = findExistingTimelineIdentityIndex(tail, identity); const existing = tail[tailIndex]; if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { return null; @@ -707,6 +711,7 @@ export interface AssistantMessageItem { timelineCursor?: TimelinePosition; text: string; timestamp: Date; + /** Display-only fields, assigned after source-item plugin transforms. */ blockGroupId?: string; blockIndex?: number; } @@ -741,6 +746,7 @@ interface OrchestratorToolCallData { } export interface AgentToolCallData { + agentMessage?: AgentMessage; provider: AgentProvider; callId: string; name: string; @@ -771,17 +777,17 @@ export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem return item.kind === "tool_call" && item.payload.source === "agent"; } -type ActivityLogType = "system" | "info" | "success" | "error"; +type NotificationLevel = "info" | "warning" | "error"; -export interface ActivityLogItem { - kind: "activity_log"; +export interface NotificationItem { + kind: "notification"; + sourceType: "error" | "notification"; id: string; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date; - activityType: ActivityLogType; + level: NotificationLevel; message: string; - metadata?: Record; } export interface CompactionItem { @@ -798,6 +804,8 @@ export interface CompactionItem { export interface PluginTimelineStreamItem { kind: "plugin"; id: string; + /** The id of the stream item a timeline transformer replaced; set only by projection. */ + sourceId?: string; timelineCursor?: TimelinePosition; turnId?: string; timestamp: Date; @@ -1022,11 +1030,26 @@ function finalizeActiveThoughts(state: StreamItem[]): StreamItem[] { } export function streamTimelineItemIdentity(item: StreamItem): string | null { - if (isAgentToolCallItem(item)) return item.payload.data.callId; + if (isAgentToolCallItem(item)) { + return agentToolCallIdentity({ + callId: item.payload.data.callId, + turnId: item.turnId, + }); + } if (item.kind === "plugin") return `${item.pluginId}/${item.pluginItemId}`; return null; } +interface AgentToolCallIdentityInput { + callId: string; + turnId?: string; +} + +function agentToolCallIdentity(input: AgentToolCallIdentityInput): string { + if (!input.turnId) return input.callId; + return `turn:${encodeURIComponent(input.turnId)}/${encodeURIComponent(input.callId)}`; +} + function findExistingTimelineIdentityIndex(state: StreamItem[], identity: string): number { return state.findIndex((entry) => streamTimelineItemIdentity(entry) === identity); } @@ -1165,13 +1188,18 @@ export function mergeAgentToolCallItem( }; } -function appendAgentToolCall( - state: StreamItem[], - data: AgentToolCallData, - timestamp: Date, - timelineCursor?: TimelinePosition, -): StreamItem[] { - const existingIndex = findExistingTimelineIdentityIndex(state, data.callId); +interface AppendAgentToolCallInput { + state: StreamItem[]; + data: AgentToolCallData; + timestamp: Date; + turnId?: string; + timelineCursor?: TimelinePosition; +} + +function appendAgentToolCall(input: AppendAgentToolCallInput): StreamItem[] { + const { state, data, timestamp, turnId, timelineCursor } = input; + const identity = agentToolCallIdentity({ callId: data.callId, turnId }); + const existingIndex = findExistingTimelineIdentityIndex(state, identity); if (existingIndex >= 0) { const existing = state[existingIndex]; @@ -1200,8 +1228,9 @@ function appendAgentToolCall( const item: ToolCallItem = { kind: "tool_call", - id: `agent_tool_${data.callId}`, + id: `agent_tool_${identity}`, ...(timelineCursor ? { timelineCursor } : {}), + ...(turnId ? { turnId } : {}), timestamp, payload: { source: "agent", @@ -1215,6 +1244,16 @@ function appendAgentToolCall( return [...state, item]; } +function appendNotification(state: StreamItem[], entry: NotificationItem): StreamItem[] { + const index = state.findIndex((existing) => existing.id === entry.id); + if (index >= 0) { + const next = [...state]; + next[index] = entry; + return next; + } + return [...state, entry]; +} + function appendPluginTimelineItem( state: StreamItem[], item: Extract, @@ -1243,16 +1282,6 @@ function appendPluginTimelineItem( return next; } -function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { - const index = state.findIndex((existing) => existing.id === entry.id); - if (index >= 0) { - const next = [...state]; - next[index] = entry; - return next; - } - return [...state, entry]; -} - function appendTodoList( state: StreamItem[], provider: AgentProvider, @@ -1374,10 +1403,6 @@ function reduceTimelineToolCall( .trim() .replace(/[.\s-]+/g, "_") .toLowerCase(); - if (event.provider === "claude" && normalizedToolName === "exitplanmode") { - return state; - } - if ( event.provider === "claude" && (normalizedToolName === "todowrite" || normalizedToolName === "todo_write") @@ -1415,9 +1440,9 @@ function reduceTimelineToolCall( ); } - return appendAgentToolCall( + return appendAgentToolCall({ state, - { + data: { provider: event.provider, callId: item.callId, name: item.name, @@ -1425,10 +1450,12 @@ function reduceTimelineToolCall( error: item.error, detail: item.detail, metadata: item.metadata, + ...(item.agentMessage ? { agentMessage: item.agentMessage } : {}), }, timestamp, timelineCursor, - ); + turnId: event.turnId, + }); } function reduceTimelineCompaction( @@ -1459,7 +1486,7 @@ function reduceTimelineCompaction( } const compaction: CompactionItem = { kind: "compaction", - id: createTimelineId("compaction", item.status, timestamp), + id: createUniqueTimelineId(state, "compaction", item.status, timestamp), ...(timelineCursor ? { timelineCursor } : {}), timestamp, status: item.status, @@ -1523,15 +1550,28 @@ function reduceTimelineEvent( ); } case "error": { - const activity: ActivityLogItem = { - kind: "activity_log", - id: createTimelineId("error", item.message ?? "", timestamp), + const notification: NotificationItem = { + kind: "notification", + sourceType: "error", + id: createUniqueTimelineId(state, "error", item.message ?? "", timestamp), ...(timelineCursor ? { timelineCursor } : {}), timestamp, - activityType: "error", + level: "error", message: item.message ?? "Unknown error", }; - return finalizeActiveThoughts(appendActivityLog(state, activity)); + return finalizeActiveThoughts(appendNotification(state, notification)); + } + case "notification": { + const notification: NotificationItem = { + kind: "notification", + sourceType: "notification", + id: createUniqueTimelineId(state, "notification", item.message, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), + timestamp, + level: item.level, + message: item.message, + }; + return finalizeActiveThoughts(appendNotification(state, notification)); } case "compaction": return finalizeActiveThoughts( @@ -1693,7 +1733,8 @@ function getEventItemKind(event: AgentStreamEventPayload): StreamItem["kind"] | case "todo": return "todo_list"; case "error": - return "activity_log"; + case "notification": + return "notification"; case "plugin": return "plugin"; default: @@ -1721,14 +1762,6 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { }); } -function createAssistantBlockId(params: { groupId: string; blockIndex: number }): string { - return `${params.groupId}:block:${params.blockIndex}`; -} - -function getTrailingNewlineSuffix(text: string): string { - return /\n+$/.exec(text)?.[0] ?? ""; -} - function getActiveAssistantHeadIndex(head: StreamItem[]): number { for (let index = head.length - 1; index >= 0; index -= 1) { if (head[index]?.kind === "assistant_message") { @@ -1757,73 +1790,6 @@ function getTailAssistantToResume(params: { return params.tailAssistant; } -function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): { - tail: StreamItem[]; - head: StreamItem[]; - changedTail: boolean; - changedHead: boolean; -} { - const assistantIndex = getActiveAssistantHeadIndex(params.head); - const activeItem = params.head[assistantIndex]; - if (assistantIndex < 0 || !activeItem || activeItem.kind !== "assistant_message") { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blocks = splitMarkdownBlocks(activeItem.text); - if (blocks.length < 2) { - return { - tail: params.tail, - head: params.head, - changedTail: false, - changedHead: false, - }; - } - - const blockGroupId = activeItem.blockGroupId ?? activeItem.id; - const firstBlockIndex = activeItem.blockIndex ?? 0; - const completedBlocks = blocks.slice(0, -1); - const liveBlock = `${blocks[blocks.length - 1] ?? ""}${getTrailingNewlineSuffix(activeItem.text)}`; - const promotedItems = completedBlocks.map((block, offset) => ({ - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + offset, - }), - blockGroupId, - blockIndex: firstBlockIndex + offset, - text: block, - })); - - const nextTail = flushHeadToTail(params.tail, promotedItems); - const liveItem: AssistantMessageItem = { - ...activeItem, - id: createAssistantBlockId({ - groupId: blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - }), - blockGroupId, - blockIndex: firstBlockIndex + completedBlocks.length, - text: liveBlock, - }; - const nextHead = [ - ...params.head.slice(0, assistantIndex), - liveItem, - ...params.head.slice(assistantIndex + 1), - ]; - - return { - tail: nextTail, - head: nextHead, - changedTail: nextTail !== params.tail, - changedHead: true, - }; -} - /** * Flush head items to tail, avoiding duplicates. */ @@ -2067,13 +2033,7 @@ export function applyStreamEvent(params: { if (incomingKind !== null && isStreamableKind(incomingKind)) { const reservedItemIds = incomingKind === "assistant_message" && getActiveAssistantHeadIndex(nextHead) < 0 - ? new Set( - nextTail.flatMap((item) => - item.kind === "assistant_message" && item.blockGroupId - ? [item.id, item.blockGroupId] - : [item.id], - ), - ) + ? new Set(nextTail.map((item) => item.id)) : undefined; const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, @@ -2084,16 +2044,6 @@ export function applyStreamEvent(params: { nextHead = reduced; changedHead = true; } - if (incomingKind === "assistant_message") { - const promoted = promoteCompletedAssistantBlocks({ - tail: nextTail, - head: nextHead, - }); - nextTail = promoted.tail; - nextHead = promoted.head; - changedTail = changedTail || promoted.changedTail; - changedHead = changedHead || promoted.changedHead; - } return { tail: nextTail, head: nextHead, changedTail, changedHead }; } diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx new file mode 100644 index 0000000000..0199c08ff2 --- /dev/null +++ b/packages/app/src/usage/agent-usage.tsx @@ -0,0 +1,74 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { UsageCard } from "./card"; +import { usageCopy } from "./copy"; +import { useUsageDisplay } from "./display"; +import { useAgentUsage } from "./queries"; +import type { UsageReportEntry } from "./types"; + +/** + * The usage of the account an agent runs under, for its context window details: one card per + * report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an + * agent with no account to report. + */ +export function AgentUsage({ + serverId, + agentId, + refreshable, +}: { + serverId: string; + agentId: string; + /** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */ + refreshable: boolean; +}) { + const view = useAgentUsage(serverId, agentId); + if (view.kind === "none") return null; + return ( + <> + + {view.kind === "ready" ? ( + + ) : ( + + {view.kind === "loading" ? usageCopy.loading : view.message} + + )} + + ); +} + +function AgentUsageCards({ + serverId, + agentId, + reports, + refreshable, +}: { + serverId: string; + agentId: string; + reports: readonly UsageReportEntry[]; + refreshable: boolean; +}) { + const { display } = useUsageDisplay(reports); + return reports.map((entry) => ( + + )); +} + +const styles = StyleSheet.create((theme) => ({ + divider: { height: 1, backgroundColor: theme.colors.borderAccent }, + message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/provider-usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx similarity index 77% rename from packages/app/src/provider-usage/balance-bar.tsx rename to packages/app/src/usage/balance-bar.tsx index dbf18bea87..751193eec0 100644 --- a/packages/app/src/provider-usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -1,32 +1,34 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Text, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { clampPct, formatAmount, formatResetLabel } from "./format"; -import type { ProviderUsageBalance, ProviderUsageTone } from "./types"; +import type { UsageBalance, UsageTone } from "./types"; interface ResolvedBalance { amountText: string; usedPct: number | null; } -function resolveBalance(balance: ProviderUsageBalance): ResolvedBalance { +function resolveBalance(balance: UsageBalance, locale: string): ResolvedBalance { const { used, remaining, limit, unit } = balance; + const format = (value: number) => formatAmount(value, unit, locale); if (limit != null && limit > 0) { const usedAmount = used ?? (remaining != null ? limit - remaining : null); const usedPct = usedAmount != null ? (usedAmount / limit) * 100 : null; - const usedText = usedAmount != null ? formatAmount(usedAmount, unit) : "—"; - return { amountText: `${usedText} / ${formatAmount(limit, unit)}`, usedPct }; + const usedText = usedAmount != null ? format(usedAmount) : "—"; + return { amountText: `${usedText} / ${format(limit)}`, usedPct }; } if (remaining != null) { - return { amountText: `${formatAmount(remaining, unit)} left`, usedPct: null }; + return { amountText: `${format(remaining)} left`, usedPct: null }; } if (used != null) { - return { amountText: formatAmount(used, unit), usedPct: null }; + return { amountText: format(used), usedPct: null }; } return { amountText: "—", usedPct: null }; } -function fillToneStyle(tone: ProviderUsageTone) { +function fillToneStyle(tone: UsageTone) { switch (tone) { case "ok": return styles.fillOk; @@ -39,8 +41,9 @@ function fillToneStyle(tone: ProviderUsageTone) { } } -export function ProviderUsageBalanceBar({ balance }: { balance: ProviderUsageBalance }) { - const { amountText, usedPct } = resolveBalance(balance); +export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { + const { i18n } = useTranslation(); + const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; const resetLabel = formatResetLabel(balance.resetsAt); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx new file mode 100644 index 0000000000..a1936e0f97 --- /dev/null +++ b/packages/app/src/usage/card.tsx @@ -0,0 +1,386 @@ +import { RotateCw } from "lucide-react-native"; +import { useCallback, useMemo } from "react"; +import { Text, View, type StyleProp, type TextStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + extraMutedIconColorMapping, + iconButtonChromeGlyphSize, + smallIconButtonChromeFrameSize, +} from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { StatusBadge } from "@/components/ui/status-badge"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { useCompactTimeAgo } from "@/hooks/use-time-ago"; +import { UsageBalanceBar } from "./balance-bar"; +import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; +import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { useReportRefresh } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; +import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; +import { UsageWindowBar } from "./window-bar"; + +function statusText(report: UsageReport): string | null { + if (report.status === "available") return null; + return report.status === "error" ? "Error" : "Unavailable"; +} + +function reportContent(report: UsageReport) { + if (report.status === "available") + return { + windows: report.windows, + balances: report.balances ?? [], + details: report.details ?? [], + message: undefined, + }; + const message = + report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; + return { windows: [], balances: [], details: [], message }; +} + +function reportMessages(entry: UsageReportEntry): string[] { + if (entry.report.status === "available") return []; + if (entry.loginErrors) + return entry.loginErrors.map( + (login) => `${login.harness}: ${reportContent(login.report).message}`, + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + return [ + entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), + ]; +} + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +export function UsageCard({ + serverId, + agentId, + entry, + display, + pinnable, + refreshable, + compact = false, +}: { + serverId: string; + agentId?: string; + entry: UsageReportEntry; + display: UsageDisplay; + /** Whether each window row pins the window to the sidebar. */ + pinnable: boolean; + /** Whether the header has a Refresh button. Without one the freshness shows on the card. */ + refreshable: boolean; + compact?: boolean; +}) { + const isCompact = useIsCompactFormFactor(); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); + // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. + const showsFreshnessInline = isNative || isCompact || !refreshable; + const usage = entry.report; + const status = statusText(usage); + const footer = entry.account.label ?? null; + const { windows, balances, details } = reportContent(usage); + const messages = reportMessages(entry); + + const containerStyle = useMemo( + () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], + [compact], + ); + const dotStyle = useMemo( + () => [ + styles.statusDot, + usage.status === "available" && styles.statusDotAvailable, + usage.status === "error" && styles.statusDotError, + ], + [usage.status], + ); + + return ( + + + + + {entry.sourceLabel} + + {usage.status === "available" && usage.planLabel ? ( + + ) : null} + + {status ? ( + + + {status} + + ) : null} + {refreshable ? ( + + ) : null} + + + {messages.length > 0 ? ( + + {messages.join("\n")} + + ) : null} + + {windows.length > 0 || balances.length > 0 ? ( + + {windows.map((window) => ( + + ))} + {balances.map((balance) => ( + + ))} + + ) : null} + + {details.length > 0 ? ( + + {details.map((detail) => ( + + + {detail.label} + + + {detail.value} + + + ))} + + ) : null} + + {footer || showsFreshnessInline ? ( + + + {footer} + + {showsFreshnessInline ? ( + + ) : null} + + ) : null} + + {refreshState === "failed" ? ( + + {usageCopy.refreshFailed} + + ) : null} + + ); +} + +function CardWindowBar({ + entry, + window, + display, + pinnable, +}: { + entry: UsageReportEntry; + window: UsageWindow; + display: UsageDisplay; + pinnable: boolean; +}) { + const pin = useMemo( + () => ({ sourceId: entry.sourceId, windowId: window.id }), + [entry.sourceId, window.id], + ); + const { togglePin } = display; + const toggle = useCallback(() => togglePin(pin), [pin, togglePin]); + return ( + + ); +} + +/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */ +function UsageRefreshButton({ + sourceLabel, + fetchedAt, + refreshState, + onRefresh, + compact, +}: { + sourceLabel: string; + fetchedAt: string; + refreshState: UsageRefresh; + onRefresh: () => void; + compact: boolean; +}) { + const isPending = refreshState === "pending"; + const iconSize = paneContentToolbarIconSize(compact); + const freshness = useMemo( + () => ( + + ), + [fetchedAt], + ); + return ( + + {isPending ? ( + + ) : ( + + )} + + ); +} + +/** Its own component so the relative-time clock re-renders one ``, not the card. */ +function UsageFreshness({ + fetchedAt, + style, + testID, +}: { + fetchedAt: string; + style: StyleProp; + testID: string; +}) { + const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + return ( + + {formatUsageFreshness(elapsed)} + + ); +} + +// The Refresh glyph lands on the card's right rail and the header keeps its text height; +// the button's larger hitbox overhangs both instead of pushing them. +function iconHitboxOverhang(compact: boolean) { + const overhang = + (smallIconButtonChromeFrameSize(compact) - iconButtonChromeGlyphSize("small", compact)) / 2; + return { marginRight: -overhang, marginVertical: -overhang }; +} + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[3], + }, + // The gap is one step under the card padding: window rows add their own vertical padding. + containerPadded: { + gap: theme.spacing[3], + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + }, + containerCompact: { + gap: theme.spacing[3], + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + name: { + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + headerSpacer: { + flex: 1, + }, + statusRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.colors.foregroundMuted, + }, + statusDotAvailable: { + backgroundColor: theme.colors.statusSuccess, + }, + statusDotError: { + backgroundColor: theme.colors.statusDanger, + }, + statusLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + // Window rows carry their own vertical padding, which already separates them. + bars: { + gap: theme.spacing[1], + }, + details: { + gap: theme.spacing[1], + }, + detailRow: { + flexDirection: "row", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + detailLabel: { + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + detailValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + error: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.4, + }, + footerRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + footer: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + freshness: { + flexShrink: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + tooltipText: { + color: theme.colors.popoverForeground, + fontSize: theme.fontSize.sm, + }, + refreshButton: iconHitboxOverhang(false), + refreshButtonCompact: iconHitboxOverhang(true), +})); diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx new file mode 100644 index 0000000000..20297c9df0 --- /dev/null +++ b/packages/app/src/usage/controls.tsx @@ -0,0 +1,92 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { HostFilter } from "@/components/hosts/host-filter"; +import { useMemo, type ReactElement } from "react"; +import { UsageRefreshButton } from "./refresh-button"; +import { UsageOptions } from "./options"; +import type { UsageHost } from "./model"; +import { useHostUsage } from "./queries"; +import type { UsageView } from "./types"; + +/** The hosts to choose between, and which one is shown. */ +export interface UsageHostSelection { + hosts: UsageHost[]; + serverId: string; + onSelect: (serverId: string) => void; +} + +/** + * The controls on the right of every usage title row: the host filter when there is more than one + * host, Refresh all, and the Settings cog. A host that cannot report usage keeps only the host filter. + */ +export function UsageControls({ + view, + onRefresh, + hostSelection, +}: { + view: UsageView; + onRefresh: () => void; + hostSelection?: UsageHostSelection; +}) { + const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); + return ( + + {hasHostChoice(hostSelection) ? ( + + ) : null} + {view.kind === "unavailable" ? null : ( + <> + + + + )} + + ); +} + +function hasHostChoice( + hostSelection: UsageHostSelection | undefined, +): hostSelection is UsageHostSelection { + return hostSelection !== undefined && hostSelection.hosts.length > 1; +} + +function usageHostOptionTestID(serverId: string): string { + return `usage-host-filter-item-${serverId}`; +} + +const styles = StyleSheet.create((theme) => ({ + controls: { + flexShrink: 1, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, +})); + +/** + * One host’s usage and title-row controls for the Usage modal. + * Returns null when there is nothing to control, so headers leave no empty island. + */ +export function useHostUsageWithControls(hostSelection: UsageHostSelection): { + view: UsageView; + refresh: () => void; + controls: ReactElement | null; +} { + const { view, refresh } = useHostUsage(hostSelection.serverId); + const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable"; + const controls = useMemo( + () => + hasControls ? ( + + ) : null, + [hasControls, hostSelection, refresh, view], + ); + return { view, refresh, controls }; +} diff --git a/packages/app/src/usage/copy.test.ts b/packages/app/src/usage/copy.test.ts new file mode 100644 index 0000000000..a5176eba93 --- /dev/null +++ b/packages/app/src/usage/copy.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; +import { usageCopy } from "./copy"; + +test.each([ + [ + { kind: "expired", expiresAt: "2026-10-01T09:00:00Z", refreshedBy: "claude" }, + "Login expired 3h ago. Run claude to refresh it.", + ], + [{ kind: "expired", expiresAt: "2026-10-01T09:00:00Z" }, "Login expired 3h ago. Sign in again."], + [ + { kind: "rejected", status: 403, refreshedBy: "codex" }, + "Login rejected (HTTP 403). Run codex to refresh it.", + ], + [{ kind: "rejected", status: 401 }, "Login rejected (HTTP 401). Sign in again."], + [{ kind: "no_quota", detail: "No active coding plan" }, "No active coding plan"], +] as const)("problem %j", (problem, sentence) => { + expect(usageCopy.problem(problem, new Date("2026-10-01T12:00:00Z"))).toBe(sentence); +}); diff --git a/packages/app/src/usage/copy.ts b/packages/app/src/usage/copy.ts new file mode 100644 index 0000000000..639272283b --- /dev/null +++ b/packages/app/src/usage/copy.ts @@ -0,0 +1,40 @@ +import type { UsageProblem } from "@getpaseo/protocol/messages"; +import { formatCompactTimeAgo, formatCompactTimeAgoAsProse } from "@/utils/time"; + +// User-facing copy for the usage surfaces, kept in one file so localization is a +// single-file change. +export const usageCopy = { + problem: (problem: UsageProblem, now: Date = new Date()): string => { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const ago = formatCompactTimeAgoAsProse(formatCompactTimeAgo(new Date(problem.expiresAt), now)); + return `Login expired ${ago}. ${remedy}`; + }, + title: "Usage", + planUsage: "Plan usage", + options: "Settings", + refresh: "Refresh", + refreshAll: "Refresh all", + refreshing: "Refreshing...", + refreshFailed: "Unable to refresh usage", + updated: "Updated", + loading: "Loading usage...", + empty: "No usage data", + noHosts: "No connected hosts", + errorTitle: "Unable to load usage", + agentError: (reason: string) => `Unable to load usage: ${reason}`, + hostUnavailable: (host: string) => `Connect to ${host} to see usage`, + hostUpgradeRequired: (host: string) => `Update ${host} to see usage`, + clientUnavailable: "Host connection is not ready", + retry: "Try again", + pin: "Pin", + unpin: "Unpin", + displayAs: "Percentages", + displayUsed: "Used", + displayRemaining: "Remaining", + showInSidebar: "Summary in sidebar", + showInSidebarHint: "Pinned windows show in the sidebar footer", +} as const; diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts new file mode 100644 index 0000000000..5b4e534693 --- /dev/null +++ b/packages/app/src/usage/display.ts @@ -0,0 +1,62 @@ +import { useCallback, useMemo } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; +import { useUsageHostId } from "./hosts"; +import { useUsageHostReports } from "./queries"; +import { + isUsagePinned, + setUsageDisplayAs, + toggleUsagePin, + type UsageDisplayAs, + type UsagePin, + type UsagePreferences, +} from "./preferences"; +import type { UsageReportEntry } from "./types"; + +/** What usage views need from the preferences: how to read percents, and the pins. */ +export interface UsageDisplay { + displayAs: UsageDisplayAs; + setDisplayAs: (displayAs: UsageDisplayAs) => void; + isPinned: (pin: UsagePin) => boolean; + togglePin: (pin: UsagePin) => void; +} + +interface UsagePreferencesState { + preferences: UsagePreferences; + display: UsageDisplay; +} + +/** The device's usage preferences, with pins resolved against a host's reports. */ +export function useUsagePreferences(serverId?: string): UsagePreferencesState { + const defaultServerId = useUsageHostId(); + return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); +} + +/** The device's usage preferences, with pins resolved against the given reports. */ +export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { + const { settings, updateSettings } = useAppSettings(); + const preferences = settings.usage; + const setDisplayAs = useCallback( + (displayAs: UsageDisplayAs) => { + void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) })); + }, + [updateSettings], + ); + const togglePin = useCallback( + (pin: UsagePin) => { + void updateSettings((current) => ({ + usage: toggleUsagePin({ preferences: current.usage, pin, reports }), + })); + }, + [updateSettings, reports], + ); + const display = useMemo( + () => ({ + displayAs: preferences.displayAs, + setDisplayAs, + isPinned: (pin) => isUsagePinned(preferences, pin, reports), + togglePin, + }), + [preferences, reports, setDisplayAs, togglePin], + ); + return { preferences, display }; +} diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts new file mode 100644 index 0000000000..88fe227ae6 --- /dev/null +++ b/packages/app/src/usage/format.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { formatAmount } from "./format"; + +describe("formatAmount", () => { + it("groups thousands in the app's language", () => { + expect(formatAmount(12345, "credits", "en")).toBe("12,345"); + expect(formatAmount(12345, "requests", "en")).toBe("12,345"); + expect(formatAmount(12345, "credits", "fr").replace(/\s/g, " ")).toBe("12 345"); + }); + + it("formats dollars as the language writes currency", () => { + expect(formatAmount(1234.5, "usd", "en")).toBe("$1,234.50"); + expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); + }); +}); diff --git a/packages/app/src/provider-usage/format.ts b/packages/app/src/usage/format.ts similarity index 59% rename from packages/app/src/provider-usage/format.ts rename to packages/app/src/usage/format.ts index d903818f48..5adef2154d 100644 --- a/packages/app/src/provider-usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -1,5 +1,6 @@ import { formatTokenCount } from "@/components/context-window-meter.utils"; -import type { ProviderUsageBalanceUnit } from "./types"; +import type { UsageDisplayAs } from "./preferences"; +import type { UsageBalanceUnit } from "./types"; export function clampPct(value: number): number { return Math.max(0, Math.min(100, value)); @@ -9,6 +10,11 @@ export function formatPct(value: number): string { return `${Math.round(clampPct(value))}%`; } +/** "31%" of the window used, or "69% left" of it. */ +export function formatDisplayPct(value: number, displayAs: UsageDisplayAs): string { + return displayAs === "used" ? formatPct(value) : `${formatPct(value)} left`; +} + function relativeDuration(iso: string): string | null { const diffMs = new Date(iso).getTime() - Date.now(); if (!Number.isFinite(diffMs)) return null; @@ -28,26 +34,14 @@ export function formatResetLabel(iso: string | null | undefined): string | null return rel === "now" ? "resetting now" : `resets ${rel}`; } -export function formatAgo(iso: string | null | undefined): string | null { - if (!iso) return null; - const diffMs = Date.now() - new Date(iso).getTime(); - if (!Number.isFinite(diffMs)) return null; - if (diffMs < 60_000) return "just now"; - const diffMinutes = Math.floor(diffMs / 60_000); - const diffHours = Math.floor(diffMinutes / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffDays > 0) return `${diffDays}d ago`; - if (diffHours > 0) return `${diffHours}h ago`; - return `${diffMinutes}m ago`; -} - -export function formatAmount(value: number, unit: ProviderUsageBalanceUnit): string { +/** A balance amount as the app's language writes it: "$1,234.50", "12,345". */ +export function formatAmount(value: number, unit: UsageBalanceUnit, locale: string): string { switch (unit) { case "usd": - return `$${value.toFixed(2)}`; + return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value); case "tokens": return formatTokenCount(value); default: - return value.toLocaleString(); + return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); } } diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx new file mode 100644 index 0000000000..927d32e7d8 --- /dev/null +++ b/packages/app/src/usage/host-usage-section.tsx @@ -0,0 +1,20 @@ +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useHostUsage } from "./queries"; +import { UsageSection } from "./usage-section"; + +/** A host's usage reports, for its settings page. */ +export function HostUsageSection({ serverId }: { serverId: string }) { + const { view, refresh } = useHostUsage(serverId); + const { display } = useUsagePreferences(serverId); + return ( + + ); +} diff --git a/packages/app/src/usage/hosts.ts b/packages/app/src/usage/hosts.ts new file mode 100644 index 0000000000..dba21dc316 --- /dev/null +++ b/packages/app/src/usage/hosts.ts @@ -0,0 +1,59 @@ +import { useCallback, useMemo } from "react"; +import { useAppSettings } from "@/hooks/use-settings"; +import { + useActiveWorkspaceSelection, + useLastWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; +import { + resolveUsageHostId, + resolveUsageModalHostId, + type UsageHost, + type UsageHostChoice, +} from "./model"; +import { setUsageHost } from "./preferences"; +import { useUsageHosts } from "./queries"; + +/** The active workspace's host; off a workspace route, the last workspace visited. */ +function useActiveServerId(): string | null { + const active = useActiveWorkspaceSelection(); + const last = useLastWorkspaceSelection(); + return active?.serverId ?? last?.serverId ?? null; +} + +function useUsageHostChoice(): UsageHostChoice { + const { settings } = useAppSettings(); + return { + pickedServerId: settings.usage.serverId, + activeServerId: useActiveServerId(), + hosts: useUsageHosts(), + }; +} + +/** The host the sidebar Usage row reads, or null when none reports usage. */ +export function useUsageHostId(): string | null { + return resolveUsageHostId(useUsageHostChoice()); +} + +/** + * The host the Usage modal shows, and the hosts to pick from. A pick + * is saved on the device, so the sidebar Usage row and later visits show the same host. + */ +export function useUsageHostSelection(): { + serverId: string | null; + connectedHosts: UsageHost[]; + select: (serverId: string) => void; +} { + const { updateSettings } = useAppSettings(); + const choice = useUsageHostChoice(); + const connectedHosts = useMemo( + () => choice.hosts.filter((host) => host.isConnected), + [choice.hosts], + ); + const select = useCallback( + (serverId: string) => { + void updateSettings((current) => ({ usage: setUsageHost(current.usage, serverId) })); + }, + [updateSettings], + ); + return { serverId: resolveUsageModalHostId(choice), connectedHosts, select }; +} diff --git a/packages/app/src/usage/in-sidebar.ts b/packages/app/src/usage/in-sidebar.ts new file mode 100644 index 0000000000..96883bb80f --- /dev/null +++ b/packages/app/src/usage/in-sidebar.ts @@ -0,0 +1,19 @@ +import { useCallback } from "react"; +import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items"; + +/** + * Whether the sidebar footer shows the Usage summary: the same switch as Settings > Sidebar. + * Window rows pin to the summary only while it is on. + */ +export function useUsageInSidebar(): { + inSidebar: boolean; + setInSidebar: (visible: boolean) => void; +} { + const { items, setVisible } = useSidebarNavItems("footer"); + const inSidebar = items.some((item) => item.key === "usage" && item.visible); + const setInSidebar = useCallback( + (visible: boolean) => setVisible("usage", visible), + [setVisible], + ); + return { inSidebar, setInSidebar }; +} diff --git a/packages/app/src/usage/index.ts b/packages/app/src/usage/index.ts new file mode 100644 index 0000000000..45599aba2d --- /dev/null +++ b/packages/app/src/usage/index.ts @@ -0,0 +1,9 @@ +export { AgentUsage } from "./agent-usage"; +export { HostUsageSection } from "./host-usage-section"; +export { useHostReportsUsage } from "./queries"; +export { + UsageSidebarItem, + UsageSidebarRoot, + useHasUsageSummary, + useOpenSidebarUsage, +} from "./sidebar-item"; diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx new file mode 100644 index 0000000000..10e76ba57f --- /dev/null +++ b/packages/app/src/usage/list.tsx @@ -0,0 +1,41 @@ +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; +import { UsageCard } from "./card"; +import type { UsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; +import type { UsageReportEntry } from "./types"; + +/** One card per report (source + account). Window rows pin while the sidebar summary is on. */ +export function UsageList({ + serverId, + reports, + display, +}: { + serverId: string; + reports: UsageReportEntry[]; + display: UsageDisplay; +}) { + const { inSidebar } = useUsageInSidebar(); + return ( + + {reports.map((entry) => ( + + + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + gap: theme.spacing[3], + }, +})); diff --git a/packages/app/src/usage/meter.tsx b/packages/app/src/usage/meter.tsx new file mode 100644 index 0000000000..37842cafb5 --- /dev/null +++ b/packages/app/src/usage/meter.tsx @@ -0,0 +1,74 @@ +import { useMemo } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { clampPct } from "./format"; +import type { UsageTone } from "./types"; + +function fillToneStyle(tone: UsageTone) { + switch (tone) { + case "ok": + return styles.fillOk; + case "warning": + return styles.fillWarning; + case "danger": + return styles.fillDanger; + default: + return styles.fillDefault; + } +} + +/** + * A window's share as a bar, coloured by its tone. `quiet` fills it with a surface colour instead, + * for summaries where a row of coloured bars would be noise. The caller sizes the track. + */ +export function UsageMeter({ + percent, + tone, + quiet = false, + style, +}: { + percent: number; + tone: UsageTone; + quiet?: boolean; + style?: StyleProp; +}) { + const fillWidth = clampPct(percent); + const fillStyle = useMemo>( + () => [styles.fill, quiet ? styles.fillQuiet : fillToneStyle(tone), { width: `${fillWidth}%` }], + [fillWidth, quiet, tone], + ); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + track: { + height: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface3, + overflow: "hidden", + }, + // Follows the track, so a caller that sizes the track sizes the bar. + fill: { + height: "100%", + borderRadius: theme.borderRadius.full, + }, + fillQuiet: { + backgroundColor: theme.colors.surface4, + }, + fillDefault: { + backgroundColor: theme.colors.foregroundMuted, + }, + fillOk: { + backgroundColor: theme.colors.statusSuccess, + }, + fillWarning: { + backgroundColor: theme.colors.statusWarning, + }, + fillDanger: { + backgroundColor: theme.colors.statusDanger, + }, +})); diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts new file mode 100644 index 0000000000..ad293bb23e --- /dev/null +++ b/packages/app/src/usage/model.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { + displayPercent, + formatUsageFreshness, + resolveAgentUsageView, + replaceReport, + resolveUsageRefresh, + resolveUsageHostId, + resolveUsageModalHostId, + resolveUsageView, + settleReports, + upsertReport, + usageWindowRowLabel, + type UsageHost, + type UsageQueryState, +} from "./model"; +import type { UsageReportEntry, UsageWindow } from "./types"; + +function entry(input: { + windows?: UsageWindow[]; + planLabel?: string; + icon?: string; + sourceId?: string; +}): UsageReportEntry { + return { + id: `${input.sourceId ?? "fixture"}:account-1`, + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: input.sourceId ?? "fixture", + sourceLabel: "Fixture source", + ...(input.icon ? { icon: input.icon } : {}), + report: { + status: "available", + ...(input.planLabel ? { planLabel: input.planLabel } : {}), + windows: input.windows ?? [], + }, + }; +} + +function ready(data: UsageReportEntry[]): UsageQueryState { + return { data, error: null, isFetching: false }; +} + +describe("resolveUsageView", () => { + it("asks for a host update when the host lacks usage sources", () => { + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: true, + supportsUsage: false, + query: ready([entry({})]), + }), + ).toEqual({ kind: "unavailable", message: "Update Laptop to see usage" }); + }); + + it("asks for a connection before anything else", () => { + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: false, + supportsUsage: false, + query: undefined, + }), + ).toEqual({ kind: "unavailable", message: "Connect to Laptop to see usage" }); + }); + + it("moves from loading to ready to error", () => { + const base = { hostLabel: "Laptop", isConnected: true, supportsUsage: true }; + expect(resolveUsageView({ ...base, query: undefined })).toEqual({ kind: "loading" }); + expect( + resolveUsageView({ ...base, query: { data: [], error: null, isFetching: true } }), + ).toEqual({ kind: "ready", reports: [], isRefreshing: true }); + expect( + resolveUsageView({ + ...base, + query: { data: undefined, error: new Error("boom"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "boom" }); + }); +}); + +const hosts: UsageHost[] = [ + { serverId: "offline", label: "Offline", isConnected: false, supportsUsage: true }, + { serverId: "old", label: "Old", isConnected: true, supportsUsage: false }, + { serverId: "a", label: "Alpha", isConnected: true, supportsUsage: true }, + { serverId: "b", label: "Beta", isConnected: true, supportsUsage: true }, +]; + +describe("resolveUsageHostId", () => { + const choose = (pickedServerId: string | null, activeServerId: string | null) => + resolveUsageHostId({ pickedServerId, activeServerId, hosts }); + + it("reads the picked host over the active workspace's host", () => { + expect(choose("a", "b")).toBe("a"); + }); + + it("reads the active workspace's host when nothing usable is picked", () => { + expect(choose(null, "b")).toBe("b"); + expect(choose("offline", "b")).toBe("b"); + expect(choose("old", "b")).toBe("b"); + }); + + it("falls back to the first connected host that reports usage", () => { + expect(choose(null, null)).toBe("a"); + expect(choose(null, "offline")).toBe("a"); + expect(choose(null, "old")).toBe("a"); + }); + + it("has no host when none reports usage", () => { + expect( + resolveUsageHostId({ + pickedServerId: "old", + activeServerId: "old", + hosts: hosts.slice(0, 2), + }), + ).toBeNull(); + }); +}); + +describe("resolveUsageModalHostId", () => { + it("shows the picked host while it stays connected, even one that cannot report usage", () => { + expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe( + "old", + ); + expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe( + "b", + ); + }); + + it("otherwise shows the host the sidebar row reads", () => { + for (const pickedServerId of [null, "a", "offline"]) { + for (const activeServerId of ["b", null, "old", "offline"]) { + const choice = { pickedServerId, activeServerId, hosts }; + expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice)); + } + } + }); + + it("shows the first connected host when none reports usage, so the modal says to update it", () => { + expect( + resolveUsageModalHostId({ + pickedServerId: null, + activeServerId: null, + hosts: hosts.slice(0, 2), + }), + ).toBe("old"); + expect( + resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }), + ).toBeNull(); + }); +}); + +describe("usageWindowRowLabel", () => { + it("names the window, its percent and its reset after what pinning it pins", () => { + expect( + usageWindowRowLabel({ + pinLabel: "Pin Claude Session", + value: "31%", + trailing: "resets in 2h", + }), + ).toBe("Pin Claude Session, 31% · resets in 2h"); + expect(usageWindowRowLabel({ pinLabel: "Pin Codex Weekly", value: "—", trailing: null })).toBe( + "Pin Codex Weekly, —", + ); + }); +}); + +describe("displayPercent", () => { + const window = (input: Partial): UsageWindow => ({ + id: "weekly", + label: "Weekly", + ...input, + }); + + it("reads the share used or the share left", () => { + expect(displayPercent(window({ usedPct: 31 }), "used")).toBe(31); + expect(displayPercent(window({ usedPct: 31 }), "remaining")).toBe(69); + expect(displayPercent(window({ remainingPct: 40 }), "used")).toBe(60); + expect(displayPercent(window({ usedPct: 50, remainingPct: 45 }), "remaining")).toBe(45); + }); + + it("has no percent when the window reports none", () => { + expect(displayPercent(window({}), "remaining")).toBeNull(); + }); +}); + +describe("formatUsageFreshness", () => { + it("says when the report was fetched", () => { + expect(formatUsageFreshness("now")).toBe("Updated just now"); + expect(formatUsageFreshness("3m")).toBe("Updated 3m ago"); + expect(formatUsageFreshness("Jan 15")).toBe("Updated Jan 15"); + }); +}); + +describe("resolveUsageRefresh", () => { + it("is pending while a refresh runs, even after an earlier failure", () => { + expect(resolveUsageRefresh({ isPending: true, error: new Error("boom") })).toBe("pending"); + }); + + it("is failed after a refresh errors", () => { + expect(resolveUsageRefresh({ isPending: false, error: new Error("boom") })).toBe("failed"); + }); + + it("is idle before and after a successful refresh", () => { + expect(resolveUsageRefresh({ isPending: false, error: null })).toBe("idle"); + }); +}); + +describe("replaceReport", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("swaps only the refreshed report, in place", () => { + const refreshed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(replaceReport([alpha, beta], alpha.id, refreshed)).toEqual([refreshed, beta]); + }); + + it("drops a report the daemon no longer knows", () => { + expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]); + }); +}); + +describe("upsertReport", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("starts a list from the first streamed report", () => { + expect(upsertReport(undefined, alpha)).toEqual([alpha]); + }); + + it("appends a report the list does not have yet", () => { + expect(upsertReport([alpha], beta)).toEqual([alpha, beta]); + }); + + it("replaces a report the list already has, in place", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(upsertReport([alpha, beta], streamed)).toEqual([streamed, beta]); + }); + + it("keeps a copy fetched after the streamed one, such as a Refresh that landed first", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(upsertReport([refreshed, beta], alpha)).toEqual([refreshed, beta]); + }); +}); + +describe("settleReports", () => { + const alpha = entry({ sourceId: "alpha", planLabel: "Old" }); + const beta = entry({ sourceId: "beta" }); + + it("takes the finished list, dropping reports the host no longer has", () => { + const streamed = entry({ sourceId: "alpha", planLabel: "New" }); + expect(settleReports([alpha, beta], [streamed])).toEqual([streamed]); + }); + + it("keeps a report refreshed while the list streamed over its older copy in the list", () => { + const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" }; + expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]); + }); + + it("takes the finished list as is when nothing was shown yet", () => { + expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]); + }); +}); + +describe("resolveAgentUsageView", () => { + const report = entry({ sourceId: "claude" }); + const idle: UsageQueryState = { data: undefined, error: null, isFetching: false }; + + it("shows nothing on a host that cannot report usage", () => { + expect(resolveAgentUsageView({ canReport: false, query: idle })).toEqual({ kind: "none" }); + }); + + it("loads until the first report streams in", () => { + expect( + resolveAgentUsageView({ canReport: true, query: { ...idle, isFetching: true } }), + ).toEqual({ kind: "loading" }); + }); + + it("shows each report as it streams in, before the request finishes", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: null, isFetching: true }, + }), + ).toEqual({ kind: "ready", reports: [report] }); + }); + + it("shows nothing for an agent with no account to report", () => { + expect(resolveAgentUsageView({ canReport: true, query: { ...idle, data: [] } })).toEqual({ + kind: "none", + }); + }); + + it("says why a failed request has nothing to show", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { ...idle, error: new Error("Unknown agent") }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); + + it("says the request failed instead of showing the reports from before it", () => { + expect( + resolveAgentUsageView({ + canReport: true, + query: { data: [report], error: new Error("Unknown agent"), isFetching: false }, + }), + ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); + }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts new file mode 100644 index 0000000000..a32da7ea4d --- /dev/null +++ b/packages/app/src/usage/model.ts @@ -0,0 +1,187 @@ +import { formatCompactTimeAgoAsProse } from "@/utils/time"; +import { usageCopy } from "./copy"; +import type { UsageDisplayAs } from "./preferences"; +import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; + +export function usedPercent(window: UsageWindow): number | null { + if (window.usedPct != null) return window.usedPct; + if (window.remainingPct != null) return 100 - window.remainingPct; + return null; +} + +/** The percent a window shows under the user's used/remaining preference. */ +export function displayPercent(window: UsageWindow, displayAs: UsageDisplayAs): number | null { + if (displayAs === "used") return usedPercent(window); + if (window.remainingPct != null) return window.remainingPct; + const used = usedPercent(window); + return used == null ? null : 100 - used; +} + +/** + * A window row's accessible label: what pinning it pins, then what the row shows. The row is a + * checkbox, so its checked state says whether it is pinned: "Pin Claude Session, 31% · resets in + * 2h", checked. + */ +export function usageWindowRowLabel(input: { + pinLabel: string; + value: string; + trailing: string | null | undefined; +}): string { + const summary = input.trailing ? `${input.value} · ${input.trailing}` : input.value; + return `${input.pinLabel}, ${summary}`; +} + +/** When a report was fetched, from its compact relative time: "Updated 3m ago". */ +export function formatUsageFreshness(compactTimeAgo: string): string { + return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; +} + +/** A user-requested refresh of one report. The previous report stays on screen throughout. */ +export type UsageRefresh = "idle" | "pending" | "failed"; + +export function resolveUsageRefresh(mutation: { + isPending: boolean; + error: unknown; +}): UsageRefresh { + if (mutation.isPending) return "pending"; + if (mutation.error) return "failed"; + return "idle"; +} + +/** + * A host's report list with one report swapped for its refreshed copy, in place. + * `null` means the daemon no longer knows the ID, so the report leaves the list. + */ +export function replaceReport( + reports: readonly UsageReportEntry[], + reportId: string, + refreshed: UsageReportEntry | null, +): UsageReportEntry[] { + if (!refreshed) return reports.filter((report) => report.id !== reportId); + return reports.map((report) => (report.id === reportId ? refreshed : report)); +} + +/** + * The later-fetched of two copies of one report. A list request's copies were fetched when it + * started, so a Refresh that lands while it streams is newer than what the request still delivers. + */ +function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry { + return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving; +} + +/** + * A report list with one streamed report in place of its previous copy, or appended if new. A + * copy fetched after the streamed one stays. + */ +export function upsertReport( + reports: readonly UsageReportEntry[] | undefined, + report: UsageReportEntry, +): UsageReportEntry[] { + if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report]; + return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry)); +} + +/** + * The list a finished request leaves: its reports, dropping any the host no longer has, except + * that a copy on screen fetched after the request's copy stays. + */ +export function settleReports( + shown: readonly UsageReportEntry[] | undefined, + finished: readonly UsageReportEntry[], +): UsageReportEntry[] { + return finished.map((report) => { + const copy = shown?.find((entry) => entry.id === report.id); + return copy ? laterFetched(copy, report) : report; + }); +} + +export interface UsageQueryState { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +} + +export function resolveUsageView(input: { + hostLabel: string; + isConnected: boolean; + supportsUsage: boolean; + query: UsageQueryState | undefined; +}): UsageView { + const { hostLabel, isConnected, supportsUsage, query } = input; + if (!isConnected) return { kind: "unavailable", message: usageCopy.hostUnavailable(hostLabel) }; + if (!supportsUsage) { + return { kind: "unavailable", message: usageCopy.hostUpgradeRequired(hostLabel) }; + } + if (query?.data) { + return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; + } + if (query?.error) { + return { + kind: "error", + message: query.error instanceof Error ? query.error.message : String(query.error), + }; + } + return { kind: "loading" }; +} + +/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */ +export type AgentUsageView = + | { kind: "none" } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[] }; + +export function resolveAgentUsageView(input: { + canReport: boolean; + query: UsageQueryState; +}): AgentUsageView { + const { canReport, query } = input; + if (!canReport) return { kind: "none" }; + // A failed request keeps the reports from before it, or those that streamed in before it failed; + // shown alone they would pass for the agent's complete, current usage. + if (query.error) { + const reason = query.error instanceof Error ? query.error.message : String(query.error); + return { kind: "error", message: usageCopy.agentError(reason) }; + } + if (query.data) { + return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data }; + } + return { kind: "loading" }; +} + +export interface UsageHost { + serverId: string; + label: string; + isConnected: boolean; + supportsUsage: boolean; +} + +/** Where usage looks for its host: the user's saved pick, then the workspace they are in. */ +export interface UsageHostChoice { + pickedServerId: string | null; + activeServerId: string | null; + hosts: readonly UsageHost[]; +} + +/** + * The host the sidebar Usage row reads: the picked host, else the active workspace's host, else the + * first host. Each only while it is connected and reports usage. + */ +export function resolveUsageHostId(choice: UsageHostChoice): string | null { + const reporting = choice.hosts.filter((host) => host.isConnected && host.supportsUsage); + const find = (serverId: string | null) => reporting.find((host) => host.serverId === serverId); + return ( + (find(choice.pickedServerId) ?? find(choice.activeServerId) ?? reporting[0])?.serverId ?? null + ); +} + +/** + * The host the Usage modal shows: the picked host while it is connected, even one that cannot + * report usage so the modal says to update it; else the sidebar row's host; else the first + * connected host. + */ +export function resolveUsageModalHostId(choice: UsageHostChoice): string | null { + const connected = choice.hosts.filter((host) => host.isConnected); + const picked = connected.find((host) => host.serverId === choice.pickedServerId); + return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; +} diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx new file mode 100644 index 0000000000..886c21e190 --- /dev/null +++ b/packages/app/src/usage/options.tsx @@ -0,0 +1,70 @@ +import { View } from "react-native"; +import { Settings } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { DropdownMenu, DropdownMenuContent } from "@/components/ui/dropdown-menu"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { SettingsRow, SettingsSwitch } from "@/components/settings"; +import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { settingsStyles } from "@/styles/settings"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; +import type { UsageDisplayAs } from "./preferences"; + +const DISPLAY_AS_OPTIONS: SegmentedControlOption[] = [ + { value: "used", label: usageCopy.displayUsed, testID: "usage-display-used" }, + { value: "remaining", label: usageCopy.displayRemaining, testID: "usage-display-remaining" }, +]; + +const ThemedSettings = withUnistyles(Settings); + +/** The shared usage preferences, opened from a cog as a popover or compact sheet. */ +export function UsageOptions() { + const compact = useIsCompactFormFactor(); + const { display } = useUsagePreferences(); + const { inSidebar, setInSidebar } = useUsageInSidebar(); + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts new file mode 100644 index 0000000000..9965251595 --- /dev/null +++ b/packages/app/src/usage/pinned.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import type { UsagePreferences } from "./preferences"; +import { + choosePinnedUsageLayout, + resolvePinnedUsage, + type PinnedUsageSource, + type PinnedUsageWindow, +} from "./pinned"; +import type { UsageReportEntry, UsageWindow } from "./types"; + +function report(input: { + sourceId: string; + sourceLabel: string; + account?: string; + icon?: string; + windows: UsageWindow[]; +}): UsageReportEntry { + return { + id: `${input.sourceId}:${input.account ?? "default"}`, + account: input.account ? { label: input.account } : {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: input.sourceId, + sourceLabel: input.sourceLabel, + ...(input.icon ? { icon: input.icon } : {}), + report: { status: "available", windows: input.windows }, + }; +} + +const claude = report({ + sourceId: "claude", + sourceLabel: "Claude", + icon: "", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31 }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80 }, + ], +}); +const codex = report({ + sourceId: "codex", + sourceLabel: "Codex", + windows: [{ id: "weekly", label: "Weekly", remainingPct: 88 }], +}); + +function windows(reports: UsageReportEntry[], prefs: UsagePreferences): PinnedUsageWindow[] { + return resolvePinnedUsage(reports, prefs).flatMap((source) => source.windows); +} + +function preferences( + pinned: UsagePreferences["pins"], + displayAs: UsagePreferences["displayAs"] = "used", +): UsagePreferences { + return { displayAs, pins: pinned, serverId: null }; +} + +describe("choosePinnedUsageLayout", () => { + const widths = { labelsWidth: 200, percentsWidth: 120, windowCount: 2 }; + + it("shows meters only when every window's meter fits beside the labels", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 240 })).toBe("meters"); + expect(choosePinnedUsageLayout({ ...widths, available: 239 })).toBe("labels"); + }); + + it("drops the labels before it lets the line overflow", () => { + expect(choosePinnedUsageLayout({ ...widths, available: 199 })).toBe("percents"); + }); + + it("shows labels until the widths are measured", () => { + expect(choosePinnedUsageLayout({ ...widths, available: null })).toBe("labels"); + expect(choosePinnedUsageLayout({ ...widths, available: 999, labelsWidth: null })).toBe( + "labels", + ); + }); +}); + +describe("resolvePinnedUsage", () => { + it("defaults to source-wide pins collected from each account", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [ + { id: "empty", label: "Empty" }, + { id: "weekly", label: "Weekly", usedPct: 90 }, + ], + }); + const empty = report({ + sourceId: "empty", + sourceLabel: "Empty", + windows: [{ id: "empty", label: "Empty" }], + }); + expect( + windows([claude, codex, work, empty], preferences(null)).map((item) => [ + item.key, + item.percentText, + ]), + ).toEqual([ + ["claude:default/five-hour", "31%"], + ["claude:default/weekly", "80%"], + ["claude:work/weekly", "90%"], + ["codex:default/weekly", "12%"], + ]); + }); + + it("pins replace defaults, and removing all pins restores defaults", () => { + const reports = [claude, codex]; + expect( + windows(reports, preferences([{ sourceId: "claude", windowId: "weekly" }])).map( + (item) => item.key, + ), + ).toEqual(["claude:default/weekly"]); + expect(windows(reports, preferences(null)).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "codex:default/weekly", + ]); + }); + + it("groups pinned windows by source in report order, not pin order", () => { + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "weekly", label: "Weekly", usedPct: 90 }], + }); + const sources = resolvePinnedUsage( + [claude, codex, work], + preferences([ + { sourceId: "claude", windowId: "weekly" }, + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "claude", windowId: "five-hour" }, + ]), + ); + + const windowKeys = (source: PinnedUsageSource) => source.windows.map((window) => window.key); + expect(sources.map((source) => [source.key, windowKeys(source)])).toEqual([ + ["claude:default", ["claude:default/five-hour", "claude:default/weekly"]], + ["claude:work", ["claude:work/weekly"]], + ["codex:default", ["codex:default/weekly"]], + ]); + expect(sources[0]).toEqual({ + key: "claude:default", + icon: "", + windows: [ + { + key: "claude:default/five-hour", + label: "Claude Session 31% used", + shortLabel: "5h", + percent: 31, + percentText: "31%", + tone: "default", + }, + { + key: "claude:default/weekly", + label: "Claude Weekly 80% used", + shortLabel: "wk", + percent: 80, + percentText: "80%", + tone: "warning", + }, + ], + }); + }); + + it("names a window by its label when the source gives no short label", () => { + const items = windows([codex], preferences([{ sourceId: "codex", windowId: "weekly" }])); + + expect(items.map((item) => item.shortLabel)).toEqual(["Weekly"]); + }); + + it("defaults to every window the source marks as summary, when it marks any", () => { + const marked = report({ + sourceId: "claude", + sourceLabel: "Claude", + windows: [ + { id: "five-hour", label: "Session", shortLabel: "5h", usedPct: 31, summary: true }, + { id: "weekly", label: "Weekly", shortLabel: "wk", usedPct: 80, summary: true }, + { id: "weekly-fable", label: "Weekly · Fable", usedPct: 8 }, + ], + }); + + expect(windows([marked, codex], preferences(null)).map((item) => item.key)).toEqual([ + "claude:default/five-hour", + "claude:default/weekly", + "codex:default/weekly", + ]); + }); + + it("keeps an empty short label empty, for a percent that needs no name", () => { + const opencode = report({ + sourceId: "opencode", + sourceLabel: "OpenCode Go", + windows: [{ id: "rolling", label: "Rolling", shortLabel: "", usedPct: 21 }], + }); + const items = windows([opencode], preferences(null)); + + expect(items.map((item) => item.shortLabel)).toEqual([""]); + }); + + it("formats the share left when the user reads usage as remaining", () => { + const items = windows( + [claude, codex], + preferences( + [ + { sourceId: "claude", windowId: "five-hour" }, + { sourceId: "codex", windowId: "weekly" }, + ], + "remaining", + ), + ); + + expect(items.map((item) => item.percentText)).toEqual(["69%", "88%"]); + expect(items.map((item) => item.label)).toEqual([ + "Claude Session 69% left", + "Codex Weekly 88% left", + ]); + }); + + it("leaves out a pinned window that no report has, or that reports no percent", () => { + const noPercent = report({ + sourceId: "opencode", + sourceLabel: "OpenCode", + windows: [{ id: "monthly", label: "Monthly" }], + }); + const items = windows( + [claude, noPercent], + preferences([ + { sourceId: "claude", windowId: "monthly" }, + { sourceId: "codex", windowId: "weekly" }, + { sourceId: "opencode", windowId: "monthly" }, + ]), + ); + + expect(items).toEqual([]); + }); + + it("shows a pin once per account of the source", () => { + const personal = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "personal", + windows: [{ id: "five-hour", label: "5-hour", usedPct: 10 }], + }); + const work = report({ + sourceId: "claude", + sourceLabel: "Claude", + account: "work", + windows: [{ id: "five-hour", label: "5-hour", usedPct: 90 }], + }); + const items = windows( + [personal, codex, work], + preferences([{ sourceId: "claude", windowId: "five-hour" }]), + ); + + expect(items.map((item) => [item.label, item.percentText])).toEqual([ + ["Claude (personal) 5-hour 10% used", "10%"], + ["Claude (work) 5-hour 90% used", "90%"], + ]); + }); +}); + +it("keeps unavailable and error reports out of the sidebar summary", () => { + const expired: UsageReportEntry = { + ...claude, + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, + }, + }; + const failed: UsageReportEntry = { + ...codex, + report: { status: "error", error: "Store deleted" }, + }; + expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); +}); diff --git a/packages/app/src/usage/pinned.ts b/packages/app/src/usage/pinned.ts new file mode 100644 index 0000000000..547b2c3e48 --- /dev/null +++ b/packages/app/src/usage/pinned.ts @@ -0,0 +1,97 @@ +import { formatPct } from "./format"; +import { displayPercent } from "./model"; +import { effectiveUsagePins, type UsagePreferences } from "./preferences"; +import { windowTone } from "./tone"; +import type { UsageReportEntry, UsageTone } from "./types"; + +/** One summary window of one account, as the sidebar Usage item shows it. */ +export interface PinnedUsageWindow { + key: string; + /** Accessible description, including the source, account, window and percent meaning. */ + label: string; + /** The window's short name ("5h"), empty for none, or its label when the source sends none. */ + shortLabel: string; + /** The share shown, under the user's used/remaining preference. */ + percent: number; + percentText: string; + tone: UsageTone; +} + +/** One account's summary windows, under its source's icon. */ +export interface PinnedUsageSource { + key: string; + icon: string | null; + windows: PinnedUsageWindow[]; +} + +/** How much of each window the one-line summary has room for. */ +export type PinnedUsageLayout = "meters" | "labels" | "percents"; + +// A meter narrower than this is a sliver that reads as noise rather than a share. +export const MIN_METER_WIDTH = 16; +/** The space between a meter and its text. */ +export const METER_GAP = 4; + +/** + * The richest layout that fits on one line: every window's meter, else its short label, else its + * percent alone. Meters are all or none; a line never mixes them. Until the widths are known it + * shows labels, which never flashes meters that turn out not to fit. + */ +export function choosePinnedUsageLayout(input: { + available: number | null; + labelsWidth: number | null; + percentsWidth: number | null; + windowCount: number; +}): PinnedUsageLayout { + const { available, labelsWidth, percentsWidth, windowCount } = input; + if (available === null || labelsWidth === null || percentsWidth === null) return "labels"; + if (labelsWidth + windowCount * (MIN_METER_WIDTH + METER_GAP) <= available) return "meters"; + if (labelsWidth <= available) return "labels"; + return percentsWidth < labelsWidth ? "percents" : "labels"; +} + +function describe(entry: UsageReportEntry, windowLabel: string): string { + const account = entry.account.label ? ` (${entry.account.label})` : ""; + return `${entry.sourceLabel}${account} ${windowLabel}`; +} + +/** Reports with every account of a source together, sources in the order they first appear. */ +function groupBySource(reports: readonly UsageReportEntry[]): UsageReportEntry[] { + const sourceIds = [...new Set(reports.map((entry) => entry.sourceId))]; + return sourceIds.flatMap((sourceId) => reports.filter((entry) => entry.sourceId === sourceId)); +} + +/** + * The sidebar Usage item's summary: one group per account, grouped by source, each with its + * windows in its report's order. Accounts with nothing to show are left out. + */ +export function resolvePinnedUsage( + reports: readonly UsageReportEntry[], + preferences: UsagePreferences, +): PinnedUsageSource[] { + const pins = effectiveUsagePins(preferences, reports); + const meaning = preferences.displayAs === "remaining" ? "left" : "used"; + return groupBySource(reports).flatMap((entry) => { + if (entry.report.status !== "available") return []; + const windows = entry.report.windows + .filter( + (window) => + displayPercent(window, preferences.displayAs) !== null && + pins.some((pin) => pin.sourceId === entry.sourceId && pin.windowId === window.id), + ) + .map((window) => { + const percent = displayPercent(window, preferences.displayAs) ?? 0; + const percentText = formatPct(percent); + return { + key: `${entry.id}/${window.id}`, + label: `${describe(entry, window.label)} ${percentText} ${meaning}`, + shortLabel: window.shortLabel ?? window.label, + percent, + percentText, + tone: windowTone(window), + }; + }); + if (windows.length === 0) return []; + return [{ key: entry.id, icon: entry.icon ?? null, windows }]; + }); +} diff --git a/packages/app/src/usage/preferences.test.ts b/packages/app/src/usage/preferences.test.ts new file mode 100644 index 0000000000..43dccc466d --- /dev/null +++ b/packages/app/src/usage/preferences.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_USAGE_PREFERENCES, + isUsagePinned, + setUsageDisplayAs, + setUsageHost, + toggleUsagePin, + UsagePreferencesSchema, +} from "./preferences"; + +const claudeFiveHour = { sourceId: "claude", windowId: "five-hour" }; +const codexWeekly = { sourceId: "codex", windowId: "weekly" }; + +describe("usage preferences", () => { + it("pins windows in the order the user pins them", () => { + const pinned = toggleUsagePin({ + preferences: toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: codexWeekly, + reports: [], + }), + pin: claudeFiveHour, + reports: [], + }); + + expect(pinned.pins).toEqual([codexWeekly, claudeFiveHour]); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "weekly" }, [])).toBe(true); + expect(isUsagePinned(pinned, { sourceId: "codex", windowId: "five-hour" }, [])).toBe(false); + }); + + it("unpins a pinned window and keeps the others in order", () => { + const three = [codexWeekly, claudeFiveHour, { sourceId: "claude", windowId: "weekly" }]; + const unpinned = toggleUsagePin({ + preferences: { displayAs: "used", pins: three, serverId: null }, + pin: claudeFiveHour, + reports: [], + }); + + expect(unpinned.pins).toEqual([codexWeekly, { sourceId: "claude", windowId: "weekly" }]); + }); + + it("switches between used and remaining without touching pins", () => { + const preferences = { displayAs: "used" as const, pins: [codexWeekly], serverId: null }; + + expect(setUsageDisplayAs(preferences, "remaining")).toEqual({ + displayAs: "remaining", + pins: [codexWeekly], + serverId: null, + }); + }); + + it("remembers the picked host without touching pins", () => { + expect(setUsageHost({ ...DEFAULT_USAGE_PREFERENCES, pins: [codexWeekly] }, "server")).toEqual({ + displayAs: "used", + pins: [codexWeekly], + serverId: "server", + }); + }); + + it("reads preferences saved before hosts could be picked as no pick", () => { + expect(UsagePreferencesSchema.parse({ displayAs: "remaining", pinned: [codexWeekly] })).toEqual( + { displayAs: "remaining", pins: [codexWeekly], serverId: null }, + ); + }); +}); + +it("default sidebar windows are pinned on cards and the first toggle removes only that row", () => { + const reports = [ + { + id: "claude:default", + sourceId: "claude", + sourceLabel: "Claude", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [ + { id: "five_hour", label: "5-hour", summary: true, usedPct: 31 }, + { id: "weekly", label: "Weekly", summary: true, usedPct: 54 }, + ], + }, + }, + { + id: "codex:default", + sourceId: "codex", + sourceLabel: "Codex", + account: {}, + fetchedAt: "2026-10-02T00:00:00Z", + report: { + status: "available" as const, + windows: [{ id: "five_hour", label: "5-hour", summary: true, usedPct: 7 }], + }, + }, + ]; + const pins = [ + { sourceId: "claude", windowId: "five_hour" }, + { sourceId: "claude", windowId: "weekly" }, + { sourceId: "codex", windowId: "five_hour" }, + ]; + for (const pin of pins) expect(isUsagePinned(DEFAULT_USAGE_PREFERENCES, pin, reports)).toBe(true); + const changed = toggleUsagePin({ + preferences: DEFAULT_USAGE_PREFERENCES, + pin: pins[1]!, + reports, + }); + expect(isUsagePinned(changed, pins[1]!, reports)).toBe(false); + expect(isUsagePinned(changed, pins[0]!, reports)).toBe(true); + expect(isUsagePinned(changed, pins[2]!, reports)).toBe(true); + const empty = toggleUsagePin({ + preferences: toggleUsagePin({ preferences: changed, pin: pins[0]!, reports }), + pin: pins[2]!, + reports, + }); + const restored = UsagePreferencesSchema.parse(JSON.parse(JSON.stringify(empty))); + for (const pin of pins) expect(isUsagePinned(restored, pin, reports)).toBe(false); +}); + +it("reads old selections without losing display or host and preserves an explicit empty selection", () => { + for (const [input, pins] of [ + [{ pinned: [] }, null], + [{ pinned: [codexWeekly] }, [codexWeekly]], + [{ pins: [], pinned: [codexWeekly] }, []], + [{ pins: null, pinned: [codexWeekly] }, null], + [{ pins: "bad", pinned: [codexWeekly] }, null], + ] as const) { + expect( + UsagePreferencesSchema.parse({ displayAs: "remaining", serverId: "host", ...input }), + ).toEqual({ displayAs: "remaining", serverId: "host", pins }); + } +}); diff --git a/packages/app/src/usage/preferences.ts b/packages/app/src/usage/preferences.ts new file mode 100644 index 0000000000..e082870af6 --- /dev/null +++ b/packages/app/src/usage/preferences.ts @@ -0,0 +1,105 @@ +import { z } from "zod"; +import { displayPercent } from "./model"; +import type { UsageReportEntry } from "./types"; + +/** Whether percentages read as the share used or the share left. */ +export type UsageDisplayAs = "used" | "remaining"; + +/** A window the user pinned to the sidebar Usage item. It matches every account of the source. */ +export interface UsagePin { + sourceId: string; + windowId: string; +} + +/** Device-local. `pins` is in selection order. */ +export interface UsagePreferences { + displayAs: UsageDisplayAs; + /** null follows source defaults; an array is the user's selection, including none. */ + pins: UsagePin[] | null; + /** The host the user picked to show usage for; null until they pick one. */ + serverId: string | null; +} + +export const DEFAULT_USAGE_PREFERENCES: UsagePreferences = { + displayAs: "used", + pins: null, + serverId: null, +}; + +const pinsSchema = z.array(z.object({ sourceId: z.string(), windowId: z.string() })); + +export const UsagePreferencesSchema = z + .object({ + displayAs: z.enum(["used", "remaining"]).catch("used"), + pins: pinsSchema.nullable().optional().catch(null), + // COMPAT(usagePinSelection): added in v0.11, remove after 2027-04-02. + // Old [] meant defaults. Read it only when the new selection is absent. + pinned: pinsSchema.optional().catch([]), + serverId: z.string().nullable().catch(null), + }) + .transform(({ displayAs, pins, pinned, serverId }): UsagePreferences => { + const legacyPins = pinned && pinned.length > 0 ? pinned : null; + return { displayAs, pins: pins === undefined ? legacyPins : pins, serverId }; + }) + .catch(DEFAULT_USAGE_PREFERENCES); + +function samePin(a: UsagePin, b: UsagePin): boolean { + return a.sourceId === b.sourceId && a.windowId === b.windowId; +} + +/** The only owner of default and customized pin selection, shared by every usage surface. */ +export function effectiveUsagePins( + preferences: UsagePreferences, + reports: readonly UsageReportEntry[], +): UsagePin[] { + if (preferences.pins !== null) return preferences.pins; + const pins: UsagePin[] = []; + for (const entry of reports) { + if (entry.report.status !== "available") continue; + const withPercent = entry.report.windows.filter( + (window) => displayPercent(window, preferences.displayAs) !== null, + ); + const marked = withPercent.filter((window) => window.summary); + for (const window of marked.length > 0 ? marked : withPercent.slice(0, 1)) { + const pin = { sourceId: entry.sourceId, windowId: window.id }; + if (!pins.some((existing) => samePin(existing, pin))) pins.push(pin); + } + } + return pins; +} + +export function isUsagePinned( + preferences: UsagePreferences, + pin: UsagePin, + reports: readonly UsageReportEntry[], +): boolean { + return effectiveUsagePins(preferences, reports).some((pinned) => samePin(pinned, pin)); +} + +/** The first edit snapshots the displayed defaults before toggling one window. */ +export function toggleUsagePin({ + preferences, + pin, + reports, +}: { + preferences: UsagePreferences; + pin: UsagePin; + reports: readonly UsageReportEntry[]; +}): UsagePreferences { + const selected = effectiveUsagePins(preferences, reports); + const pins = selected.some((existing) => samePin(existing, pin)) + ? selected.filter((existing) => !samePin(existing, pin)) + : [...selected, { sourceId: pin.sourceId, windowId: pin.windowId }]; + return { ...preferences, pins }; +} + +export function setUsageDisplayAs( + preferences: UsagePreferences, + displayAs: UsageDisplayAs, +): UsagePreferences { + return { ...preferences, displayAs }; +} + +export function setUsageHost(preferences: UsagePreferences, serverId: string): UsagePreferences { + return { ...preferences, serverId }; +} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts new file mode 100644 index 0000000000..7b6f210543 --- /dev/null +++ b/packages/app/src/usage/queries.ts @@ -0,0 +1,253 @@ +import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client"; +import { useCallback, useMemo } from "react"; +import { + skipToken, + useMutation, + useQueryClient, + type QueryClient, + type QueryKey, +} from "@tanstack/react-query"; +import { useShallow } from "zustand/shallow"; +import { useFetchQuery } from "@/data/query"; +import { + getHostRuntimeStore, + useHostRuntimeConnectionStatuses, + useHostRuntimeIsConnected, + useHosts, +} from "@/runtime/host-runtime"; +import { useSessionStore, type SessionState } from "@/stores/session-store"; +import { usageCopy } from "./copy"; +import { + replaceReport, + resolveAgentUsageView, + resolveUsageRefresh, + resolveUsageView, + settleReports, + type UsageHost, + type UsageQueryState, + type AgentUsageView, + type UsageRefresh, + upsertReport, +} from "./model"; +import type { UsageReportEntry, UsageView } from "./types"; + +// The daemon caches each report for five minutes, so re-reading it is cheap. Only +// an explicit refresh passes `forceRefresh` and reaches the source's API. +const REPORTS_STALE_TIME_MS = 60_000; + +/** Every report list of a host: its own and each agent's. */ +function hostUsageQueryKey(serverId: string) { + return ["usage", serverId] as const; +} + +function usageReportsQueryKey(serverId: string) { + return [...hostUsageQueryKey(serverId), "reports"] as const; +} + +function agentUsageQueryKey(serverId: string, agentId: string) { + return [...hostUsageQueryKey(serverId), "agent", agentId] as const; +} + +function requireClient(serverId: string) { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) throw new Error(usageCopy.clientUnavailable); + return client; +} + +/** + * Lists a host's reports, or one agent's, writing each into `queryKey` as it streams in so a slow + * source never holds back the others. The finished list then replaces the streamed one, dropping + * any report the host no longer has, but never a report refreshed while the list streamed. + */ +async function streamReports(input: { + queryClient: QueryClient; + queryKey: QueryKey; + serverId: string; + agentId?: string; + forceRefresh?: boolean; + /** Once aborted, reports still on their way are dropped instead of written. */ + signal?: AbortSignal; +}): Promise { + const { queryClient, queryKey, serverId, agentId, forceRefresh = false, signal } = input; + const { reports } = await requireClient(serverId).listUsageReports( + { agentId, forceRefresh }, + (report) => { + if (signal?.aborted) return; + queryClient.setQueryData(queryKey, (current) => + upsertReport(current, report), + ); + }, + ); + return settleReports(queryClient.getQueryData(queryKey), reports); +} + +function listReports( + queryClient: QueryClient, + serverId: string, + forceRefresh = false, +): Promise { + return streamReports({ + queryClient, + queryKey: usageReportsQueryKey(serverId), + serverId, + forceRefresh, + }); +} + +async function getReport( + serverId: string, + reportId: string, + forceRefresh = false, + agentId?: string, +): Promise { + return ( + ( + await requireClient(serverId).listUsageReports( + agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh }, + ) + ).reports.find((report) => report.id === reportId) ?? null + ); +} + +function supportsUsage(session: SessionState | undefined): boolean { + return supportsUsageReports(session?.serverInfo?.features); +} + +async function refreshReports(queryClient: QueryClient, serverId: string): Promise { + await queryClient.fetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: () => listReports(queryClient, serverId, true), + staleTime: 0, + }); +} + +function toQueryState(query: { + data: UsageReportEntry[] | undefined; + error: unknown; + isFetching: boolean; +}): UsageQueryState { + return { data: query.data, error: query.error, isFetching: query.isFetching }; +} + +/** Usage reports for one host, as shown on its settings page. */ +export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } { + const queryClient = useQueryClient(); + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + const query = useFetchQuery({ + queryKey: usageReportsQueryKey(serverId), + queryFn: () => listReports(queryClient, serverId), + enabled: isConnected && isSupported, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + const refresh = useCallback(() => { + void refreshReports(queryClient, serverId).catch(() => undefined); + }, [queryClient, serverId]); + const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; + const view = resolveUsageView({ + hostLabel, + isConnected, + supportsUsage: isSupported, + query: toQueryState(query), + }); + return { view, refresh }; +} + +const NO_REPORTS: UsageReportEntry[] = []; + +/** + * The reports of the sidebar's usage host, which is connected and reports usage; none until they + * load, or without a host. + */ +export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const queryClient = useQueryClient(); + const query = useFetchQuery({ + queryKey: usageReportsQueryKey(serverId ?? ""), + queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, + dataShape: "list", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + return query.data ?? NO_REPORTS; +} + +/** + * The reports of the account an agent runs under, as its meter's tooltip or sheet shows them. + * Fetched while either is mounted, so only while it is open. + */ +export function useAgentUsage(serverId: string, agentId: string): AgentUsageView { + const queryClient = useQueryClient(); + const canReport = useHostReportsUsage(serverId); + const queryKey = agentUsageQueryKey(serverId, agentId); + const query = useFetchQuery({ + queryKey, + // Consuming the signal cancels the request when the details close, so a reopen sends a new + // request instead of joining one scoped to the login the agent ran under before. + queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }), + enabled: canReport, + // Another agent's reports never stand in while this one's load. + dataShape: "value", + // Dropped as soon as the details close: an agent resumed under another login keeps its ID, so + // reports kept from an earlier open could show the old login. The daemon's cache keeps each + // open's request cheap. + gcTime: 0, + // The daemon's errors (an unknown agent) do not heal on retry, and reopening the details + // fetches again; retrying would hold the loading sentence for seconds instead. + retry: false, + staleTimeMs: REPORTS_STALE_TIME_MS, + }); + return resolveAgentUsageView({ canReport, query: toQueryState(query) }); +} + +/** Whether a host is connected and reports usage, read without fetching anything. */ +export function useHostReportsUsage(serverId: string): boolean { + const isConnected = useHostRuntimeIsConnected(serverId); + const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); + return isConnected && isSupported; +} + +/** Every host with whether it is connected and reports usage, in host order. */ +export function useUsageHosts(): UsageHost[] { + const hosts = useHosts(); + const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); + const supportedServerIds = useSessionStore( + useShallow((state) => serverIds.filter((serverId) => supportsUsage(state.sessions[serverId]))), + ); + return useMemo( + () => + hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + isConnected: connectionStatuses.get(host.serverId) === "online", + supportsUsage: supportedServerIds.includes(host.serverId), + })), + [connectionStatuses, hosts, supportedServerIds], + ); +} + +/** + * Refreshes a host report, or the agent's session reports. The result replaces the card + * in the requesting view. Host and agent views can use different logins of the same account. + */ +export function useReportRefresh( + serverId: string, + reportId: string, + agentId?: string, +): { refresh: () => void; refreshState: UsageRefresh } { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => getReport(serverId, reportId, true, agentId), + onSuccess: (report) => { + queryClient.setQueryData( + agentId === undefined + ? usageReportsQueryKey(serverId) + : agentUsageQueryKey(serverId, agentId), + (reports) => (reports ? replaceReport(reports, reportId, report) : reports), + ); + }, + }); + const { mutate } = mutation; + const refresh = useCallback(() => mutate(), [mutate]); + return { refresh, refreshState: resolveUsageRefresh(mutation) }; +} diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx new file mode 100644 index 0000000000..48754c4e19 --- /dev/null +++ b/packages/app/src/usage/refresh-button.tsx @@ -0,0 +1,34 @@ +import { RotateCw } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { usageCopy } from "./copy"; + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); + +/** + * Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the + * header's island, so its highlight is round. + */ +export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { + const compact = useIsCompactFormFactor(); + const iconSize = paneContentToolbarIconSize(compact); + return ( + + {busy ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx new file mode 100644 index 0000000000..d90013d8bf --- /dev/null +++ b/packages/app/src/usage/sidebar-item.tsx @@ -0,0 +1,284 @@ +import { + createContext, + Fragment, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import { + Pressable, + Text, + View, + type LayoutChangeEvent, + type PressableStateCallbackType, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useUsagePreferences } from "./display"; +import { useUsageHostId } from "./hosts"; +import { useUsageHostReports } from "./queries"; +import { UsageSourceIcon } from "./source-icon"; +import { UsageMeter } from "./meter"; +import { + choosePinnedUsageLayout, + METER_GAP, + MIN_METER_WIDTH, + resolvePinnedUsage, + type PinnedUsageLayout, + type PinnedUsageSource, +} from "./pinned"; +import { UsageModal } from "./usage-modal"; + +/** Each summary window with data on the usage host, under its source; empty while none has. */ +function useUsageSummary(): readonly PinnedUsageSource[] { + const { preferences } = useUsagePreferences(); + const reports = useUsageHostReports(useUsageHostId()); + return useMemo(() => resolvePinnedUsage(reports, preferences), [preferences, reports]); +} + +/** Whether the sidebar Usage item has anything to show. */ +export function useHasUsageSummary(): boolean { + return useUsageSummary().length > 0; +} + +/** + * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing + * while no summary window has data, since the footer's Usage icon already opens Usage. + * Pressing it opens the Usage modal. + */ +export function UsageSidebarItem() { + const { t } = useTranslation(); + const label = t(builtinSidebarNavLabelKey("usage")); + const openUsage = useOpenSidebarUsage(); + const sources = useUsageSummary(); + if (sources.length === 0) return null; + return ; +} + +const OpenSidebarUsageContext = createContext<(() => void) | null>(null); + +/** + * One owner for the footer icon and summary: both open the Usage modal over the current screen. + * The modal mounts on first open and unmounts once it has finished closing, so its host's reports + * load only while it is shown. + */ +export function UsageSidebarRoot({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const openUsage = useCallback(() => { + setMounted(true); + setOpen(true); + }, []); + const closeUsage = useCallback(() => setOpen(false), []); + const unmountUsage = useCallback(() => setMounted(false), []); + + return ( + + {children} + {mounted ? : null} + + ); +} + +/** Both sidebar entry points send the same open command. */ +export function useOpenSidebarUsage(): () => void { + const openUsage = useContext(OpenSidebarUsageContext); + if (!openUsage) throw new Error("Sidebar Usage must be inside UsageSidebarRoot."); + return openUsage; +} + +function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string { + const windows = sources.flatMap((source) => source.windows); + return `${label}: ${windows.map((window) => window.label).join(", ")}`; +} + +function triggerStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) { + return hovered ? [styles.trigger, styles.triggerHovered] : styles.trigger; +} + +function layoutWidth(event: LayoutChangeEvent): number { + return event.nativeEvent.layout.width; +} + +/** + * One line: each account's icon, then per window a meter and "31% 5h". Off-screen copies measure + * the line with labels and with percents alone; the richest layout that fits is shown, and what + * it leaves out takes no space at all. + */ +function PinnedUsageTrigger({ + label, + sources, + onPress, +}: { + label: string; + sources: readonly PinnedUsageSource[]; + onPress: () => void; +}) { + const [available, setAvailable] = useState(null); + const [labelsWidth, setLabelsWidth] = useState(null); + const [percentsWidth, setPercentsWidth] = useState(null); + const handleAvailableLayout = useCallback((event: LayoutChangeEvent) => { + setAvailable(layoutWidth(event)); + }, []); + const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => { + setLabelsWidth(layoutWidth(event)); + }, []); + const handlePercentsLayout = useCallback((event: LayoutChangeEvent) => { + setPercentsWidth(layoutWidth(event)); + }, []); + const layout = choosePinnedUsageLayout({ + available, + labelsWidth, + percentsWidth, + windowCount: sources.reduce((count, source) => count + source.windows.length, 0), + }); + return ( + + + + + + + + + + + + + + ); +} + +/** + * The line's items as direct children of one row, so every meter shares the leftover width + * equally. + */ +function PinnedUsageLine({ + sources, + layout, + measuring = false, +}: { + sources: readonly PinnedUsageSource[]; + layout: PinnedUsageLayout; + measuring?: boolean; +}) { + return sources.map((source, sourceIndex) => ( + + + + + {source.windows.map((window, windowIndex) => { + const leadStyle = windowIndex === 0 ? styles.iconGap : styles.windowGap; + return ( + + {layout === "meters" ? ( + + + + ) : null} + + {window.percentText} + {layout === "percents" || window.shortLabel === "" ? null : ( + {` ${window.shortLabel}`} + )} + + + ); + })} + + )); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: "100%", + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + // Same row geometry and leading rail as Add project and the footer icons. + minHeight: 28, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + }, + triggerHovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + line: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + overflow: "hidden", + }, + // Wide enough that nothing inside wraps or shrinks; each child reports its natural width. + measure: { + position: "absolute", + top: 0, + left: 0, + width: 10_000, + opacity: 0, + }, + measured: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + }, + sourceGap: { + marginLeft: theme.spacing[3], + }, + iconGap: { + marginLeft: theme.spacing[1.5], + }, + windowGap: { + marginLeft: theme.spacing[2], + }, + // Every slot takes an equal share of the leftover width, so the line fills the row. + meterSlot: { + flexGrow: 1, + flexShrink: 1, + flexBasis: 0, + minWidth: MIN_METER_WIDTH, + marginRight: METER_GAP, + }, + meter: { + height: 6, + borderRadius: 3, + }, + percent: { + flexShrink: 0, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontVariant: ["tabular-nums"], + }, + windowLabel: { + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/usage/source-icon.tsx b/packages/app/src/usage/source-icon.tsx new file mode 100644 index 0000000000..779f42f841 --- /dev/null +++ b/packages/app/src/usage/source-icon.tsx @@ -0,0 +1,26 @@ +import { Gauge } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { SvgIcon } from "@/components/provider-icons"; +import type { Theme } from "@/styles/theme"; + +function UsageSourceIconBase({ + svg, + size, + color = "", +}: { + svg: string | null; + size: number; + color?: string; +}) { + if (!svg) return ; + return ; +} + +const ThemedUsageSourceIcon = withUnistyles(UsageSourceIconBase); + +const mutedIconColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +/** The source's catalog SVG, or a generic gauge when the source ships none. */ +export function UsageSourceIcon({ svg, size }: { svg: string | null; size: number }) { + return ; +} diff --git a/packages/app/src/usage/tone.ts b/packages/app/src/usage/tone.ts new file mode 100644 index 0000000000..d35d836f8b --- /dev/null +++ b/packages/app/src/usage/tone.ts @@ -0,0 +1,12 @@ +import { usedPercent } from "./model"; +import type { UsageTone, UsageWindow } from "./types"; + +/** The source's tone for a window, or one derived from how much of it is used. */ +export function windowTone(window: UsageWindow): UsageTone { + if (window.tone) return window.tone; + const usedPct = usedPercent(window); + if (usedPct == null) return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "default"; +} diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts new file mode 100644 index 0000000000..58b8e31c98 --- /dev/null +++ b/packages/app/src/usage/types.ts @@ -0,0 +1,16 @@ +import type { ProviderUsageTone, UsageReportEntry } from "@getpaseo/protocol/messages"; + +export type { UsageReportEntry }; +export type UsageReport = UsageReportEntry["report"]; +export type UsageTone = ProviderUsageTone; +export type AvailableUsageReport = Extract; +export type UsageWindow = AvailableUsageReport["windows"][number]; +export type UsageBalance = NonNullable[number]; +export type UsageBalanceUnit = UsageBalance["unit"]; + +/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ +export type UsageView = + | { kind: "unavailable"; message: string } + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; reports: UsageReportEntry[]; isRefreshing: boolean }; diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx new file mode 100644 index 0000000000..8305c3f878 --- /dev/null +++ b/packages/app/src/usage/usage-modal.tsx @@ -0,0 +1,93 @@ +import { useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; +import { useHostUsageWithControls } from "./controls"; +import { usageCopy } from "./copy"; +import { useUsagePreferences } from "./display"; +import { useUsageHostSelection } from "./hosts"; +import type { UsageHost } from "./model"; +import { UsageBody, UsageMessage } from "./usage-section"; + +interface UsageModalProps { + visible: boolean; + onClose: () => void; + onDismiss: () => void; +} + +/** + * Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It + * shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its + * title row. + */ +export function UsageModal(props: UsageModalProps) { + const { serverId, connectedHosts, select } = useUsageHostSelection(); + if (!serverId) { + return ( + + + + ); + } + // Keyed by host so a refresh in flight on one host never shows on another's cards. + return ( + + ); +} + +function HostUsageModal({ + serverId, + hosts, + onSelectHost, + ...props +}: UsageModalProps & { + serverId: string; + hosts: UsageHost[]; + onSelectHost: (serverId: string) => void; +}) { + const { display } = useUsagePreferences(); + const hostSelection = useMemo( + () => ({ hosts, serverId, onSelect: onSelectHost }), + [hosts, onSelectHost, serverId], + ); + const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + return ( + + + + + + ); +} + +function UsageModalFrame({ + visible, + onClose, + onDismiss, + actions, + children, +}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) { + const { t } = useTranslation(); + const title = t(builtinSidebarNavLabelKey("usage")); + const header = useMemo(() => ({ title, actions }), [actions, title]); + return ( + + {children} + + ); +} diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx new file mode 100644 index 0000000000..ffb40e985d --- /dev/null +++ b/packages/app/src/usage/usage-section.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Alert } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/components/settings/headings/settings-section"; +import { usageCopy } from "./copy"; +import type { UsageDisplay } from "./display"; +import { UsageControls } from "./controls"; +import { UsageList } from "./list"; +import type { UsageView } from "./types"; + +export function UsageSection({ + serverId, + title, + view, + display, + onRefresh, + testID, +}: { + serverId: string; + title: string; + view: UsageView; + display: UsageDisplay; + onRefresh: () => void; + testID?: string; +}) { + const trailing = useMemo( + () => , + [onRefresh, view], + ); + + return ( + + + + ); +} + +/** What a host's usage view shows: its reports, or why there are none. */ +export function UsageBody({ + serverId, + view, + display, + onRefresh, +}: { + serverId: string; + view: UsageView; + display: UsageDisplay; + onRefresh: () => void; +}) { + if (view.kind === "unavailable") { + return ; + } + + if (view.kind === "loading") { + return ; + } + + if (view.kind === "error") { + return ( + + + + ); + } + + if (view.reports.length === 0) { + return ; + } + + return ; +} + +export function UsageMessage({ text }: { text: string }) { + return ( + + {text} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyCard: { + padding: theme.spacing[4], + alignItems: "center", + }, + emptyText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx new file mode 100644 index 0000000000..5260a1c44b --- /dev/null +++ b/packages/app/src/usage/window-bar.tsx @@ -0,0 +1,219 @@ +import { Pin } from "lucide-react-native"; +import { useMemo } from "react"; +import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { usageCopy } from "./copy"; +import { formatDisplayPct, formatResetLabel } from "./format"; +import { UsageMeter } from "./meter"; +import { displayPercent, usageWindowRowLabel } from "./model"; +import type { UsageDisplayAs } from "./preferences"; +import { windowTone } from "./tone"; +import type { UsageTone, UsageWindow } from "./types"; + +// Pinned rows carry the pinned surface; hovering an unpinned row previews it at half strength, +// so a hover never reads as the selection. Pinned rows do not react to hover. +function highlightStyle(pinned: boolean, hovered: boolean) { + if (pinned) return styles.highlightPinned; + return hovered ? styles.highlightHovered : styles.highlightNone; +} + +export function UsageWindowBar({ + window, + displayAs, + pinnable, + pinned, + onTogglePin, + pinLabel, + pinTestID, +}: { + window: UsageWindow; + displayAs: UsageDisplayAs; + /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ + pinnable: boolean; + pinned: boolean; + onTogglePin: () => void; + /** What the row pins, naming the source and window: "Pin Claude Session". */ + pinLabel: string; + pinTestID: string; +}) { + const shownPct = displayPercent(window, displayAs); + const tone = windowTone(window); + + const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const trailing = isAtRisk + ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() + : formatResetLabel(window.resetsAt); + + const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; + const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); + const content = { + label: window.label, + value, + trailing, + isAtRisk, + percent: shownPct ?? 0, + tone, + pinned, + }; + + // Same padding as the pinnable row, so bars line up in both modes. + if (!pinnable) { + return ( + + + + ); + } + + // The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same + // padding so toggling only changes the background. + return ( + + {({ hovered }: { hovered?: boolean }) => ( + + )} + + ); +} + +function WindowRowContent({ + highlight, + label, + value, + trailing, + isAtRisk, + percent, + tone, + pinnable, + pinned, +}: { + highlight: StyleProp; + label: string; + value: string; + trailing: string | null | undefined; + isAtRisk: boolean; + percent: number; + tone: UsageTone; + pinnable: boolean; + pinned: boolean; +}) { + return ( + <> + + + + + + {label} + + + {value} + {trailing ? ( + {` · ${trailing}`} + ) : null} + + + + + {pinnable ? : null} + + + ); +} + +const ThemedPin = withUnistyles(Pin); + +function UsagePinGlyph({ pinned }: { pinned: boolean }) { + const iconMapping = useMemo( + () => (theme: { colors: { foregroundMuted: string } }) => ({ + color: theme.colors.foregroundMuted, + fill: pinned ? theme.colors.foregroundMuted : "none", + }), + [pinned], + ); + return ( + + + + + + + + {pinned ? usageCopy.unpin : usageCopy.pin} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + contentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }, + windowContent: { flex: 1, gap: 3 }, + pin: { width: 12, alignItems: "center" }, + tooltipText: { color: theme.colors.popoverForeground, fontSize: theme.fontSize.sm }, + row: { + gap: 3, + // The highlight bleeds into the card padding so the label and bar stay on the card's rail. + marginHorizontal: -theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1.5], + // Its own stacking context, so the highlight layer paints above the card and below the text. + zIndex: 0, + }, + highlightNone: { + display: "none", + }, + highlightPinned: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + }, + // The pinned surface at half strength: a separate layer, so the text keeps full opacity. + highlightHovered: { + ...StyleSheet.absoluteFillObject, + zIndex: -1, + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.surface2, + opacity: theme.opacity[50], + }, + labelRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing[2], + }, + label: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + value: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + reset: { + color: theme.colors.foregroundMuted, + fontWeight: theme.fontWeight.normal, + }, + atRisk: { + color: theme.colors.statusDanger, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts index 3de5188e3b..22eddf8152 100644 --- a/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts +++ b/packages/app/src/utils/__tests__/split-markdown-blocks.test.ts @@ -75,6 +75,49 @@ describe("splitMarkdownBlocks", () => { ).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]); }); + it("keeps a link reference definition with the paragraph that uses it", () => { + expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([ + "See the [docs][d].\n\n[d]: https://example.com", + ]); + }); + + it("folds a leading definition-only block into the block below it", () => { + expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([ + '[d]: https://example.com "Docs"\n\nSee the [docs][d].', + ]); + }); + + it("folds several definition lines and several definition blocks into one block", () => { + expect( + splitMarkdownBlocks( + "See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: \n\n[c]: https://example.com/c 'Third'\n\nAfter", + ), + ).toEqual([ + "See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: \n\n[c]: https://example.com/c 'Third'", + "After", + ]); + }); + + it("recognizes every destination the renderer accepts, including escaped spaces", () => { + expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([ + "See [docs].\n\n[docs]: docs\\ folder/readme", + ]); + expect(splitMarkdownBlocks("See [docs].\n\n[docs]: 'Title'")).toEqual([ + "See [docs].\n\n[docs]: 'Title'", + ]); + }); + + it("leaves a definition-only message as its own block", () => { + expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]); + }); + + it("does not fold a paragraph that merely starts with a bracketed link", () => { + expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([ + "Intro", + "[Link](https://example.com) and more prose", + ]); + }); + it("treats triple newlines as a split point and filters empty blocks", () => { expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([ "First paragraph", diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index b9a0d8e6be..ac5a6f84f3 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,7 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, - activeTurn: rest.activeTurn ?? null, + turn: rest.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index bf1e0a53ef..85e311345b 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import type { Agent } from "@/stores/session-store"; import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { @@ -50,28 +49,9 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE }; } -function replica(id: string, status: Agent["status"]): Agent { - return { - ...snapshot(id, status), - activeTurn: null, - serverId: "server", - createdAt: new Date("2026-07-12T10:00:00.000Z"), - updatedAt: new Date("2026-07-12T10:00:00.000Z"), - lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), - lastUserMessageAt: null, - attentionTimestamp: null, - archivedAt: null, - parentAgentId: null, - }; -} - describe("agent directory reconciliation", () => { - it("reports snapshot-only and buffered running transitions exactly once", () => { + it("preserves snapshot and buffered protocol status without interpreting liveness", () => { const result = reconcileAgentDirectory({ - previous: new Map([ - ["snapshot", replica("snapshot", "running")], - ["buffered", replica("buffered", "running")], - ]), snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], deltas: [ { @@ -87,8 +67,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); - expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + expect(result.map(({ agent }) => [agent.id, agent.status])).toEqual([ ["snapshot", "idle"], ["buffered", "idle"], ]); @@ -96,7 +75,6 @@ describe("agent directory reconciliation", () => { it("preserves ordered upserts and removals received after page one", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [entry("updated", "idle"), entry("removed", "idle")], deltas: [ { @@ -108,9 +86,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ - ["updated", "live"], - ]); + expect(result.map(({ agent }) => [agent.id, agent.title])).toEqual([["updated", "live"]]); }); it("keeps newer page metadata when a stale buffered upsert arrives", () => { @@ -120,7 +96,6 @@ describe("agent directory reconciliation", () => { projectName: "stale project", }; const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [ { ...entry("agent", "running"), @@ -145,16 +120,14 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - projectName: result.entries[0]?.project.projectName, - stopped: result.stoppedRunningAgentIds, - }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + title: result[0]?.agent.title, + status: result[0]?.agent.status, + projectName: result[0]?.project.projectName, + }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [entry("agent", "idle")], deltas: [ { @@ -168,13 +141,11 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.status).toBe("running"); - expect(result.stoppedRunningAgentIds).toEqual([]); + expect(result[0]?.agent.status).toBe("running"); }); it("accepts usage from a stale buffered upsert without regressing metadata", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -201,9 +172,9 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - usage: result.entries[0]?.agent.lastUsage, + title: result[0]?.agent.title, + status: result[0]?.agent.status, + usage: result[0]?.agent.lastUsage, }).toEqual({ title: "newer page", status: "idle", @@ -213,7 +184,6 @@ describe("agent directory reconciliation", () => { it("preserves usage when a stale buffered upsert omits it", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -236,6 +206,6 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + expect(result[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); }); }); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts index 86130f5d2a..fa34f911bd 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -1,39 +1,20 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import type { Agent } from "@/stores/session-store"; import type { AgentDirectoryDelta } from "./agent-directory-sync"; import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; export function reconcileAgentDirectory(input: { - previous: ReadonlyMap; snapshot: FetchAgentsEntry[]; deltas: readonly AgentDirectoryDelta[]; -}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } { +}): FetchAgentsEntry[] { const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry])); - const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status])); - const stoppedRunningAgentIds = new Set(); - - for (const entry of input.snapshot) { - if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { - stoppedRunningAgentIds.add(entry.agent.id); - } - statuses.set(entry.agent.id, entry.agent.status); - } for (const delta of input.deltas) { if (delta.kind === "remove") { entries.delete(delta.agentId); - statuses.delete(delta.agentId); - stoppedRunningAgentIds.delete(delta.agentId); continue; } const previousEntry = entries.get(delta.agent.id); const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); - if (acceptedAgent.status === "running") { - stoppedRunningAgentIds.delete(delta.agent.id); - } else if (statuses.get(delta.agent.id) === "running") { - stoppedRunningAgentIds.add(delta.agent.id); - } - statuses.set(delta.agent.id, acceptedAgent.status); const previousProject = previousEntry?.project; const acceptedProject = acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; @@ -55,8 +36,5 @@ export function reconcileAgentDirectory(input: { }); } - return { - entries: Array.from(entries.values()), - stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), - }; + return Array.from(entries.values()); } diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 71fb4cc350..0362aa56de 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -8,7 +8,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import { createUserMessage } from "@/types/stream"; -import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; +import { AgentStoreProjection } from "@/runtime/directory-sync/internal/agent-store"; function createAgentPayload( input: Partial> & { @@ -79,79 +79,18 @@ function beginPendingSubmission(serverId: string, agentId: string): string { return clientMessageId; } -function applyAgentStatus(input: { - serverId: string; - agentId: string; - status: AgentSnapshotPayload["status"]; - updatedAt: string; - lastUserMessageAt?: string; -}): void { - const agent = createAgentPayload({ - id: input.agentId, - status: input.status, - updatedAt: input.updatedAt, - lastUserMessageAt: input.lastUserMessageAt ?? null, - }); - applyAgentDirectoryDelta({ - serverId: input.serverId, - delta: { kind: "upsert", agent, project: createEntry(agent).project }, - }); -} - -describe("turn liveness authority", () => { - it("normalizes old-daemon status into the shared activity replica", () => { - const serverId = "server-turn-liveness"; - const agentId = "agent-1"; - const startedAt = "2026-07-27T10:00:01.000Z"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ serverId, agentId, status: "idle", updatedAt: startedAt }); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:02.000Z", - lastUserMessageAt: startedAt, - }); - expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ - phase: "open", - turnId: null, - startedAt: new Date(startedAt), - cancellationRequestId: null, - }); - - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:03.000Z", - }); - - expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); - store.clearSession(serverId); - }); -}); - describe("message submission authority", () => { it("does not settle a submission from an unrelated running transition", () => { const serverId = "server-running-is-not-submission-ack"; const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:01.000Z", + const agent = createAgentPayload({ id: agentId, status: "running" }); + new AgentStoreProjection(serverId).applyDelta({ + kind: "upsert", + agent, + project: createEntry(agent).project, }); expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ @@ -169,12 +108,6 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); store.setAgentStreamState(serverId, agentId, { tail: [ @@ -213,10 +146,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - replaceFetchedAgentDirectory({ - serverId, - entries: [createEntry(createAgentPayload({ id: "agent" }))], - }); + new AgentStoreProjection(serverId).replaceFetched([ + createEntry(createAgentPayload({ id: "agent" })), + ]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -229,29 +161,24 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, - }), - ), - ], - }); + const projection = new AgentStoreProjection(serverId); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + }), + ), + ]); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, - }), - ), - ], - }); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + }), + ), + ]); expect( useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, @@ -260,55 +187,60 @@ describe("replaceFetchedAgentDirectory", () => { store.clearSession(serverId); }); - it("removes every replica-owned artifact for a removed agent", () => { - const serverId = "server-removal"; - const agentId = "removed-agent"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - const agent = { - ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), - projectPlacement: null, - }; - store.setAgents(serverId, new Map([[agentId, agent]])); - store.setAgentDetails(serverId, new Map([[agentId, agent]])); - store.setQueuedMessages( - serverId, - new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), - ); - store.setAgentTimelineCursor( - serverId, - new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), - ); - store.setPendingPermissions( - serverId, - new Map([["permission", { key: "permission", agentId, request: null as never }]]), - ); - store.setInitializingAgents(serverId, new Map([[agentId, true]])); - setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); + it.each(["directory", "entity"] as const)( + "cleans removed %s state without invalidating an independent transcript", + (kind) => { + const serverId = "server-removal"; + const agentId = "removed-agent"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const agent = { + ...normalizeAgentSnapshot(createAgentPayload({ id: agentId }), serverId), + projectPlacement: null, + }; + store.setAgents(serverId, new Map([[agentId, agent]])); + store.setAgentDetails(serverId, new Map([[agentId, agent]])); + store.setQueuedMessages( + serverId, + new Map([[agentId, [{ id: "queued", text: "next", attachments: [] }]]]), + ); + store.setAgentTimelineCursor( + serverId, + new Map([[agentId, { epoch: "epoch", startSeq: 1, endSeq: 2 }]]), + ); + store.setPendingPermissions( + serverId, + new Map([["permission", { key: "permission", agentId, request: null as never }]]), + ); + store.setInitializingAgents(serverId, new Map([[agentId, true]])); + setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + const projection = new AgentStoreProjection(serverId); + if (kind === "directory") projection.applyDelta({ kind: "remove", agentId }); + else projection.remove(agentId); - const session = useSessionStore.getState().sessions[serverId]; - expect({ - agents: session?.agents.has(agentId), - details: session?.agentDetails.has(agentId), - queued: session?.queuedMessages.has(agentId), - cursor: session?.agentTimelineCursor.has(agentId), - permissions: session?.pendingPermissions.size, - initializing: session?.initializingAgents.has(agentId), - archivePending: isAgentArchiving({ queryClient, serverId, agentId }), - }).toEqual({ - agents: false, - details: false, - queued: false, - cursor: false, - permissions: 0, - initializing: false, - archivePending: false, - }); + const session = useSessionStore.getState().sessions[serverId]; + expect({ + agents: session?.agents.has(agentId), + details: session?.agentDetails.has(agentId), + queued: session?.queuedMessages.has(agentId), + cursor: session?.agentTimelineCursor.has(agentId), + permissions: session?.pendingPermissions.size, + initializing: session?.initializingAgents.has(agentId), + archivePending: isAgentArchiving({ queryClient, serverId, agentId }), + }).toEqual({ + agents: false, + details: false, + queued: false, + cursor: kind === "directory", + permissions: 0, + initializing: false, + archivePending: false, + }); - store.clearSession(serverId); - }); + store.clearSession(serverId); + }, + ); it("keeps newer metadata while accepting usage-only updates and legacy workspace ownership", () => { const serverId = "server-usage"; @@ -346,28 +278,27 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - applyAgentDirectoryDelta({ - serverId, - delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + const projection = new AgentStoreProjection(serverId); + projection.applyDelta({ + kind: "upsert", + agent: current, + project: createEntry(current).project, }); store.flushAgentLastActivity(); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - const staleResult = applyAgentDirectoryDelta({ - serverId, - delta: { - kind: "upsert", - agent: { - ...current, - title: "stale", - status: "idle", - updatedAt: "2026-07-12T10:00:00.000Z", - lastUsage: { inputTokens: 20, outputTokens: 8 }, - pendingPermissions: [permission("stale-permission")], - archivedAt: "2026-07-12T10:00:00.000Z", - }, - project: createEntry(current).project, + const staleResult = projection.applyDelta({ + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", }, + project: createEntry(current).project, }); store.flushAgentLastActivity(); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 2d81158c81..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,15 +1,8 @@ -import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { queryClient } from "@/data/query-client"; -import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; -import { useDraftStore } from "@/stores/draft-store"; -import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; type AgentDirectoryFetchEntry = FetchAgentsEntry; export type AgentDirectoryDelta = Extract< @@ -17,128 +10,6 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; -export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { - agentId: string; - stoppedRunning: boolean; -} { - if (input.delta.kind === "remove") { - removeAgentDirectoryReplica(input.serverId, input.delta.agentId); - return { agentId: input.delta.agentId, stoppedRunning: false }; - } - return upsertAgentDirectoryReplica(input.serverId, input.delta); -} - -type AgentUpsertDelta = Extract; - -function upsertAgentDirectoryReplica( - serverId: string, - delta: AgentUpsertDelta, -): { agentId: string; stoppedRunning: boolean } { - const normalized = normalizeAgentSnapshot(delta.agent, serverId); - const session = useSessionStore.getState().sessions[serverId]; - const previousAgent = - session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); - const legacyWorkspaceId = - previousAgent?.workspaceId ?? - Array.from(session?.workspaces.values() ?? []).find( - (workspace) => - session?.serverInfo?.features?.workspaceMultiplicity !== true && - workspace.workspaceDirectory === normalized.cwd, - )?.id; - const agent: Agent = { - ...normalized, - workspaceId: normalized.workspaceId ?? legacyWorkspaceId, - projectPlacement: - resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? - previousAgent?.projectPlacement, - }; - const acceptedAgent = upsertAgentReplica(serverId, agent); - if (acceptedAgent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); - } - replaceAgentPendingPermissions(serverId, acceptedAgent); - useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); - return { - agentId: acceptedAgent.id, - stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", - }; -} - -export function upsertAgentReplica(serverId: string, agent: Agent): Agent { - let acceptedAgent = agent; - useSessionStore.getState().setAgents(serverId, (current) => { - const currentAgent = current.get(agent.id); - acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); - if (acceptedAgent === currentAgent) return current; - const next = new Map(current); - next.set(agent.id, acceptedAgent); - return next; - }); - applyAgentTurnSnapshot(serverId, acceptedAgent); - return acceptedAgent; -} - -function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { - useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { - type: "snapshot", - activeTurn: agent.activeTurn, - }); -} - -export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { - const pendingPermissions = new Map( - useSessionStore.getState().sessions[serverId]?.pendingPermissions, - ); - for (const [key, pending] of pendingPermissions) { - if (pending.agentId === agent.id) pendingPermissions.delete(key); - } - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); -} - -export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { - const store = useSessionStore.getState(); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; - store.setAgents(serverId, removeKey); - store.setAgentDetails(serverId, removeKey); - store.setQueuedMessages(serverId, removeKey); - store.setAgentTimelineCursor(serverId, removeKey); - store.setInitializingAgents(serverId, removeKey); - store.setPendingPermissions(serverId, (current) => { - const next = new Map(current); - for (const [key, pending] of next) { - if (pending.agentId === agentId) next.delete(key); - } - return next.size === current.size ? current : next; - }); - store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - store.setAgentStreamTail(serverId, removeKey); - store.clearAgentStreamHead(serverId, agentId); - store.applyAgentTurnLiveness(serverId, agentId, { type: "destructive_close" }); - useSessionStore.setState((state) => { - if (!state.agentLastActivity.has(agentId)) return state; - const agentLastActivity = new Map(state.agentLastActivity); - agentLastActivity.delete(agentId); - return { ...state, agentLastActivity }; - }); - useDraftStore.getState().clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId, agentId }), - }); - const initKey = getInitKey(serverId, agentId); - if (getInitDeferred(initKey)) { - rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); - } -} - interface PendingPermissionEntry { key: string; agentId: string; @@ -175,49 +46,3 @@ export function buildAgentDirectoryState(input: { return { agents, pendingPermissions }; } - -export function replaceFetchedAgentDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): { agents: Map } { - const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); - const store = useSessionStore.getState(); - for (const agent of fetchedAgents.values()) { - if (agent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); - } - } - - const currentAgents = store.sessions[input.serverId]?.agents ?? new Map(); - const agents = new Map(); - for (const [agentId, fetchedAgent] of fetchedAgents) { - const current = currentAgents.get(agentId); - agents.set(agentId, current && equal(current, fetchedAgent) ? current : fetchedAgent); - } - - store.setAgents(input.serverId, agents); - for (const agent of agents.values()) { - applyAgentTurnSnapshot(input.serverId, agent); - } - store.setAgentDetails(input.serverId, (prev) => { - let next: Map | null = null; - for (const agentId of agents.keys()) { - if (!prev.has(agentId)) { - continue; - } - next ??= new Map(prev); - next.delete(agentId); - } - return next ?? prev; - }); - - const lastActivityByAgentId = new Map(); - for (const agent of agents.values()) { - lastActivityByAgentId.set(agent.id, agent.lastActivityAt); - } - store.setAgentLastActivityBatch(lastActivityByAgentId); - - store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setHasHydratedAgents(input.serverId, true); - return { agents }; -} diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index 7df88ada97..f380dc1118 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,14 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + turn: + overrides.turn ?? + ({ + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + } as const), lastActivityAt: overrides.lastActivityAt ?? now, cwd: overrides.cwd ?? "/tmp/repo", provider: overrides.provider ?? ("openai" as AggregatedAgent["provider"]), @@ -63,6 +71,28 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("uses turn liveness instead of protocol status for active grouping", () => { + const old = new Date("2020-01-01T00:00:00.000Z"); + const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); + const idle = makeAgent({ + id: "idle", + status: "running", + lastActivityAt: old, + turn: { phase: "idle", cancellationRequestId: null }, + }); + + const grouped = groupAgents([open, idle]); + + const activeIds = grouped.activeGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + const inactiveIds = grouped.inactiveGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + expect(activeIds).toEqual(["open"]); + expect(inactiveIds).toEqual(["idle"]); + }); + it("groups active agents by remote URL when available", () => { const agents = [ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }), diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index a741a81d39..eaca2d1511 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -264,7 +264,9 @@ const MAX_INACTIVE_PER_PROJECT = 5; */ function isAgentTrulyActive(agent: AggregatedAgent): boolean { return ( - agent.status === "running" || agent.requiresAttention || (agent.pendingPermissionCount ?? 0) > 0 + agent.turn.phase === "open" || + agent.requiresAttention || + (agent.pendingPermissionCount ?? 0) > 0 ); } diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index 334e36d9f2..e1be8c5a0c 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -57,15 +57,25 @@ describe("normalizeAgentSnapshot", () => { activeTurn: { turnId: "turn-1", startedAt }, }), "server-1", - ).activeTurn, - ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); expect( normalizeAgentSnapshot( createSnapshot({ status: "running", lastUserMessageAt: startedAt }), "server-1", - ).activeTurn, - ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); }); it("derives parentAgentId from the parent label while preserving labels", () => { diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index a035a054a2..c1c70c640e 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,7 +1,11 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; -import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; +import { + TURN_LIVENESS_IDLE, + type ActiveTurnIdentity, + type TurnLiveness, +} from "@/timeline/turn-liveness"; import type { Agent } from "@/stores/session-store"; function normalizeActiveTurn( @@ -18,13 +22,23 @@ function normalizeActiveTurn( return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; } +function normalizeTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): TurnLiveness { + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + return activeTurn + ? { phase: "open", ...activeTurn, cancellationRequestId: null } + : TURN_LIVENESS_IDLE; +} + function projectActiveTurn(agent: Agent): Pick { - if (agent.activeTurn === null) return { activeTurn: null }; - if (agent.activeTurn.turnId === null) return {}; + if (agent.turn.phase === "idle") return { activeTurn: null }; + if (agent.turn.turnId === null) return {}; return { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, }; } @@ -88,14 +102,14 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. - const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + const turn = normalizeTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, - activeTurn, + turn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index d4b99655f2..3ebee656dd 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import { clearAssistantImageMetadataCache, estimateAssistantMessageHeightFromCache, @@ -20,25 +21,53 @@ describe("assistant image metadata", () => { ).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]); }); - it("reuses cached metadata across canonical and raw source keys", () => { - setAssistantImageMetadata( - { - source: "/tmp/paseo-codex-screenshot.png", - workspaceRoot: "/workspaces/paseo", - serverId: "server-1", - }, - { width: 1200, height: 800 }, - ); + it("keeps local image metadata scoped to its server and workspace", () => { + const image = { + source: "screenshot.png", + workspaceRoot: "/workspace/one", + serverId: "server-1", + }; + setAssistantImageMetadata(image, { width: 1200, height: 800 }); - expect( - getAssistantImageMetadata({ - source: "/tmp/paseo-codex-screenshot.png", - }), - ).toEqual({ + expect(getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })).toBeNull(); + expect(getAssistantImageMetadata({ ...image, serverId: "server-2" })).toBeNull(); + expect(getAssistantImageMetadata({ source: image.source })).toBeNull(); + expect(getAssistantImageMetadata(image)).toEqual({ width: 1200, height: 800, aspectRatio: 1.5, }); + + setAssistantImageMetadata( + { ...image, workspaceRoot: "/workspace/two" }, + { width: 800, height: 1200 }, + ); + expect(getAssistantImageMetadata(image)?.aspectRatio).toBe(1.5); + expect( + getAssistantImageMetadata({ ...image, workspaceRoot: "/workspace/two" })?.aspectRatio, + ).toBe(2 / 3); + }); + + it("reuses direct image metadata without workspace context", () => { + const source = "https://example.com/shared.png"; + setAssistantImageMetadata( + { source, workspaceRoot: "/workspace/one", serverId: "server-1" }, + { width: 1200, height: 800 }, + ); + expect(getAssistantImageMetadata({ source })).toEqual({ + width: 1200, + height: 800, + aspectRatio: 1.5, + }); + }); + + it("reserves the default image aspect ratio before metadata arrives", () => { + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "![Pending](https://example.com/pending.png)", + contentMaxWidth: 608, + }), + ).toBe(464); }); it("estimates assistant message height from cached image metadata", () => { @@ -50,9 +79,10 @@ describe("assistant image metadata", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); @@ -60,8 +90,14 @@ describe("assistant image metadata", () => { const source = `data:image/png;base64,${"a".repeat(512)}`; setAssistantImageMetadata({ source }, { width: 1200, height: 800 }); - const imageOnlyHeight = estimateAssistantMessageHeightFromCache(`![Screenshot](${source})`); - const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n![Screenshot](${source})`); + const imageOnlyHeight = estimateAssistantMessageHeightFromCache({ + markdown: `![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); + const mixedHeight = estimateAssistantMessageHeightFromCache({ + markdown: `Text\n\n![Screenshot](${source})`, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }); expect(imageOnlyHeight).toBeGreaterThan(220); expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0); diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index 9f6e159b0d..ac7bd217b2 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -1,7 +1,11 @@ -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; import { createImageSourceCacheKey } from "@/attachments/utils"; +export interface AssistantImageContext { + serverId?: string; + workspaceRoot?: string; +} + export interface AssistantImageMetadata { width: number; height: number; @@ -14,7 +18,8 @@ const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500; const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g; -const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8; +export const ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO = 3 / 2; +const ASSISTANT_IMAGE_INSET = 8; const ASSISTANT_IMAGE_MIN_HEIGHT = 160; const ASSISTANT_IMAGE_BLOCK_GAP = 24; const ASSISTANT_MESSAGE_BASE_HEIGHT = 96; @@ -66,15 +71,9 @@ function parseAssistantImageMarkdown(markdown: string): { }; } -function createSourceAliasKey(source: string): string { - return `source:${createImageSourceCacheKey(source)}`; -} - -function createResolutionKey(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string | null { +function getAssistantImageMetadataKey( + input: AssistantImageContext & { source: string }, +): string | null { const resolution = resolveAssistantImageSource({ source: input.source, workspaceRoot: input.workspaceRoot, @@ -85,53 +84,28 @@ function createResolutionKey(input: { if (resolution.kind === "direct") { return `direct:${createImageSourceCacheKey(resolution.uri)}`; } - return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`; + // A file path identifies an image only within its daemon and workspace. + // Callers without that context must use the default aspect ratio. + if (!input.serverId) { + return null; + } + return JSON.stringify(["file", input.serverId, resolution.cwd, resolution.path]); } -function getAssistantImageMetadataKeys(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): string[] { - const source = input.source.trim(); - if (!source) { - return []; +export function getAssistantImageMetadata( + input: AssistantImageContext & { source: string }, +): AssistantImageMetadata | null { + const key = getAssistantImageMetadataKey(input); + const metadata = key ? assistantImageMetadataCache.get(key) : undefined; + if (!key || !metadata) { + return null; } - - const keys = [createSourceAliasKey(source)]; - const resolutionKey = createResolutionKey(input); - if (resolutionKey) { - keys.unshift(resolutionKey); - } - return [...new Set(keys)]; -} - -export function getAssistantImageMetadata(input: { - source: string; - workspaceRoot?: string; - serverId?: string; -}): AssistantImageMetadata | null { - for (const key of getAssistantImageMetadataKeys(input)) { - const metadata = assistantImageMetadataCache.get(key); - if (metadata) { - touchCacheEntry( - assistantImageMetadataCache, - key, - metadata, - ASSISTANT_IMAGE_METADATA_CACHE_LIMIT, - ); - return metadata; - } - } - return null; + touchCacheEntry(assistantImageMetadataCache, key, metadata, ASSISTANT_IMAGE_METADATA_CACHE_LIMIT); + return metadata; } export function setAssistantImageMetadata( - input: { - source: string; - workspaceRoot?: string; - serverId?: string; - }, + input: AssistantImageContext & { source: string }, dimensions: { width: number; height: number }, ): AssistantImageMetadata | null { const { width, height } = dimensions; @@ -145,7 +119,8 @@ export function setAssistantImageMetadata( aspectRatio: width / height, }; - for (const key of getAssistantImageMetadataKeys(input)) { + const key = getAssistantImageMetadataKey(input); + if (key) { touchCacheEntry( assistantImageMetadataCache, key, @@ -177,25 +152,31 @@ export function extractAssistantImageSources(markdown: string): string[] { return parsed.sources; } -export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { +export interface AssistantMessageHeightEstimateInput { + markdown: string; + contentMaxWidth: number; + imageContext?: AssistantImageContext; +} + +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown); if (parsed.sources.length === 0) { return null; } - const knownHeights = parsed.sources - .map((source) => getAssistantImageMetadata({ source })) - .filter((metadata): metadata is AssistantImageMetadata => metadata !== null) - .map((metadata) => - Math.max( - ASSISTANT_IMAGE_MIN_HEIGHT, - Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio), - ), + const imageHeights = parsed.sources.map((source) => { + const aspectRatio = + getAssistantImageMetadata({ source, ...imageContext })?.aspectRatio ?? + ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO; + return Math.max( + ASSISTANT_IMAGE_MIN_HEIGHT, + Math.round((contentMaxWidth - ASSISTANT_IMAGE_INSET) / aspectRatio), ); - - if (knownHeights.length === 0) { - return null; - } + }); const baseHeight = parsed.hasNonImageText ? ASSISTANT_MESSAGE_BASE_HEIGHT @@ -203,8 +184,8 @@ export function estimateAssistantMessageHeightFromCache(markdown: string): numbe const estimatedHeight = baseHeight + - knownHeights.reduce((sum, height) => sum + height, 0) + - ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length; + imageHeights.reduce((sum, height) => sum + height, 0) + + ASSISTANT_IMAGE_BLOCK_GAP * imageHeights.length; return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight); } diff --git a/packages/app/src/utils/assistant-markdown-parser.test.ts b/packages/app/src/utils/assistant-markdown-parser.test.ts index 70d806a1fb..05b1366e1c 100644 --- a/packages/app/src/utils/assistant-markdown-parser.test.ts +++ b/packages/app/src/utils/assistant-markdown-parser.test.ts @@ -2,6 +2,147 @@ import { describe, expect, it } from "vitest"; import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; describe("createAssistantMarkdownParser", () => { + it("keeps bold text bold through every partial closing marker", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + + for (const source of ["**bold", "**bold*", "**bold**"]) { + expect(parser.renderInline(source)).toBe("bold"); + } + }); + + it("shows the growing link label and only links a complete destination", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + const source = "[docs](https://example.com/path)"; + for (let length = 1; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe( + "docs".slice(0, Math.max(0, length - 1)), + ); + } + expect(parser.renderInline(source)).toBe('docs'); + }); + + it.each([ + ["**", "strong"], + ["__", "strong"], + ["*", "em"], + ["_", "em"], + ["~~", "s"], + ["`", "code"], + ["``", "code"], + ])("keeps %s formatting stable as text and closing markers arrive", (marker, tag) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 1; length <= 4; length++) { + expect(parser.renderInline(marker + "text".slice(0, length))).toBe( + `<${tag}>${"text".slice(0, length)}`, + ); + } + for (let length = 0; length <= marker.length; length++) { + expect(parser.renderInline(marker + "text" + marker.slice(0, length))).toBe( + `<${tag}>text`, + ); + } + }); + + it("keeps combined and nested emphasis stable through closing markers", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (const closing of ["", "*", "**", "***"]) { + expect(parser.renderInline("***both" + closing)).toBe("both"); + } + expect(parser.renderInline("**bold and *italic")).toBe( + "bold and italic", + ); + expect(parser.renderInline("*italic and **bold")).toBe( + "italic and bold", + ); + }); + + it.each(["*", "**", "***", "_", "__", "~~", "`", "``"])( + "hides an opening %s while waiting for its text", + (marker) => { + expect( + createAssistantMarkdownParser({ streaming: true }).renderInline("hello " + marker), + ).toBe("hello "); + }, + ); + + it.each([ + "[docs](https://example.com/a(b)c)", + '[docs](https://example.com "a title)")', + "[docs]()", + "[docs](file:///tmp/example.ts)", + ])("waits for the entire destination of %s", (source) => { + const parser = createAssistantMarkdownParser({ streaming: true }); + for (let length = 6; length < source.length; length++) { + expect(parser.renderInline(source.slice(0, length))).toBe("docs"); + } + expect(parser.renderInline(source)).toBe(createAssistantMarkdownParser().renderInline(source)); + }); + + it("preserves formatting in incomplete labels and around incomplete links", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("[**bold")).toBe("bold"); + expect(parser.renderInline("[**bold**](https://exam")).toBe("bold"); + expect(parser.renderInline("**see [docs](https://exam")).toBe("see docs"); + }); + + it("does not auto-link a URL label before the destination is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("Read [example.com](https://exam")).toBe("Read example.com"); + expect(parser.renderInline("Read [example.com](https://example.org)")).toBe( + 'Read example.com', + ); + }); + + it.each([ + "\\*literal", + "\\[literal", + "some_identifier", + "some__identifier", + "`**literal [link](url`", + "``a ` b``", + "**already closed** after", + "[bad](javascript:alert(1))", + " { + expect(createAssistantMarkdownParser({ streaming: true }).renderInline(source)).toBe( + createAssistantMarkdownParser().renderInline(source), + ); + }); + + it.each([ + "```md\n**literal [link](url", + "~~~md\n**literal [link](url", + " **literal [link](url", + "**earlier\n\nplain tail", + "- **earlier\n- plain tail", + ])("leaves literal code and earlier blocks alone: %s", (source) => { + expect(createAssistantMarkdownParser({ streaming: true }).render(source)).toBe( + createAssistantMarkdownParser().render(source), + ); + }); + + it("completes inline formatting inside the final list item", () => { + expect(createAssistantMarkdownParser({ streaming: true }).render("- first\n- **bold")).toBe( + "

    \n
  • first
  • \n
  • bold
  • \n
\n", + ); + }); + + it("hides incomplete images until their source is complete", () => { + const parser = createAssistantMarkdownParser({ streaming: true }); + expect(parser.renderInline("before ![alt](https://exam")).toBe("before "); + expect(parser.renderInline("before ![alt](https://example.com/image.png)")).toBe( + 'before alt', + ); + }); + + it("keeps ordinary parsing for completed messages", () => { + const parser = createAssistantMarkdownParser(); + expect(parser.renderInline("**unfinished")).toBe("**unfinished"); + expect(parser.renderInline("[unfinished")).toBe("[unfinished"); + }); + it("renders agent text verbatim", () => { const parser = createAssistantMarkdownParser(); diff --git a/packages/app/src/utils/assistant-markdown-parser.ts b/packages/app/src/utils/assistant-markdown-parser.ts index 5e64fb629f..e1e8f6f38a 100644 --- a/packages/app/src/utils/assistant-markdown-parser.ts +++ b/packages/app/src/utils/assistant-markdown-parser.ts @@ -1,7 +1,8 @@ import type MarkdownIt from "markdown-it"; import { createMarkdownParser } from "@/utils/markdown-parser"; +import { enableStreamingMarkdown } from "@/utils/streaming-markdown"; -export function createAssistantMarkdownParser(): MarkdownIt { +export function createAssistantMarkdownParser({ streaming = false } = {}): MarkdownIt { const parser = createMarkdownParser({ linkify: true }); const defaultValidateLink = parser.validateLink.bind(parser); @@ -10,5 +11,9 @@ export function createAssistantMarkdownParser(): MarkdownIt { parser.validateLink = (url: string) => url.trim().toLowerCase().startsWith("file://") || defaultValidateLink(url); + if (streaming) { + enableStreamingMarkdown(parser); + } + return parser; } diff --git a/packages/app/src/utils/assistant-message-height-estimate.test.ts b/packages/app/src/utils/assistant-message-height-estimate.test.ts index 7c6822723c..d4e3a2ff94 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.test.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { DEFAULT_CONTENT_MAX_WIDTH } from "@/styles/theme"; import { clearAssistantMessageHeightEstimateCache, estimateAssistantMessageHeightFromCache, @@ -27,7 +28,29 @@ describe("assistant message height estimate", () => { height: 41.1, }); - expect(estimateAssistantMessageHeightFromCache("First paragraph\n\nSecond paragraph")).toBe(97); + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "First paragraph\n\nSecond paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBe(97); + }); + + it("reads block heights measured at the configured content width", () => { + setAssistantMarkdownBlockHeight({ block: "Wide paragraph", width: 1584, height: 20 }); + + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: 1600, + }), + ).toBe(44); + expect( + estimateAssistantMessageHeightFromCache({ + markdown: "Wide paragraph", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), + ).toBeNull(); }); it("falls back to image metadata when markdown blocks are not measured", () => { @@ -39,9 +62,10 @@ describe("assistant message height estimate", () => { ); expect( - estimateAssistantMessageHeightFromCache( - "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", - ), + estimateAssistantMessageHeightFromCache({ + markdown: "Here is the screenshot\n\n![Screenshot](https://example.com/landscape.png)", + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }), ).toBeGreaterThan(220); }); }); diff --git a/packages/app/src/utils/assistant-message-height-estimate.ts b/packages/app/src/utils/assistant-message-height-estimate.ts index 05d702fb41..02e153e0bb 100644 --- a/packages/app/src/utils/assistant-message-height-estimate.ts +++ b/packages/app/src/utils/assistant-message-height-estimate.ts @@ -1,9 +1,11 @@ -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache } from "@/utils/assistant-image-metadata"; +import { + type AssistantMessageHeightEstimateInput, + estimateAssistantMessageHeightFromCache as estimateAssistantImageMessageHeightFromCache, +} from "@/utils/assistant-image-metadata"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; const ASSISTANT_MARKDOWN_BLOCK_HEIGHT_CACHE_LIMIT = 1000; -const ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 16; +const ASSISTANT_MARKDOWN_BLOCK_INSET = 16; const ASSISTANT_MESSAGE_VERTICAL_PADDING = 24; const ASSISTANT_MARKDOWN_BLOCK_GAP = 12; @@ -78,7 +80,10 @@ export function setAssistantMarkdownBlockHeight(input: { return height; } -function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number | null { +function estimateAssistantMarkdownBlockHeightFromCache( + markdown: string, + contentMaxWidth: number, +): number | null { const blocks = splitMarkdownBlocks(markdown); if (blocks.length === 0) { return null; @@ -88,7 +93,7 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number for (const block of blocks) { const key = createMarkdownBlockHeightKey({ block, - width: ASSISTANT_MARKDOWN_BLOCK_ESTIMATE_WIDTH, + width: contentMaxWidth - ASSISTANT_MARKDOWN_BLOCK_INSET, }); const cachedHeight = key ? assistantMarkdownBlockHeightCache.get(key) : undefined; if (cachedHeight === undefined) { @@ -104,10 +109,14 @@ function estimateAssistantMarkdownBlockHeightFromCache(markdown: string): number ); } -export function estimateAssistantMessageHeightFromCache(markdown: string): number | null { +export function estimateAssistantMessageHeightFromCache({ + markdown, + contentMaxWidth, + imageContext, +}: AssistantMessageHeightEstimateInput): number | null { return ( - estimateAssistantMarkdownBlockHeightFromCache(markdown) ?? - estimateAssistantImageMessageHeightFromCache(markdown) + estimateAssistantMarkdownBlockHeightFromCache(markdown, contentMaxWidth) ?? + estimateAssistantImageMessageHeightFromCache({ markdown, contentMaxWidth, imageContext }) ); } diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index 599f7d5f43..f2b78b4e57 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -5,6 +5,8 @@ import { extractHostPortFromWebSocketUrl, normalizeHostPort, parseConnectionUri, + parseRelayConnectionUri, + serializeRelayConnectionUri, parseHostPort, serializeConnectionUri, serializeConnectionUriForStorage, @@ -22,6 +24,8 @@ export { extractHostPortFromWebSocketUrl, normalizeHostPort, parseConnectionUri, + parseRelayConnectionUri, + serializeRelayConnectionUri, parseHostPort, serializeConnectionUri, serializeConnectionUriForStorage, diff --git a/packages/app/src/utils/file-mention-autocomplete.test.ts b/packages/app/src/utils/file-mention-autocomplete.test.ts index 4f818e25e2..c74c821522 100644 --- a/packages/app/src/utils/file-mention-autocomplete.test.ts +++ b/packages/app/src/utils/file-mention-autocomplete.test.ts @@ -48,6 +48,74 @@ describe("findActiveFileMention", () => { }); expect(mention).toBeNull(); }); + + it("returns null for the issue #1364 angle-bracket email example", () => { + const text = "user <12345+user@noreply.example.com>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (word char before @)", () => { + const text = "alice@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for email addresses (+ immediately before @)", () => { + // Local part ends with + so the character at index-1 is '+', not a word char. + const text = "noreply+@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null for plus-tagged email local parts", () => { + const text = "noreply+tag@example.com"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("returns null when query contains angle brackets", () => { + const text = "check @foo end"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.indexOf(" end"), + }); + expect(mention).toBeNull(); + }); + + it("returns null when query ends with a closing angle bracket", () => { + const text = "check @foo>"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toBeNull(); + }); + + it("still detects a file mention after an email in the same input", () => { + const text = "user <12345+user@noreply.example.com> and also @file"; + const mention = findActiveFileMention({ + text, + cursorIndex: text.length, + }); + expect(mention).toEqual({ + start: text.lastIndexOf("@"), + end: text.length, + query: "file", + }); + }); }); describe("formatQuotedFileMentionPath", () => { diff --git a/packages/app/src/utils/file-mention-autocomplete.ts b/packages/app/src/utils/file-mention-autocomplete.ts index d19424cfe4..fe09b52f03 100644 --- a/packages/app/src/utils/file-mention-autocomplete.ts +++ b/packages/app/src/utils/file-mention-autocomplete.ts @@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput { relativePath: string; } -const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/; +const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/; export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null { const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length)); @@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe atIndex >= 0; atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1) ) { + // Skip @ that looks like part of an email address (local-part char immediately before @). + if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) { + continue; + } + const query = beforeCursor.slice(atIndex + 1); if (INVALID_MENTION_QUERY_CHARS.test(query)) { continue; diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 5b8b6ad8d1..c13d6f25f9 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -492,7 +492,10 @@ export function resolveKnownHostRoute(input: { export const SETTINGS_SECTION_SLUGS = [ "general", "appearance", - "layout", + "sidebar", + "chat", + "terminal", + "browser", "editor", "shortcuts", "integrations", diff --git a/packages/app/src/utils/http-url.ts b/packages/app/src/utils/http-url.ts new file mode 100644 index 0000000000..116c7eb8e4 --- /dev/null +++ b/packages/app/src/utils/http-url.ts @@ -0,0 +1,9 @@ +/** Shared allowlist for links passed to an OS opener or a workspace browser. */ +export function isHttpUrl(url: string): boolean { + try { + const protocol = new URL(url).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +} diff --git a/packages/app/src/utils/mac-user-agent.ts b/packages/app/src/utils/mac-user-agent.ts new file mode 100644 index 0000000000..45fb0b104c --- /dev/null +++ b/packages/app/src/utils/mac-user-agent.ts @@ -0,0 +1,14 @@ +/** + * What the browser says about the platform. `getShortcutOs()` is the app's + * answer for keyboard policy and consults native and Electron first; this is the + * browser half of it, kept free of React Native imports so the terminal WebView + * bundle can use it too. + */ +export function isMacUserAgent(): boolean { + if (typeof navigator === "undefined") return false; + const userAgent = navigator.userAgent ?? ""; + const platform = (navigator as Navigator & { platform?: string }).platform ?? ""; + return ( + /Macintosh|Mac OS|iPhone|iPad|iPod/i.test(userAgent) || /Mac|iPhone|iPad|iPod/i.test(platform) + ); +} diff --git a/packages/app/src/utils/open-external-url.test.ts b/packages/app/src/utils/open-external-url.test.ts new file mode 100644 index 0000000000..29566587df --- /dev/null +++ b/packages/app/src/utils/open-external-url.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { openExternalUrl } from "./open-external-url"; + +describe("external URL refusal", () => { + it.each(["mailto:person@example.com", "javascript:alert(1)", "/relative", "invalid"])( + "safely ignores %s for fire-and-forget callers", + async (url) => { + await expect(openExternalUrl(url)).resolves.toBeUndefined(); + }, + ); +}); diff --git a/packages/app/src/utils/open-external-url.ts b/packages/app/src/utils/open-external-url.ts index 0225383d7f..a676987649 100644 --- a/packages/app/src/utils/open-external-url.ts +++ b/packages/app/src/utils/open-external-url.ts @@ -2,17 +2,10 @@ import * as Linking from "expo-linking"; import { getDesktopHost } from "@/desktop/host"; import { isWeb } from "@/constants/platform"; -const ALLOWED_EXTERNAL_URL_PROTOCOLS = new Set(["http:", "https:"]); - -function isAllowedExternalUrl(url: string): boolean { - try { - return ALLOWED_EXTERNAL_URL_PROTOCOLS.has(new URL(url).protocol); - } catch { - return false; - } -} +import { isHttpUrl } from "./http-url"; export async function openExternalUrl(url: string): Promise { + if (!isHttpUrl(url)) return; if (isWeb) { const opener = getDesktopHost()?.opener?.openUrl; if (typeof opener === "function") { @@ -20,20 +13,9 @@ export async function openExternalUrl(url: string): Promise { return; } - // Mirror the Electron opener's scheme allowlist: forge-supplied URLs - // (check target_url, job links) reach this sink, so refuse anything that - // is not plain http(s) instead of handing it to window.open. - if (!isAllowedExternalUrl(url)) { - return; - } - window.open(url, "_blank", "noopener,noreferrer"); return; } - if (!isAllowedExternalUrl(url)) { - return; - } - await Linking.openURL(url); } diff --git a/packages/app/src/utils/shortcut-platform.ts b/packages/app/src/utils/shortcut-platform.ts index 6c14983411..41d55796eb 100644 --- a/packages/app/src/utils/shortcut-platform.ts +++ b/packages/app/src/utils/shortcut-platform.ts @@ -2,16 +2,12 @@ import { Platform } from "react-native"; import { getIsElectronRuntimeMac } from "@/constants/layout"; import type { ShortcutOs } from "@/utils/format-shortcut"; import { isNative } from "@/constants/platform"; +import { isMacUserAgent } from "@/utils/mac-user-agent"; export function getShortcutOs(): ShortcutOs { if (isNative) { return Platform.OS === "ios" ? "mac" : "non-mac"; } if (getIsElectronRuntimeMac()) return "mac"; - if (typeof navigator === "undefined") return "non-mac"; - const ua = navigator.userAgent ?? ""; - const platform = (navigator as Navigator & { platform?: string }).platform ?? ""; - const isApple = - /Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform); - return isApple ? "mac" : "non-mac"; + return isMacUserAgent() ? "mac" : "non-mac"; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 6b008e3dc6..ac9663c6da 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,3 +1,4 @@ +import { i18n } from "@/i18n/i18next"; import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, @@ -211,6 +212,7 @@ describe("buildStatusSidebarShortcutModel", () => { ["p1", "Project 1"], ["p2", "Project 2"], ]), + i18n.t, ), }); @@ -247,7 +249,7 @@ describe("buildStatusSidebarShortcutModel", () => { ]; const model = buildStatusSidebarShortcutModel({ - groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])), + groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t), collapsedStatusGroupKeys: new Set(["needs_input"]), }); diff --git a/packages/app/src/utils/split-markdown-blocks.ts b/packages/app/src/utils/split-markdown-blocks.ts index 636642e12a..8282fc3ca9 100644 --- a/packages/app/src/utils/split-markdown-blocks.ts +++ b/packages/app/src/utils/split-markdown-blocks.ts @@ -1,6 +1,37 @@ import MarkdownIt from "markdown-it"; +// Only block maps are needed here; inline parsing belongs to each rendered block. const markdownBlockParser = new MarkdownIt(); +markdownBlockParser.core.ruler.disable("inline"); + +// The renderer decides what counts as a definition, so ask the same parser: a block +// that produces no tokens but registers references is nothing but definitions. +function isLinkReferenceDefinitionBlock(block: string): boolean { + const env: { references?: Record } = {}; + const tokens = markdownBlockParser.parse(block, env); + return tokens.length === 0 && Object.keys(env.references ?? {}).length > 0; +} + +/** + * Definitions render nothing and resolve nothing on their own, so a block made only of + * them would paint an empty row and break every reference that pointed at it. Fold it + * into the block it belongs to: the one above, or the one below when it leads. + */ +function foldLinkReferenceDefinitions(blocks: string[]): string[] { + const folded: string[] = []; + let leading: string[] = []; + for (const block of blocks) { + if (isLinkReferenceDefinitionBlock(block)) { + if (folded.length > 0) folded[folded.length - 1] += `\n\n${block}`; + else leading.push(block); + continue; + } + folded.push([...leading, block].join("\n\n")); + leading = []; + } + if (leading.length > 0) folded.push(leading.join("\n\n")); + return folded; +} export function splitMarkdownBlocks(text: string): string[] { if (text.length === 0) { @@ -41,7 +72,7 @@ export function splitMarkdownBlocks(text: string): string[] { blocks.push(currentLines.join("\n")); } - return blocks.filter((block) => block.length > 0); + return foldLinkReferenceDefinitions(blocks.filter((block) => block.length > 0)); } function getStructuralBlankLines(text: string, lines: string[]): Set { diff --git a/packages/app/src/utils/streaming-markdown/index.ts b/packages/app/src/utils/streaming-markdown/index.ts new file mode 100644 index 0000000000..b70b464d76 --- /dev/null +++ b/packages/app/src/utils/streaming-markdown/index.ts @@ -0,0 +1,153 @@ +import type MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; + +const STREAMING_TAIL = Symbol("streaming markdown tail"); + +/** Adds provisional inline formatting without changing the source or literal code blocks. */ +export function enableStreamingMarkdown(parser: MarkdownIt): void { + parser.core.ruler.at("inline", (state) => { + const tail = state.tokens.findLast((token) => token.nesting !== -1); + for (const token of state.tokens) { + if (token.type === "inline") { + const env = { ...state.env, [STREAMING_TAIL]: token === tail }; + token.children = []; + state.md.inline.parse(token.content, state.md, env, token.children); + } + } + }); + parser.inline.ruler.before("backticks", "streaming_code", completeCode); + parser.inline.ruler.before("strikethrough", "streaming_marker", hideOpeningMarker); + parser.inline.ruler.after("image", "streaming_link", completeLink); + parser.inline.ruler2.after("balance_pairs", "streaming_emphasis", completeEmphasis); + parser.core.ruler.after("linkify", "streaming_link_text", (state) => { + for (const block of state.tokens) { + for (const token of block.children ?? []) { + if (token.type === "streaming_link_text") token.type = "text"; + } + } + }); +} + +function isStreamingTail(state: StateInline): boolean { + return state.env[STREAMING_TAIL] === true && state.posMax === state.src.length; +} + +function hideOpeningMarker(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + if (!/^(?:\*{1,3}|_{1,3}|~{1,2})$/.test(state.src.slice(state.pos))) return false; + if (state.scanDelims(state.pos, state.src[state.pos] !== "_").can_close) return false; + state.pos = state.posMax; + return true; +} + +function completeCode(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state) || state.src[state.pos] !== "`") return false; + const runs = /`+/g; + runs.lastIndex = state.pos; + const opening = runs.exec(state.src)!; + let closing: RegExpExecArray | null; + while ((closing = runs.exec(state.src))) { + if (closing[0].length === opening[0].length) return false; + } + let content = state.src.slice(state.pos + opening[0].length); + // A shorter trailing run may be the first characters of the closing marker. + const partialClosing = content.match(/`+$/); + if (partialClosing && partialClosing[0].length < opening[0].length) { + content = content.slice(0, -partialClosing[0].length); + } + if (content) { + const token = state.push("code_inline", "code", 0); + token.markup = opening[0]; + token.content = content.replace(/\n/g, " ").replace(/^ (.+) $/, "$1"); + } + state.pos = state.posMax; + return true; +} + +function completeLink(state: StateInline, silent: boolean): boolean { + if (silent || !isStreamingTail(state)) return false; + const image = state.src[state.pos] === "!"; + const start = state.pos + (image ? 1 : 0); + if (state.src[start] !== "[") return false; + const labelEnd = state.md.helpers.parseLinkLabel(state, start, false); + if (labelEnd !== -1 && labelEnd + 1 < state.posMax) { + if (state.src[labelEnd + 1] !== "(" || hasClosedDestination(state.src, labelEnd + 1)) { + return false; + } + } + const end = state.posMax; + if (!image) { + if (state.pending) state.pushPending(); + const firstLabelToken = state.tokens.length; + state.pos = start + 1; + state.posMax = labelEnd === -1 ? end : labelEnd; + state.level++; + state.md.inline.tokenize(state); + state.level--; + // Keep URL-shaped labels out of the automatic linkifier until the real + // destination arrives. Restore ordinary text before tokens reach the view. + for (let index = firstLabelToken; index < state.tokens.length; index++) { + if (state.tokens[index].type === "text") state.tokens[index].type = "streaming_link_text"; + } + } + state.pos = state.posMax = end; + return true; +} + +function hasClosedDestination(source: string, start: number): boolean { + let depth = 0; + let quote = ""; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "\\") { + index++; + } else if (quote) { + if (char === quote) quote = ""; + } else if (char === "<") { + quote = ">"; + } else if ((char === '"' || char === "'") && /\s/.test(source[index - 1])) { + quote = char; + } else if (char === "(") { + depth++; + } else if (char === ")" && --depth === 0) { + return true; + } + } + return false; +} + +function completeEmphasis(state: StateInline): boolean { + if (!isStreamingTail(state)) return false; + const open = []; + let closedThrough = -1; + // Reuse markdown-it's escape, word-boundary, nesting and delimiter matching + // decisions. Only unmatched openers outside already-closed spans reach EOF. + for (const [index, delimiter] of state.delimiters.entries()) { + if (delimiter.end !== -1) { + closedThrough = Math.max(closedThrough, delimiter.end); + } else if (index > closedThrough && delimiter.open) { + open.push(delimiter); + } + } + for (const delimiter of open.toReversed()) { + if (delimiter.marker === 126 && /(? { }); }); }); + +describe("resolveMacTerminalEditingShortcut", () => { + function shortcut( + key: string, + modifiers: Partial>, + ) { + return resolveMacTerminalEditingShortcut({ + key, + ctrlKey: modifiers.ctrl ?? false, + shiftKey: modifiers.shift ?? false, + altKey: modifiers.alt ?? false, + metaKey: modifiers.meta ?? false, + }); + } + + it("maps cmd+arrow to line start/end", () => { + expect(shortcut("ArrowLeft", { meta: true })).toBe("\x01"); + expect(shortcut("ArrowRight", { meta: true })).toBe("\x05"); + }); + + it("maps option+arrow to word movement", () => { + expect(shortcut("ArrowLeft", { alt: true })).toBe("\x1bb"); + expect(shortcut("ArrowRight", { alt: true })).toBe("\x1bf"); + }); + + it("maps cmd+backspace to kill-line", () => { + expect(shortcut("Backspace", { meta: true })).toBe("\x15"); + }); + + it("leaves other combinations to xterm", () => { + expect(shortcut("ArrowLeft", {})).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, shift: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, ctrl: true })).toBeNull(); + expect(shortcut("ArrowRight", { meta: true, alt: true })).toBeNull(); + expect(shortcut("ArrowLeft", { alt: true, shift: true })).toBeNull(); + expect(shortcut("ArrowUp", { meta: true })).toBeNull(); + expect(shortcut("ArrowDown", { alt: true })).toBeNull(); + // Option+Backspace already works natively in xterm (ESC DEL). + expect(shortcut("Backspace", { alt: true })).toBeNull(); + expect(shortcut("k", { meta: true })).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index a25d4e08a7..09ba53c182 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -135,6 +135,51 @@ export function shouldInterceptDomTerminalKey(args: { return false; } +interface MacTerminalEditingShortcutInput { + key: string; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +} + +// macOS terminals translate these editing chords instead of forwarding raw modified +// keys: Terminal.app and iTerm2 ("Natural Text Editing") send ^A/^E for cmd+arrows, +// ESC b / ESC f for option+arrows, and ^U for cmd+backspace. xterm ignores meta+arrow +// entirely, and 6.x dropped the 5.x macOS conversion of option+arrow to ESC b/f, so +// without this the shell receives nothing or an `ESC[1;3C`-style sequence it can't +// parse (which zsh renders as a stray "C"/"D" plus a bell). +export function resolveMacTerminalEditingShortcut( + args: MacTerminalEditingShortcutInput, +): string | null { + if (args.ctrlKey || args.shiftKey) { + return null; + } + if (args.metaKey && !args.altKey) { + switch (args.key) { + case "ArrowLeft": + return "\x01"; + case "ArrowRight": + return "\x05"; + case "Backspace": + return "\x15"; + default: + return null; + } + } + if (args.altKey && !args.metaKey) { + switch (args.key) { + case "ArrowLeft": + return "\x1bb"; + case "ArrowRight": + return "\x1bf"; + default: + return null; + } + } + return null; +} + export function mergeTerminalModifiers(args: { pendingModifiers: PendingTerminalModifiers; ctrlKey: boolean; diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index cf6fca82e6..4dc82dda29 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; +import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection"; class FakeDaemonClient implements DaemonProbeClient { @@ -169,16 +170,15 @@ describe("test-daemon-connection connectToDaemon", () => { }); }); - it("passes direct TCP connection passwords into the client config", async () => { + it("passes the host password into the client config", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); const result = await connectToDaemon( { id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767", - password: "shared-secret", }, - undefined, + { password: "shared-secret" }, probe.deps, ); await result.client.close(); @@ -242,7 +242,7 @@ describe("test-daemon-connection connectToDaemon", () => { it("surfaces auth rejection as an incorrect password", async () => { const { connectToDaemon } = await import("./test-daemon-connection"); probe.failNextConnection( - new Error("Transport closed (code 4001)"), + new DaemonAuthenticationError("incorrect_password"), "Transport closed (code 4001)", ); @@ -252,13 +252,13 @@ describe("test-daemon-connection connectToDaemon", () => { id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767", - password: "wrong-secret", }, - undefined, + { password: "wrong-secret" }, probe.deps, ), ).rejects.toMatchObject({ message: "Incorrect password", + authFailureReason: "incorrect_password", }); }); @@ -272,9 +272,8 @@ describe("test-daemon-connection connectToDaemon", () => { id: "direct:lan:6767", type: "directTcp", endpoint: "lan:6767", - password: "shared-secret", }, - undefined, + { password: "shared-secret" }, probe.deps, ), ).rejects.toMatchObject({ diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index a631bc7739..b882b1f30a 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -1,4 +1,9 @@ -import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { + DaemonAuthenticationError, + DaemonClient, + getDaemonAuthFailureReason, + type DaemonAuthFailureReason, +} from "@getpaseo/client/internal/daemon-client"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { getOrCreateClientId } from "./client-id"; @@ -16,6 +21,7 @@ import type { DesktopDaemonTransportTarget } from "@/desktop/daemon/desktop-daem export interface DaemonProbeClient { readonly lastError: string | null; + readonly authFailureReason?: DaemonAuthFailureReason | null; connect(): Promise; close(): Promise; getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null; @@ -84,41 +90,53 @@ function pickBestReason(reason: string | null, lastError: string | null): string return "Unable to connect"; } -function isIncorrectPasswordFailure(input: { - config: DaemonClientConfig; - reason: string | null; - lastError: string | null; -}): boolean { - if (!input.config.password) { - return false; - } - const details = [input.reason, input.lastError].filter(Boolean).join("\n").toLowerCase(); - return ( - details.includes("401") || - details.includes("4001") || - details.includes("unauthorized") || - details.includes("code 1006") - ); -} - export class DaemonConnectionTestError extends Error { reason: string | null; lastError: string | null; + authFailureReason: DaemonAuthFailureReason | null; - constructor(message: string, details: { reason: string | null; lastError: string | null }) { + constructor( + message: string, + details: { + reason: string | null; + lastError: string | null; + authFailureReason?: DaemonAuthFailureReason | null; + }, + ) { super(message); this.name = "DaemonConnectionTestError"; this.reason = details.reason; this.lastError = details.lastError; + this.authFailureReason = details.authFailureReason ?? null; } } +export function getConnectionAuthFailureReason(error: unknown): DaemonAuthFailureReason | null { + if (error instanceof DaemonConnectionTestError) return error.authFailureReason; + return getDaemonAuthFailureReason(error); +} + +function resolveConnectionCredentials( + connection: HostConnection, + options: + | { password?: string; localCredential?: DaemonClientConfig["localCredential"] } + | undefined, +): Pick { + const password = options?.password; + return { + ...(password ? { password } : {}), + ...(options?.localCredential ? { localCredential: options.localCredential } : {}), + }; +} + export async function buildClientConfig( connection: HostConnection, serverId?: string, options?: { capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; + password?: string; + localCredential?: DaemonClientConfig["localCredential"]; }, deps: Pick< DaemonConnectionDependencies, @@ -136,6 +154,7 @@ export async function buildClientConfig( appVersion: deps.resolveAppVersion() ?? undefined, suppressSendErrors: true, reconnect: { enabled: false }, + ...resolveConnectionCredentials(connection, options), ...(options?.capabilities ? { capabilities: options.capabilities } : {}), ...(options?.trace ? { trace: options.trace } : {}), ...((connection.type === "directSocket" || connection.type === "directPipe") && @@ -167,7 +186,6 @@ export async function buildClientConfig( return { ...base, url: buildDaemonWebSocketUrl(connection.endpoint, { useTls: connection.useTls ?? false }), - ...(connection.password ? { password: connection.password } : {}), }; } @@ -245,11 +263,13 @@ export function connectAndProbe( error instanceof Error ? error.message : String(error), ); const lastError = normalizeNonEmptyString(client.lastError); - const message = isIncorrectPasswordFailure({ config, reason, lastError }) - ? "Incorrect password" + const authFailureReason = + getDaemonAuthFailureReason(error) ?? client.authFailureReason ?? null; + const message = authFailureReason + ? new DaemonAuthenticationError(authFailureReason).message : pickBestReason(reason, lastError); void client.close().catch(() => undefined); - reject(new DaemonConnectionTestError(message, { reason, lastError })); + reject(new DaemonConnectionTestError(message, { reason, lastError, authFailureReason })); }); }, ); @@ -258,6 +278,8 @@ export function connectAndProbe( interface ProbeOptions { serverId?: string; timeoutMs?: number; + password?: string; + localCredential?: DaemonClientConfig["localCredential"]; capabilities?: DaemonClientConfig["capabilities"]; trace?: DaemonClientConfig["trace"]; } diff --git a/packages/app/src/utils/time.test.ts b/packages/app/src/utils/time.test.ts index f979797b55..335c663682 100644 --- a/packages/app/src/utils/time.test.ts +++ b/packages/app/src/utils/time.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "vitest"; import { describeCompactTimeAgo, + describeTimeAgo, formatCompactTimeAgo, + formatCompactTimeAgoAsProse, formatDuration, formatMessageTimestamp, formatTimeAgo, @@ -12,7 +14,8 @@ describe("formatTimeAgo", () => { it.each([ ["2026-07-16T11:59:55.000Z", "just now"], - ["2026-07-16T11:59:30.000Z", "30s ago"], + ["2026-07-16T11:59:30.000Z", "just now"], + ["2026-07-16T11:59:00.000Z", "1m ago"], ["2026-07-16T11:55:00.000Z", "5m ago"], ["2026-07-16T10:00:00.000Z", "2h ago"], ["2026-07-13T12:00:00.000Z", "3d ago"], @@ -22,6 +25,20 @@ describe("formatTimeAgo", () => { }); }); +describe("describeTimeAgo", () => { + const now = new Date("2026-07-16T12:00:00.000Z"); + + it.each([ + ["2026-07-16T11:59:30.000Z", "just now", "minute"], + ["2026-07-16T11:55:00.000Z", "5m ago", "minute"], + ["2026-07-16T10:00:00.000Z", "2h ago", "hour"], + ["2026-07-13T12:00:00.000Z", "3d ago", "day"], + ["2026-01-15T12:00:00.000Z", "Jan 15", "static"], + ] as const)("formats %s as %s at %s resolution", (date, label, resolution) => { + expect(describeTimeAgo(new Date(date), now)).toEqual({ label, resolution }); + }); +}); + describe("describeCompactTimeAgo", () => { const now = new Date("2026-07-16T12:00:00.000Z"); @@ -63,6 +80,15 @@ describe("describeCompactTimeAgo", () => { }); }); +describe("formatCompactTimeAgoAsProse", () => { + it("puts elapsed labels back into prose and leaves dates alone", () => { + expect(formatCompactTimeAgoAsProse("now")).toBe("just now"); + expect(formatCompactTimeAgoAsProse("3m")).toBe("3m ago"); + expect(formatCompactTimeAgoAsProse("2h")).toBe("2h ago"); + expect(formatCompactTimeAgoAsProse("Jan 15")).toBe("Jan 15"); + }); +}); + describe("formatDuration", () => { it("renders sub-minute durations as whole seconds", () => { expect(formatDuration(0)).toBe("0s"); @@ -108,6 +134,17 @@ describe("formatMessageTimestamp", () => { expect(formatted).toMatch(/10:12 PM|22:12/); }); + it("shows the full date for last week's same weekday, even under seven days ago", () => { + // 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday, + // so a weekday label would read as today. + const now = new Date(2026, 8, 25, 11, 47); + const date = new Date(2026, 8, 18, 12, 2); + const formatted = formatMessageTimestamp(date, now); + expect(formatted).toMatch(/Sep|September/); + expect(formatted).toMatch(/18/); + expect(formatted).not.toMatch(/Friday/); + }); + it("includes full date for older timestamps", () => { const now = new Date(2026, 4, 14, 17, 30); const date = new Date(2026, 3, 1, 9, 5); diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts index f91487dfb5..948ee8eeb3 100644 --- a/packages/app/src/utils/time.ts +++ b/packages/app/src/utils/time.ts @@ -1,50 +1,10 @@ /** - * How long ago something was, before it's worded. The two formatters below share this so - * their thresholds can't drift apart; they differ only in how much room they have to say it. - * - * `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do. - */ -type Elapsed = - | { kind: "now" } - | { kind: "elapsed"; value: string } - | { kind: "date"; value: string }; - -function describeElapsed(date: Date, now: Date): Elapsed { - const diffMs = now.getTime() - date.getTime(); - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffSec < 10) return { kind: "now" }; - if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` }; - if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` }; - if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` }; - if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` }; - - // For older dates, show abbreviated month and day - const month = date.toLocaleDateString("en-US", { month: "short" }); - return { kind: "date", value: `${month} ${date.getDate()}` }; -} - -/** - * Format a date as a human-friendly relative time string - * Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15" - */ -export function formatTimeAgo(date: Date, now: Date = new Date()): string { - const elapsed = describeElapsed(date, now); - if (elapsed.kind === "now") return "just now"; - if (elapsed.kind === "elapsed") return `${elapsed.value} ago`; - return elapsed.value; -} - -/** - * How often a compact label can change, which is all a caller needs to know to keep it honest. + * How often a relative label can change, which is all a caller needs to know to keep it honest. * `static` means it never will again. */ export type RelativeTimeResolution = "minute" | "hour" | "day" | "static"; -export interface CompactTimeAgo { +export interface RelativeTimeLabel { label: string; resolution: RelativeTimeResolution; } @@ -56,47 +16,95 @@ const DAY_MS = 24 * HOUR_MS; const ABSOLUTE_AFTER_MS = 7 * DAY_MS; /** - * The same instant with the prose removed, for somewhere with no room for it — a dense list - * where the column is understood to be a timestamp and "ago" is the only word on the line. - * Examples: "now", "5m", "2h", "3d", "Jan 15". + * How long ago something was, before it's worded. The prose and compact formatters share this so + * their thresholds can't drift apart; they differ only in how much room they have to say it. + * + * `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do. * * Deliberately never sub-minute. A seconds label is only correct for the second it was rendered, * so it either lies or forces a once-a-second re-render of a list that has nothing new to say. * Everything under a minute is "now", which is both true and stable. * - * The resolution comes back with the label so a caller can wake at the rate the label actually - * changes instead of guessing — see `useCompactTimeAgo`. + * The resolution comes back with the value so a caller can wake at the rate the label actually + * changes instead of guessing — see `useTimeAgo`. */ -export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo { +type Age = + | { kind: "now"; resolution: "minute" } + | { kind: "elapsed"; value: string; resolution: Exclude } + | { kind: "date"; value: string; resolution: "static" }; + +function describeAge(date: Date, now: Date): Age { const elapsedMs = now.getTime() - date.getTime(); if (elapsedMs < MINUTE_MS) { - return { label: "now", resolution: "minute" }; + return { kind: "now", resolution: "minute" }; } if (elapsedMs < HOUR_MS) { - return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" }; + return { + kind: "elapsed", + value: `${Math.floor(elapsedMs / MINUTE_MS)}m`, + resolution: "minute", + }; } if (elapsedMs < DAY_MS) { - return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" }; + return { kind: "elapsed", value: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" }; } if (elapsedMs < ABSOLUTE_AFTER_MS) { - return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" }; + return { kind: "elapsed", value: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" }; } const month = date.toLocaleDateString("en-US", { month: "short" }); - return { label: `${month} ${date.getDate()}`, resolution: "static" }; + return { kind: "date", value: `${month} ${date.getDate()}`, resolution: "static" }; +} + +/** + * A human-friendly relative time, with the resolution it changes at. + * Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15". + */ +export function describeTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel { + const age = describeAge(date, now); + if (age.kind === "now") return { label: "just now", resolution: age.resolution }; + if (age.kind === "elapsed") return { label: `${age.value} ago`, resolution: age.resolution }; + return { label: age.value, resolution: age.resolution }; +} + +export function formatTimeAgo(date: Date, now: Date = new Date()): string { + return describeTimeAgo(date, now).label; +} + +/** + * The same instant with the prose removed, for somewhere with no room for it — a dense list + * where the column is understood to be a timestamp and "ago" is the only word on the line. + * Examples: "now", "5m", "2h", "3d", "Jan 15". + */ +export function describeCompactTimeAgo(date: Date, now: Date = new Date()): RelativeTimeLabel { + const age = describeAge(date, now); + if (age.kind === "now") return { label: "now", resolution: age.resolution }; + return { label: age.value, resolution: age.resolution }; } export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string { return describeCompactTimeAgo(date, now).label; } -function isSameLocalDay(a: Date, b: Date): boolean { - return ( - a.getFullYear() === b.getFullYear() && - a.getMonth() === b.getMonth() && - a.getDate() === b.getDate() - ); +/** + * A compact label put back into a sentence, for a tooltip or a line with room for prose: + * "now" → "just now", "5m" → "5m ago", "Jan 15" stays a date. + */ +export function formatCompactTimeAgoAsProse(label: string): string { + if (label === "now") return "just now"; + if (/^\d/.test(label)) return `${label} ago`; + return label; +} + +/** + * How many local midnights lie between two instants: 0 for the same day, 1 for yesterday. + * Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on + * today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one. + */ +function localCalendarDaysBetween(earlier: Date, later: Date): number { + const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS); } // Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved @@ -120,19 +128,18 @@ function getTimeFormatter(): Intl.DateTimeFormat { /** * Format a chat-message timestamp for hover-revealed UI. * - Same day: "10:11 PM" or "22:11" depending on user preference - * - Within ~6 days: "Wednesday 10:11 PM" - * - Older: "14 May 2026, 10:11 PM" + * - The previous 6 calendar days: "Wednesday 10:11 PM" + * - Older, including today's weekday last week: "14 May 2026, 10:11 PM" */ export function formatMessageTimestamp(date: Date, now: Date = new Date()): string { const time = getTimeFormatter().format(date); + const daysAgo = localCalendarDaysBetween(date, now); - if (isSameLocalDay(date, now)) { + if (daysAgo === 0) { return time; } - const diffMs = now.getTime() - date.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays >= 0 && diffDays < 7) { + if (daysAgo > 0 && daysAgo < 7) { const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); return `${weekday} ${time}`; } diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 09ada95695..7ccca095f1 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -6,6 +6,7 @@ function agent(input: { id: string; workspaceId?: string; status?: Agent["status"]; + turn?: Agent["turn"]; updatedAt: string; attentionTimestamp?: string | null; requiresAttention?: boolean; @@ -19,7 +20,16 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", - activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, + turn: + input.turn ?? + (input.status === "running" + ? { + phase: "open", + turnId: "turn-1", + startedAt: null, + cancellationRequestId: null, + } + : { phase: "idle", cancellationRequestId: null }), createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, @@ -56,6 +66,41 @@ function agent(input: { } describe("workspace agent activity index", () => { + it("uses turn liveness for running while preserving protocol lifecycle states", () => { + const result = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "open", + agent({ + id: "open", + workspaceId: "workspace-open", + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + [ + "idle-error", + agent({ + id: "idle-error", + workspaceId: "workspace-error", + status: "error", + turn: { phase: "idle", cancellationRequestId: null }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + ]), + ); + + expect(result.get("workspace-open")?.status).toBe("running"); + expect(result.get("workspace-error")?.status).toBe("failed"); + }); + it("keeps the latest active root agent for each workspace", () => { const index = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 3e4355ee3a..69f3c23da4 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -8,6 +8,11 @@ export interface WorkspaceAgentActivity { enteredAt: Date | null; } +function workspaceAgentStatus(agent: Agent): Agent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + export function buildWorkspaceAgentActivityIndex( agents: ReadonlyMap, previous?: ReadonlyMap, @@ -29,7 +34,7 @@ export function buildWorkspaceAgentActivityIndex( latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt); const status = deriveSidebarStateBucket({ - status: agent.status, + status: workspaceAgentStatus(agent), pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/app/src/utils/workspace-directory.test.ts b/packages/app/src/utils/workspace-directory.test.ts index b40bdd9705..b3973fb7b6 100644 --- a/packages/app/src/utils/workspace-directory.test.ts +++ b/packages/app/src/utils/workspace-directory.test.ts @@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory"; describe("resolveWorkspaceDirectory", () => { + it("preserves significant whitespace in a workspace folder name", () => { + expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo "); + expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo "); + }); it("canonicalizes a workspace directory and returns null when blank", () => { expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe( "C:/repo/app", diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 8c778372d9..3cf3134939 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st } export function normalizeWorkspacePath(value: string | null | undefined): string | null { - const trimmed = trimNonEmpty(value); - if (!trimmed) { + if (typeof value !== "string" || value.trim().length === 0) { return null; } - const withUnixSeparators = trimmed.replace(/\\/g, "/"); + const withUnixSeparators = value.replace(/\\/g, "/"); if (withUnixSeparators === "/") { return withUnixSeparators; } diff --git a/packages/app/src/voice/audio-engine.native.ts b/packages/app/src/voice/audio-engine.native.ts deleted file mode 100644 index 465bab3c73..0000000000 --- a/packages/app/src/voice/audio-engine.native.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { - AudioEngine, - AudioEngineCallbacks, - AudioPlaybackSource, -} from "@/voice/audio-engine-types"; - -interface QueuedAudio { - audio: AudioPlaybackSource; - resolve: (duration: number) => void; - reject: (error: Error) => void; -} - -interface AudioEngineTraceOptions { - traceLabel?: string; -} - -function parsePcmSampleRate(mimeType: string): number | null { - const match = /rate=(\d+)/i.exec(mimeType); - if (!match) { - return null; - } - const rate = Number(match[1]); - return Number.isFinite(rate) && rate > 0 ? rate : null; -} - -function resamplePcm16(pcm: Uint8Array, fromRate: number, toRate: number): Uint8Array { - if (fromRate === toRate) { - return pcm; - } - - const inputSamples = Math.floor(pcm.length / 2); - const outputSamples = Math.floor((inputSamples * toRate) / fromRate); - const out = new Uint8Array(outputSamples * 2); - const ratio = fromRate / toRate; - - const readInt16 = (sampleIndex: number): number => { - const i = sampleIndex * 2; - if (i + 1 >= pcm.length) { - return 0; - } - const lo = pcm[i]; - const hi = pcm[i + 1]; - let value = (hi << 8) | lo; - if (value & 0x8000) { - value = value - 0x10000; - } - return value; - }; - - const writeInt16 = (sampleIndex: number, value: number): void => { - const clamped = Math.max(-32768, Math.min(32767, Math.round(value))); - const i = sampleIndex * 2; - out[i] = clamped & 0xff; - out[i + 1] = (clamped >> 8) & 0xff; - }; - - for (let i = 0; i < outputSamples; i++) { - const srcPos = i * ratio; - const i0 = Math.floor(srcPos); - const frac = srcPos - i0; - const s0 = readInt16(i0); - const s1 = readInt16(Math.min(inputSamples - 1, i0 + 1)); - writeInt16(i, s0 + (s1 - s0) * frac); - } - - return out; -} - -export function createAudioEngine( - callbacks: AudioEngineCallbacks, - _options?: AudioEngineTraceOptions, -): AudioEngine { - const native = require("@getpaseo/expo-two-way-audio"); - - const refs: { - initialized: boolean; - captureActive: boolean; - muted: boolean; - queue: QueuedAudio[]; - processingQueue: boolean; - playbackTimeout: ReturnType | null; - activePlayback: { - resolve: (duration: number) => void; - reject: (error: Error) => void; - settled: boolean; - } | null; - destroyed: boolean; - } = { - initialized: false, - captureActive: false, - muted: false, - queue: [], - processingQueue: false, - playbackTimeout: null, - activePlayback: null, - destroyed: false, - }; - - const microphoneSubscription = native.addExpoTwoWayAudioEventListener( - "onMicrophoneData", - (event: { data: Uint8Array }) => { - if (!refs.captureActive || refs.muted) { - return; - } - const pcm = event.data; - callbacks.onCaptureData(pcm); - }, - ); - const volumeSubscription = native.addExpoTwoWayAudioEventListener( - "onInputVolumeLevelData", - (event: { data: number }) => { - if (!refs.captureActive) { - return; - } - const level = refs.muted ? 0 : event.data; - callbacks.onVolumeLevel(level); - }, - ); - const interruptionSubscription = native.addExpoTwoWayAudioEventListener( - "onAudioInterruption", - (event: { data: string }) => { - if (event.data !== "blocked") { - return; - } - const wasCaptureActive = refs.captureActive; - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - if (wasCaptureActive) { - callbacks.onInterruption?.(); - } - }, - ); - - async function ensureInitialized(): Promise { - if (refs.initialized) { - return; - } - const success = await native.initialize(); - if (!success) { - throw new Error("expo-two-way-audio: native initialize() returned false"); - } - refs.initialized = true; - } - - /** - * Release the OS audio session as soon as we are neither capturing nor playing. - * Holding it keeps the user's background music paused — on iOS the non-mixing - * `.playAndRecord` category survives backgrounding and is re-asserted on every - * foreground, so an unreleased session means their music never comes back. - */ - function releaseSessionIfIdle(): void { - if (!refs.initialized || refs.destroyed) { - return; - } - if (refs.captureActive || refs.activePlayback || refs.queue.length > 0) { - return; - } - // The wrapper no-ops on binaries whose native module predates this function. - native.releaseAudioSession(); - } - - async function ensureMicrophonePermission(): Promise { - let permission = await native.getMicrophonePermissionsAsync().catch(() => null); - if (!permission?.granted) { - permission = await native.requestMicrophonePermissionsAsync().catch(() => null); - } - if (!permission?.granted) { - throw new Error( - "Microphone permission is required to capture audio. Please enable microphone access in system settings.", - ); - } - } - - function clearPlaybackTimeout(): void { - if (refs.playbackTimeout) { - clearTimeout(refs.playbackTimeout); - refs.playbackTimeout = null; - } - } - - async function playAudio(audio: AudioPlaybackSource): Promise { - await ensureInitialized(); - - return await new Promise((resolve, reject) => { - refs.activePlayback = { resolve, reject, settled: false }; - - audio - .arrayBuffer() - .then((arrayBuffer) => { - const pcm = new Uint8Array(arrayBuffer); - const inputRate = parsePcmSampleRate(audio.type || "") ?? 24000; - - // Native AudioEngine expects 16kHz PCM16 - const pcm16k = resamplePcm16(pcm, inputRate, 16000); - const durationSec = pcm16k.length / 2 / 16000; - - native.resumePlayback(); - native.playPCMData(pcm16k); - - clearPlaybackTimeout(); - refs.playbackTimeout = setTimeout(() => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (!active || active.settled) { - return; - } - active.settled = true; - refs.activePlayback = null; - resolve(durationSec); - }, durationSec * 1000); - return undefined; - }) - .catch((error: unknown) => { - clearPlaybackTimeout(); - const active = refs.activePlayback; - if (active && !active.settled) { - active.settled = true; - refs.activePlayback = null; - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - }); - } - - async function processQueue(): Promise { - if (refs.processingQueue || refs.queue.length === 0) { - return; - } - - refs.processingQueue = true; - while (refs.queue.length > 0) { - const item = refs.queue.shift()!; - try { - const duration = await playAudio(item.audio); - item.resolve(duration); - } catch (error) { - item.reject(error instanceof Error ? error : new Error(String(error))); - } - } - refs.processingQueue = false; - releaseSessionIfIdle(); - } - - return { - async initialize() { - await ensureInitialized(); - }, - - async destroy() { - if (refs.destroyed) { - return; - } - refs.destroyed = true; - this.stop(); - this.clearQueue(); - if (refs.captureActive) { - native.toggleRecording(false); - refs.captureActive = false; - } - clearPlaybackTimeout(); - refs.muted = false; - callbacks.onVolumeLevel(0); - if (refs.initialized) { - native.tearDown(); - refs.initialized = false; - } - microphoneSubscription.remove(); - volumeSubscription.remove(); - interruptionSubscription.remove(); - }, - - async startCapture() { - if (refs.captureActive) { - return; - } - - try { - await ensureMicrophonePermission(); - await ensureInitialized(); - const isRecording = native.toggleRecording(true); - if (!isRecording) { - throw new Error( - "Microphone capture could not start because Android audio focus is unavailable.", - ); - } - refs.captureActive = true; - } catch (error) { - const wrapped = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(wrapped); - throw wrapped; - } - }, - - async stopCapture() { - if (refs.captureActive) { - native.toggleRecording(false); - } - refs.captureActive = false; - refs.muted = false; - callbacks.onVolumeLevel(0); - releaseSessionIfIdle(); - }, - - toggleMute() { - refs.muted = !refs.muted; - if (refs.muted) { - callbacks.onVolumeLevel(0); - } - return refs.muted; - }, - - isMuted() { - return refs.muted; - }, - - async play(audio: AudioPlaybackSource) { - return await new Promise((resolve, reject) => { - refs.queue.push({ audio, resolve, reject }); - if (!refs.processingQueue) { - void processQueue(); - } - }); - }, - - stop() { - native.stopPlayback(); - clearPlaybackTimeout(); - const active = refs.activePlayback; - refs.activePlayback = null; - if (active && !active.settled) { - active.settled = true; - active.reject(new Error("Playback stopped")); - } - releaseSessionIfIdle(); - }, - - clearQueue() { - while (refs.queue.length > 0) { - refs.queue.shift()!.reject(new Error("Playback stopped")); - } - refs.processingQueue = false; - releaseSessionIfIdle(); - }, - - isPlaying() { - return refs.activePlayback !== null; - }, - }; -} diff --git a/packages/app/src/voice/voice-runtime.test.ts b/packages/app/src/voice/voice-runtime.test.ts index aa79d6b245..189140c7f6 100644 --- a/packages/app/src/voice/voice-runtime.test.ts +++ b/packages/app/src/voice/voice-runtime.test.ts @@ -1,9 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { createVoiceRuntime, type VoiceSessionAdapter } from "@/voice/voice-runtime"; import { REALTIME_VOICE_VAD_CONFIG } from "@/voice/realtime-voice-config"; +const CUE_MIME_TYPE = "audio/pcm;rate=16000;bits=16"; +const THINKING_TONE_MIN_SILENCE_MS = 1500; +const SEGMENT_MIME_TYPE = "audio/pcm;rate=24000;bits=16"; + function createAudioEngineMock(): AudioEngine { return { initialize: vi.fn().mockResolvedValue(undefined), @@ -252,10 +256,11 @@ describe("voice runtime", () => { runtime.onAssistantAudioFinished("server-1"); expect(runtime.getSnapshot().phase).toBe("waiting"); + await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS); expect(engine.play).toHaveBeenCalled(); }); - it("starts the thinking tone when an agent turn begins before playback", async () => { + it("starts the thinking tone once the wait for a reply outlasts an inter-segment gap", async () => { const adapter = createSessionAdapter(); const { runtime, engine } = createRuntime(); runtime.registerSession(adapter); @@ -264,6 +269,10 @@ describe("voice runtime", () => { runtime.onTurnEvent("server-1", "agent-1", "turn_started"); expect(runtime.getSnapshot().phase).toBe("waiting"); + await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS - 1); + expect(engine.play).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); expect(engine.play).toHaveBeenCalled(); }); @@ -284,6 +293,7 @@ describe("voice runtime", () => { runtime.onTurnEvent("server-1", "agent-1", "turn_started"); expect(runtime.getSnapshot().phase).toBe("waiting"); + await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS); expect(engine.play).toHaveBeenCalledTimes(1); vi.mocked(engine.stop).mockClear(); vi.mocked(engine.clearQueue).mockClear(); @@ -297,12 +307,60 @@ describe("voice runtime", () => { runtime.onServerSpeechStateChanged("server-1", true); - expect(engine.stop).toHaveBeenCalledTimes(1); - expect(engine.clearQueue).toHaveBeenCalledTimes(1); + expect(engine.stop).not.toHaveBeenCalled(); + expect(engine.clearQueue).not.toHaveBeenCalled(); + expect(vi.mocked(engine.play).mock.calls[0][1]?.aborted).toBe(true); resolvePlay(0.1); }); + it("does not play the thinking tone in the gap between two segments of one reply", async () => { + const adapter = createSessionAdapter(); + const engine = createAudioEngineMock(); + const played: string[] = []; + vi.mocked(engine.play).mockImplementation(async (source: { type: string }) => { + played.push(source.type); + return 0.1; + }); + const { runtime } = createRuntime({ engine }); + runtime.registerSession(adapter); + + await runtime.startVoice("server-1", "agent-1"); + runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + + runtime.handleAudioOutput( + "server-1", + createAudioPayload({ + id: "seg-1-chunk-0", + groupId: "seg-1", + chunkIndex: 0, + isLastChunk: true, + }), + ); + await vi.waitFor(() => { + expect(runtime.getSnapshot().phase).toBe("waiting"); + }); + + await vi.advanceTimersByTimeAsync(400); + + runtime.handleAudioOutput( + "server-1", + createAudioPayload({ + id: "seg-2-chunk-0", + groupId: "seg-2", + chunkIndex: 0, + isLastChunk: true, + }), + ); + await vi.waitFor(() => { + expect(played).toContain(SEGMENT_MIME_TYPE); + }); + + const replyStart = played.indexOf(SEGMENT_MIME_TYPE); + expect(replyStart).toBeGreaterThanOrEqual(0); + expect(played.slice(replyStart).filter((type) => type === CUE_MIME_TYPE)).toEqual([]); + }); + it("returns to listening after assistant playback once the turn is complete", async () => { const adapter = createSessionAdapter(); const { runtime, engine } = createRuntime(); @@ -310,6 +368,7 @@ describe("voice runtime", () => { await runtime.startVoice("server-1", "agent-1"); runtime.onTurnEvent("server-1", "agent-1", "turn_started"); + await vi.advanceTimersByTimeAsync(THINKING_TONE_MIN_SILENCE_MS); runtime.onAssistantAudioStarted("server-1"); runtime.onTurnEvent("server-1", "agent-1", "turn_completed"); runtime.onAssistantAudioFinished("server-1"); diff --git a/packages/app/src/voice/voice-runtime.ts b/packages/app/src/voice/voice-runtime.ts index 6bb42499e4..3b7843e2f7 100644 --- a/packages/app/src/voice/voice-runtime.ts +++ b/packages/app/src/voice/voice-runtime.ts @@ -2,7 +2,7 @@ import { Buffer } from "buffer"; import type { AgentStreamEventPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import type { DaemonServerInfo } from "@/stores/session-store"; -import type { AudioEngine } from "@/voice/audio-engine-types"; +import type { AudioEngine } from "@/audio"; import { THINKING_TONE_NATIVE_PCM_BASE64, THINKING_TONE_NATIVE_PCM_DURATION_MS, @@ -11,6 +11,13 @@ import { const PCM_MIME_TYPE = "audio/pcm;rate=16000;bits=16"; const KEEP_AWAKE_TAG = "paseo:voice"; const THINKING_TONE_REPEAT_GAP_MS = 350; +/** + * A reply is spoken as one TTS segment per sentence, and the daemon starts the next segment + * only once the current one has finished playing, so a reply in progress is punctuated by short + * silences. The cue is for a wait the user cannot otherwise explain, so it starts only once the + * silence has outlasted those gaps. + */ +const THINKING_TONE_MIN_SILENCE_MS = 1500; const DISPLAY_VOLUME_PUBLISH_INTERVAL_MS = 120; const DISPLAY_VOLUME_CHANGE_EPSILON = 0.02; const DISPLAY_VOLUME_ATTACK = 0.35; @@ -111,10 +118,8 @@ interface RuntimePlaybackState { } interface CueState { - active: boolean; - token: number; + controller: AbortController | null; timeout: ReturnType | null; - playing: boolean; } const INITIAL_SNAPSHOT: VoiceRuntimeSnapshot = { @@ -203,10 +208,8 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { generation: 0, }; const cue: CueState = { - active: false, - token: 0, + controller: null, timeout: null, - playing: false, }; const cuePcm16 = Uint8Array.from(Buffer.from(THINKING_TONE_NATIVE_PCM_BASE64, "base64")); const cueSource = { @@ -435,18 +438,12 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { } function stopCue(): void { - const hadActive = cue.active || cue.timeout !== null || cue.playing; - cue.active = false; - cue.token += 1; + cue.controller?.abort(); + cue.controller = null; if (cue.timeout) { clearTimeout(cue.timeout); cue.timeout = null; } - cue.playing = false; - if (hadActive) { - deps.engine.stop(); - deps.engine.clearQueue(); - } } function resetCaptureTelemetry(): void { @@ -460,29 +457,26 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { stopCue(); return; } - if (cue.active) { + if (cue.controller) { return; } - cue.active = true; - cue.token += 1; - const token = cue.token; + const controller = new AbortController(); + cue.controller = controller; const playNext = () => { - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } - cue.playing = true; void deps.engine - .play(cueSource) + .play(cueSource, controller.signal) .catch((error) => { - if (cue.token !== token) { + if (controller.signal.aborted) { return; } console.warn(`[VoiceRuntime#${instanceId}] Cue playback failed:`, error); }) .finally(() => { - cue.playing = false; - if (!cue.active || cue.token !== token) { + if (controller.signal.aborted) { return; } cue.timeout = setTimeout( @@ -492,7 +486,7 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { }); }; - playNext(); + cue.timeout = setTimeout(playNext, THINKING_TONE_MIN_SILENCE_MS); } const uploader: ContinuousVoiceUploader = { @@ -913,10 +907,9 @@ export function createVoiceRuntime(deps: VoiceRuntimeDeps): VoiceRuntime { if (isSpeaking) { const shouldInterruptPlayback = state.snapshot.phase === "playing" || playback.groups.size > 0; - const hadCue = cue.active || cue.timeout !== null || cue.playing; resetPlaybackState(); stopCue(); - if (shouldInterruptPlayback && !hadCue) { + if (shouldInterruptPlayback) { deps.engine.stop(); deps.engine.clearQueue(); } diff --git a/packages/app/src/workspace-labels/index.ts b/packages/app/src/workspace-labels/index.ts index b2c46ab748..95fd5a3a26 100644 --- a/packages/app/src/workspace-labels/index.ts +++ b/packages/app/src/workspace-labels/index.ts @@ -147,6 +147,11 @@ class WorkspaceLabelsController { client: DaemonClient; supportsWorkspaceLabels: boolean; }): Promise { + const existing = this.connections.get(input.serverId); + if (existing?.client === input.client && input.supportsWorkspaceLabels) { + await this.refresh(input.serverId); + return; + } this.disconnect(input.serverId); const replica = this.replicas.get(input.serverId) ?? new HostWorkspaceLabelReplica(); this.replicas.set(input.serverId, replica); @@ -155,18 +160,38 @@ class WorkspaceLabelsController { return; } - let connection: HostConnection | undefined; - const unsubscribe = input.client.on("workspace.label.update", (message) => { - if (this.connections.get(input.serverId) !== connection) return; - if (!replica.applyUpdate(message)) { - void this.refresh(input.serverId).catch(() => undefined); - return; - } - this.publish(input.serverId, replica, "online", null); - }); - connection = { client: input.client, replica, unsubscribe }; + const subscription = input.client.observeWorkspaceLabels(); + const connection: HostConnection = { + client: input.client, + replica, + unsubscribe: () => { + void subscription.release().catch(() => undefined); + }, + }; this.connections.set(input.serverId, connection); - await this.refresh(input.serverId); + subscription.subscribe({ + snapshot: (payload) => { + replica.applyList(payload); + this.publish(input.serverId, replica, "online", null); + }, + update: (message) => { + if (message.type !== "workspace.label.update") return; + if (!replica.applyUpdate(message)) { + void this.refresh(input.serverId).catch(() => undefined); + return; + } + this.publish(input.serverId, replica, "online", null); + }, + error: (error) => { + this.publish( + input.serverId, + replica, + "online", + error instanceof Error ? error.message : i18n.t("workspaceLabels.errors.load"), + ); + }, + }); + await subscription.ready; } disconnect(serverId: string): void { @@ -207,7 +232,6 @@ class WorkspaceLabelsController { if (!connection) return; try { const payload = await connection.client.listWorkspaceLabels({ - subscriptionId: `workspace-labels:${serverId}`, sync: connection.replica.snapshot().cursor, }); if (this.connections.get(serverId) !== connection) return; diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx index 0dc8f1c53f..a23e7e0925 100644 --- a/packages/app/src/workspace-labels/manager-modal.tsx +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({ onClose={onClose} header={header} footer={footer} - // Bound the compact scroller to the live snap height, so the edit view's footer stays on - // screen instead of being pushed past the bottom of the sheet. - sizeContentToCurrentSnapPoint testID="workspace-label-manager" > {draft && editing ? ( diff --git a/packages/app/src/workspace-labels/picker.tsx b/packages/app/src/workspace-labels/picker.tsx index 7afdaeabe6..a0e2348308 100644 --- a/packages/app/src/workspace-labels/picker.tsx +++ b/packages/app/src/workspace-labels/picker.tsx @@ -131,6 +131,7 @@ function WorkspaceLabelPickerPage({ }, [assignedLabels, host?.status, labels, model]); const snapshot = useSyncExternalStore(model.subscribe, model.snapshot, model.snapshot); const offline = !snapshot.online; + const error = snapshot.error ?? (host?.error ? t("workspaceLabels.errors.load") : null); const pending = useMemo(() => new Set(snapshot.pendingNames), [snapshot.pendingNames]); const toggle = useCallback( (label: WorkspaceLabelDefinition, assigned: boolean) => { @@ -159,9 +160,7 @@ function WorkspaceLabelPickerPage({ > {t("workspaceLabels.create")} - {snapshot.error ? ( - {snapshot.error} - ) : null} + {error ? {error} : null} {host?.status === "unsupported" ? ( {t("workspaceLabels.updateHostUse")} ) : null} diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts index 46f7aba864..c4a6de7a21 100644 --- a/packages/app/src/workspace-recovery/model.test.ts +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { recoverWorkspaceSelection, resolveWorkspaceRecoveryModel } from "./model"; +import { resolveWorkspaceRecoveryModel } from "./model"; describe("resolveWorkspaceRecoveryModel", () => { it("keeps newer recovery actions visible but non-actionable", () => { @@ -29,24 +29,3 @@ describe("resolveWorkspaceRecoveryModel", () => { }); }); }); - -describe("recoverWorkspaceSelection", () => { - it("restores the workspace and selected archived agent as one recovery action", async () => { - const operations: string[] = []; - - await recoverWorkspaceSelection({ - workspaceId: "workspace-1", - agentId: "agent-1", - client: { - restoreWorkspace: async (workspaceId) => { - operations.push(`workspace:${workspaceId}`); - }, - refreshAgent: async (agentId) => { - operations.push(`agent:${agentId}`); - }, - }, - }); - - expect(operations).toEqual(["workspace:workspace-1", "agent:agent-1"]); - }); -}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index 2c85a06f4a..5d5ebf15e0 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -34,22 +34,6 @@ export interface WorkspaceRecoveryController { retryInspection: () => void; } -export interface WorkspaceSelectionRecoveryClient { - restoreWorkspace: (workspaceId: string) => Promise; - refreshAgent: (agentId: string) => Promise; -} - -export async function recoverWorkspaceSelection(input: { - client: WorkspaceSelectionRecoveryClient; - workspaceId: string; - agentId?: string | null; -}): Promise { - await input.client.restoreWorkspace(input.workspaceId); - if (input.agentId) { - await input.client.refreshAgent(input.agentId); - } -} - function resolveRecoveryPhase(input: { pending: boolean; error: string | null; diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index ab19957190..d91aa3ce6e 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -4,11 +4,7 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { toErrorMessage } from "@/utils/error-messages"; -import { - recoverWorkspaceSelection, - resolveWorkspaceRecoveryModel, - type WorkspaceRecoveryController, -} from "./model"; +import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model"; export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model"; @@ -32,7 +28,6 @@ function waitForMinimumRecoveryLoadingTime(): Promise { export function useWorkspaceRecovery(input: { serverId: string; workspaceId: string; - agentId?: string | null; enabled: boolean; }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); @@ -61,11 +56,7 @@ export function useWorkspaceRecovery(input: { } await waitForRecoveryLoadingPresentation(); await waitForMinimumRecoveryLoadingTime(); - await recoverWorkspaceSelection({ - client, - workspaceId: input.workspaceId, - agentId: input.agentId, - }); + await client.restoreWorkspace(input.workspaceId); }, }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 8488c8f515..3b16c6b8d5 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,7 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt, updatedAt: createdAt, lastUserMessageAt: null, @@ -61,7 +61,7 @@ function makeAgent(input: { const WORKSPACE_ID = "ws-1"; describe("workspace agent visibility", () => { - it("keeps subagents active and known while excluding them from auto-open", () => { + it("keeps subagents active while excluding them from auto-open", () => { const parent = makeAgent({ id: "parent-agent", cwd: "/repo/worktree", @@ -84,10 +84,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); }); - it("keeps archived subagents known but excludes them from active and auto-open", () => { + it("excludes archived subagents from active and auto-open", () => { const archivedChild = makeAgent({ id: "archived-child", cwd: "/repo/worktree", @@ -103,7 +102,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set()); expect(result.autoOpenAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set(["archived-child"])); }); it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { @@ -129,7 +127,6 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); }); it("auto-opens a subagent whose parent belongs to another workspace", () => { @@ -155,10 +152,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["child-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["child-agent"])); }); - it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { + it("excludes archived agents from the active directory", () => { const visible = makeAgent({ id: "visible-agent", cwd: "/repo/worktree", @@ -191,12 +187,9 @@ describe("workspace agent visibility", () => { expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"])); - expect(result.knownAgentIds.has("visible-agent")).toBe(true); - expect(result.knownAgentIds.has("archived-agent")).toBe(true); - expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); - it("treats lazy historical details as known without making them active", () => { + it("does not make historical details active", () => { const active = makeAgent({ id: "active-agent", cwd: "/repo/worktree", @@ -216,7 +209,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["active-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"])); }); it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { @@ -281,7 +273,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["stamped-agent"])); }); it("excludes a stamped agent whose workspaceId belongs to another workspace sharing the cwd", () => { @@ -302,7 +293,6 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("excludes agents without a workspaceId", () => { @@ -316,14 +306,12 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set()); - expect(result.knownAgentIds).toEqual(new Set()); }); it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => { const agentVisibility = { activeAgentIds: new Set(["active-agent"]), autoOpenAgentIds: new Set(["root-agent"]), - knownAgentIds: new Set(["active-agent", "archived-agent"]), }; expect( @@ -341,7 +329,6 @@ describe("workspace agent visibility", () => { terminalsHydrated: true, activeAgentIds: agentVisibility.activeAgentIds, autoOpenAgentIds: agentVisibility.autoOpenAgentIds, - knownAgentIds: agentVisibility.knownAgentIds, knownTerminalIds: ["terminal-1", "script-terminal"], standaloneTerminalIds: ["terminal-1"], hasActivePendingTerminalCreate: false, @@ -354,12 +341,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b", "c"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); @@ -368,12 +353,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; const b = { activeAgentIds: new Set(["b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -382,26 +365,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; const b = { activeAgentIds: new Set(["a", "b"]), autoOpenAgentIds: new Set(["b"]), - knownAgentIds: new Set(["a", "b"]), - }; - expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); - }); - - it("returns false when knownAgentIds differ", () => { - const a = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a"]), - }; - const b = { - activeAgentIds: new Set(["a"]), - autoOpenAgentIds: new Set(["a"]), - knownAgentIds: new Set(["a", "b"]), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); @@ -410,12 +377,10 @@ describe("workspace agent visibility", () => { const a = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; const b = { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 1f8f5c5ed4..399b786ef6 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -6,7 +6,6 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { activeAgentIds: Set; autoOpenAgentIds: Set; - knownAgentIds: Set; } function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { @@ -24,13 +23,11 @@ export function deriveWorkspaceAgentVisibility(input: { return { activeAgentIds: new Set(), autoOpenAgentIds: new Set(), - knownAgentIds: new Set(), }; } const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); - const knownAgentIds = new Set(); const agentsById = new Map([ ...(agentDetails?.entries() ?? []), ...(sessionAgents?.entries() ?? []), @@ -39,7 +36,6 @@ export function deriveWorkspaceAgentVisibility(input: { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; } - knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; @@ -48,14 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: { } } } - for (const agent of agentDetails?.values() ?? []) { - if (!agentBelongsToWorkspace(agent, workspaceId)) { - continue; - } - knownAgentIds.add(agent.id); - } - - return { activeAgentIds, autoOpenAgentIds, knownAgentIds }; + return { activeAgentIds, autoOpenAgentIds }; } export function buildWorkspaceTabSnapshot(input: { @@ -72,7 +61,6 @@ export function buildWorkspaceTabSnapshot(input: { terminalsHydrated: input.terminalsHydrated, activeAgentIds: input.agentVisibility.activeAgentIds, autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds, - knownAgentIds: input.agentVisibility.knownAgentIds, knownTerminalIds: input.knownTerminalIds, standaloneTerminalIds: input.standaloneTerminalIds, hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate, @@ -86,8 +74,7 @@ export function workspaceAgentVisibilityEqual( ): boolean { return ( setsEqual(a.activeAgentIds, b.activeAgentIds) && - setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && - setsEqual(a.knownAgentIds, b.knownAgentIds) + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) ); } diff --git a/packages/app/src/workspace-tabs/launcher/index.tsx b/packages/app/src/workspace-tabs/launcher/index.tsx index ba4998fda9..ea983b4bd8 100644 --- a/packages/app/src/workspace-tabs/launcher/index.tsx +++ b/packages/app/src/workspace-tabs/launcher/index.tsx @@ -18,7 +18,7 @@ import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { NewTabSelection } from "@/workspace-tabs/new-tab"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; +import { panelCanLaunchInPane, panelSupportsHost, type PaneHost } from "@/panels/panel-manifest"; import { getPanelRegistration, type PanelIconProps, @@ -63,6 +63,8 @@ export interface WorkspaceTabLaunchGroup { accessory?: { id: string; label: string; run: () => void }; } +const EMPTY_PANE_PANEL_KINDS: readonly WorkspaceTabTarget["kind"][] = []; + const NewTabLauncherContext = createContext(null); export function NewTabLauncherProvider({ @@ -75,7 +77,7 @@ export function NewTabLauncherProvider({ return {children}; } -const BUILT_IN_SELECTIONS: Record = { +const BUILT_IN_SELECTIONS = { agent: { kind: "agent" }, terminal: { kind: "terminal" }, changes: { kind: "target", target: { kind: "changes_tree" } }, @@ -83,7 +85,7 @@ const BUILT_IN_SELECTIONS: Record = { files: { kind: "target", target: { kind: "files" } }, browser: { kind: "browser" }, pullRequest: { kind: "target", target: { kind: "pull_request" } }, -}; +} satisfies Record; function getLaunchPresentation(kind: WorkspaceTabTarget["kind"]): PanelPresentation { const registration = getPanelRegistration(kind); @@ -95,8 +97,10 @@ export function useWorkspaceTabLaunchCatalog(input: { serverId: string; purpose: WorkspaceTabLaunchPurpose; host: PaneHost; + surface: "menu" | "panel"; + panePanelKinds?: readonly WorkspaceTabTarget["kind"][]; }): readonly WorkspaceTabLaunchGroup[] { - const { serverId, purpose, host } = input; + const { serverId, purpose, host, surface, panePanelKinds = EMPTY_PANE_PANEL_KINDS } = input; const { t } = useTranslation(); const router = useRouter(); const launcher = useContext(NewTabLauncherContext); @@ -116,6 +120,7 @@ export function useWorkspaceTabLaunchCatalog(input: { }, [router, serverId]); return useMemo(() => { + const isExplorerMenu = host === "explorer" && surface === "menu"; const changesPresentation = getLaunchPresentation("changes_tree"); const diffPresentation = getLaunchPresentation("working_diff"); const filesPresentation = getLaunchPresentation("files"); @@ -128,6 +133,7 @@ export function useWorkspaceTabLaunchCatalog(input: { shortcutActionId: "workspace-tab-target-agent", disabled: false, panelKind: "draft", + hidden: isExplorerMenu, launch: launchSelection(BUILT_IN_SELECTIONS.agent), }, terminal: { @@ -219,7 +225,7 @@ export function useWorkspaceTabLaunchCatalog(input: { if (pluginItems.length > 0) { groups.push({ id: "plugin-panels", label: null, items: pluginItems }); } - if (profiles.length > 0) { + if (!isExplorerMenu && profiles.length > 0) { groups.push({ id: "terminal-profiles", label: t("workspace.tabs.actions.terminalProfilesMenu"), @@ -238,7 +244,13 @@ export function useWorkspaceTabLaunchCatalog(input: { }, }); } - return groups; + if (surface !== "menu") return groups; + return groups.flatMap((group) => { + const items = group.items.filter((item) => + panelCanLaunchInPane(item.panelKind, panePanelKinds), + ); + return items.length > 0 ? [{ ...group, items }] : []; + }); }, [ config?.terminalProfiles, editTerminalProfiles, @@ -247,6 +259,8 @@ export function useWorkspaceTabLaunchCatalog(input: { plugins, purpose, host, + surface, + panePanelKinds, serverId, t, ]); diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts index a2ede20ddf..864e0ec43f 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.test.ts @@ -17,9 +17,9 @@ describe("getBuiltInLaunchOrder", () => { it("leads with companion tools in a supporting pane", () => { expect(getBuiltInLaunchOrder("supporting")).toEqual([ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts index dfed374170..5c2c77cdfe 100644 --- a/packages/app/src/workspace-tabs/launcher/internal/catalog.ts +++ b/packages/app/src/workspace-tabs/launcher/internal/catalog.ts @@ -10,9 +10,9 @@ export const PRIMARY_LAUNCH_ORDER = [ export const SUPPORTING_LAUNCH_ORDER = [ "changes", + "terminal", "diff", "files", - "terminal", "agent", "browser", "pullRequest", diff --git a/packages/app/src/workspace-tabs/open-beside.ts b/packages/app/src/workspace-tabs/open-beside.ts index 7acda716c1..b36d227c3b 100644 --- a/packages/app/src/workspace-tabs/open-beside.ts +++ b/packages/app/src/workspace-tabs/open-beside.ts @@ -4,6 +4,7 @@ import { collectAllPanes, DEFAULT_PANE_ID, findPaneById, + findPaneContainingTab, useWorkspaceLayoutStore, type WorkspaceTabPlacement, } from "@/stores/workspace-layout-store"; @@ -109,6 +110,10 @@ export function openWorkspaceTargetAtLocation( if (shouldOpenBeside && !targetAlreadyExists) { const paneId = store.ensureSidePane(input.workspaceKey); placement = paneId ? { mode: "prefer", paneId } : undefined; + } else if (!shouldOpenBeside && layout && input.parentTabId) { + // A target opened from a tab belongs in that tab's pane, whichever pane has focus. + const parentPane = findPaneContainingTab(layout.root, input.parentTabId); + placement = parentPane ? { mode: "prefer", paneId: parentPane.id } : undefined; } return store.openTab({ workspaceKey: input.workspaceKey, diff --git a/packages/app/src/workspace-tabs/open-supporting-view.test.ts b/packages/app/src/workspace-tabs/open-supporting-view.test.ts index c0fa1c5927..b3e6f4c976 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.test.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.test.ts @@ -16,6 +16,7 @@ import { useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; import { + autoOpenWorkspacePullRequest, openComposerChanges, openWorkspaceChanges, openWorkspacePullRequest, @@ -31,6 +32,7 @@ beforeEach(() => { explorerTabByCheckout: {}, }); useWorkspaceLayoutStore.setState({ + pullRequestTabAutoOpenedByWorkspace: {}, layoutByWorkspace: {}, explorerSidebarPaneIdByWorkspace: {}, sidePaneIdByWorkspace: {}, @@ -178,3 +180,152 @@ describe("openWorkspacePullRequest", () => { expect(sidePane?.tabIds).toContain(pullRequestTab?.tabId); }); }); + +describe("autoOpenWorkspacePullRequest", () => { + it("silently adds the PR after Changes once, and never restores it after closing", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + const input = { workspaceKey: WORKSPACE_KEY, destination: "explorer" as const }; + autoOpenWorkspacePullRequest(input); + const after = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + const explorer = findPaneById(after.root, "explorer")!; + expect(explorer.tabIds).toEqual([ + "files", + "changes_tree", + "pull_request", + "terminal_terminal-1", + ]); + expect(explorer.hidden).toBe(true); + expect(explorer.focusedTabId).toBe(findPaneById(before.root, "explorer")!.focusedTabId); + expect(after.focusedPaneId).toBe(before.focusedPaneId); + store.closeTab(WORKSPACE_KEY, "pull_request"); + autoOpenWorkspacePullRequest(input); + expect( + collectAllTabs(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root).map( + (tab) => tab.target.kind, + ), + ).not.toContain("pull_request"); + }); +}); + +describe("automatic PR placement", () => { + it.each(["main", "side"] as const)( + "silently appends to the configured %s pane", + (destination) => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + const paneId = destination === "main" ? "main" : store.ensureSidePane(WORKSPACE_KEY)!; + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "terminal", terminalId: "terminal-1" }, + intent: "background", + placement: { mode: "prefer", paneId }, + }); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + const after = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + expect(findPaneById(after.root, paneId)!.tabIds).toEqual([ + ...findPaneById(before.root, paneId)!.tabIds, + "pull_request", + ]); + expect(findPaneById(after.root, paneId)!.focusedTabId).toBe( + findPaneById(before.root, paneId)!.focusedTabId, + ); + expect(after.focusedPaneId).toBe(before.focusedPaneId); + expect(findPaneById(after.root, "explorer")!.hidden).toBe(true); + }, + ); + + it("creates a side pane without stealing workspace focus", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "side" }); + const state = useWorkspaceLayoutStore.getState(); + const layout = state.layoutByWorkspace[WORKSPACE_KEY]; + expect(findPaneById(layout.root, state.sidePaneIdByWorkspace[WORKSPACE_KEY])!.tabIds).toEqual([ + "pull_request", + ]); + expect(layout.focusedPaneId).toBe("main"); + }); + + it.each(["main", "side", "explorer"] as const)( + "leaves a manually moved PR in main when detection prefers %s", + (destination) => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + store.moveTabToPane(WORKSPACE_KEY, "pull_request", "main"); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toEqual(before); + expect( + useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY], + ).toBeUndefined(); + store.closeTab(WORKSPACE_KEY, "pull_request"); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination }); + expect( + collectAllTabs( + useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root, + ).map((tab) => tab.target.kind), + ).not.toContain("pull_request"); + expect( + useWorkspaceLayoutStore.getState().sidePaneIdByWorkspace[WORKSPACE_KEY], + ).toBeUndefined(); + }, + ); + + it("preserves reordering before and after detection", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "pull_request" }, + intent: "background", + placement: { mode: "prefer", paneId: "explorer" }, + }); + store.reorderTabsInPane(WORKSPACE_KEY, "explorer", ["pull_request", "files", "changes_tree"]); + const before = useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]; + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "explorer" }); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "side" }); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY]).toEqual(before); + }); + + it("appends in Explorer when Changes was closed", () => { + const store = useWorkspaceLayoutStore.getState(); + store.openTab({ + workspaceKey: WORKSPACE_KEY, + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.closeTab(WORKSPACE_KEY, "changes_tree"); + autoOpenWorkspacePullRequest({ workspaceKey: WORKSPACE_KEY, destination: "explorer" }); + expect( + findPaneById( + useWorkspaceLayoutStore.getState().layoutByWorkspace[WORKSPACE_KEY].root, + "explorer", + )!.tabIds, + ).toEqual(["files", "pull_request"]); + }); +}); diff --git a/packages/app/src/workspace-tabs/open-supporting-view.ts b/packages/app/src/workspace-tabs/open-supporting-view.ts index 047cdd3f97..b91bf732c9 100644 --- a/packages/app/src/workspace-tabs/open-supporting-view.ts +++ b/packages/app/src/workspace-tabs/open-supporting-view.ts @@ -1,3 +1,12 @@ +import { + collectAllPanes, + collectAllTabs, + createWorkspaceLayoutWithExplorerSidebar, + DEFAULT_PANE_ID, + findPaneContainingTab, + resolveExplorerSidebarPaneId, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; import type { OpenInSidePanePreferences, PullRequestOpenLocation } from "@/hooks/use-settings"; import type { ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { @@ -67,3 +76,47 @@ export function openWorkspacePullRequest(input: OpenWorkspacePullRequestInput): location: input.destination, }); } + +/** Adds a detected desktop PR once without revealing or selecting its destination. */ +export function autoOpenWorkspacePullRequest(input: { + workspaceKey: string | null; + destination: PullRequestOpenLocation; +}): string | null { + if (!input.workspaceKey) return null; + const workspaceKey = input.workspaceKey; + const store = useWorkspaceLayoutStore.getState(); + return store.autoOpenPullRequestTab(workspaceKey, () => { + const layout = + store.layoutByWorkspace[workspaceKey] ?? createWorkspaceLayoutWithExplorerSidebar(); + // A background open finds an existing PR tab wherever the user left it. + if (collectAllTabs(layout.root).some((tab) => tab.target.kind === "pull_request")) { + return {}; + } + const explorerPaneId = resolveExplorerSidebarPaneId( + layout, + store.explorerSidebarPaneIdByWorkspace[workspaceKey], + ); + let paneId = explorerPaneId; + if (input.destination === "side") { + paneId = store.ensureSidePane(workspaceKey, { focus: false }); + } else if (input.destination === "main") { + const panes = collectAllPanes(layout.root); + const mainPane = + panes.find((pane) => pane.id === DEFAULT_PANE_ID) ?? + panes.find((pane) => pane.id !== explorerPaneId && !pane.hidden); + paneId = mainPane?.id ?? null; + } + const changes = + input.destination === "explorer" + ? collectAllTabs(layout.root).find( + (tab) => + tab.target.kind === "changes_tree" && + findPaneContainingTab(layout.root, tab.tabId)?.id === paneId, + ) + : undefined; + return { + placement: paneId ? { mode: "prefer", paneId } : undefined, + insertionPosition: changes ? { afterTabId: changes.tabId } : undefined, + }; + }); +} diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index d7d2dd3976..3b8ed4ca83 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -136,6 +136,24 @@ describe("resolveWorkspaceFilePaths", () => { }); }); + it("resolves files under a drive-root workspace without doubling the separator", () => { + expect(resolveWorkspaceFilePaths({ path: "C:/src/app.ts", workspaceRoot: "C:\\" })).toEqual({ + absolutePath: "C:/src/app.ts", + relativePath: "src/app.ts", + }); + expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "C:\\" })).toEqual({ + absolutePath: "C:/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("resolves files under the POSIX root workspace", () => { + expect(resolveWorkspaceFilePaths({ path: "/src/app.ts", workspaceRoot: "/" })).toEqual({ + absolutePath: "/src/app.ts", + relativePath: "src/app.ts", + }); + }); + it("returns null for home-relative paths that cannot be anchored", () => { expect( resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }), diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index f09d5bd81f..7668de923c 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -129,6 +129,17 @@ function startsWithPath(value: string, prefix: string): boolean { : value.startsWith(prefix); } +function pathWithinRoot(root: string, relativePath: string): string { + return root.endsWith("/") ? `${root}${relativePath}` : `${root}/${relativePath}`; +} + +/** Whether an absolute path names the workspace directory itself. */ +export function isWorkspaceRootPath(input: { path: string; workspaceRoot: string }): boolean { + const path = normalizeAbsolutePath(input.path.trim().replace(/\\/g, "/")); + const root = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/")); + return path !== null && root !== null && pathsEqual(path, root); +} + export interface ResolvedWorkspaceFilePaths { /** Absolute path on the host, suitable for opening in an editor / file manager. */ absolutePath: string; @@ -159,7 +170,7 @@ export function resolveWorkspaceFilePaths(input: { if (pathsEqual(normalizedFile, workspaceRoot)) { return null; } - const prefix = `${workspaceRoot}/`; + const prefix = pathWithinRoot(workspaceRoot, ""); const relativePath = startsWithPath(normalizedFile, prefix) ? normalizedFile.slice(prefix.length) : null; @@ -174,5 +185,5 @@ export function resolveWorkspaceFilePaths(input: { if (!relativePath) { return null; } - return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }; + return { absolutePath: pathWithinRoot(workspaceRoot, relativePath), relativePath }; } diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 7f50c4b57a..263596badc 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -1,27 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility"; -import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; -import { - applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, - buildLegacyDaemonWorkspaceSnapshot, -} from "./legacy-daemon-workspaces"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; +import { applyLegacyDaemonWorkspaceOwnership } from "./legacy-daemon-workspaces"; const SERVER_ID = "srv_legacy"; -function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function legacyAgent(input: { id: string; cwd: string; @@ -83,87 +68,9 @@ afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); }); -describe("buildLegacyDaemonWorkspaceSnapshot", () => { - it("creates path-backed workspace rows and stamps legacy agents with their workspace id", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ - serverId: SERVER_ID, - entries: [ - legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" }), - legacyAgent({ id: "agent-idle", cwd: "/repo/app", status: "idle" }), - ], - }); - - expect(Array.from(snapshot.workspaces.values())).toEqual([ - expect.objectContaining({ - id: "/repo/app", - projectId: "/repo", - projectDisplayName: "repo", - projectRootPath: "/repo", - workspaceDirectory: "/repo/app", - projectKind: "git", - workspaceKind: "checkout", - name: "app", - status: "running", - scripts: [], - }), - ]); - expect( - Array.from(snapshot.agents.values()).map((agent) => ({ - id: agent.id, - serverId: agent.serverId, - cwd: agent.cwd, - workspaceId: agent.workspaceId, - })), - ).toEqual([ - { - id: "agent-running", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - { - id: "agent-idle", - serverId: SERVER_ID, - cwd: "/repo/app", - workspaceId: "/repo/app", - }, - ]); - }); - - it("keeps matching legacy path projects separate across hosts", () => { - const first = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-a", - entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })], - }); - const second = buildLegacyDaemonWorkspaceSnapshot({ - serverId: "host-b", - entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })], - }); - - const projects = buildWorkspaceStructureProjects({ - sessions: [ - { - serverId: "host-a", - projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace), - workspaces: first.workspaces.values(), - }, - { - serverId: "host-b", - projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace), - workspaces: second.workspaces.values(), - }, - ], - }); - - expect(projects).toHaveLength(2); - expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([ - "host-a", - "host-b", - ]); - }); - +describe("applyLegacyDaemonWorkspaceOwnership", () => { it("keeps old-daemon agent updates attached to the path-backed workspace", () => { - const snapshot = buildLegacyDaemonWorkspaceSnapshot({ + const snapshot = buildAgentDirectoryState({ serverId: SERVER_ID, entries: [legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" })], }); @@ -174,7 +81,8 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { hostname: null, version: "0.1.96", }); - store.setWorkspaces(SERVER_ID, snapshot.workspaces); + const cachedAgent = getSnapshotAgent(snapshot, "agent-running"); + snapshot.agents.set(cachedAgent.id, { ...cachedAgent, workspaceId: "/repo/app" }); store.setAgents(SERVER_ID, snapshot.agents); const existingAgent = getSnapshotAgent(snapshot, "agent-running"); @@ -197,47 +105,4 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { expect(stampedUpdate.workspaceId).toBe("/repo/app"); expect(visibility.activeAgentIds).toEqual(new Set(["agent-running"])); }); - - it("does not backfill path-backed workspaces after hydration is cancelled", async () => { - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - store.updateSessionServerInfo(SERVER_ID, { - serverId: SERVER_ID, - hostname: null, - version: "0.1.96", - }); - let cancelled = false; - let didFetchAgents = false; - const client: Pick = { - fetchAgents: async () => { - didFetchAgents = true; - cancelled = true; - return { - requestId: "req_cancelled_backfill", - subscriptionId: null, - entries: [legacyAgent({ id: "agent-cancelled", cwd: "/repo/app" })], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }, - }; - - const didBackfill = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId: SERVER_ID, - workspaces: new Map(), - projects: new Map(), - isCancelled: () => cancelled, - }); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(didFetchAgents).toBe(true); - expect(didBackfill).toBe(true); - expect(session?.agents.size).toBe(0); - expect(session?.workspaces.size).toBe(0); - expect(session?.hasHydratedWorkspaces).toBe(false); - }); }); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 6de82a3f3e..08c0ddf3ee 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -1,173 +1,19 @@ -import type { - DaemonClient, - FetchAgentsEntry, - FetchAgentsOptions, -} from "@getpaseo/client/internal/daemon-client"; import { - deriveAgentStateBucket, - getWorkspaceStateBucketPriority, -} from "@getpaseo/protocol/agent-state-bucket"; -import type { - Agent, - DaemonServerInfo, - ProjectDescriptor, - WorkspaceDescriptor, + useSessionStore, + type Agent, + type DaemonServerInfo, + type WorkspaceDescriptor, } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { - buildAgentDirectoryState, - replaceFetchedAgentDirectory, -} from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; -export interface LegacyDaemonWorkspaceSnapshot { - agents: Map; - workspaces: Map; -} - -export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot { - subscriptionId: string | null; -} - -interface LegacyDaemonWorkspaceDirectoryReadResult { - entries: FetchAgentsEntry[]; - subscriptionId: string | null; -} - -interface LegacyDaemonWorkspaceBackfillInput { - client: Pick; - serverId: string; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - isCancelled?: () => boolean; -} - -const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ - { key: "updated_at", direction: "desc" }, -]; - -// COMPAT(legacyWorkspaceDaemon): v0.1.97 app talking to <=v0.1.96 daemons. -// Older daemons expose agents by cwd but may have no workspace registry rows. -// Keep all cwd -> synthetic workspace behavior in this file so the shim is -// deleted by removing this module and its call sites once the daemon floor is v0.1.97. -export function buildLegacyDaemonWorkspaceSnapshot(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = buildAgentDirectoryState({ - serverId: input.serverId, - entries, - }); - - return { - agents, - workspaces: buildLegacyWorkspaces(entries), - }; -} - -export function shouldUseLegacyDaemonWorkspaceDirectory( - serverInfo: DaemonServerInfo | null | undefined, -): boolean { - return ( - serverInfo !== null && - serverInfo !== undefined && - serverInfo.features?.workspaceMultiplicity !== true - ); -} - +// COMPAT(legacyWorkspaceDaemon): added in v0.1.97, remove after 2027-03-09. +// Cached or partial agent records can predate workspace IDs; preserve their placement. function shouldBackfillLegacyDaemonWorkspaceDirectory( serverInfo: DaemonServerInfo | null | undefined, ): boolean { return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function fetchLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - serverId: string; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -}): Promise { - const directory = await readLegacyDaemonWorkspaceDirectory(input); - if (!directory) { - throw new Error("Legacy daemon workspace directory fetch was cancelled."); - } - const snapshot = replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return { ...snapshot, subscriptionId: directory.subscriptionId }; -} - -export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( - input: LegacyDaemonWorkspaceBackfillInput, -): Promise { - if (input.workspaces.size > 0 || input.projects.size > 0) { - return false; - } - const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(serverInfo)) { - return false; - } - if (input.isCancelled?.()) { - return true; - } - const directory = await readLegacyDaemonWorkspaceDirectory({ - client: input.client, - isCancelled: input.isCancelled, - }); - if (!directory || input.isCancelled?.()) { - return true; - } - replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return true; -} - -export async function readLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; - isCancelled?: () => boolean; -}): Promise { - const entries: FetchAgentsEntry[] = []; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const pageLimit = input.page?.limit ?? 200; - - while (true) { - if (input.isCancelled?.()) { - return null; - } - - const payload = await input.client.fetchAgents({ - sort: LEGACY_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, - }); - if (input.isCancelled?.()) { - return null; - } - - entries.push(...payload.entries); - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; - } - - return { entries, subscriptionId }; -} - export function applyLegacyDaemonWorkspaceOwnership(input: { serverId: string; agent: Agent; @@ -182,10 +28,11 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { } const existingAgent = - session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id) ?? null; + session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id); + const workspaces = session?.workspaces; const workspaceId = existingAgent?.workspaceId ?? - resolveLegacyWorkspaceIdFromAgent(input.agent, session?.workspaces) ?? + resolveLegacyWorkspaceIdFromAgent(input.agent, workspaces) ?? null; if (!workspaceId) { return input.agent; @@ -198,178 +45,9 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -export function replaceLegacyDaemonWorkspaceDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries, - }); - const workspaces = buildLegacyWorkspaces(entries); - const store = useSessionStore.getState(); - store.setWorkspaces(input.serverId, workspaces); - store.setProjects( - input.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), - ); - store.setHasHydratedWorkspaces(input.serverId, true); - return { agents, workspaces }; -} - -function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - -function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"], -): boolean { - const page = pageInfo as { - hasMore?: boolean; - hasMoreAfter?: boolean; - }; - if (typeof page.hasMore === "boolean") { - return page.hasMore; - } - if (typeof page.hasMoreAfter === "boolean") { - return page.hasMoreAfter; - } - return false; -} - -function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"], -): string | null { - const page = pageInfo as { - nextCursor?: string | null; - afterCursor?: string | null; - }; - if (typeof page.nextCursor === "string") { - return page.nextCursor; - } - if (typeof page.afterCursor === "string") { - return page.afterCursor; - } - return null; -} - -export function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { - return entries.map((entry) => { - const workspaceId = resolveLegacyWorkspaceId(entry); - return { - ...entry, - agent: { - ...entry.agent, - workspaceId, - }, - }; - }); -} - -export function buildLegacyWorkspaces( - entries: FetchAgentsEntry[], -): Map { - const workspaces = new Map(); - for (const entry of entries) { - const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); - const status = deriveAgentStateBucket({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissions.length, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }); - const statusEnteredAt = parseLegacyAgentTimestamp(entry); - const existing = workspaces.get(workspaceId); - if (!existing) { - workspaces.set(workspaceId, createLegacyWorkspace(entry, status, statusEnteredAt)); - continue; - } - if ( - getWorkspaceStateBucketPriority(status) < getWorkspaceStateBucketPriority(existing.status) - ) { - workspaces.set(workspaceId, { - ...existing, - status, - statusEnteredAt, - }); - } - } - return workspaces; -} - -function createLegacyWorkspace( - entry: FetchAgentsEntry, - status: WorkspaceDescriptor["status"], - statusEnteredAt: Date | null, -): WorkspaceDescriptor { - const workspaceDirectory = resolveLegacyWorkspaceId(entry); - const checkout = entry.project.checkout; - const projectRootPath = - normalizeWorkspacePath(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd) ?? - workspaceDirectory; - return { - id: workspaceDirectory, - projectId: entry.project.projectKey, - projectDisplayName: entry.project.projectName, - projectCustomName: null, - projectRootPath, - workspaceDirectory, - projectKind: checkout.isGit ? "git" : "non_git", - workspaceKind: resolveLegacyWorkspaceKind(checkout), - name: resolveLegacyWorkspaceName(entry, workspaceDirectory), - title: null, - status, - statusEnteredAt, - archivingAt: null, - diffStat: null, - scripts: [], - gitRuntime: checkout.isGit - ? { - currentBranch: checkout.currentBranch, - remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, - isDirty: null, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - } - : null, - githubRuntime: null, - project: entry.project, - }; -} - -function resolveLegacyWorkspaceKind( - checkout: FetchAgentsEntry["project"]["checkout"], -): WorkspaceDescriptor["workspaceKind"] { - if (!checkout.isGit) { - return "directory"; - } - if (checkout.isPaseoOwnedWorktree) { - return "worktree"; - } - return "checkout"; -} - -function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { - return ( - normalizeWorkspacePath(entry.project.checkout.cwd) ?? - normalizeWorkspacePath(entry.agent.cwd) ?? - entry.agent.cwd - ); -} - function resolveLegacyWorkspaceIdFromAgent( agent: Agent, - workspaces: Map | undefined, + workspaces: ReadonlyMap | undefined, ): string | null { const cwd = normalizeWorkspacePath(agent.cwd); if (!cwd) { @@ -384,30 +62,3 @@ function resolveLegacyWorkspaceIdFromAgent( return cwd; } - -function resolveLegacyWorkspaceName(entry: FetchAgentsEntry, workspaceDirectory: string): string { - const explicitName = entry.project.workspaceName?.trim(); - if (explicitName) { - return explicitName; - } - const branchName = entry.project.checkout.currentBranch?.trim(); - if (branchName && branchName !== "HEAD") { - return branchName; - } - return workspaceDirectoryName(workspaceDirectory); -} - -function workspaceDirectoryName(directory: string): string { - const trimmed = directory.trim().replace(/[/]+$/g, ""); - const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); - return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; -} - -function parseLegacyAgentTimestamp(entry: FetchAgentsEntry): Date | null { - const value = entry.agent.attentionTimestamp ?? entry.agent.updatedAt; - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} diff --git a/packages/app/src/workspace/mark-unread.ts b/packages/app/src/workspace/mark-unread.ts new file mode 100644 index 0000000000..0cb3310e19 --- /dev/null +++ b/packages/app/src/workspace/mark-unread.ts @@ -0,0 +1,26 @@ +import { i18n } from "@/i18n/i18next"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; + +export async function markWorkspaceUnread(serverId: string, workspaceId: string): Promise { + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + throw new Error(i18n.t("workspace.terminal.hostDisconnected")); + } + + const workspaceKey = `${serverId}:${workspaceId}`; + const layout = useWorkspaceLayoutStore.getState(); + // End viewing before restoring attention. The next pane focus reads it normally; + // leaving the workspace must not consume the mark the user just made. + const focusToken = layout.layoutByWorkspace[workspaceKey]?.focusedPaneId + ? layout.unfocusPane(workspaceKey) + : null; + try { + await client.markWorkspaceUnread(workspaceId); + } catch (error) { + if (focusToken) { + layout.restorePaneFocus(workspaceKey, focusToken); + } + throw error; + } +} diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index b4db600e60..e7fe660f3b 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -1,3 +1,5 @@ +import { seedSessionHosts } from "@/test/seed-session"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -85,10 +87,12 @@ function storedWorkspace(id: string): WorkspaceDescriptor | undefined { } beforeEach(() => { + seedSessionHosts([SERVER_ID, SECOND_SERVER_ID]); useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); }); afterEach(() => { + seedSessionHosts([]); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" }); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" }); clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" }); @@ -99,7 +103,7 @@ afterEach(() => { describe("archiveWorkspaceOptimistically", () => { it("hides the workspace and marks the archive pending while the daemon call runs", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const releaseArchive = deferred(); const client = createClient(vi.fn(async () => releaseArchive.promise)); @@ -124,7 +128,7 @@ describe("archiveWorkspaceOptimistically", () => { it("restores the workspace and clears pending state when the daemon rejects the archive", async () => { const archived = workspace(); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const client = createClient( vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })), ); @@ -154,7 +158,7 @@ describe("archiveWorkspacesOptimistically", () => { workspaceDirectory: "/repo/project/workspace-2", name: "workspace-2", }); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first, second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first, second]); const client = createClient( vi.fn(async (workspaceId) => archivePayload({ @@ -183,8 +187,8 @@ describe("archiveWorkspacesOptimistically", () => { name: "workspace-2", }); useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); - useSessionStore.getState().mergeWorkspaces(SERVER_ID, [first]); - useSessionStore.getState().mergeWorkspaces(SECOND_SERVER_ID, [second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SECOND_SERVER_ID, [second]); const archivedByServer = new Map(); const clientFor = (serverId: string) => diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 0bb4bcf5f9..a4e5a00e80 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { clearWorkspaceArchivePending, markWorkspaceArchivePending, @@ -50,7 +51,7 @@ function hideWorkspaceOptimistically( serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); - useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); + getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; } @@ -64,7 +65,7 @@ function restoreOptimisticallyHiddenWorkspace(input: { workspaceId: input.workspaceId, }); if (input.snapshot.workspace) { - useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]); + getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } } diff --git a/packages/app/test-stubs/async-storage.ts b/packages/app/test-stubs/async-storage.ts new file mode 100644 index 0000000000..68cc5dd114 --- /dev/null +++ b/packages/app/test-stubs/async-storage.ts @@ -0,0 +1,45 @@ +/** + * AsyncStorage has no implementation under Node: its default export reaches for + * the native module and every call rejects, so any test that touches a + * persisted store emits unhandled rejections from the persist middleware. + * + * This is a real in-memory key-value store with the same contract, not a + * recording double, so a test can read back what a store wrote. + */ +const entries = new Map(); + +export function clearAsyncStorageStub(): void { + entries.clear(); +} + +const AsyncStorageStub = { + getItem: (key: string): Promise => Promise.resolve(entries.get(key) ?? null), + setItem: (key: string, value: string): Promise => { + entries.set(key, value); + return Promise.resolve(); + }, + removeItem: (key: string): Promise => { + entries.delete(key); + return Promise.resolve(); + }, + getAllKeys: (): Promise => Promise.resolve([...entries.keys()]), + // `provider-snapshot-cache` declares its own `ProviderSnapshotStorage` port and + // hands it the real AsyncStorage, so a stub that stops at the single-key calls + // fails any test that reaches the provider cache with `multiGet is not a function`. + multiGet: (keys: readonly string[]): Promise> => + Promise.resolve(keys.map((key) => [key, entries.get(key) ?? null] as [string, string | null])), + multiSet: (pairs: ReadonlyArray): Promise => { + for (const [key, value] of pairs) entries.set(key, value); + return Promise.resolve(); + }, + multiRemove: (keys: readonly string[]): Promise => { + for (const key of keys) entries.delete(key); + return Promise.resolve(); + }, + clear: (): Promise => { + entries.clear(); + return Promise.resolve(); + }, +}; + +export default AsyncStorageStub; diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 0ab1cd7215..784a7569ff 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -12,6 +12,10 @@ export const BottomSheetScrollView = PassThrough; export const BottomSheetView = PassThrough; export const BottomSheetTextInput = Stub; +export function useBottomSheetModal(): never { + throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. const sharedValue = (value: unknown) => ({ get: () => value }); diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 1484e7a7d9..654053543f 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -7,6 +7,10 @@ const testTheme = { statusDanger: "#b91c1c", statusWarning: "#d97706", statusMerged: "#7c3aed", + // Same rule as the real theme: the status color at 12%. + statusSuccessTint: "#15803d1f", + statusDangerTint: "#b91c1c1f", + statusWarningTint: "#d977061f", // The light band's values, so a test can name the colour it expects. statusDotSuccess: "#299f51", statusDotDanger: "#f12e2f", diff --git a/packages/app/vitest-environment.test.ts b/packages/app/vitest-environment.test.ts new file mode 100644 index 0000000000..95b9b9343b --- /dev/null +++ b/packages/app/vitest-environment.test.ts @@ -0,0 +1,67 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const appDir = fileURLToPath(new URL(".", import.meta.url)); +const rootDir = path.resolve(appDir, "../.."); +const require = createRequire(import.meta.url); +const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs"); +const tsgoEntry = path.join( + path.dirname(require.resolve("@typescript/native-preview/package.json")), + "bin/tsgo.js", +); + +it("the test configuration typechecks with Expo's generated environment declaration", () => { + const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-")); + try { + writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// \n'); + writeFileSync( + path.join(fixtureDir, "tsconfig.json"), + JSON.stringify({ + extends: "../tsconfig.json", + files: ["../vitest.config.ts", "expo-env.d.ts"], + include: [], + }), + ); + const result = spawnSync( + process.execPath, + [tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")], + { cwd: appDir, encoding: "utf8", timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } +}, 40_000); + +it.each([ + { + name: "root", + cwd: rootDir, + args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"], + }, + { + name: "app", + cwd: appDir, + args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"], + }, +])( + "$name tests can render React when the shell exports NODE_ENV=production", + ({ cwd, args }) => { + const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], { + cwd, + env: { ...process.env, NODE_ENV: "production" }, + encoding: "utf8", + timeout: 30_000, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stdout + result.stderr).toBe(0); + }, + 40_000, +); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 3f96ad3bc0..e2d67aca70 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright"; import path from "path"; import fs from "fs"; +// Set this before Vite resolves dependencies and Vitest starts its workers. +Object.assign(process.env, { NODE_ENV: "test" }); + const appNodeModules = path.resolve(__dirname, "node_modules"); const rootNodeModules = path.resolve(__dirname, "../../node_modules"); const resolvePackageEntry = (packageName: string) => { @@ -22,7 +25,12 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"], + include: [ + "src/**/*.{test,spec}.{ts,tsx}", + "native-release-version.test.ts", + "app.config.test.ts", + "vitest-environment.test.ts", + ], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], }, @@ -56,17 +64,32 @@ export default defineConfig({ server: { deps: { fallbackCJS: true, - inline: ["zustand", "@tanstack/react-query", "react-native-web"], + inline: [ + "zustand", + "@tanstack/react-query", + "react-native-web", + "react-native-gesture-handler", + "react-native-keyboard-controller", + ], }, }, }, - // Reanimated ships one file per platform and picks between them by extension - // (`findHostInstance.web.js`). Vite's dependency optimizer does not apply `resolve.extensions`, + // Reanimated and gesture-handler pick platform files by extension + // (e.g. `GestureHandlerRootView.web.js`). Vite's optimizer does not apply `resolve.extensions`, // so it scans the native files and dies on imports react-native-web has no answer for. // Unbundled, the same imports go through the resolver below and land on the web files. optimizeDeps: { - include: ["react/jsx-runtime"], - exclude: ["react-native-reanimated"], + // Bundle the CJS dependencies of the excluded gesture-handler package for the browser. + include: [ + "react/jsx-runtime", + "react-native-gesture-handler > hoist-non-react-statics", + "react-native-gesture-handler > invariant", + ], + exclude: [ + "react-native-reanimated", + "react-native-gesture-handler", + "react-native-keyboard-controller", + ], }, // The globals a React Native bundler defines, which esbuild is no longer there to supply for // the package excluded above. @@ -101,6 +124,22 @@ export default defineConfig({ replacement: path.resolve(__dirname, "../relay/src/index.ts"), }, { find: "@", replacement: path.resolve(__dirname, "src") }, + // Keep keyboard-controller's imports in Vite so native aliases and platform extensions apply. + { + find: /^react-native-keyboard-controller$/, + replacement: path.resolve( + resolvePackageEntry("react-native-keyboard-controller"), + "lib/module/index.js", + ), + }, + // The CJS entry bypasses Vite's React Native alias and web-extension resolution. + { + find: /^react-native-gesture-handler$/, + replacement: path.resolve( + rootNodeModules, + "react-native-gesture-handler/lib/module/index.js", + ), + }, // Must precede the `react-native` alias: a string `find` matches by prefix, so this subpath // would otherwise resolve inside a react-native-web *file* and break the dependency scan. // Reanimated only imports it on the native path, which no test takes. @@ -154,6 +193,12 @@ export default defineConfig({ find: /^expo-linking$/, replacement: path.resolve(__dirname, "test-stubs/expo-linking.ts"), }, + // No Node implementation: every call on the real module rejects, so a test + // touching a persisted store drowns in unhandled rejections. + { + find: /^@react-native-async-storage\/async-storage$/, + replacement: path.resolve(__dirname, "test-stubs/async-storage.ts"), + }, { find: /^lucide-react-native$/, replacement: path.resolve(__dirname, "test-stubs/lucide-react-native.ts"), diff --git a/packages/app/vitest.setup.ts b/packages/app/vitest.setup.ts index feb490d91e..8834ab649f 100644 --- a/packages/app/vitest.setup.ts +++ b/packages/app/vitest.setup.ts @@ -6,6 +6,23 @@ const globalWithTestShims = globalThis as typeof globalThis & Record + Object.assign(new window.EventTarget(), { + media, + matches: false, + onchange: null, + addListener(listener: EventListener) { + this.addEventListener("change", listener); + }, + removeListener(listener: EventListener) { + this.removeEventListener("change", listener); + }, + }); +} + if (typeof globalThis.self === "undefined") { globalWithTestShims.self = globalThis; } diff --git a/packages/cli/package.json b/packages/cli/package.json index b39aa4e3c0..4fdcfba018 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/server": "0.7.2", + "@getpaseo/client": "0.11.2", + "@getpaseo/protocol": "0.11.2", + "@getpaseo/server": "0.11.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 3243529c23..18f4348ff7 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -46,10 +46,25 @@ describe("canonical CLI surface", () => { expect(help).toContain("--forge "); }); - it("uses background for execution and reserves detach for ownership", () => { + it("uses --no-wait for execution, --internal for visibility, and reserves detach for ownership", () => { const run = createCli().commands.find((command) => command.name() === "run"); - expect(run?.helpInformation()).toContain("--background"); - expect(run?.helpInformation()).not.toContain("--detach"); + const help = run?.helpInformation(); + expect(help).toContain("--no-wait"); + expect(help).not.toContain("--background"); + expect(help).not.toContain("--background"); + expect(help).not.toContain("--detach"); + }); + + it("hides internal agents and workspaces from listings unless --internal is passed", () => { + const cli = createCli(); + const ls = cli.commands.find((command) => command.name() === "ls"); + const workspace = cli.commands.find((command) => command.name() === "workspace"); + const workspaceLs = workspace?.commands.find((command) => command.name() === "ls"); + const workspaceCreate = workspace?.commands.find((command) => command.name() === "create"); + + expect(ls?.helpInformation()).toContain("--background"); + expect(workspaceLs?.helpInformation()).toContain("--background"); + expect(workspaceCreate?.helpInformation()).toContain("--background"); }); it("offers thinking configuration when running, updating, and scheduling agents", () => { @@ -79,9 +94,9 @@ describe("canonical CLI surface", () => { expect(plugin?.commands.map((command) => command.name())).toEqual([ "init", "ls", + "status", "logs", "install", - "status", "update", "reload", "enable", @@ -96,3 +111,15 @@ describe("canonical CLI surface", () => { ).toContain("--id "); }); }); + +it("preserves omitted and explicit false workspace background intent", () => { + const readOptions = (args: string[]) => { + const workspace = createCli().commands.find((command) => command.name() === "workspace")!; + const create = workspace.commands.find((command) => command.name() === "create")!; + create.parseOptions(args); + return create.opts(); + }; + expect(readOptions([]).background).toBeUndefined(); + expect(readOptions(["--background"]).background).toBe(true); + expect(readOptions(["--no-background"]).background).toBe(false); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 275d8f6170..dceb3f372d 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,9 @@ +import { pairCommand } from "./commands/daemon/pair.js"; import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; +import { createUsageCommand } from "./commands/usage/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; import { createPluginCommand } from "./commands/plugin/index.js"; import { createProjectCommand } from "./commands/project/index.js"; @@ -15,9 +17,9 @@ import { createHeartbeatCommand } from "./commands/heartbeat/index.js"; import { createHubCommand } from "./commands/hub/index.js"; import { createHooksCommand } from "./commands/hooks.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; -import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; -import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js"; -import { runDaemonReloadCommand } from "./commands/daemon/reload.js"; +import { daemonStatusCommand } from "./commands/daemon/status.js"; +import { daemonRestartCommand } from "./commands/daemon/restart.js"; +import { daemonReloadCommand } from "./commands/daemon/reload.js"; import { addLsOptions, runLsCommand } from "./commands/agent/ls.js"; import { addRunOptions, runRunCommand } from "./commands/agent/run.js"; import { addLogsOptions, runLogsCommand } from "./commands/agent/logs.js"; @@ -35,19 +37,12 @@ import { onboardCommand } from "./commands/onboard.js"; import { addDaemonHostOption, addJsonAndDaemonHostOptions, - addJsonOption, withGlobalOptions, } from "./utils/command-options.js"; import { resolveCliVersion } from "./version.js"; const VERSION = resolveCliVersion(); -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} - export function createCli(): Command { const program = new Command(); @@ -125,51 +120,10 @@ export function createCli(): Command { program.addCommand(daemonStartCommand()); program.addCommand(createHooksCommand()); - addJsonOption( - program - .command("status") - .description('Show local daemon status (alias for "paseo daemon status")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runDaemonStatusCommand)); - - addJsonAndDaemonHostOptions( - program.command("reload").description('Reload daemon config (alias for "paseo daemon reload")'), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption( - program - .command("restart") - .description('Restart local daemon (alias for "paseo daemon restart")'), - ) - .option("--home ", "Paseo home directory (default: ~/.paseo)") - .option("--timeout ", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen ", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port ", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option( - "--hostnames ", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts ").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runDaemonRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); + program.addCommand(daemonStatusCommand()); + program.addCommand(daemonRestartCommand()); + program.addCommand(daemonReloadCommand()); + program.addCommand(pairCommand()); // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()); @@ -195,6 +149,7 @@ export function createCli(): Command { // Provider commands program.addCommand(createProviderCommand()); + program.addCommand(createUsageCommand()); program.addCommand(createPluginCommand()); // Speech model commands @@ -207,5 +162,37 @@ export function createCli(): Command { // Added in v0.2.0; remove after 2027-01-17. program.addCommand(createWorktreeCommand(), { hidden: true }); + // Stop root parsing at the command so `plugin update --version` belongs to update. + // Keep global options available after a command, as they were before positional parsing. + program.enablePositionalOptions(); + for (const command of program.commands) { + for (const option of program.options) { + if (option.long === "--version") continue; + if (!command.options.some((local) => local.long === option.long)) command.addOption(option); + if (option.long === "--home" || option.long === "--host") continue; + command.on(`option:${option.name()}`, () => { + const key = option.attributeName(); + program.setOptionValueWithSource(key, command.opts()[key], "cli"); + }); + } + if (command.name() !== "plugin") + command.version(VERSION, "-v, --version", "output the version number"); + } + + const enforceSelectorDuplicates = (command: Command) => { + for (const option of command.options) { + if (option.long !== "--home" && option.long !== "--host") continue; + option.argParser((value: string, previous: string | undefined) => { + if (previous !== undefined && previous !== value) + throw { + code: "TARGET_AMBIGUOUS", + message: `Conflicting duplicate ${option.long} selectors.`, + }; + return value; + }); + } + command.commands.forEach(enforceSelectorDuplicates); + }; + enforceSelectorDuplicates(program); return program; } diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index ff9f80afa2..c7809ba565 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -44,8 +43,6 @@ export async function runArchiveCommand( options: AgentArchiveOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -56,18 +53,7 @@ export async function runArchiveCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index fb58f71fa0..a881f2d7f8 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -5,17 +5,18 @@ export function addAttachOptions(cmd: Command): Command { .description("Attach to a running agent's output stream") .argument("", "Agent ID (or prefix)"); } -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, } from "../../utils/timeline.js"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; -import type { AgentStreamEventPayload, AgentStreamMessage } from "@getpaseo/protocol/messages"; +import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; export interface AgentAttachOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; [key: string]: unknown; } @@ -105,23 +106,13 @@ export async function runAttachCommand( options: AgentAttachOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo attach "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); @@ -134,7 +125,7 @@ export async function runAttachCommand( const resolvedId = fetchResult.agent.id; // Print header - console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`); + console.log(`Fetching history for agent ${resolvedId.substring(0, 7)}...`); console.log(`(Press Ctrl+C to detach)\n`); // Print existing output from timeline fetch. @@ -152,39 +143,25 @@ export async function runAttachCommand( } // Subscribe to new events - const unsubscribe = client.on("agent_stream", (msg: unknown) => { - const message = msg as AgentStreamMessage; - if (message.type !== "agent_stream") return; - if (message.payload.agentId !== resolvedId) return; - - printStreamEvent(message.payload.event); + const unsubscribe = client.subscribeAgentTimeline(resolvedId, (message) => { + if (message.type === "agent.timeline.replacement") { + console.log("\n[Timeline replaced; earlier output is no longer current]"); + } else if (message.type === "agent.timeline.subscription_restored") { + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); + } else if (message.type === "agent.timeline.error") { + console.error(`Timeline observation stopped: ${message.payload.error}`); + } else { + printStreamEvent(message.payload.event); + } }); - // Handle Ctrl+C to detach gracefully - let detached = false; - const detach = () => { - if (detached) return; - detached = true; + await unsubscribe.ready; + console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`); - console.log("\n\nDetaching from agent..."); - unsubscribe(); - client - .close() - .then(() => { - process.exit(0); - }) - .catch(() => { - process.exit(1); - }); - }; - - process.on("SIGINT", detach); - process.on("SIGTERM", detach); - - // Keep the process alive - await new Promise(() => { - // Wait indefinitely until interrupted - }); + await waitForStop(); + console.log("\n\nDetaching from agent..."); + unsubscribe(); + await client.close(); } catch (err) { await client.close().catch(() => {}); const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/commands/agent/delete.test.ts b/packages/cli/src/commands/agent/delete.test.ts index 56aa83dc65..b9d00cd5ef 100644 --- a/packages/cli/src/commands/agent/delete.test.ts +++ b/packages/cli/src/commands/agent/delete.test.ts @@ -1,22 +1,51 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; import { runDeleteCommand } from "./delete.js"; const agent = { id: "11111111-1111-4111-8111-111111111111", status: "running", - archivedAt: null, + archivedAt: null as string | null, cwd: "/tmp/project", }; +const archivedAgent = { + id: "22222222-2222-4222-8222-222222222222", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/project/nested", +}; +const archivedOutsideAgent = { + id: "33333333-3333-4333-8333-333333333333", + status: "closed", + archivedAt: "2026-10-01T00:00:00.000Z", + cwd: "/tmp/elsewhere", +}; +let daemonAgents: (typeof agent)[] = [agent]; +const toEntry = (entry: typeof agent) => ({ agent: entry }); +// The fake daemon pages history two agents at a time. +const historyPage = (cursor: string | undefined) => { + const start = Number(cursor ?? 0); + const end = start + 2; + return { + entries: daemonAgents.slice(start, end).map(toEntry), + pageInfo: { nextCursor: end < daemonAgents.length ? String(end) : null }, + }; +}; const cancelAgent = vi.fn(async () => { throw new Error("active run cancellation was not acknowledged"); }); +const fetchAgentHistory = vi.fn(async (options: { page: { cursor?: string } }) => + historyPage(options.page.cursor), +); const deleteAgent = vi.fn(async () => undefined); const close = vi.fn(async () => undefined); vi.mock("../../utils/client.js", () => ({ connectToDaemon: vi.fn(async () => ({ - fetchAgents: vi.fn(async () => ({ entries: [{ agent }] })), + fetchAgents: vi.fn(async () => historyPage(undefined)), + fetchAgentHistory, fetchAgent: vi.fn(async () => ({ agent })), cancelAgent, deleteAgent, @@ -26,9 +55,16 @@ vi.mock("../../utils/client.js", () => ({ })); describe("runDeleteCommand", () => { - it("force-deletes a running agent when graceful cancellation is refused", async () => { - const result = await runDeleteCommand(agent.id, {}, {} as never); + beforeEach(() => { + daemonAgents = [agent]; + deleteAgent.mockClear(); + fetchAgentHistory.mockClear(); + }); + it("force-deletes a running agent when graceful cancellation is refused", async () => { + const result = await runDeleteCommand(agent.id, { daemonTarget }, {} as never); + + expect(fetchAgentHistory).not.toHaveBeenCalled(); expect(cancelAgent).toHaveBeenCalledWith(agent.id); expect(deleteAgent).toHaveBeenCalledWith(agent.id); expect(result.data).toEqual({ @@ -36,4 +72,30 @@ describe("runDeleteCommand", () => { agentIds: [agent.id], }); }); + + it("deletes archived agents on every history page with --all", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand(undefined, { daemonTarget, all: true }, {} as never); + + expect(result.data).toEqual({ + deletedCount: 3, + agentIds: [agent.id, archivedAgent.id, archivedOutsideAgent.id], + }); + }); + + it("deletes archived agents inside the directory with --cwd", async () => { + daemonAgents = [agent, archivedAgent, archivedOutsideAgent]; + + const result = await runDeleteCommand( + undefined, + { daemonTarget, cwd: "/tmp/project" }, + {} as never, + ); + + expect(result.data).toEqual({ + deletedCount: 2, + agentIds: [agent.id, archivedAgent.id], + }); + }); }); diff --git a/packages/cli/src/commands/agent/delete.ts b/packages/cli/src/commands/agent/delete.ts index c8502c9a19..5e48751bb8 100644 --- a/packages/cli/src/commands/agent/delete.ts +++ b/packages/cli/src/commands/agent/delete.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; +import type { DaemonClient, FetchAgentHistoryEntry } from "@getpaseo/client/internal/daemon-client"; export function addDeleteOptions(cmd: Command): Command { return cmd @@ -34,13 +34,25 @@ export interface AgentDeleteOptions extends CommandOptions { export type AgentDeleteResult = SingleResult; +// History includes archived agents and agents whose provider is unavailable. +async function fetchHistoryAgents(client: DaemonClient) { + const agents: FetchAgentHistoryEntry["agent"][] = []; + let cursor: string | undefined; + do { + const payload = await client.fetchAgentHistory({ + page: { limit: 200, ...(cursor ? { cursor } : {}) }, + }); + agents.push(...payload.entries.map((entry) => entry.agent)); + cursor = payload.pageInfo.nextCursor ?? undefined; + } while (cursor); + return agents; +} + export async function runDeleteCommand( id: string | undefined, options: AgentDeleteOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id && !options.all && !options.cwd) { const error: CommandError = { code: "MISSING_ARGUMENT", @@ -50,33 +62,19 @@ export async function runDeleteCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { - const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); - let agents = fetchPayload.entries.map((entry) => entry.agent); const deletedIds: string[] = []; + let agents: FetchAgentHistoryEntry["agent"][]; - if (options.all) { - agents = agents.filter((a) => !a.archivedAt); - } else if (options.cwd) { - agents = agents.filter((a) => { - if (a.archivedAt) return false; - return isSameOrDescendantPath(options.cwd!, a.cwd); - }); - } else if (id) { - const fetchResult = await client.fetchAgent({ agentId: id }); + if (options.all || options.cwd) { + agents = await fetchHistoryAgents(client); + if (!options.all) { + agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd)); + } + } else { + const fetchResult = await client.fetchAgent({ agentId: id! }); if (!fetchResult) { const error: CommandError = { code: "AGENT_NOT_FOUND", @@ -97,6 +95,7 @@ export async function runDeleteCommand( await client.deleteAgent(agent.id); return { ok: true as const, id: agent.id }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); return { ok: false as const, id: agent.id, message }; } diff --git a/packages/cli/src/commands/agent/detach.ts b/packages/cli/src/commands/agent/detach.ts index 5de8ae8c9a..9de223dffb 100644 --- a/packages/cli/src/commands/agent/detach.ts +++ b/packages/cli/src/commands/agent/detach.ts @@ -1,6 +1,5 @@ import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandError, CommandOptions, @@ -26,17 +25,7 @@ export async function runDetachCommand( options: CommandOptions, _command: Command, ): Promise> { - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - } satisfies CommandError; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 48a678de4a..761b2a0f1e 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -43,6 +43,7 @@ describe("resolveImportCwd", () => { const result = await runImportCommand( "pi-session-1", { + daemonTarget: { kind: "endpoint", host: "example.test:12345" }, provider: "pi", cwd: "/tmp/project", }, diff --git a/packages/cli/src/commands/agent/import.ts b/packages/cli/src/commands/agent/import.ts index d3d27d627b..fa42b6860a 100644 --- a/packages/cli/src/commands/agent/import.ts +++ b/packages/cli/src/commands/agent/import.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { collectMultiple } from "../../utils/command-options.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { agentRunSchema, type AgentRunResult } from "./run.js"; @@ -94,28 +94,11 @@ export function resolveImportCwd(explicitCwd: string | undefined, defaultCwd: st return cwd; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise>> { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - export async function runImportCommand( sessionIdArg: string, options: AgentImportOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); const sessionId = sessionIdArg.trim(); if (!sessionId) { throw { @@ -129,7 +112,7 @@ export async function runImportCommand( const cwd = resolveImportCwd(options.cwd, process.cwd()); const labels = parseImportLabels(options.label); - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agent = await client.importAgent({ diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index 93f1da3d4d..bad8808526 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; export function addInspectOptions(cmd: Command): Command { @@ -217,8 +217,6 @@ export async function runInspectCommand( options: AgentInspectOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -229,18 +227,7 @@ export async function runInspectCommand( throw error; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/logs.test.ts b/packages/cli/src/commands/agent/logs.test.ts new file mode 100644 index 0000000000..e551aa3791 --- /dev/null +++ b/packages/cli/src/commands/agent/logs.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js"; + +function collect(items: readonly AgentTimelineItem[], turnId?: string): string { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }); + for (const item of items) { + writer.push(item, turnId); + } + writer.end(); + return output; +} + +describe("createFollowTranscriptWriter", () => { + it("matches one-shot logs when reasoning and assistant text alternate", () => { + const items: AgentTimelineItem[] = [ + { type: "reasoning", text: "Check " }, + { type: "reasoning", text: "the commands." }, + { type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" }, + { type: "reasoning", text: "Then summarize." }, + { type: "assistant_message", text: "Done.", messageId: "msg_2" }, + ]; + + expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items)); + }); + + it("keeps a word that arrives split across fragments intact", () => { + const output = collect([ + { type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" }, + { type: "assistant_message", text: "-cycle label\n", messageId: "msg_1" }, + ]); + + expect(output).toBe("the paseo-pr-cycle label\n"); + }); + + it("streams text as it arrives without inventing a line break", () => { + const chunks: string[] = []; + const writer = createFollowTranscriptWriter((chunk) => { + chunks.push(chunk); + }); + + writer.push({ + type: "assistant_message", + text: "first line", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line"); + + writer.push({ + type: "assistant_message", + text: "\nsecond", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line\nsecond"); + + writer.push({ + type: "assistant_message", + text: " line\n", + messageId: "msg_1", + }); + expect(chunks.join("")).toBe("first line\nsecond line"); + + writer.end(); + expect(chunks.join("")).toBe("first line\nsecond line\n"); + }); + + it("emits the same transcript the one-shot command prints for the same items", () => { + const items: AgentTimelineItem[] = [ + { + type: "assistant_message", + text: "Plan for paseo-pr", + messageId: "msg_1", + }, + { + type: "assistant_message", + text: "-cycle:\n- build\n- test\n", + messageId: "msg_1", + }, + { type: "error", message: "boom" }, + { type: "assistant_message", text: "done", messageId: "msg_2" }, + ]; + + expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items)); + }); + + it("starts a new message when the messageId changes", () => { + const output = collect([ + { type: "assistant_message", text: "first", messageId: "msg_1" }, + { type: "assistant_message", text: "second", messageId: "msg_2" }, + ]); + + expect(output).toBe("first\nsecond\n"); + }); + + it("does not merge fragments across turns", () => { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }); + + writer.push({ type: "assistant_message", text: "first" }, "turn_1"); + writer.push({ type: "assistant_message", text: "second" }, "turn_2"); + writer.end(); + + expect(output).toBe("first\nsecond\n"); + }); + + it("prints the thought prefix once for a merged reasoning stream", () => { + const output = collect([ + { type: "reasoning", text: "checking the" }, + { type: "reasoning", text: " lockfile\nthen the build\n" }, + ]); + + expect(output).toBe("[Thought] checking the lockfile\nthen the build\n"); + }); + + it("keeps reasoning blocks separate across hidden timeline items", () => { + let output = ""; + const writer = createFollowTranscriptWriter((chunk) => { + output += chunk; + }, "reasoning"); + writer.push({ type: "reasoning", text: "checking files" }); + writer.push({ + type: "tool_call", + callId: "tool_1", + name: "Bash", + status: "completed", + error: null, + detail: { type: "unknown", input: "ls", output: "files" }, + }); + writer.push({ type: "reasoning", text: "running tests" }); + writer.end(); + + expect(output).toBe("[Thought] checking files\n[Thought] running tests\n"); + }); + + it("keeps interior blank lines and drops trailing whitespace", () => { + const output = collect([ + { + type: "assistant_message", + text: " # Title\n\nBody\n\n ", + messageId: "msg_1", + }, + ]); + + expect(output).toBe("# Title\n\nBody\n"); + }); +}); diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 14355aee81..ed77d4be62 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -1,14 +1,14 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; -import type { CommandOptions } from "../../output/index.js"; +import { connectToDaemon } from "../../utils/client.js"; +import { waitForStop } from "../../utils/wait-for-stop.js"; +import type { CommandError, CommandOptions } from "../../output/index.js"; import { fetchProjectedTimelineItems, LIVE_HISTORY_FETCH_TIMEOUT_MS, } from "../../utils/timeline.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; -import type { AgentStreamMessage } from "@getpaseo/protocol/messages"; -import { curateAgentActivity } from "@getpaseo/server"; +import { curateAgentActivity } from "@getpaseo/server/agent-activity"; export function addLogsOptions(cmd: Command): Command { return cmd @@ -32,12 +32,29 @@ export type AgentLogsResult = void; export const NO_ACTIVITY_MESSAGE = "No activity to display."; +interface FetchAgentTimelineItemsOptions { + timeoutMs?: number; + sinceTimestampMs?: number; +} + +interface FollowModeInput { + client: DaemonClient; + agentId: string; + options: AgentLogsOptions; + sinceTimestampMs: number | undefined; +} + export async function fetchAgentTimelineItems( client: DaemonClient, agentId: string, - options?: { timeoutMs?: number }, + options?: FetchAgentTimelineItemsOptions, ): Promise { - return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs }); + return fetchProjectedTimelineItems({ + client, + agentId, + timeoutMs: options?.timeoutMs, + sinceTimestampMs: options?.sinceTimestampMs, + }); } export function formatAgentActivityTranscript( @@ -53,6 +70,134 @@ export function formatAgentActivityTranscript( ); } +type StreamingTextType = "assistant_message" | "reasoning"; + +const THOUGHT_PREFIX = "[Thought] "; + +interface PendingStreamingText { + type: StreamingTextType; + turnId: string | undefined; + messageId: string | undefined; + buffer: string; + emitted: boolean; +} + +export interface FollowTranscriptWriter { + push(item: AgentTimelineItem, turnId?: string): void; + end(): void; +} + +function lastNonWhitespaceIndex(text: string): number { + for (let index = text.length - 1; index >= 0; index -= 1) { + if (!/\s/.test(text[index])) { + return index; + } + } + return -1; +} + +/** + * Follow mode receives one streaming fragment per event, while the one-shot + * transcript sees the whole timeline and merges consecutive assistant and + * reasoning fragments with exact concatenation (`projectTimelineRows`). Printing + * each fragment on its own line splits words at fragment boundaries and + * reprints the reasoning prefix, so a chain of fragments is written as one + * stream instead: text goes out as it arrives, and only the trailing whitespace + * is held back so the message ends trimmed the way the transcript does. + */ +export function createFollowTranscriptWriter( + write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk), + filter?: string, +): FollowTranscriptWriter { + let pending: PendingStreamingText | null = null; + + const emit = (chain: PendingStreamingText, text: string): void => { + if (!chain.emitted) { + chain.emitted = true; + if (chain.type === "reasoning") { + write(THOUGHT_PREFIX); + } + } + write(text); + }; + + const flushContent = (chain: PendingStreamingText): void => { + if (!chain.emitted) { + chain.buffer = chain.buffer.replace(/^\s+/, ""); + } + const lastContent = lastNonWhitespaceIndex(chain.buffer); + if (lastContent < 0) { + return; + } + emit(chain, chain.buffer.slice(0, lastContent + 1)); + chain.buffer = chain.buffer.slice(lastContent + 1); + }; + + const closePending = (): void => { + const chain = pending; + pending = null; + if (!chain) { + return; + } + const tail = chain.emitted ? chain.buffer.trimEnd() : chain.buffer.trim(); + if (tail) { + emit(chain, tail); + } + if (chain.emitted) { + write("\n"); + } + }; + + const continuesChain = (item: AgentTimelineItem, turnId: string | undefined): boolean => { + if (!pending || pending.type !== item.type || pending.turnId !== turnId) { + return false; + } + if (item.type !== "assistant_message") { + return true; + } + return item.messageId === undefined || item.messageId === pending.messageId; + }; + + const startChain = ( + item: Extract, + turnId: string | undefined, + ): PendingStreamingText => { + closePending(); + const chain: PendingStreamingText = { + type: item.type, + turnId, + messageId: item.type === "assistant_message" ? item.messageId : undefined, + buffer: "", + emitted: false, + }; + pending = chain; + return chain; + }; + + return { + push(item, turnId) { + if (!matchesFilter(item, filter)) { + closePending(); + return; + } + if (item.type === "assistant_message" || item.type === "reasoning") { + const chain = continuesChain(item, turnId) && pending ? pending : startChain(item, turnId); + chain.buffer += item.text; + flushContent(chain); + return; + } + closePending(); + const transcript = formatAgentActivityTranscript([item]); + if (transcript && transcript !== NO_ACTIVITY_MESSAGE) { + write(`${transcript}\n`); + } + }, + end() { + closePending(); + }, + }; +} + function parseTailCount(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const parsed = Number.parseInt(raw, 10); @@ -62,6 +207,19 @@ function parseTailCount(raw: string | undefined): number | undefined { return parsed; } +function parseSinceTimestamp(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const timestampMs = Date.parse(raw); + if (Number.isNaN(timestampMs)) { + throw { + code: "INVALID_TIMESTAMP", + message: `Invalid --since value: ${raw}`, + details: "Use a timestamp such as 2026-01-01T00:00:00Z.", + } satisfies CommandError; + } + return timestampMs; +} + /** * Check if a timeline item matches the filter type */ @@ -92,23 +250,14 @@ export async function runLogsCommand( options: AgentLogsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!id) { console.error("Error: Agent ID required"); console.error("Usage: paseo agent logs "); process.exit(1); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`Error: Cannot connect to daemon at ${host}: ${message}`); - console.error("Start the daemon with: paseo daemon start"); - process.exit(1); - } + const sinceTimestampMs = parseSinceTimestamp(options.since); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: id }); @@ -128,12 +277,19 @@ export async function runLogsCommand( await client.close().catch(() => {}); process.exit(1); } - await runFollowMode(client, resolvedId, options); + await runFollowMode({ + client, + agentId: resolvedId, + options, + sinceTimestampMs, + }); return; } // Fetch timeline directly via cursor RPC. - let timelineItems = await fetchAgentTimelineItems(client, resolvedId); + let timelineItems = await fetchAgentTimelineItems(client, resolvedId, { + sinceTimestampMs, + }); // Apply filter if (options.filter) { @@ -158,6 +314,7 @@ export async function runLogsCommand( const transcript = formatAgentActivityTranscript(timelineItems, tailCount); console.log(transcript); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); console.error(`Error: Failed to get logs: ${message}`); await client.close().catch(() => {}); @@ -168,11 +325,12 @@ export async function runLogsCommand( /** * Follow mode: stream logs in real-time until interrupted */ -async function runFollowMode( - client: DaemonClient, - agentId: string, - options: AgentLogsOptions, -): Promise { +async function runFollowMode({ + client, + agentId, + options, + sinceTimestampMs, +}: FollowModeInput): Promise { const DEFAULT_FOLLOW_TAIL = 10; const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL; @@ -181,6 +339,7 @@ async function runFollowMode( try { existingItems = await fetchAgentTimelineItems(client, agentId, { timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS, + sinceTimestampMs, }); } catch (error) { console.warn("Warning: failed to fetch existing timeline", error); @@ -202,37 +361,48 @@ async function runFollowMode( // Subscribe to new events const tailLabel = tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`; - console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); - const unsubscribe = client.on("agent_stream", (msg: unknown) => { - const message = msg as AgentStreamMessage; - if (message.type !== "agent_stream") return; - if (message.payload.agentId !== agentId) return; + const writer = createFollowTranscriptWriter(undefined, options.filter); + const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => { + if (message.type === "agent.timeline.replacement") { + writer.end(); + console.log("\n[Timeline replaced; earlier output is no longer current]"); + return; + } + + if (message.type === "agent.timeline.error") { + writer.end(); + console.error(`Timeline observation stopped: ${message.payload.error}`); + return; + } + if (message.type === "agent.timeline.subscription_restored") { + writer.end(); + console.log("\n[Reconnected; live output resumed. Events may have been missed.]"); + return; + } if (message.payload.event.type === "timeline") { - const item = message.payload.event.item; - // Apply filter - if (options.filter && !matchesFilter(item, options.filter)) { + const matchesSince = + sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs; + if (!matchesSince) { return; } - // Print each timeline item as it arrives using the curator format - const transcript = formatAgentActivityTranscript([item]); - if (transcript !== NO_ACTIVITY_MESSAGE) { - console.log(transcript); - } + const item = message.payload.event.item; + writer.push(item, message.payload.event.turnId); + } else if ( + message.payload.event.type === "turn_completed" || + message.payload.event.type === "turn_failed" || + message.payload.event.type === "turn_canceled" + ) { + writer.end(); } }); - // Wait for interrupt - await new Promise((resolve) => { - const cleanup = () => { - unsubscribe(); - resolve(); - }; - - process.on("SIGINT", cleanup); - process.on("SIGTERM", cleanup); - }); + await unsubscribe.ready; + console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`); + await waitForStop(); + writer.end(); + unsubscribe(); await client.close(); } diff --git a/packages/cli/src/commands/agent/ls.test.ts b/packages/cli/src/commands/agent/ls.test.ts index c66b34b94d..8aedf73fad 100644 --- a/packages/cli/src/commands/agent/ls.test.ts +++ b/packages/cli/src/commands/agent/ls.test.ts @@ -23,15 +23,21 @@ describe("buildAgentLsFetchOptions", () => { }); }); + it("asks the daemon for internal agents only with --internal", () => { + expect(buildAgentLsFetchOptions({ background: true, global: true })).toEqual({ + filter: { includeBackground: true }, + }); + }); + it("fetches global non-archived agents for -g", () => { expect(buildAgentLsFetchOptions({ global: true })).toEqual({}); }); - it("keeps -a within the active scope", () => { + it("includes archived and background agents with -a", () => { expect(buildAgentLsFetchOptions({ all: true })).toEqual({ - scope: "active", filter: { includeArchived: true, + includeBackground: true, }, }); }); @@ -40,6 +46,7 @@ describe("buildAgentLsFetchOptions", () => { expect(buildAgentLsFetchOptions({ all: true, global: true })).toEqual({ filter: { includeArchived: true, + includeBackground: true, }, }); }); diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index 37978d9011..0990446bc5 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; import { collectMultiple } from "../../utils/command-options.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; @@ -12,8 +12,9 @@ type FetchAgentsOptions = NonNullable< export function addLsOptions(cmd: Command): Command { return cmd .description("List agents. By default excludes archived agents.") - .option("-a, --all", "Include archived agents") + .option("-a, --all", "Include archived and background agents") .option("-g, --global", "List agents across all directories") + .option("--background", "Include background workspace agents") .option( "--label ", "Filter by label (can be used multiple times)", @@ -104,26 +105,13 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { export type AgentLsResult = ListResult; -function daemonConnectionFailure(host: string, cause: unknown): CommandError { - const reason = cause instanceof Error ? cause.message : String(cause); - const isSsh = host.trim().startsWith("ssh://"); - return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot reach the daemon at ${host}: ${reason}`, - details: isSsh - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : [ - "Start a local daemon with: paseo daemon start", - "To use another daemon, pass --host or set PASEO_HOST.", - ].join("\n"), - }; -} - export interface AgentLsOptions extends CommandOptions { /** -a: Include archived agents */ all?: boolean; /** -g: List agents across all directories */ global?: boolean; + /** --background: Include background workspace agents */ + background?: boolean; /** Filter by specific status */ status?: string; /** Filter by specific cwd */ @@ -148,7 +136,7 @@ function parseLabelFilters(labels: string[] | undefined): Record } export function buildAgentLsFetchOptions( - options: Pick, + options: Pick, ): FetchAgentsOptions { const labelFilters = parseLabelFilters(options.label); const normalizedThinkingOptionId = options.thinking?.trim(); @@ -157,6 +145,9 @@ export function buildAgentLsFetchOptions( if (options.all) { daemonFilter.includeArchived = true; } + if (options.background || options.all) { + daemonFilter.includeBackground = true; + } if (Object.keys(labelFilters).length > 0) { daemonFilter.labels = labelFilters; } @@ -165,7 +156,7 @@ export function buildAgentLsFetchOptions( } const fetchOptions: FetchAgentsOptions = {}; - if (!options.global) { + if (!options.global && !options.all) { fetchOptions.scope = "active"; } if (Object.keys(daemonFilter).length > 0) { @@ -178,21 +169,14 @@ export function buildAgentLsFetchOptions( * Agent ls command semantics: * - `paseo agent ls` → active non-archived agents * - `paseo agent ls -g` → global non-archived agents - * - `paseo agent ls -a` → active agents, including archived + * - `paseo agent ls -a` → all agents, including archived and background * - `paseo agent ls -ag` → global agents, including archived */ export async function runLsCommand( options: AgentLsOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw daemonConnectionFailure(host, err); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const normalizedThinkingOptionId = options.thinking?.trim(); diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 46229b580c..cbd7bfbd56 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -55,7 +55,7 @@ export async function runModeCommand( _command: Command, ): Promise { const normalizedMode = mode?.trim(); - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); // Validate arguments if (!options.list && !normalizedMode) { @@ -64,7 +64,7 @@ export async function runModeCommand( let client: Awaited> | undefined; try { - client = await connectToDaemon({ host: options.host }); + client = await connectToDaemon({ target: options.daemonTarget }); const fetchResult = await client.fetchAgent({ agentId: id }); if (!fetchResult) { const error: CommandError = { diff --git a/packages/cli/src/commands/agent/open.ts b/packages/cli/src/commands/agent/open.ts index 333ec61b4e..2c1eab36d2 100644 --- a/packages/cli/src/commands/agent/open.ts +++ b/packages/cli/src/commands/agent/open.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; +import type { AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; import { openDesktopWithAgent } from "../open.js"; import type { CommandError, @@ -30,18 +31,24 @@ export function addOpenOptions(command: Command): Command { .option("--server ", "Server ID (defaults to the local daemon)"); } -async function resolveServerId(options: CommandOptions): Promise { +function toOpenError(err: unknown): CommandError { + if (err && typeof err === "object" && "code" in err) { + return err as CommandError; + } + const message = err instanceof Error ? err.message : String(err); + return { code: "OPEN_FAILED", message: `Failed to open agent: ${message}` }; +} + +async function resolveAgentTarget( + agentId: string, + options: CommandOptions, +): Promise { const explicitServerId = typeof options.server === "string" ? options.server.trim() : ""; if (explicitServerId) { - return explicitServerId; + return { serverId: explicitServerId, agentId }; } - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (error) { - throw buildDaemonConnectionCommandError({ host: options.host, error }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const serverId = client.getLastServerInfoMessage()?.serverId.trim(); if (!serverId) { @@ -51,7 +58,19 @@ async function resolveServerId(options: CommandOptions): Promise { }; throw error; } - return serverId; + + const fetchResult = await client.fetchAgent({ agentId }); + if (!fetchResult) { + const error: CommandError = { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentId}`, + details: 'Use "paseo ls" to list available agents', + }; + throw error; + } + return { serverId, agentId: fetchResult.agent.id }; + } catch (err) { + throw toOpenError(err); } finally { await client.close().catch(() => {}); } @@ -71,12 +90,12 @@ export async function runOpenCommand( throw error; } - const serverId = await resolveServerId(options); - await openDesktopWithAgent({ serverId, agentId }); + const target = await resolveAgentTarget(agentId, options); + await openDesktopWithAgent(target); return { type: "single", - data: { agentId, serverId, status: "opened" }, + data: { ...target, status: "opened" }, schema: openAgentSchema, }; } diff --git a/packages/cli/src/commands/agent/reload.ts b/packages/cli/src/commands/agent/reload.ts index 607dfa278a..f6cda480e2 100644 --- a/packages/cli/src/commands/agent/reload.ts +++ b/packages/cli/src/commands/agent/reload.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js"; +import { connectToDaemon, resolveAgentId } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -40,8 +39,6 @@ export async function runReloadCommand( options: AgentReloadOptions, _command: Command, ): Promise { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { code: "MISSING_AGENT_ID", @@ -51,18 +48,7 @@ export async function runReloadCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/run.test.ts b/packages/cli/src/commands/agent/run.test.ts index 755638d4d3..5e64ccba81 100644 --- a/packages/cli/src/commands/agent/run.test.ts +++ b/packages/cli/src/commands/agent/run.test.ts @@ -1,18 +1,72 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { resolveCallerAgentId as resolveRunCallerAgentId } from "../../utils/caller-agent.js"; import { resolveExistingRunWorkspace, - resolveRunCallerAgentId, runRunCommand, + waitsForFinish, type AgentRunOptions, } from "./run"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + +// Answers fetchAgent the way a daemon does: an unknown id is an error. +function daemonWithAgents(...agentIds: string[]) { + return { + async fetchAgent({ agentId }: { agentId: string }) { + if (!agentIds.includes(agentId)) { + throw new Error(`Agent not found: ${agentId}`); + } + return { agent: { id: agentId } }; + }, + }; +} + +describe("run wait policy", () => { + it("waits by default and stops waiting on --no-wait or a legacy alias", () => { + expect(waitsForFinish({})).toBe(true); + expect(waitsForFinish({ wait: false })).toBe(false); + expect(waitsForFinish({ background: true })).toBe(false); + expect(waitsForFinish({ detach: true })).toBe(false); + }); +}); + describe("managed agent caller context", () => { - it("propagates a trimmed PASEO_AGENT_ID", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent"); + it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("parent-agent"), { + PASEO_AGENT_ID: " parent-agent ", + }), + ).resolves.toBe("parent-agent"); }); - it("omits blank caller ids", () => { - expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined(); + it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents("other-agent"), { + PASEO_AGENT_ID: "parent-agent", + }), + ).resolves.toBeUndefined(); + }); + + it.each(["DAEMON_CONNECTION_LOST", "DAEMON_REQUEST_TIMEOUT"] as const)( + "preserves %s instead of dropping the caller", + async (code) => { + const disconnectedDaemon = { + async fetchAgent(): Promise { + throw new DaemonConnectionError("Caller lookup transport failed", code); + }, + }; + + await expect( + resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }), + ).rejects.toBeInstanceOf(DaemonConnectionError); + }, + ); + + it("omits blank caller ids", async () => { + await expect( + resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }), + ).resolves.toBeUndefined(); }); }); @@ -65,8 +119,13 @@ describe("runRunCommand option validation", () => { } }); - async function expectInvalidOptions(options: AgentRunOptions, messageMatch: RegExp) { - await expect(runRunCommand("do something", options, {} as never)).rejects.toMatchObject({ + async function expectInvalidOptions( + options: Omit, + messageMatch: RegExp, + ) { + await expect( + runRunCommand("do something", { ...options, daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS", message: expect.stringMatching(messageMatch), }); @@ -84,7 +143,11 @@ describe("runRunCommand option validation", () => { // must clear validation. It still fails later (provider resolution), which // is enough to prove the new guard did not reject it. await expect( - runRunCommand("do something", { newWorkspace: "worktree", provider: undefined }, {} as never), + runRunCommand( + "do something", + { newWorkspace: "worktree", provider: undefined, daemonTarget }, + {} as never, + ), ).rejects.not.toMatchObject({ code: "INVALID_OPTIONS" }); }); diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index a642909952..86cca5f75c 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -1,7 +1,11 @@ import { Command, Option } from "commander"; -import { getStructuredAgentResponse, StructuredAgentResponseError } from "@getpaseo/server"; +import { + getStructuredAgentResponse, + StructuredAgentResponseError, +} from "@getpaseo/server/agent-response"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -23,10 +27,13 @@ export function addRunOptions(cmd: Command): Command { cmd .description("Create and start an agent with a task") .argument("", "The task/prompt for the agent") - .option("-d, --background", "Run in background") + .option("--no-wait", "Return immediately without waiting for completion") + // COMPAT(backgroundRunFlag): --background was the documented spelling of + // --no-wait. Added in v0.2.0; remove after 2027-04-01. + .addOption(new Option("-d, --background", "Legacy alias for --no-wait").hideHelp()) // COMPAT(detachRunFlag): --detach used to mean background execution, not // ownership transfer. Added in v0.2.0; remove after 2027-01-17. - .addOption(new Option("--detach", "Legacy alias for --background").hideHelp()) + .addOption(new Option("--detach", "Legacy alias for --no-wait").hideHelp()) .option("--title ", "Assign a title to the agent") .addOption(new Option("--name <name>", "Hidden alias for --title").hideHelp()) .option( @@ -107,6 +114,7 @@ export const agentRunSchema: OutputSchema<AgentRunResult> = { }; export interface AgentRunOptions extends CommandOptions { + wait?: boolean; background?: boolean; detach?: boolean; title?: string; @@ -140,6 +148,7 @@ function resolveNewWorkspaceKind(options: AgentRunOptions): string | undefined { function buildRunWorkspaceSource(options: AgentRunOptions, cwd: string) { const newWorkspace = resolveNewWorkspaceKind(options) ?? "local"; return buildWorkspaceSource({ + daemonTarget: options.daemonTarget, isolation: newWorkspace, path: cwd, mode: options.worktreeMode, @@ -181,6 +190,7 @@ function loadOutputSchema(value: string): Record<string, unknown> { try { source = readFileSync(resolve(trimmed), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -195,6 +205,7 @@ function loadOutputSchema(value: string): Record<string, unknown> { try { parsed = JSON.parse(source); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "INVALID_OUTPUT_SCHEMA", @@ -382,19 +393,25 @@ function validateRunOptions(prompt: string, options: AgentRunOptions, outputSche } satisfies CommandError; } + if (options.background) { + console.error("Warning: --background (-d) is deprecated. Use --no-wait instead."); + } + validateRunWorkspaceOptions(options); - if (outputSchema && runsInBackground(options)) { + if (outputSchema && !waitsForFinish(options)) { throw { code: "INVALID_OPTIONS", - message: "--output-schema cannot be used with --background", + message: "--output-schema cannot be used with --no-wait", details: "Structured output requires waiting for the agent to finish", } satisfies CommandError; } } -function runsInBackground(options: Pick<AgentRunOptions, "background" | "detach">): boolean { - return Boolean(options.background || options.detach); +export function waitsForFinish( + options: Pick<AgentRunOptions, "wait" | "background" | "detach">, +): boolean { + return options.wait !== false && !options.background && !options.detach; } function parseWaitTimeoutOption(waitTimeout: string | undefined): number { @@ -406,6 +423,7 @@ function parseWaitTimeoutOption(waitTimeout: string | undefined): number { } return ms; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -432,6 +450,7 @@ function loadRunImages( mimeType, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to read image ${imagePath}: ${message}`, { cause: err }); } @@ -482,22 +501,6 @@ function parseKeyValueFlags( return labels; } -async function connectToDaemonOrThrow( - hostOption: string | undefined, - host: string, -): Promise<ConnectedDaemonClient> { - try { - return await connectToDaemon({ host: hostOption }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - } satisfies CommandError; - } -} - // A workspace is the explicit home of a run: it owns the directory the agent // runs in. The CLI resolves one before creating any agent, so no run leans on // createAgent's legacy cwd->workspace fallback. @@ -534,7 +537,7 @@ export async function resolveExistingRunWorkspace( // Workspace policy for `paseo run`. Precedence: // 1. --workspace <id> -> run in that existing workspace -// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace +// 2. caller agent -> daemon resolves the caller's workspace // 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals // 4. --new-workspace <kind> -> mint a new workspace explicitly // 5. bare run -> mint a new local-backed workspace for cwd @@ -542,6 +545,7 @@ async function resolveRunWorkspace( client: ConnectedDaemonClient, options: AgentRunOptions, cwd: string, + callerAgentId: string | undefined, ): Promise<RunWorkspace> { const newWorkspace = resolveNewWorkspaceKind(options); const explicit = newWorkspace ? undefined : options.workspace?.trim(); @@ -550,7 +554,7 @@ async function resolveRunWorkspace( return resolveExistingRunWorkspace(client, explicit); } - if (!newWorkspace && resolveRunCallerAgentId()) { + if (!newWorkspace && callerAgentId) { return { cwd }; } @@ -563,7 +567,10 @@ async function resolveRunWorkspace( // TODO: thread the run `prompt` as firstAgentContext so workspace-level // title/branch generation picks up the task description (U8/U6 deferred). const source = buildRunWorkspaceSource(options, cwd); - const result = await client.createWorkspace({ source }); + const result = await client.createWorkspace({ + source, + ...(callerAgentId ? { callerAgentId } : {}), + }); if (!result.workspace) { throw { @@ -587,7 +594,6 @@ export async function runRunCommand( options: AgentRunOptions, _command: Command, ): Promise<SingleResult<AgentRunResult>> { - const host = getDaemonHost({ host: options.host }); const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined; validateRunOptions(prompt, options, outputSchema); @@ -596,7 +602,7 @@ export async function runRunCommand( const resolvedProviderModel = resolveProviderAndModel(options); const resolvedTitle = options.title ?? options.name; - const client = await connectToDaemonOrThrow(options.host, host); + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Resolve working directory @@ -618,9 +624,9 @@ export async function runRunCommand( const env = parseRunEnv(options.env); const requestEnv = Object.keys(env).length > 0 ? env : undefined; - const workspace = await resolveRunWorkspace(client, options, cwd); + const callerAgentId = await resolveCallerAgentId(client); + const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId); const workspaceId = workspace.id; - const callerAgentId = resolveRunCallerAgentId(); const runCwd = workspace.cwd; if (outputSchema) { @@ -714,8 +720,8 @@ export async function runRunCommand( labels: Object.keys(labels).length > 0 ? labels : undefined, }); - // Default run behavior is foreground: wait for completion unless background execution is set. - if (!runsInBackground(options)) { + // Default run behavior is foreground: wait for completion unless --no-wait is set. + if (waitsForFinish(options)) { const state = await client.waitForFinish(agent.id, waitTimeoutMs); await client.close(); @@ -751,9 +757,3 @@ export async function runRunCommand( throw error; } } - -export function resolveRunCallerAgentId( - env: { PASEO_AGENT_ID?: string } = process.env, -): string | undefined { - return env.PASEO_AGENT_ID?.trim() || undefined; -} diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 06249289b7..69e0c36d9d 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; import { collectMultiple } from "../../utils/command-options.js"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -78,6 +78,7 @@ async function readImageFiles( return { data: buffer.toString("base64"), mimeType }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "IMAGE_READ_ERROR", @@ -128,6 +129,7 @@ async function resolvePromptInput(options: { try { return await readFile(resolve(promptFilePath), "utf8"); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "PROMPT_FILE_READ_ERROR", @@ -166,8 +168,6 @@ export async function runSendCommand( options: AgentSendOptions, _command: Command, ): Promise<SingleResult<AgentSendResult>> { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -184,18 +184,7 @@ export async function runSendCommand( promptFile: options.promptFile, }); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { // Read image files if provided @@ -203,7 +192,10 @@ export async function runSendCommand( options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined; // Send the message - await client.sendAgentMessage(agentIdArg, promptInput, { images }); + await client.sendAgentMessage(agentIdArg, promptInput, { + images, + sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined, + }); // If --no-wait, return immediately if (options.wait === false) { diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index 42d269fcf2..3207bca4f8 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import { isSameOrDescendantPath } from "../../utils/paths.js"; import type { CommandOptions, @@ -42,8 +41,6 @@ export async function runStopCommand( options: AgentStopOptions, _command: Command, ): Promise<AgentStopResult> { - const host = getDaemonHost({ host: options.host }); - // Validate arguments - need either an id, --all, or --cwd if (!id && !options.all && !options.cwd) { const error: CommandError = { @@ -54,18 +51,7 @@ export async function runStopCommand( throw error; } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } }); diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index d23ad2d3ac..f41150b563 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -203,8 +203,6 @@ export async function runUpdateCommand( options: AgentUpdateOptions, _command: Command, ): Promise<AgentUpdateCommandResult> { - const host = getDaemonHost({ host: options.host }); - // Validate arguments if (!agentIdArg || agentIdArg.trim().length === 0) { const error: CommandError = { @@ -217,18 +215,7 @@ export async function runUpdateCommand( const changes = parseAgentChanges(options); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const fetchResult = await client.fetchAgent({ agentId: agentIdArg }); diff --git a/packages/cli/src/commands/agent/wait.ts b/packages/cli/src/commands/agent/wait.ts index 6c800746cf..3e5e5b533a 100644 --- a/packages/cli/src/commands/agent/wait.ts +++ b/packages/cli/src/commands/agent/wait.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, SingleResult, @@ -72,6 +72,7 @@ function parseWaitTimeout(timeout: string | undefined): { timeoutLabel: `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`, }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); throw { code: "INVALID_TIMEOUT", @@ -142,8 +143,6 @@ export async function runWaitCommand( options: AgentWaitOptions, _command: Command, ): Promise<SingleResult<AgentWaitResult>> { - const host = getDaemonHost({ host: options.host }); - if (!agentIdArg || agentIdArg.trim().length === 0) { throw { code: "MISSING_AGENT_ID", @@ -154,18 +153,7 @@ export async function runWaitCommand( const { timeoutMs, timeoutLabel } = parseWaitTimeout(options.timeout); - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { try { diff --git a/packages/cli/src/commands/clone.ts b/packages/cli/src/commands/clone.ts index 6986934c30..cf3001b775 100644 --- a/packages/cli/src/commands/clone.ts +++ b/packages/cli/src/commands/clone.ts @@ -1,7 +1,6 @@ import type { Command } from "commander"; import { isCompleteGitRemote } from "@getpaseo/protocol/git-remote"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { buildDaemonConnectionCommandError, connectToDaemon } from "../utils/client.js"; +import { connectToDaemon } from "../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../output/index.js"; import type { CommandOptions } from "../output/with-output.js"; @@ -45,12 +44,7 @@ export async function runCloneCommand( throw cmdError("INVALID_ARGUMENT", "--protocol is required for owner/repo repository names"); } - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - throw buildDaemonConnectionCommandError({ host: options.host, error: err }); - } + const client = await connectToDaemon({ target: options.daemonTarget }); if (client.getLastServerInfoMessage()?.features?.projectGithubClone !== true) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/daemon/config.ts b/packages/cli/src/commands/daemon/config.ts new file mode 100644 index 0000000000..c4f5cbf321 --- /dev/null +++ b/packages/cli/src/commands/daemon/config.ts @@ -0,0 +1,116 @@ +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; +import { + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "@getpaseo/server/configuration"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; + +function redact(value: unknown): unknown { + if (Array.isArray(value)) return value.map(redact); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(key) + ? "[redacted]" + : redact(item), + ]), + ); +} + +function result(data: Record<string, unknown>) { + return { + type: "single" as const, + data, + schema: { + idField: () => "configuration", + columns: [], + renderHuman: () => JSON.stringify(data, null, 2), + }, + }; +} + +function homeOf(options: CommandOptions) { + if (options.daemonTarget.kind !== "instance") + throw new Error("Configuration edits require --home"); + return options.daemonTarget.home; +} + +async function applySaved(home: string, options: CommandOptions) { + const nextCommand = `paseo daemon start --home ${JSON.stringify(home)}`; + const instance = await readDaemonInstance(home); + if (!instance?.listen) + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand, + }); + let client; + try { + client = await connectToDaemon({ target: options.daemonTarget, timeout: 1_500 }); + } catch { + return result({ + action: "saved", + applied: false, + message: "Saved; not applied to a running daemon", + nextCommand: `paseo daemon reload --home ${JSON.stringify(home)}`, + }); + } + try { + const reload = await client.reloadDaemonConfig(); + return result({ action: "saved", ...reload }); + } catch (error) { + throw { code: "CONFIG_SAVED_RELOAD_FAILED", message: `Saved; reload failed: ${String(error)}` }; + } finally { + await client.close(); + } +} + +export function daemonConfigCommand(): Command { + const config = new Command("config").description( + "Read or edit persistent local configuration (local operation)", + ); + addLocalDaemonOptions(config.command("get [path]")).action( + withOutput(async (field: string | undefined, options: CommandOptions, _command: Command) => { + const persisted = readPersistedConfig(homeOf(options)); + const value = field ? getPersistedConfigValue(persisted, field) : persisted; + return result({ + source: "configured", + path: field ?? null, + set: value !== undefined, + value: + field && /password|(?:api|private|access)[_-]?key|token|secret|authorization/i.test(field) + ? "[redacted]" + : redact(value), + }); + }), + ); + addLocalDaemonOptions(config.command("set <path> <value>")) + .option("--string", "Interpret value literally as a string") + .action( + withOutput(async (field: string, raw: string, options: CommandOptions, _command: Command) => { + let value: unknown = raw; + if (!options.string) { + try { + value = JSON.parse(raw); + } catch { + /* Unquoted input is a string. */ + } + } + editPersistedConfig(homeOf(options), field, { value }); + return applySaved(homeOf(options), options); + }), + ); + addLocalDaemonOptions(config.command("unset <path>")).action( + withOutput(async (field: string, options: CommandOptions, _command: Command) => { + editPersistedConfig(homeOf(options), field, { unset: true }); + return applySaved(homeOf(options), options); + }), + ); + return config; +} diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 869d87607c..5f9a8f842d 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -1,81 +1,32 @@ -import { Command, Option } from "commander"; -import { startCommand } from "./start.js"; -import { runStatusCommand } from "./status.js"; -import { runStopCommand } from "./stop.js"; -import { runRestartCommand } from "./restart.js"; +import { Command } from "commander"; +import { startCommand, daemonRunCommand } from "./start.js"; +import { daemonStatusCommand } from "./status.js"; +import { daemonStopCommand } from "./stop.js"; +import { daemonRestartCommand } from "./restart.js"; import { runSetPasswordCommand } from "./set-password.js"; import { pairCommand } from "./pair.js"; -import { runDaemonReloadCommand } from "./reload.js"; +import { daemonReloadCommand } from "./reload.js"; +import { daemonConfigCommand } from "./config.js"; import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command-options.js"; - -function resolveHostnamesOption(hostnames: unknown, allowedHosts: unknown): string | undefined { - if (typeof hostnames === "string") return hostnames; - if (typeof allowedHosts === "string") return allowedHosts; - return undefined; -} +import { addJsonOption, addLocalDaemonOptions } from "../../utils/command-options.js"; export function createDaemonCommand(): Command { const daemon = new Command("daemon").description("Manage the Paseo daemon"); - - daemon.addCommand(startCommand()); - daemon.addCommand(pairCommand()); - - addJsonAndDaemonHostOptions( - daemon.command("reload").description("Reload config.json without restarting the daemon"), - ).action(withOutput(runDaemonReloadCommand)); - - addJsonOption(daemon.command("status").description("Show local daemon status")) - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runStatusCommand)); - - addJsonOption(daemon.command("stop").description("Stop the local daemon")) - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") - .option("--timeout <seconds>", "Wait timeout before failing (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option("--kill-timeout <seconds>", "Wait after SIGKILL before failing (default: 3)") - .action(withOutput(runStopCommand)); - - addJsonOption(daemon.command("restart").description("Restart the local daemon")) - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") - .option("--timeout <seconds>", "Wait timeout before force step (default: 15)") - .option("--force", "Send SIGKILL if graceful stop times out") - .option( - "--listen <listen>", - "Listen target for restarted daemon (host:port, port, or unix socket)", - ) - .option("--port <port>", "Port for restarted daemon listen target") - .option("--relay", "Enable relay on restarted daemon") - .option("--no-relay", "Disable relay on restarted daemon") - .option("--no-mcp", "Disable Agent MCP on restarted daemon") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI on restarted daemon") - .option("--no-web-ui", "Disable the bundled daemon web UI on restarted daemon") - .option( - "--hostnames <hosts>", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts <hosts>").hideHelp()) - .action( - withOutput((...args) => { - const [options, command] = args.slice(-2) as [(typeof args)[number], Command]; - return runRestartCommand( - { - ...options, - hostnames: resolveHostnamesOption(options.hostnames, options.allowedHosts), - }, - command, - ); - }), - ); - + for (const command of [ + startCommand(), + daemonRunCommand(), + daemonStatusCommand(), + daemonStopCommand(), + daemonRestartCommand(), + daemonReloadCommand(), + pairCommand(), + daemonConfigCommand(), + ]) + daemon.addCommand(command); addJsonOption( - daemon - .command("set-password") - .description("Prompt for and save a hashed daemon password to config.json"), - ) - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") - .action(withOutput(runSetPasswordCommand)); - + addLocalDaemonOptions( + daemon.command("set-password").description("Save a hashed daemon password (local operation)"), + ), + ).action(withOutput(runSetPasswordCommand)); return daemon; } diff --git a/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..fd7e845ecd --- /dev/null +++ b/packages/cli/src/commands/daemon/lifecycle.e2e.test.ts @@ -0,0 +1,688 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rm, writeFile, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import net from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { hashDaemonPassword } from "@getpaseo/server/auth"; +import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control"; +import { expect, test } from "vitest"; +import { connectToDaemon } from "../../utils/client.js"; + +const repo = fileURLToPath(new URL("../../../../..", import.meta.url)); +const cli = path.join(repo, "packages/cli/dist/index.js"); + +async function port() { + const server = net.createServer(); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const value = (server.address() as net.AddressInfo).port; + await new Promise<void>((resolve) => server.close(() => resolve())); + if (value === 6767 || value === 6768) throw new Error("Unsafe test port"); + return value; +} + +async function fixture() { + const root = await mkdtemp(path.join(tmpdir(), "paseo lifecycle ")); + const env = { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + HOME: root, + USERPROFILE: root, + }; + const homes = [path.join(root, "a"), path.join(root, "b")]; + const owned = new Map<string, { pid: number; startedAt: string }>(); + async function run(args: string[], overrides: NodeJS.ProcessEnv = {}) { + const child = spawn(process.execPath, [cli, ...args], { + cwd: root, + env: { ...env, ...overrides }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = "", + stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + const code = await new Promise<number | null>((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGTERM"); + reject(new Error(`CLI timed out: ${args.join(" ")}\n${stderr}`)); + }, 45_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + for (const home of homes) { + try { + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + owned.set(home, lock); + } catch { + /* No acquired launch. */ + } + } + return { code, stdout, stderr, json: () => JSON.parse(stdout) }; + } + async function ok(args: string[], overrides?: NodeJS.ProcessEnv) { + const result = await run(["--json", ...args], overrides); + expect(result.code, `${args.join(" ")}\n${result.stdout}\n${result.stderr}`).toBe(0); + return result.json(); + } + async function liveStatus(home: string, overrides?: NodeJS.ProcessEnv) { + let status; + await expect + .poll( + async () => { + status = await ok(["status", "--home", home], overrides); + return status; + }, + { timeout: 30_000 }, + ) + // A connected socket can outlive a status RPC timeout without reporting the worker. + .toMatchObject({ connectedDaemon: "reachable", workerPid: expect.any(Number) }); + return status; + } + async function configure(home: string, listen: string) { + for (const [field, value] of [ + ["daemon.listen", listen], + ["daemon.relay.enabled", "false"], + ["features.webUi.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ]) { + await ok(["daemon", "config", "set", field!, value!, "--home", home]); + } + } + async function close() { + for (const [home, captured] of owned) { + try { + const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8")); + if (lock.pid === captured.pid && lock.startedAt === captured.startedAt) { + await run(["daemon", "stop", "--home", home, "--force", "--timeout", "2"]); + } + } catch { + /* Already exited. */ + } + } + await rm(root, { recursive: true, force: true }); + } + return { root, homes, env, run, ok, liveStatus, configure, close }; +} + +test("managed two-home restart retains its supervisor and never routes ordinary commands or stop to the other home", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + const portA = await port(), + portB = await port(), + movedPort = await port(); + try { + await f.configure(a, `127.0.0.1:${portA}`); + await f.configure(b, `127.0.0.1:${portB}`); + const launchA = await f.ok(["start", "--home", a, "--timeout", "30"]); + const poisoned = { + PASEO_HOME: a, + PASEO_HOST: `127.0.0.1:${portA}`, + PASEO_LISTEN: `127.0.0.1:${portA}`, + PORT: String(portA), + PASEO_WEB_UI_ENABLED: "true", + PASEO_RELAY_ENABLED: "true", + }; + const launchB = await f.ok(["daemon", "start", "--home", b, "--timeout", "30"], poisoned); + expect(launchA.listen).toBe(`127.0.0.1:${portA}`); + expect(launchB.listen).toBe(`127.0.0.1:${portB}`); + const beforeA = await f.liveStatus(a); + const beforeB = await f.liveStatus(b, poisoned); + if (process.platform !== "win32") { + for (const home of [a, b]) expect((await stat(home)).mode & 0o777).toBe(0o700); + expect(existsSync(path.join(f.root, ".paseo"))).toBe(false); + } + + const repoB = path.join(f.root, "project-b"); + await mkdir(repoB); + await f.ok(["project", "create", repoB, "--home", b], poisoned); + expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); + expect((await f.ok(["project", "ls", "--home", b], poisoned)).length).toBe(1); + const stream = await f.run(["logs", "missing-agent", "--follow", "--home", b], poisoned); + expect(stream.stderr).toContain("No agent found matching: missing-agent"); + const restart = await f.ok(["restart", "--home", b, "--timeout", "30"], poisoned); + expect(restart.supervisorPid).toBe(launchB.pid); + expect(restart.workerPid).not.toBe(beforeB.workerPid); + const saved = await f.ok( + ["daemon", "config", "set", "daemon.listen", `127.0.0.1:${movedPort}`, "--home", b], + poisoned, + ); + expect(saved.restartRequiredPaths).toContain("daemon.listen"); + await f.ok(["daemon", "restart", "--home", b, "--timeout", "30"], poisoned); + const moved = await f.liveStatus(b); + expect(moved.listen).toBe(`127.0.0.1:${movedPort}`); + expect(moved.pid).toBe(launchB.pid); + await f.ok(["daemon", "stop", "--home", b], poisoned); + const stoppedQuery = await f.run(["--json", "project", "ls", "--home", b], poisoned); + expect(stoppedQuery.code).toBe(1); + expect(stoppedQuery.stderr).toContain("DAEMON_NOT_RUNNING"); + const afterA = await f.liveStatus(a); + expect(afterA.workerPid).toBe(beforeA.workerPid); + expect(afterA.pid).toBe(beforeA.pid); + expect(await f.ok(["project", "ls", "--home", a])).toEqual([]); + } finally { + await f.close(); + } +}, 120_000); + +test("removed flags and ambiguous targets fail before side effects; observation does not create a home", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.ok(["status", "--home", home]); + await f.ok(["daemon", "config", "get", "--home", home]); + expect(existsSync(home)).toBe(false); + for (const args of [ + ["start", "--port", "12345"], + ["start", "--foreground"], + ["daemon", "restart", "--no-relay"], + ]) { + const result = await f.run(["--json", ...args, "--home", home]); + expect(result.code).toBe(1); + expect(result.stderr).toContain("REMOVED_LAUNCH_OPTION"); + expect(existsSync(home)).toBe(false); + } + const ambiguous = await f.run(["--home", home, "project", "ls", "--host", "unused:12345"]); + expect(ambiguous.stderr).toContain("Choose either --home or --host"); + expect(existsSync(home)).toBe(false); + } finally { + await f.close(); + } +}, 30_000); + +test("local status uses the credential and reports the server id of a password-protected daemon", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.daemon.auth = { password: await hashDaemonPassword("secret") }; + await writeFile(configPath, JSON.stringify(config)); + await f.ok(["start", "--home", home, "--timeout", "30"]); + const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" }); + expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({ + connectedDaemon: "reachable", + serverId: authenticated.serverId, + }); + } finally { + await f.close(); + } +}, 60_000); + +test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + const portA = await port(), + portB = await port(); + try { + await f.configure(a, `127.0.0.1:${portA}`); + await f.configure(b, `127.0.0.1:${portA}`); + await f.ok(["start", "--home", a, "--timeout", "30"]); + const before = await f.liveStatus(a); + const failed = await f.run(["--json", "start", "--home", b, "--timeout", "30"]); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain("DAEMON_START_FAILED"); + await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portB}`, "--home", b]); + await f.ok(["start", "--home", b, "--timeout", "30"]); + await f.ok(["daemon", "config", "set", "daemon.listen", `127.0.0.1:${portA}`, "--home", b]); + const restart = await f.run(["--json", "restart", "--home", b, "--timeout", "30"]); + expect(restart.code).toBe(1); + expect(restart.stderr).toContain("RESTART_NOT_CONFIRMED"); + await f.ok(["daemon", "stop", "--home", b]); + expect((await f.liveStatus(a)).workerPid).toBe(before.workerPid); + } finally { + await f.close(); + } +}, 120_000); + +test("a slow worker outlives the caller's readiness deadline and remains stoppable", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 5000));', + ); + const launch = await f.run(["--json", "start", "--home", home, "--timeout", "1"], { + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, + }); + expect(launch.code).toBe(1); + expect(launch.stderr).toContain("DAEMON_NOT_READY"); + expect(launch.stderr).toContain("remains running"); + expect(launch.stderr).toContain("Logs:"); + expect(launch.stderr).toContain("Status:"); + expect(launch.stderr).toContain("Stop:"); + const pending = await f.ok(["status", "--home", home]); + expect(pending.localDaemon).toBe("not_ready"); + expect(pending.listen).toBeNull(); + process.kill(pending.pid, 0); + const already = await f.ok(["start", "--home", home]); + expect(already.action).toBe("already_running"); + expect(already.pid).toBe(pending.pid); + await f.ok(["project", "ls", "--home", home]); + expect((await f.ok(["status", "--home", home])).pid).toBe(pending.pid); + await f.ok(["daemon", "stop", "--home", home]); + } finally { + await f.close(); + } +}, 60_000); + +test("worker restart preserves an already-running legacy supervisor's launch flags", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const filePort = await port(), + launchPort = await port(); + try { + await f.configure(home, `127.0.0.1:${filePort}`); + const launch = await startDaemonInstance({ + home, + command: process.execPath, + args: [ + path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js"), + "--no-relay", + "--no-web-ui", + ], + // The legacy CLI translated --port into PASEO_LISTEN before spawning. + env: { ...f.env, PASEO_LISTEN: `127.0.0.1:${launchPort}` }, + mode: "deployment", + timeoutMs: 30_000, + }); + const before = await f.liveStatus(home); + expect(before.listen).toBe(`127.0.0.1:${launchPort}`); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"], { + PASEO_LISTEN: `127.0.0.1:${filePort}`, + }); + expect(restarted.supervisorPid).toBe(launch.instance.pid); + expect(restarted.workerPid).not.toBe(before.workerPid); + expect((await f.ok(["status", "--home", home])).listen).toBe(before.listen); + } finally { + await f.close(); + } +}, 60_000); + +test.skipIf(process.platform === "win32")( + "POSIX stop ignores a wrong endpoint even with copied identities; supported live identity overrides still restart", + async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + try { + await f.configure(a, `127.0.0.1:${await port()}`); + await f.configure(b, `127.0.0.1:${await port()}`); + await writeFile(path.join(b, "server-id"), "saved-b"); + await f.ok(["start", "--home", a, "--timeout", "30"]); + await f.ok(["start", "--home", b, "--timeout", "30"], { PASEO_SERVER_ID: "live-b" }); + const beforeA = await f.liveStatus(a); + expect((await f.liveStatus(b)).serverId).toBe("live-b"); + await f.ok(["restart", "--home", b, "--timeout", "30"]); + const bLock = JSON.parse(await readFile(path.join(b, "paseo.pid"), "utf8")); + await writeFile( + path.join(b, "paseo.pid"), + JSON.stringify({ ...bLock, listen: beforeA.listen }), + ); + await writeFile(path.join(b, "server-id"), beforeA.serverId); + await f.ok(["daemon", "stop", "--home", b]); + expect((await f.liveStatus(a)).workerPid).toBe(beforeA.workerPid); + } finally { + await f.close(); + } + }, + 60_000, +); + +test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])( + "explicit cancellation before readiness cleans up only this launch: %j", + async (...launchArgs) => { + const f = await fixture(); + const home = f.homes[0]!; + let child: ReturnType<typeof spawn> | undefined; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + child = spawn(process.execPath, [cli, ...launchArgs, "--home", home], { + cwd: repo, + env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, + stdio: "ignore", + }); + const exited = new Promise((resolve) => child!.once("exit", resolve)); + // The lock file exists before its contents identify the supervisor. + let supervisorPid: number | undefined; + await expect + .poll( + async () => { + supervisorPid = (await readDaemonInstance(home))?.pid; + return supervisorPid; + }, + { timeout: 10_000 }, + ) + .toBeTypeOf("number"); + await f.ok(["status", "--home", home]); + child.kill("SIGINT"); + await exited; + expect(() => process.kill(supervisorPid!, 0)).toThrow(); + expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped"); + } finally { + child?.kill("SIGTERM"); + await f.close(); + } + }, + 60_000, +); + +test.runIf(process.platform === "win32")( + "Windows unbound stop requires explicit force", + async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + const pending = await f.run(["--json", "start", "--home", home, "--timeout", "1"], { + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, + }); + expect(pending.stderr).toContain("DAEMON_NOT_READY"); + const refused = await f.run(["--json", "daemon", "stop", "--home", home]); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain("STOP_NO_GRACEFUL_CHANNEL"); + const stopped = await f.ok(["daemon", "stop", "--home", home, "--force"]); + expect(stopped.forced).toBe(true); + expect(stopped.usedLifecycleRpc).toBe(false); + } finally { + await f.close(); + } + }, + 60_000, +); + +test("empty explicit selectors never select the ambient daemon or create local state", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["start", "--home", home]); + const before = await f.liveStatus(home); + for (const args of [ + ["daemon", "stop", "--home", ""], + ["daemon", "stop", "--host", ""], + ["start", "--home", ""], + ["daemon", "config", "set", "daemon.relay.enabled", "true", "--home", ""], + ]) { + const refused = await f.run(["--json", ...args], { PASEO_HOME: home }); + expect(refused.code).toBe(1); + expect(refused.stderr).toContain("TARGET_INVALID"); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + expect(existsSync(path.join(f.root, "paseo.pid"))).toBe(false); + expect(existsSync(path.join(f.root, "config.json"))).toBe(false); + } + } finally { + await f.close(); + } +}, 60_000); + +test.skipIf(process.platform === "win32")( + "restart confirms the replacement worker when a provider probe is slow", + async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const provider = path.join(f.root, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), 1800);\n`, + { mode: 0o700 }, + ); + const configPath = path.join(home, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")); + config.agents = { + ...config.agents, + providers: { claude: { command: { mode: "replace", argv: [provider] } } }, + }; + await writeFile(configPath, JSON.stringify(config)); + const launch = await f.ok(["start", "--home", home, "--timeout", "30"]); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]); + expect(restarted.supervisorPid).toBe(launch.pid); + expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid); + } finally { + await f.close(); + } + }, + 60_000, +); + +test("IPv6 publication supports home-selected query and worker restart", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const endpoint = `[::1]:${await port()}`; + try { + await f.configure(home, endpoint); + const launch = await f.ok(["start", "--home", home, "--timeout", "30"]); + expect(launch.listen).toBe(endpoint); + expect(await f.ok(["project", "ls", "--home", home])).toEqual([]); + const restarted = await f.ok(["restart", "--home", home, "--timeout", "30"]); + expect(restarted.supervisorPid).toBe(launch.pid); + expect(restarted.workerPid).not.toBe(restarted.previousWorkerPid); + } finally { + await f.close(); + } +}, 60_000); + +test("raw log following subscribes to a stored agent that exists only in B", async () => { + const f = await fixture(); + const [a, b] = f.homes as [string, string]; + let follower: ReturnType<typeof spawn> | undefined; + try { + await f.configure(a, `127.0.0.1:${await port()}`); + await f.configure(b, `127.0.0.1:${await port()}`); + const agentId = "4de8b157-7614-4c38-b189-e50e57fb9498"; + const now = new Date().toISOString(); + await mkdir(path.join(b, "agents")); + await writeFile( + path.join(b, "agents", `${agentId}.json`), + JSON.stringify({ + id: agentId, + provider: "codex", + cwd: f.root, + title: "B-only stored agent", + createdAt: now, + updatedAt: now, + lastStatus: "closed", + persistence: null, + }), + ); + const launchA = await f.ok(["start", "--home", a]); + await f.ok(["start", "--home", b]); + const absent = await f.run(["logs", agentId, "--home", a]); + expect(absent.stderr).toContain(`No agent found matching: ${agentId}`); + follower = spawn( + process.execPath, + [cli, "logs", agentId, "--follow", "--tail", "0", "--home", b], + { + cwd: f.root, + env: { ...f.env, PASEO_HOME: a, PASEO_HOST: launchA.listen }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = "", + errors = ""; + follower.stdout!.on("data", (data) => { + output += data; + }); + follower.stderr!.on("data", (data) => { + errors += data; + }); + await expect.poll(() => output, { timeout: 15_000 }).toContain("Following logs (no history;"); + expect(errors).not.toContain("No agent found matching"); + } finally { + if (follower && follower.exitCode === null) { + const exited = new Promise((resolve) => follower!.once("exit", resolve)); + follower.kill("SIGINT"); + await exited; + } + await f.close(); + } +}, 60_000); + +test("a connection pinned to an earlier supervisor refuses its replacement", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["start", "--home", home]); + const captured = (await readDaemonInstance(home))!; + await f.ok(["daemon", "stop", "--home", home]); + await f.ok(["start", "--home", home]); + const before = await f.liveStatus(home); + await expect( + connectToDaemon({ target: { kind: "instance", home }, instance: captured, timeout: 1000 }), + ).rejects.toMatchObject({ code: "DAEMON_REPLACED" }); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + } finally { + await f.close(); + } +}, 60_000); + +test("foreground deployment retains environment until its owner ends the launch; endpoint stop reports acknowledgment", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const fileEndpoint = `127.0.0.1:${await port()}`, + deploymentEndpoint = `127.0.0.1:${await port()}`, + changedEndpoint = `127.0.0.1:${await port()}`; + let deployment: ReturnType<typeof spawn> | undefined; + try { + await f.configure(home, fileEndpoint); + deployment = spawn(process.execPath, [cli, "daemon", "run", "--home", home], { + cwd: f.root, + env: { ...f.env, PASEO_LISTEN: deploymentEndpoint, PASEO_RELAY_ENABLED: "false" }, + stdio: "ignore", + }); + await expect + .poll(async () => (await readDaemonInstance(home))?.listen, { timeout: 30_000 }) + .toBe(deploymentEndpoint); + const before = await f.liveStatus(home); + expect(before.configuredListen).toBe(fileEndpoint); + expect(await f.ok(["daemon", "run", "--home", home])).toMatchObject({ + action: "already_running", + pid: before.pid, + }); + expect((await f.liveStatus(home)).workerPid).toBe(before.workerPid); + const changed = await f.ok([ + "daemon", + "config", + "set", + "daemon.listen", + changedEndpoint, + "--home", + home, + ]); + expect(changed.overrideControlledPaths).toContain("daemon.listen"); + const restarted = await f.ok(["restart", "--home", home], { PASEO_LISTEN: fileEndpoint }); + expect(restarted.supervisorPid).toBe(before.pid); + expect((await f.ok(["status", "--home", home])).listen).toBe(deploymentEndpoint); + const exited = new Promise((resolve) => deployment!.once("exit", resolve)); + const remote = await f.ok(["daemon", "stop", "--host", deploymentEndpoint]); + expect(remote.action).toBe("shutdown_requested"); + await exited; + const managed = await f.ok(["start", "--home", home]); + expect(managed.listen).toBe(changedEndpoint); + expect(managed.pid).not.toBe(before.pid); + } finally { + if (deployment?.exitCode === null) deployment.kill("SIGTERM"); + await f.close(); + } +}, 90_000); + +test("malformed configuration cannot turn a readiness timeout into launch cancellation", async () => { + const f = await fixture(); + const home = f.homes[0]!; + let starter: ReturnType<typeof spawn> | undefined; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + const configPath = path.join(home, "config.json"); + const saved = await readFile(configPath, "utf8"); + const preload = path.join(f.root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 30000));', + ); + starter = spawn(process.execPath, [cli, "--json", "start", "--home", home, "--timeout", "2"], { + cwd: f.root, + env: { ...f.env, NODE_OPTIONS: `--import=${pathToFileURL(preload).href}` }, + stdio: ["ignore", "pipe", "pipe"], + }); + let errors = ""; + starter.stderr!.on("data", (data) => { + errors += data; + }); + const exited = new Promise((resolve) => starter!.once("exit", resolve)); + await expect + .poll(async () => (await readDaemonInstance(home))?.pid, { timeout: 10_000 }) + .toBeTruthy(); + const captured = (await readDaemonInstance(home))!; + await f.ok(["status", "--home", home]); + await writeFile(configPath, "{"); + await exited; + expect(errors).toContain("DAEMON_NOT_READY"); + expect(errors).toContain(path.join(home, "daemon.log")); + process.kill(captured.pid, 0); + await writeFile(configPath, saved); + await f.ok(["status", "--home", home]); + } finally { + if (starter?.exitCode === null) starter.kill("SIGTERM"); + await f.close(); + } +}, 60_000); + +test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => { + const f = await fixture(); + const home = f.homes[0]!; + const configPath = path.join(home, "config.json"); + try { + await mkdir(home, { recursive: true }); + await writeFile(configPath, '{"version":1,'); + const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`); + expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`); + expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain( + `Invalid JSON in ${configPath}`, + ); + } finally { + await f.close(); + } +}, 60_000); + +test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => { + const f = await fixture(); + const home = f.homes[0]!; + try { + await f.configure(home, `127.0.0.1:${await port()}`); + await f.ok(["daemon", "config", "set", "daemon.mcp.enabled", "false", "--home", home]); + for (const flags of [[], ["--no-mcp"]]) { + const onboard = await f.run(["onboard", "--home", home, "--voice", "disable", ...flags]); + expect(onboard.code, onboard.stderr).toBe(0); + expect( + (await f.ok(["daemon", "config", "get", "daemon.mcp.enabled", "--home", home])).value, + ).toBe(false); + } + } finally { + await f.close(); + } +}, 60_000); diff --git a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts b/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts deleted file mode 100644 index 03449fa0cb..0000000000 --- a/packages/cli/src/commands/daemon/local-daemon.supervision.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { EventEmitter } from "node:events"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { - type DaemonLaunchRuntime, - type DetachedDaemonProcess, - resolveLocalDaemonState, - startLocalDaemonDetached, - startLocalDaemonForeground, -} from "./local-daemon.js"; - -type RecordedDaemonLaunch = - | { - mode: "detached"; - command: string; - args: string[]; - options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2]; - } - | { - mode: "foreground"; - command: string; - args: string[]; - options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2]; - }; - -class FakeDaemonProcess extends EventEmitter implements DetachedDaemonProcess { - pid = 4242; - wasUnreferenced = false; - - unref(): void { - this.wasUnreferenced = true; - } -} - -class FakeDaemonRuntime implements DaemonLaunchRuntime { - readonly recordedLaunches: RecordedDaemonLaunch[] = []; - readonly daemonProcess = new FakeDaemonProcess(); - foregroundStatus = 0; - runnerEntry = "/repo/packages/server/scripts/supervisor-entrypoint.ts"; - - resolveRunnerEntry(): string { - return this.runnerEntry; - } - - resolveHome(env: NodeJS.ProcessEnv): string { - return env.PASEO_HOME ?? "/tmp/paseo"; - } - - spawnDetached( - command: string, - args: string[], - options: Parameters<DaemonLaunchRuntime["spawnDetached"]>[2], - ): DetachedDaemonProcess { - this.recordedLaunches.push({ mode: "detached", command, args, options }); - return this.daemonProcess; - } - - spawnForeground( - command: string, - args: string[], - options: Parameters<DaemonLaunchRuntime["spawnForeground"]>[2], - ) { - this.recordedLaunches.push({ mode: "foreground", command, args, options }); - return { status: this.foregroundStatus, error: undefined }; - } -} - -const tempRoots: string[] = []; - -async function createPaseoHome(config: unknown): Promise<string> { - const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-daemon-")); - tempRoots.push(root); - const paseoHome = path.join(root, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - await writeFile(path.join(paseoHome, "config.json"), JSON.stringify(config, null, 2)); - return paseoHome; -} - -function expectSupervisorLaunch(argv: string[]): void { - const joined = argv.join(" "); - expect(joined).toContain("supervisor-entrypoint"); - expect(joined).not.toContain("src/server/index.ts"); - expect(joined).not.toContain("dist/server/server/index.js"); - expect(joined).not.toContain("src/server/daemon-worker.ts"); - expect(joined).not.toContain("dist/server/server/daemon-worker.js"); -} - -describe("local daemon launch supervision", () => { - beforeEach(() => { - vi.useRealTimers(); - }); - - afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), - ); - vi.restoreAllMocks(); - }); - - test("foreground start spawns supervisor-entrypoint instead of server/index", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground({ home: "/tmp/paseo-test", relay: false }, runtime); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-relay"); - }); - - test("detached start spawns supervisor-entrypoint instead of server/index", async () => { - vi.useFakeTimers(); - const runtime = new FakeDaemonRuntime(); - - const resultPromise = startLocalDaemonDetached( - { home: "/tmp/paseo-test", mcp: false }, - runtime, - ); - await vi.advanceTimersByTimeAsync(1200); - const result = await resultPromise; - - expect(result).toEqual({ pid: 4242, logPath: "/tmp/paseo-test/daemon.log" }); - expect(runtime.daemonProcess.wasUnreferenced).toBe(true); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["detached"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("detached"); - expect(launch?.command).toBe(process.execPath); - expectSupervisorLaunch(launch?.args ?? []); - expect(launch?.args).toContain("--no-mcp"); - }); - - test("relay TLS flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - relayUseTls: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--relay-use-tls"); - expect(launch?.options?.env?.PASEO_RELAY_USE_TLS).toBe("true"); - }); - - test("web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: true, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("true"); - }); - - test("no-web UI flag is passed to the supervised daemon", async () => { - const runtime = new FakeDaemonRuntime(); - - const status = startLocalDaemonForeground( - { - home: "/tmp/paseo-test", - webUi: false, - }, - runtime, - ); - - expect(status).toBe(0); - expect(runtime.recordedLaunches.map((launch) => launch.mode)).toEqual(["foreground"]); - const launch = runtime.recordedLaunches[0]; - expect(launch?.mode).toBe("foreground"); - expect(launch?.args).toContain("--no-web-ui"); - expect(launch?.options?.env?.PASEO_WEB_UI_ENABLED).toBe("false"); - }); - - test("local daemon state keeps public relay TLS separate from daemon relay TLS", async () => { - const home = await createPaseoHome({ - version: 1, - daemon: { - relay: { - endpoint: "10.0.0.5:51185", - publicEndpoint: "paseo.example.com", - useTls: false, - publicUseTls: true, - }, - }, - }); - - const state = resolveLocalDaemonState({ home }); - - expect(state.relayEndpoint).toBe("paseo.example.com"); - expect(state.relayUseTls).toBe(false); - expect(state.relayPublicUseTls).toBe(true); - }); -}); diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index a7afb4e6b7..5391957ce1 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -1,184 +1,9 @@ -import { spawnSync, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { Command, Option } from "commander"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { loadConfig, resolvePaseoHome, spawnProcess } from "@getpaseo/server"; -import treeKill from "tree-kill"; -import { tryConnectToDaemon } from "../../utils/client.js"; - -export interface DaemonStartOptions { - port?: string; - listen?: string; - home?: string; - foreground?: boolean; - relay?: boolean; - relayUseTls?: boolean; - mcp?: boolean; - injectMcp?: boolean; - webUi?: boolean; - hostnames?: string; -} - -export interface LocalDaemonPidInfo { - pid: number; - startedAt?: string; - hostname?: string; - uid?: number; - listen?: string; - desktopManaged?: boolean; -} - -export interface LocalDaemonState { - home: string; - listen: string; - relayEnabled: boolean; - relayEndpoint: string; - relayUseTls: boolean; - relayPublicUseTls: boolean; - logPath: string; - pidPath: string; - pidInfo: LocalDaemonPidInfo | null; - running: boolean; - stalePidFile: boolean; -} - -export interface DetachedStartResult { - pid: number | null; - logPath: string; -} - -export interface StopLocalDaemonOptions { - home?: string; - timeoutMs?: number; - killTimeoutMs?: number; - force?: boolean; -} - -export interface StopLocalDaemonResult { - action: "stopped" | "not_running"; - home: string; - pid: number | null; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; -} - -interface ProcessExitDetails { - code: number | null; - signal: NodeJS.Signals | null; - error?: Error; -} - -type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails); - -export interface DetachedDaemonProcess extends Pick<ChildProcess, "once" | "pid" | "unref"> {} - -export interface ForegroundDaemonProcessResult { - status: number | null; - error?: Error; -} - -export interface DaemonLaunchRuntime { - resolveRunnerEntry(): string; - resolveHome(env: NodeJS.ProcessEnv): string; - spawnDetached( - command: string, - args: string[], - options: Parameters<typeof spawnProcess>[2], - ): DetachedDaemonProcess; - spawnForeground( - command: string, - args: string[], - options: Parameters<typeof spawnSync>[2], - ): ForegroundDaemonProcessResult; -} - -const DETACHED_STARTUP_GRACE_MS = 1200; -const PID_POLL_INTERVAL_MS = 100; -const DAEMON_LOG_FILENAME = "daemon.log"; -const DAEMON_PID_FILENAME = "paseo.pid"; - -export const DEFAULT_STOP_TIMEOUT_MS = 15_000; -export const DEFAULT_KILL_TIMEOUT_MS = 3_000; - +import { startDaemonInstance, resolvePaseoHome } from "@getpaseo/server/daemon-control"; const require = createRequire(import.meta.url); - -const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = { - resolveRunnerEntry: resolveDaemonRunnerEntry, - resolveHome: resolvePaseoHome, - spawnDetached: spawnProcess, - spawnForeground: spawnSync, -}; - -const startupReady = (): DetachedStartupResult => ({ exitedEarly: false }); - -const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({ - exitedEarly: true, - ...details, -}); - -function envWithHome(home?: string): NodeJS.ProcessEnv { - if (!home) { - return process.env; - } - - return { ...process.env, PASEO_HOME: home }; -} - -function buildRunnerArgs(options: DaemonStartOptions): string[] { - const args: string[] = []; - if (options.relay === true) { - args.push("--relay"); - } - if (options.relay === false) { - args.push("--no-relay"); - } - if (options.relayUseTls === true) { - args.push("--relay-use-tls"); - } - - if (options.mcp === false) { - args.push("--no-mcp"); - } - if (options.injectMcp === false) { - args.push("--no-inject-mcp"); - } - if (options.webUi === true) { - args.push("--web-ui"); - } - if (options.webUi === false) { - args.push("--no-web-ui"); - } - - 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.hostnames) { - childEnv.PASEO_HOSTNAMES = options.hostnames; - } - if (options.relayUseTls === true) { - childEnv.PASEO_RELAY_USE_TLS = "true"; - } - if (options.webUi === true) { - childEnv.PASEO_WEB_UI_ENABLED = "true"; - } - if (options.webUi === false) { - childEnv.PASEO_WEB_UI_ENABLED = "false"; - } - return childEnv; -} - function resolveServerRunnerFromDir(currentDir: string): string | null { const packageJsonPath = path.join(currentDir, "package.json"); if (!existsSync(packageJsonPath)) return null; @@ -215,550 +40,72 @@ function resolveDaemonRunnerEntry(): string { throw new Error("Unable to resolve @getpaseo/server package root for daemon runner"); } -function pidFilePath(paseoHome: string): string { - return path.join(paseoHome, DAEMON_PID_FILENAME); -} - -function resolveListenField(listen: unknown, sockPath: unknown): string | undefined { - if (typeof listen === "string") return listen; - if (typeof sockPath === "string") return sockPath; - return undefined; -} - -function resolveStopMessage( - forced: boolean, - lifecycleRequested: boolean, - fallbackMessage: string | null | undefined, -): string { - if (forced) return "Daemon owner process was force-stopped"; - if (lifecycleRequested) return "Daemon stopped gracefully"; - return fallbackMessage ?? "Daemon stopped via owner PID signal"; -} - -function resolveStopReason( - forced: boolean, - lifecycleRequested: boolean, -): StopLocalDaemonResult["reason"] { - if (forced) return "owner_pid_sigkill"; - if (lifecycleRequested) return "lifecycle_shutdown_rpc"; - return "owner_pid_signal"; -} - -function readPidFile(pidPath: string): LocalDaemonPidInfo | null { +export async function launchLocalDaemon(options: { + home: string; + timeoutMs?: number; + foreground?: boolean; +}) { + const abort = new AbortController(); + const cancel = () => abort.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>; - const pidValue = parsed.pid; - if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) { - return null; - } - - return { - pid: pidValue, - startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined, - hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined, - uid: typeof parsed.uid === "number" ? parsed.uid : undefined, - listen: resolveListenField(parsed.listen, parsed.sockPath), - desktopManaged: parsed.desktopManaged === true ? true : undefined, - }; - } catch { - return null; - } -} - -function tailFile(filePath: string, lines = 30): string | null { - try { - const content = readFileSync(filePath, "utf-8"); - return content.split("\n").filter(Boolean).slice(-lines).join("\n"); - } catch { - return null; - } -} - -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function readNodeErrnoCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - - return typeof error.code === "string" ? error.code : undefined; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - return false; - } -} - -function signalProcess(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - return false; - } - throw err; - } -} - -function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - try { - return signalProcess(pid, signal); - } catch (err) { - const code = readNodeErrnoCode(err); - if (code === "EPERM") { - return true; - } - throw err; - } -} - -async function signalProcessTreeSafely(pid: number, signal: NodeJS.Signals): Promise<boolean> { - if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) { - return false; - } - - return new Promise((resolve, reject) => { - treeKill(pid, signal, (err) => { - if (!err) { - resolve(true); - return; - } - - const code = readNodeErrnoCode(err); - if (code === "ESRCH") { - resolve(false); - return; - } - if (code === "EPERM") { - resolve(true); - return; - } - reject(err); + const entry = resolveDaemonRunnerEntry(); + return await startDaemonInstance({ + home: resolvePaseoHome({ PASEO_HOME: options.home }), + command: process.execPath, + args: [...(entry.endsWith(".ts") ? ["--import", "tsx"] : []), entry], + env: process.env, + mode: options.foreground ? "deployment" : "managed", + foreground: options.foreground, + timeoutMs: options.timeoutMs, + signal: abort.signal, + onReady: options.foreground + ? (instance) => + process.stdout.write(`Listening on ${instance.listen} (PID ${instance.pid})\n`) + : undefined, }); - }); -} - -async function signalProcessTreeOrOwnerSafely( - pid: number, - signal: NodeJS.Signals, -): Promise<boolean> { - try { - return await signalProcessTreeSafely(pid, signal); - } catch { - return signalProcessSafely(pid, signal); - } -} - -async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> { - const deadline = Date.now() + timeoutMs; - async function poll(): Promise<boolean> { - if (!isProcessRunning(pid)) return true; - if (Date.now() >= deadline) return !isProcessRunning(pid); - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - return poll(); -} - -async function waitForDaemonUnreachable( - state: LocalDaemonState, - timeoutMs: number, -): Promise<boolean> { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return true; - } - - const reachableHost = host; - const deadline = Date.now() + timeoutMs; - async function poll(): Promise<boolean> { - const client = await tryConnectToDaemon({ host: reachableHost, timeout: 500 }); - if (!client) { - return true; - } - await client.close().catch(() => undefined); - if (Date.now() >= deadline) { - const finalClient = await tryConnectToDaemon({ - host: reachableHost, - timeout: PID_POLL_INTERVAL_MS, - }); - if (!finalClient) { - return true; - } - await finalClient.close().catch(() => undefined); - return false; - } - await sleep(PID_POLL_INTERVAL_MS); - return poll(); - } - - return poll(); -} - -function removeStalePidFile(state: LocalDaemonState): void { - if (!state.stalePidFile) { - return; - } - - try { - unlinkSync(state.pidPath); - } catch { - // Best-effort cleanup only. The successful lifecycle stop is authoritative. - } -} - -function createNotRunningStopResult( - state: LocalDaemonState, - pid: number | null, - message: string, -): StopLocalDaemonResult { - return { - action: "not_running", - home: state.home, - pid, - forced: false, - usedLifecycleRpc: false, - reason: "not_running", - message, - }; -} - -function createStopTimeoutError( - state: LocalDaemonState, - pid: number | null, - timeoutMs: number, -): Error { - if (!state.running) { - const host = resolveTcpHostFromListen(state.listen); - return new Error( - `Timed out waiting for daemon${host ? ` at ${host}` : ""} to stop after ${Math.ceil( - timeoutMs / 1000, - )}s`, - ); - } - return new Error( - `Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`, - ); -} - -async function signalDaemonOwnerForStop( - state: LocalDaemonState, - pid: number | null, -): Promise<StopLocalDaemonResult | null> { - if (pid === null) { - return createNotRunningStopResult(state, null, "Daemon is not running"); - } - - const signaled = await signalProcessTreeOrOwnerSafely(pid, "SIGTERM"); - if (signaled) { - return null; - } - - return createNotRunningStopResult(state, pid, "Daemon process was already stopped"); -} - -async function waitForStopAfterRequest(args: { - state: LocalDaemonState; - pid: number | null; - timeoutMs: number; - killTimeoutMs: number; - force?: boolean; -}): Promise<{ stopped: boolean; forced: boolean }> { - const { state, pid, timeoutMs, killTimeoutMs, force } = args; - let stopped = - state.running && pid !== null - ? await waitForPidExit(pid, timeoutMs) - : await waitForDaemonUnreachable(state, timeoutMs); - - if (!stopped && force && state.running && pid !== null) { - await signalProcessTreeOrOwnerSafely(pid, "SIGKILL"); - stopped = await waitForPidExit(pid, killTimeoutMs); - return { stopped, forced: true }; - } - - return { stopped, forced: false }; -} - -type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string }; - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function resolveLocalPaseoHome(home?: string): string { - return resolvePaseoHome(envWithHome(home)); -} - -export function resolveTcpHostFromListen(listen: string): string | null { - const normalized = listen.trim(); - if (!normalized) { - return null; - } - - if ( - normalized.startsWith("/") || - normalized.startsWith("unix://") || - normalized.startsWith("pipe://") || - normalized.startsWith("\\\\.\\pipe\\") || - /^[A-Za-z]:[/\\]/.test(normalized) - ) { - return null; - } - - if (/^\d+$/.test(normalized)) { - return `127.0.0.1:${normalized}`; - } - - if (normalized.includes(":")) { - return normalized; - } - - return null; -} - -export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState { - const env: NodeJS.ProcessEnv = { - ...envWithHome(options.home), - // Status should reflect local persisted config + pid file, not inherited daemon env overrides. - // This is CLI-side defensive scrubbing; the daemon RPC is authoritative when available. - PASEO_LISTEN: undefined, - PASEO_HOSTNAMES: undefined, - PASEO_ALLOWED_HOSTS: undefined, - PASEO_RELAY_ENABLED: undefined, - PASEO_RELAY_ENDPOINT: undefined, - PASEO_RELAY_PUBLIC_ENDPOINT: undefined, - PASEO_RELAY_USE_TLS: undefined, - PASEO_RELAY_PUBLIC_USE_TLS: undefined, - }; - const home = resolvePaseoHome(env); - const config = loadConfig(home, { env }); - const pidPath = pidFilePath(home); - const logPath = path.join(home, DAEMON_LOG_FILENAME); - const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null; - const running = pidInfo ? isProcessRunning(pidInfo.pid) : false; - const listen = pidInfo?.listen ?? config.listen; - - return { - home, - listen, - relayEnabled: config.relayEnabled ?? true, - relayEndpoint: config.relayPublicEndpoint ?? config.relayEndpoint ?? "relay.paseo.sh:443", - relayUseTls: config.relayUseTls ?? false, - relayPublicUseTls: config.relayPublicUseTls ?? config.relayUseTls ?? false, - logPath, - pidPath, - pidInfo, - running, - stalePidFile: Boolean(pidInfo) && !running, - }; -} - -export function tailDaemonLog(home?: string, lines = 30): string | null { - const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME); - return tailFile(logPath, lines); -} - -export async function startLocalDaemonDetached( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): Promise<DetachedStartResult> { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - - const paseoHome = runtime.resolveHome(childEnv); - const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME); - const child = runtime.spawnDetached( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - detached: true, - envMode: "internal", - env: childEnv, - stdio: ["ignore", "ignore", "ignore"], - }, - ); - - child.unref(); - - const startup = await new Promise<DetachedStartupResult>((resolve) => { - let settled = false; - - const finish = (value: DetachedStartupResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish(startupExited({ code: null, signal: null, error })); - }); - - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish(startupExited({ code, signal })); - }); - }); - - if (startup.exitedEarly) { - const reason = startup.error - ? startup.error.message - : `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`; - const recentLogs = tailFile(logPath); - throw new Error( - [ - `Daemon failed to start in background (${reason}).`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - } - - return { - pid: child.pid ?? null, - logPath, - }; -} - -export function startLocalDaemonForeground( - options: DaemonStartOptions, - runtime: DaemonLaunchRuntime = defaultDaemonLaunchRuntime, -): number { - if (options.listen && options.port) { - throw new Error("Cannot use --listen and --port together"); - } - - const daemonRunnerEntry = runtime.resolveRunnerEntry(); - const childEnv = buildChildEnv(options); - const result = runtime.spawnForeground( - process.execPath, - [...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)], - { - env: childEnv, - stdio: "inherit", - }, - ); - - if (result.error) { - throw result.error; - } - - return result.status ?? 1; -} - -async function requestLifecycleShutdown( - state: LocalDaemonState, - timeoutMs: number, -): Promise<LifecycleShutdownAttempt> { - const host = resolveTcpHostFromListen(state.listen); - if (!host) { - return { - requested: false, - reason: "daemon listen target is not TCP, falling back to owner PID signal", - }; - } - - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - const client = await tryConnectToDaemon({ host, timeout: Math.min(remainingTimeoutMs(), 5000) }); - if (!client) { - return { - requested: false, - reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`, - }; - } - - try { - await client.shutdownServer({ timeout: Math.min(remainingTimeoutMs(), 5000) }); - return { requested: true }; - } catch (error) { - return { - requested: false, - reason: `daemon lifecycle shutdown request failed (${getErrorMessage( - error, - )}), falling back to owner PID signal`, - }; } finally { - await client.close().catch(() => undefined); + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); } } -export async function stopLocalDaemon( - options: StopLocalDaemonOptions = {}, -): Promise<StopLocalDaemonResult> { - const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS; - const killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS; - const state = resolveLocalDaemonState({ home: options.home }); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); +export function parseTimeoutMs(raw: unknown, fallback = 600_000): number { + if (raw === undefined) return fallback; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) + throw { code: "INVALID_TIMEOUT", message: "Timeout must be a positive number of seconds." }; + return Math.ceil(seconds * 1000); +} - const shutdownAttempt = await requestLifecycleShutdown(state, remainingTimeoutMs()); - const lifecycleRequested = shutdownAttempt.requested; +const REMOVED_LAUNCH_FLAGS: Record<string, string> = { + "--port <port>": "daemon.listen", + "--listen <listen>": "daemon.listen", + "--relay": "daemon.relay.enabled", + "--no-relay": "daemon.relay.enabled", + "--relay-use-tls": "daemon.relay.useTls", + "--no-mcp": "daemon.mcp.enabled", + "--no-inject-mcp": "daemon.mcp.injectIntoAgents", + "--web-ui": "features.webUi.enabled", + "--no-web-ui": "features.webUi.enabled", + "--hostnames <hosts>": "daemon.hostnames", + "--allowed-hosts <hosts>": "daemon.hostnames", + "--foreground": "", +}; - if (!state.pidInfo || (!state.running && !lifecycleRequested)) { - const staleSuffix = - state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : ""; - return createNotRunningStopResult( - state, - state.pidInfo?.pid ?? null, - `Daemon is not running${staleSuffix}`, - ); - } - - const pid = state.pidInfo?.pid ?? null; - const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason; - if (!lifecycleRequested) { - const notRunningResult = await signalDaemonOwnerForStop(state, pid); - if (notRunningResult) return notRunningResult; - } - - const { stopped, forced } = await waitForStopAfterRequest({ - state, - pid, - timeoutMs: remainingTimeoutMs(), - killTimeoutMs, - force: options.force, +export function rejectRemovedLaunchFlags(command: Command): Command { + for (const flag of Object.keys(REMOVED_LAUNCH_FLAGS)) + command.addOption(new Option(flag).hideHelp()); + command.hook("preAction", () => { + for (const [flag, configPath] of Object.entries(REMOVED_LAUNCH_FLAGS)) { + const name = new Option(flag).attributeName(); + if (command.getOptionValueSource(name) !== "cli") continue; + throw { + code: "REMOVED_LAUNCH_OPTION", + message: `${flag.split(" ")[0]} was removed. ${configPath ? `Use paseo daemon config set ${configPath} <value> --home <path>, then start or restart.` : "Use paseo daemon run --home <path> for foreground deployment."} Deployment environment overrides belong to paseo daemon run.`, + }; + } }); - if (!stopped) { - throw createStopTimeoutError(state, pid, timeoutMs); - } - - if (lifecycleRequested) { - removeStalePidFile(state); - } - - return { - action: "stopped", - home: state.home, - pid, - forced, - usedLifecycleRpc: lifecycleRequested, - reason: resolveStopReason(forced, lifecycleRequested), - message: resolveStopMessage(forced, lifecycleRequested, fallbackMessage), - }; + return command; } diff --git a/packages/cli/src/commands/daemon/pair.test.ts b/packages/cli/src/commands/daemon/pair.test.ts index 79117ee16a..42be1a972a 100644 --- a/packages/cli/src/commands/daemon/pair.test.ts +++ b/packages/cli/src/commands/daemon/pair.test.ts @@ -1,139 +1,28 @@ -import { describe, expect, test, vi } from "vitest"; +import { mkdtemp, rm, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "vitest"; +import { resolveLocalPairingOffer } from "./pair.js"; -import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js"; - -const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null }; -const enabledOffer: PairingOffer = { - relayEnabled: true, - url: "https://app.paseo.sh/#offer=test", - qr: null, -}; - -interface RecordedPairCommandOutput extends PairCommandOutput { - stdout: string[]; - stderr: string[]; - successes: string[]; - exitCode: number | undefined; -} - -function createRecordedOutput(): RecordedPairCommandOutput { - return { - columns: 80, - stdout: [], - stderr: [], - successes: [], - exitCode: undefined, - writeStdout(message) { - this.stdout.push(message); - }, - writeStderr(message) { - this.stderr.push(message); - }, - setExitCode(code) { - this.exitCode = code; - }, - success(message) { - this.successes.push(message); - }, - }; -} - -describe("daemon pair workflow", () => { - test("interactive decline prints direct guidance and creates no pairing output", async () => { - const resolveOffer = vi.fn(async () => disabledOffer); - const confirmRelay = vi.fn(async () => false); - const printDirectGuidance = vi.fn(); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output }, - ); - - expect(confirmRelay).toHaveBeenCalledOnce(); - expect(printDirectGuidance).toHaveBeenCalledOnce(); - expect(output.stderr.join("")).toContain("No pairing QR was created"); - expect(output.exitCode).toBe(1); - }); - - test("interactive consent enables relay and prints the refreshed offer", async () => { - const resolveOffer = vi - .fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>() - .mockResolvedValueOnce(disabledOffer) - .mockResolvedValueOnce(enabledOffer); - const output = createRecordedOutput(); - - await runPairCommand( - {}, - { - resolveOffer, - confirmRelay: async () => true, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true })); - expect(output.stdout.join("")).toContain(enabledOffer.url ?? ""); - expect(output.successes).toEqual(["Relay enabled"]); - }); - - test("JSON mode never prompts and returns a structured relay-disabled error", async () => { - const confirmRelay = vi.fn(async () => true); - const output = createRecordedOutput(); - - await runPairCommand( - { json: true }, - { - resolveOffer: async () => disabledOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => true, - output, - }, - ); - - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"'); - expect(output.exitCode).toBe(1); - }); - - test("explicit relay opts in without prompting", async () => { - const resolveOffer = vi.fn(async () => enabledOffer); - const confirmRelay = vi.fn(async () => false); - const output = createRecordedOutput(); - - await runPairCommand( - { relay: true, json: true }, - { - resolveOffer, - confirmRelay, - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output, - }, - ); - - expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true })); - expect(confirmRelay).not.toHaveBeenCalled(); - expect(output.exitCode).toBeUndefined(); - }); - - test("surfaces launch-override rejection", async () => { - await expect( - runPairCommand( - { relay: true }, - { - resolveOffer: async () => { - throw new Error("Relay is controlled by a daemon launch override"); - }, - confirmRelay: vi.fn(), - printDirectGuidance: vi.fn(), - isInteractive: () => false, - output: createRecordedOutput(), - }, - ), - ).rejects.toThrow("launch override"); - }); +test("offline pairing requires relay consent and saves it in the selected home", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-offline-pair-")); + const home = path.join(root, "home"); + try { + expect(await resolveLocalPairingOffer({ paseoHome: home })).toMatchObject({ + relayEnabled: false, + url: null, + }); + expect(existsSync(home)).toBe(false); + const offer = await resolveLocalPairingOffer({ paseoHome: home, enableRelay: true }); + expect(offer.relayEnabled).toBe(true); + expect(offer.url).toContain("offer="); + expect( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).daemon.relay.enabled, + ).toBe(true); + expect(existsSync(path.join(home, "server-id"))).toBe(true); + expect(existsSync(path.join(home, "daemon-keypair.json"))).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index 366366b76c..ab208102aa 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -1,31 +1,27 @@ import { confirm, isCancel, log } from "@clack/prompts"; import { Command } from "commander"; import chalk from "chalk"; +import { generateLocalPairingOffer } from "@getpaseo/server/pairing"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; import { - generateLocalPairingOffer, - getOrCreateServerId, - loadConfig, - resolvePaseoHome, -} from "@getpaseo/server"; -import { tryConnectToDaemon } from "../../utils/client.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { addJsonOption } from "../../utils/command-options.js"; + readPersistedConfig, + editPersistedConfig, + resolveConfigFromPersisted, +} from "@getpaseo/server/configuration"; +import { connectToDaemon } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { addJsonAndDaemonHostOptions, withGlobalOptions } from "../../utils/command-options.js"; import { formatPairingInstructions } from "../../output/pairing.js"; +import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; +import { serializeRelayConnectionUri } from "@getpaseo/protocol/daemon-endpoints"; interface PairOptions { + daemonTarget: DaemonTarget; home?: string; json?: boolean; relay?: boolean; } -export interface PairCommandDependencies { - resolveOffer: typeof resolveLocalPairingOffer; - confirmRelay: typeof confirmRelayPairing; - printDirectGuidance: typeof printDirectConnectionGuidance; - isInteractive: () => boolean; - output: PairCommandOutput; -} - export interface PairCommandOutput { columns: number | undefined; writeStdout(message: string): void; @@ -62,33 +58,32 @@ function createProcessOutput(): PairCommandOutput { } export function pairCommand(): Command { - return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") + return addJsonAndDaemonHostOptions( + new Command("pair").description("Print the daemon pairing QR code and link"), + ) .option("--relay", "Enable relay without prompting") - .action(async (_options: PairOptions, command: Command) => { - await runPairCommand(command.optsWithGlobals()); - }); + .action( + withGlobalOptions((options: PairOptions, _command: Command) => runPairCommand(options)), + ); } export async function resolveLocalPairingOffer(options: { paseoHome: string; enableRelay?: boolean; }): Promise<PairingOffer> { - const state = resolveLocalDaemonState({ home: options.paseoHome }); - const serverId = getOrCreateServerId(state.home); - const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay); - if (daemonOffer) return daemonOffer; - - if (state.running) { - throw new Error( - "The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.", + const instance = await readDaemonInstance(options.paseoHome); + if (instance) + return resolveDaemonPairingOffer( + { kind: "instance", home: options.paseoHome }, + options.enableRelay, ); - } - - const config = loadConfig(options.paseoHome); - if (options.enableRelay && !config.relayEnabled) { - throw new Error("Start the daemon before enabling relay for pairing."); - } + if (options.enableRelay) + editPersistedConfig(options.paseoHome, "daemon.relay.enabled", { value: true }); + const config = resolveConfigFromPersisted( + options.paseoHome, + readPersistedConfig(options.paseoHome, { defaultsIfMissing: true }), + { env: {} }, + ); return generateLocalPairingOffer({ paseoHome: options.paseoHome, @@ -103,23 +98,16 @@ export async function resolveLocalPairingOffer(options: { } async function resolveDaemonPairingOffer( - listen: string, - expectedServerId: string, + target: DaemonTarget, enableRelay: boolean | undefined, -): Promise<PairingOffer | null> { - const client = await tryConnectToDaemon({ - host: listen, +): Promise<PairingOffer> { + const client = await connectToDaemon({ + target, timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, }); - if (!client) return null; try { const serverInfo = client.getLastServerInfoMessage(); - if (serverInfo?.serverId.trim() !== expectedServerId) { - throw new Error( - "The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.", - ); - } if (serverInfo?.features?.daemonStatusRpc !== true) { throw new Error("Update the Paseo daemon before pairing from this command."); } @@ -132,9 +120,14 @@ async function resolveDaemonPairingOffer( throw new Error("Update the Paseo daemon before enabling relay from this command."); } await client.patchDaemonConfig({ relay: { enabled: true } }); - offer = await client.getDaemonPairingOffer({ - timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, - }); + try { + offer = await client.getDaemonPairingOffer({ timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS }); + } catch (error) { + throw new Error( + `Relay configuration was saved, but fetching the pairing offer failed: ${String(error)}`, + { cause: error }, + ); + } } return { relayEnabled: offer.relayEnabled, @@ -164,40 +157,22 @@ export function printDirectConnectionGuidance(): void { console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`); } -export async function runPairCommand( - options: PairOptions, - dependencyOverrides: Partial<PairCommandDependencies> = {}, -): Promise<void> { - if (options.home) process.env.PASEO_HOME = options.home; - const dependencies: PairCommandDependencies = { - resolveOffer: resolveLocalPairingOffer, - confirmRelay: confirmRelayPairing, - printDirectGuidance: printDirectConnectionGuidance, - isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), - output: createProcessOutput(), - ...dependencyOverrides, - }; +export async function runPairCommand(options: PairOptions): Promise<void> { + const output = createProcessOutput(); + const target = options.daemonTarget; + const resolveOffer = (enableRelay: boolean) => + target.kind === "instance" + ? resolveLocalPairingOffer({ paseoHome: target.home, enableRelay }) + : resolveDaemonPairingOffer(target, enableRelay); + const offline = target.kind === "instance" && !(await readDaemonInstance(target.home)); + const pairing = await resolveOffer(options.relay === true); - const paseoHome = resolvePaseoHome(); - let pairing = await dependencies.resolveOffer({ - paseoHome, - enableRelay: options.relay === true, - }); + if (offline) + output.writeStderr( + `Offline pairing offer. Start with: paseo daemon start --home ${JSON.stringify(target.kind === "instance" ? target.home : "")}\n`, + ); - const canPrompt = dependencies.isInteractive() && options.json !== true; - if (!pairing.relayEnabled && canPrompt) { - const shouldEnable = await dependencies.confirmRelay(); - if (!shouldEnable) { - dependencies.printDirectGuidance(); - dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`); - dependencies.output.setExitCode(1); - return; - } - pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true }); - dependencies.output.success("Relay enabled"); - } - - outputPairingResult(pairing, options, dependencies.output); + outputPairingResult(pairing, options, output); } function outputPairingResult( @@ -222,10 +197,13 @@ function outputPairingResult( return; } + const offer = parseConnectionOfferFromUrl(pairing.url); + const connectionUri = offer ? serializeRelayConnectionUri({ offer }) : null; + if (options.json) { output.writeStdout( `${JSON.stringify( - { relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr }, + { relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr, connectionUri }, null, 2, )}\n`, @@ -237,6 +215,7 @@ function outputPairingResult( formatPairingInstructions({ url: pairing.url, qr: pairing.qr, + connectionUri, columns: output.columns, }), ); diff --git a/packages/cli/src/commands/daemon/reload.test.ts b/packages/cli/src/commands/daemon/reload.test.ts index 518d02c799..a97400406e 100644 --- a/packages/cli/src/commands/daemon/reload.test.ts +++ b/packages/cli/src/commands/daemon/reload.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { render } from "../../output/render.js"; import { daemonReloadSchema, type DaemonReloadResult } from "./reload.js"; +const restartCommand = 'paseo daemon restart --home "/selected home"'; + function result(data: DaemonReloadResult) { return { type: "single" as const, data, schema: daemonReloadSchema }; } @@ -11,6 +13,7 @@ describe("daemon reload output", () => { expect( render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: ["app.baseUrl"], @@ -23,7 +26,7 @@ describe("daemon reload output", () => { "Warning: These changes require a daemon restart:", " daemon.listen", "", - "Run: paseo daemon restart", + `Run: ${restartCommand}`, "", "Warning: These settings are controlled by daemon launch overrides:", " app.baseUrl", @@ -34,6 +37,7 @@ describe("daemon reload output", () => { test("human output omits restart guidance for a live-only reload", () => { const output = render( result({ + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -45,6 +49,7 @@ describe("daemon reload output", () => { test("structured output preserves the daemon result", () => { const data = { + restartCommand, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: ["daemon.listen"], overrideControlledPaths: [], diff --git a/packages/cli/src/commands/daemon/reload.ts b/packages/cli/src/commands/daemon/reload.ts index 4f523be26a..807bd26789 100644 --- a/packages/cli/src/commands/daemon/reload.ts +++ b/packages/cli/src/commands/daemon/reload.ts @@ -1,8 +1,12 @@ -import type { Command } from "commander"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput } from "../../output/index.js"; +import { Command } from "commander"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; export interface DaemonReloadResult { + restartCommand: string; appliedPaths: string[]; restartRequiredPaths: string[]; overrideControlledPaths: string[]; @@ -20,7 +24,7 @@ export const daemonReloadSchema: OutputSchema<DaemonReloadResult> = { "Warning: These changes require a daemon restart:", ...result.data.restartRequiredPaths.map((path) => ` ${path}`), "", - "Run: paseo daemon restart", + `Run: ${result.data.restartCommand}`, ); } if (result.data.overrideControlledPaths.length > 0) { @@ -38,12 +42,13 @@ export async function runDaemonReloadCommand( options: CommandOptions, _command: Command, ): Promise<SingleResult<DaemonReloadResult>> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const payload = await client.reloadDaemonConfig(); return { type: "single", data: { + restartCommand: `paseo daemon restart ${options.daemonTarget.kind === "instance" ? `--home ${JSON.stringify(options.daemonTarget.home)}` : `--host ${JSON.stringify(describeDaemonTarget(options.daemonTarget))}`}`, appliedPaths: payload.appliedPaths, restartRequiredPaths: payload.restartRequiredPaths, overrideControlledPaths: payload.overrideControlledPaths, @@ -54,3 +59,9 @@ export async function runDaemonReloadCommand( await client.close(); } } + +export function daemonReloadCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("reload").description("Reload config.json without restarting"), + ).action(withOutput(runDaemonReloadCommand)); +} diff --git a/packages/cli/src/commands/daemon/restart.ts b/packages/cli/src/commands/daemon/restart.ts index 94bf1e0989..ef88e39a23 100644 --- a/packages/cli/src/commands/daemon/restart.ts +++ b/packages/cli/src/commands/daemon/restart.ts @@ -1,131 +1,127 @@ -import type { Command } from "commander"; +import { Command } from "commander"; import { - startLocalDaemonDetached, - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - type DaemonStartOptions, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; + readDaemonInstance, + isSameDaemonInstance, + DaemonInstanceError, +} from "@getpaseo/server/daemon-control"; +import { setTimeout as delay } from "node:timers/promises"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; -interface RestartResult { - action: "restarted"; - home: string; - pid: string; - message: string; +export function daemonRestartCommand(): Command { + return rejectRemovedLaunchFlags( + addJsonAndDaemonHostOptions( + new Command("restart").description( + "Restart the selected daemon worker, retaining its supervisor launch", + ), + ), + ) + .option("--timeout <seconds>", "Replacement readiness deadline (default: 600)") + .action(withOutput(runRestartCommand)); } -const restartResultSchema: OutputSchema<RestartResult> = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: () => "green", - }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type RestartCommandResult = SingleResult<RestartResult>; - -function parseTimeoutMs(raw: unknown): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return DEFAULT_STOP_TIMEOUT_MS; +export async function runRestartCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const deadline = Date.now() + parseTimeoutMs(options.timeout); + const remaining = () => Math.max(1, deadline - Date.now()); + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + async function checkSupervisor() { + if (target.kind !== "instance") return; + const current = await readDaemonInstance(target.home); + if (!current || !instance || !isSameDaemonInstance(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor exited or was replaced for ${target.home}.`, + ); } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid timeout value: ${raw}`, - details: "Timeout must be a positive number of seconds", - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -function toStartOptions(options: CommandOptions): DaemonStartOptions { - const startOptions: DaemonStartOptions = { - home: typeof options.home === "string" ? options.home : undefined, - listen: typeof options.listen === "string" ? options.listen : undefined, - port: typeof options.port === "string" ? options.port : undefined, - relay: typeof options.relay === "boolean" ? options.relay : undefined, - mcp: typeof options.mcp === "boolean" ? options.mcp : undefined, - injectMcp: typeof options.injectMcp === "boolean" ? options.injectMcp : undefined, - webUi: typeof options.webUi === "boolean" ? options.webUi : undefined, - hostnames: typeof options.hostnames === "string" ? options.hostnames : undefined, - }; - - if (startOptions.listen && startOptions.port) { - const error: CommandError = { - code: "INVALID_OPTIONS", - message: "Cannot use --listen and --port together", - }; - throw error; - } - - return startOptions; -} - -export async function runRestartCommand( - options: CommandOptions, - _command: Command, -): Promise<RestartCommandResult> { - const timeoutMs = parseTimeoutMs(options.timeout); - const force = options.force === true; - const startOptions = toStartOptions(options); - + if (target.kind === "instance" && !instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${target.home}.`, + ); + const client = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: remaining(), + }); + let workerPid: number; + const serverId = client.getLastServerInfoMessage()?.serverId; + let acknowledged = false; try { - let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>; + workerPid = (await client.getDaemonStatus({ timeout: remaining() })).pid; + await checkSupervisor(); try { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force, - }); - } catch (err) { - const isTimeout = - err instanceof Error && err.message.includes("Timed out waiting for daemon PID"); - if (!force && isTimeout) { - stopResult = await stopLocalDaemon({ - home: startOptions.home, - timeoutMs, - force: true, - }); - } else { - throw err; - } + await client.restartServer("cli_restart", undefined, { timeout: remaining() }); + acknowledged = true; + } catch (error) { + if (!isReconnectFailure(error)) throw error; } - - const startup = await startLocalDaemonDetached(startOptions); - const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`; - const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`; - - return { - type: "single", - data: { - action: "restarted", - home: stopResult.home, - pid: startup.pid === null ? "-" : String(startup.pid), - message: `Local daemon restarted (${before} -> ${after})`, - }, - schema: restartResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "RESTART_FAILED", - message: `Failed to restart local daemon: ${message}`, - }; - throw error; + } finally { + await client.close(); } + let lastError: unknown = "No replacement worker observed"; + while (Date.now() < deadline) { + try { + const replacement = await connectToDaemon({ + target, + instance: instance ?? undefined, + timeout: Math.min(1_000, remaining()), + }); + try { + if (replacement.getLastServerInfoMessage()?.serverId !== serverId) + throw new Error("Connected peer identity changed"); + // Status probes every provider, so it can take seconds on a healthy worker. + const status = await replacement.getDaemonStatus({ timeout: remaining() }); + if (status.pid !== workerPid) { + await checkSupervisor(); + return { + type: "single" as const, + data: { + action: "restarted", + target: describeDaemonTarget(target), + supervisorPid: instance?.pid ?? null, + previousWorkerPid: workerPid, + workerPid: status.pid, + acknowledged, + }, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + `Restarted worker ${workerPid} → ${status.pid} at ${describeDaemonTarget(target)}. Supervisor launch retained.`, + }, + }; + } + } finally { + await replacement.close(); + } + } catch (error) { + lastError = error; + const code = (error as { code?: string } | null)?.code; + if (code === "DAEMON_REPLACED" || code === "DAEMON_NOT_RUNNING") break; + if (!isReconnectFailure(error)) throw error; + } + await delay(Math.min(100, remaining())); + } + throw { + code: "RESTART_NOT_CONFIRMED", + message: `Replacement was not confirmed for ${describeDaemonTarget(target)}. Restart acknowledged: ${acknowledged}. Last observation: ${String(lastError)}`, + }; +} + +function isReconnectFailure(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + [ + "DAEMON_CONNECTION_LOST", + "DAEMON_REQUEST_TIMEOUT", + "DAEMON_UNREACHABLE", + "DAEMON_NOT_READY", + ].includes(String(error.code)), + ); } diff --git a/packages/cli/src/commands/daemon/runtime-toolchain.ts b/packages/cli/src/commands/daemon/runtime-toolchain.ts index be17439651..23fbd3bff7 100644 --- a/packages/cli/src/commands/daemon/runtime-toolchain.ts +++ b/packages/cli/src/commands/daemon/runtime-toolchain.ts @@ -1,5 +1,5 @@ import { platform } from "node:os"; -import { execCommand } from "@getpaseo/server"; +import { execCommand } from "@getpaseo/server/process"; export interface NodePathFromPidResult { nodePath: string | null; diff --git a/packages/cli/src/commands/daemon/set-password.ts b/packages/cli/src/commands/daemon/set-password.ts index 3fd407f587..b5a79ae65d 100644 --- a/packages/cli/src/commands/daemon/set-password.ts +++ b/packages/cli/src/commands/daemon/set-password.ts @@ -1,12 +1,13 @@ import path from "node:path"; import type { Command } from "commander"; import { isCancel, password as passwordPrompt } from "@clack/prompts"; +import { hashDaemonPassword } from "@getpaseo/server/auth"; import { - hashDaemonPassword, - loadPersistedConfig, + readPersistedConfig, savePersistedConfig, type PersistedConfig, -} from "@getpaseo/server"; +} from "@getpaseo/server/configuration"; +import { resolvePaseoHome } from "@getpaseo/server/daemon-control"; import type { CommandError, CommandOptions, @@ -14,7 +15,6 @@ import type { OutputSchema, SingleResult, } from "../../output/index.js"; -import { resolveLocalPaseoHome } from "./local-daemon.js"; const CONFIG_FILENAME = "config.json"; @@ -57,6 +57,17 @@ function createCommandError(code: string, message: string, details?: string): Co return { code, message, ...(details ? { details } : {}) }; } +function terminalPasswordPrompt(): PromptPassword { + if (!process.stdin.isTTY) { + throw createCommandError( + "PASSWORD_TTY_REQUIRED", + "paseo daemon set-password needs a terminal to read the password", + "Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.", + ); + } + return (message) => passwordPrompt({ message }); +} + async function promptForPassword(promptPassword: PromptPassword): Promise<string> { const first = await promptPassword("New daemon password"); if (isCancel(first)) { @@ -81,9 +92,9 @@ export async function setDaemonPasswordInConfig( newPassword: string, options: SetPasswordOptions = {}, ): Promise<SetPasswordResult> { - const paseoHome = resolveLocalPaseoHome(options.home); + const paseoHome = resolvePaseoHome({ PASEO_HOME: options.home }); const configPath = path.join(paseoHome, CONFIG_FILENAME); - const persisted = loadPersistedConfig(paseoHome); + const persisted = readPersistedConfig(paseoHome); const nextConfig: PersistedConfig = { ...persisted, daemon: { @@ -100,8 +111,8 @@ export async function setDaemonPasswordInConfig( return { action: "password_set", configPath, - restartCommand: "paseo daemon restart", - message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart`, + restartCommand: `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + message: `Password written to ${configPath}\nRestart the daemon for the change to take effect.\nRun: paseo daemon restart --home ${JSON.stringify(paseoHome)}`, }; } @@ -112,10 +123,10 @@ export async function runSetPasswordCommand( const promptPassword = typeof options.promptPassword === "function" ? (options.promptPassword as PromptPassword) - : (message: string) => passwordPrompt({ message }); + : terminalPasswordPrompt(); const newPassword = await promptForPassword(promptPassword); const result = await setDaemonPasswordInConfig(newPassword, { - home: typeof options.home === "string" ? options.home : undefined, + home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined, }); return { diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index 3a4c3df44d..47871e4ad3 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -1,71 +1,55 @@ -import { Command, Option } from "commander"; -import chalk from "chalk"; -import { - startLocalDaemonForeground, - startLocalDaemonDetached, - type DaemonStartOptions as StartOptions, -} from "./local-daemon.js"; -import { getErrorMessage } from "../../utils/errors.js"; - -export type { DaemonStartOptions as StartOptions } from "./local-daemon.js"; - -type RawStartCommandOptions = StartOptions & { - allowedHosts?: string; -}; +import { addLocalDaemonOptions } from "../../utils/command-options.js"; +import { Command } from "commander"; +import { daemonLogPath } from "@getpaseo/server/daemon-control"; +import { launchLocalDaemon, parseTimeoutMs, rejectRemovedLaunchFlags } from "./local-daemon.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; export function startCommand(): Command { - return new Command("start") - .description("Start the local Paseo daemon") - .option("--listen <listen>", "Listen target (host:port, port, or unix socket path)") - .option("--port <port>", "Port to listen on (default: 6767)") - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") - .option("--foreground", "Run in foreground (don't daemonize)") - .option("--relay", "Enable relay connection") - .option("--no-relay", "Disable relay connection") - .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") - .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") - .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") - .option("--web-ui", "Enable the bundled daemon web UI") - .option("--no-web-ui", "Disable the bundled daemon web UI") - .option( - "--hostnames <hosts>", - 'Daemon hostnames (comma-separated, e.g. "myhost,.example.com" or "true" for any)', - ) - .addOption(new Option("--allowed-hosts <hosts>").hideHelp()) - .action(async (options: RawStartCommandOptions) => { - await runStart({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("start"))) + .description("Start the local daemon from persistent configuration (local operation)") + .option("--timeout <seconds>", "Readiness deadline (default: 600)") + .action(withOutput(runStart)); } -export async function runStart(options: StartOptions): Promise<void> { - if (options.listen && options.port) { - console.error(chalk.red("Cannot use --listen and --port together")); - process.exit(1); - } - - if (!options.foreground) { - try { - const startup = await startLocalDaemonDetached(options); - console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`)); - console.log(chalk.dim(`Logs: ${startup.logPath}`)); - } catch (err) { - exitWithError(getErrorMessage(err)); - } - return; - } - try { - const status = startLocalDaemonForeground(options); - process.exit(status); - } catch (err) { - const message = getErrorMessage(err); - exitWithError(`Failed to start daemon: ${message}`); - } +export async function runStart(options: CommandOptions, _command: Command) { + if (options.daemonTarget.kind !== "instance") throw new Error("Start requires a local home"); + const home = options.daemonTarget.home; + const result = await launchLocalDaemon({ home, timeoutMs: parseTimeoutMs(options.timeout) }); + const data = { + action: result.spawned ? "started" : "already_running", + home, + pid: result.instance.pid, + listen: result.instance.listen, + logPath: daemonLogPath(home), + }; + return { + type: "single" as const, + data, + schema: { + idField: "pid" as const, + columns: [], + renderHuman: () => + `${result.spawned ? "Started" : "Already running"}: PID ${data.pid}${data.listen ? `, listening on ${data.listen}` : ", not ready"}\nLogs: ${data.logPath}`, + }, + }; } -function exitWithError(message: string): never { - console.error(chalk.red(message)); - process.exit(1); +export function daemonRunCommand(): Command { + return rejectRemovedLaunchFlags(addLocalDaemonOptions(new Command("run"))) + .description("Run a local daemon in the foreground with deployment environment overrides") + .action( + withOutput(async (options: CommandOptions, _command: Command) => { + if (options.daemonTarget.kind !== "instance") throw new Error("Run requires a local home"); + const result = await launchLocalDaemon({ + home: options.daemonTarget.home, + foreground: true, + }); + process.exitCode = result.exitCode ?? 0; + return { + type: "single" as const, + data: { pid: result.instance.pid, action: result.spawned ? "exited" : "already_running" }, + schema: { idField: "pid" as const, columns: [] }, + }; + }), + ); } diff --git a/packages/cli/src/commands/daemon/status.test.ts b/packages/cli/src/commands/daemon/status.test.ts deleted file mode 100644 index dd900f902f..0000000000 --- a/packages/cli/src/commands/daemon/status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { selectRelayStatus } from "./status.js"; - -describe("selectRelayStatus", () => { - const persisted = { - enabled: false, - endpoint: "persisted.internal:443", - publicEndpoint: "persisted.example.com:443", - useTls: true, - publicUseTls: true, - }; - - test("uses the running daemon relay state over persisted config", () => { - expect( - selectRelayStatus({ - persisted, - live: { - enabled: true, - endpoint: "live.internal:443", - publicEndpoint: "live.example.com:443", - useTls: true, - publicUseTls: true, - }, - }), - ).toBe("wss://live.example.com:443"); - }); - - test("falls back to persisted config when the daemon cannot report live state", () => { - expect(selectRelayStatus({ persisted })).toBe("disabled"); - }); -}); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 87e34f0bcf..14c20534e6 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,464 +1,134 @@ -import type { Command } from "commander"; -import { createRequire } from "node:module"; -import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; -import { connectToDaemon } from "../../utils/client.js"; -import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { resolveLocalDaemonState } from "./local-daemon.js"; -import { resolveNodePathFromPid } from "./runtime-toolchain.js"; +import { Command } from "commander"; +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; +import { + readDaemonInstance, + daemonLogPath, + isSameDaemonInstance, + DaemonInstanceError, +} from "@getpaseo/server/daemon-control"; +import { readPersistedConfig, resolveConfigFromPersisted } from "@getpaseo/server/configuration"; +import { connectToDaemon, buildDaemonConnectionCommandError } from "../../utils/client.js"; +import { withOutput, toCommandError, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget, type DaemonTarget } from "../../utils/daemon-target.js"; -const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500; - -interface ProviderBinaryStatus { - label: string; - path: string | null; - version: string | null; - source?: "daemon" | "local"; +export function daemonStatusCommand(): Command { + return addJsonAndDaemonHostOptions( + new Command("status").description("Observe the selected daemon and its published endpoint"), + ).action(withOutput(runStatusCommand)); } -interface DaemonStatus { - serverId: string | null; - localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive"; - connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed"; - home: string; - listen: string; - relay: string; - hostname: string | null; - pid: number | null; - startedAt: string | null; - owner: string | null; - logPath: string; - daemonNode: string; - cliNode: string; - cliVersion: string; - daemonVersion: string | null; - desktopManaged: boolean; - providers: ProviderBinaryStatus[]; - note?: string; -} - -interface StatusRow { - key: string; - value: string; -} - -interface CliPackageJson { - version?: unknown; -} - -const require = createRequire(import.meta.url); - -function normalizeError(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function shortenMessage(message: string, max = 120): string { - const normalized = message.replace(/\s+/g, " ").trim(); - if (normalized.length <= max) { - return normalized; - } - return `${normalized.slice(0, max - 3)}...`; -} - -function appendNote(current: string | undefined, next: string | undefined): string | undefined { - if (!next) return current; - if (!current) return next; - return `${current}; ${next}`; -} - -function resolveCliVersion(): string { - try { - const packageJson = require("../../../package.json") as CliPackageJson; - if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) { - return packageJson.version.trim(); - } - } catch { - // Fall through. - } - return "unknown"; -} - -function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> { +export async function runStatusCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const instance = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + const local = + target.kind === "instance" + ? localStatus(target.home, instance) + : { host: describeDaemonTarget(target) }; + const observed = + target.kind === "endpoint" || instance?.listen + ? await probeDaemonStatus(target, instance, local) + : { connectedDaemon: "not_probed" }; + const data: Record<string, unknown> = { ...local, ...observed }; return { - idField: "key", - columns: [ - { header: "KEY", field: "key" }, - { - header: "VALUE", - field: "value", - color: (_, item) => { - if (item.key === "Local Daemon") { - if (item.value === "running") return "green"; - if (item.value === "unresponsive") return "yellow"; - return "red"; - } - if (item.key === "Connected Daemon") { - if (item.value === "reachable") return "green"; - if (item.value === "not_probed" || item.value === "auth_required") return "yellow"; - return "red"; - } - if (item.key.startsWith(" ")) { - if (item.value === "not found" || item.value === "not found (daemon)") return "red"; - if (item.value.endsWith("(--version failed)")) return "yellow"; - return "green"; - } - return undefined; - }, - }, - ], - serialize: () => status, + type: "single" as const, + data, + schema: { + idField: () => "daemon", + columns: [], + renderHuman: () => + Object.entries(data) + .filter(([, value]) => value !== undefined) + .map( + ([key, value]) => + `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`, + ) + .join("\n"), + }, }; } -function toStatusRows(status: DaemonStatus): StatusRow[] { - const rows: StatusRow[] = [ - { key: "Server ID", value: status.serverId ?? "-" }, - { key: "Local Daemon", value: status.localDaemon }, - { key: "Connected Daemon", value: status.connectedDaemon }, - { key: "Home", value: status.home }, - { key: "Listen", value: status.listen }, - { key: "Relay", value: status.relay }, - { key: "Hostname", value: status.hostname ?? "-" }, - { key: "PID", value: status.pid === null ? "-" : String(status.pid) }, - { key: "Started", value: status.startedAt ?? "-" }, - { key: "Owner", value: status.owner ?? "-" }, - { key: "Logs", value: status.logPath }, - { key: "Daemon Node", value: status.daemonNode }, - { key: "CLI Node", value: status.cliNode }, - { key: "CLI", value: status.cliVersion }, - { key: "Daemon Version", value: status.daemonVersion ?? "-" }, - ]; - - if (status.note) { - rows.push({ key: "Note", value: status.note }); - } - - rows.push({ key: "", value: "" }); - rows.push({ key: "Providers", value: "" }); - for (const provider of status.providers) { - if (provider.source === "daemon") { - if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found (daemon)" }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (daemon)` }); - } - } else if (!provider.path) { - rows.push({ key: ` ${provider.label}`, value: "not found" }); - } else if (!provider.version) { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` }); - } else { - rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` }); - } - } - - return rows; -} - -const PROVIDER_BINARIES: { label: string; binary: string }[] = [ - { label: "Claude", binary: "claude" }, - { label: "Codex", binary: "codex" }, - { label: "OpenCode", binary: "opencode" }, -]; - -async function checkProviderBinary( - binary: string, -): Promise<{ path: string | null; version: string | null }> { - const binaryPath = await findExecutable(binary); - if (!binaryPath) { - return { path: null, version: null }; - } - try { - const { stdout } = await execCommand(binaryPath, ["--version"], { - timeout: 5000, - }); - return { path: binaryPath, version: stdout.trim() || null }; - } catch { - return { path: binaryPath, version: null }; - } -} - -async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> { - const results = await Promise.all( - PROVIDER_BINARIES.map(async ({ label, binary }) => { - const result = await checkProviderBinary(binary); - return Object.assign({ label }, result); - }), - ); - return results; -} - -function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { - if (uid === undefined && !hostname) { - return null; - } - const uidPart = uid === undefined ? "?" : String(uid); - const hostPart = hostname ?? "unknown-host"; - return `${uidPart}@${hostPart}`; -} - -interface DaemonProbeResult { - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemonOverride?: DaemonStatus["localDaemon"]; - daemonVersion?: string | null; - daemonNodeOverride?: string; - daemonProviders?: ProviderBinaryStatus[]; - relayStatus?: string; - note?: string; -} - -type DaemonAuthProbeFailure = "auth_required" | "auth_failed"; - -function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null { - if (!(error instanceof Error)) return null; - if (error.message === "Password required") return "auth_required"; - if (error.message === "Incorrect password") return "auth_failed"; - return null; -} - -function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string { - if (failure === "auth_required") { - return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`; - } - return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`; -} - -async function probeDaemonOverWebsocket(args: { - host: string; - state: ReturnType<typeof resolveLocalDaemonState>; -}): Promise<DaemonProbeResult> { - const { host, state } = args; - let client: Awaited<ReturnType<typeof connectToDaemon>>; - try { - client = await connectToDaemon({ host, timeout: 1500 }); - } catch (error) { - const authFailure = classifyDaemonAuthProbeFailure(error); - if (authFailure) { - return { - connectedDaemon: authFailure, - note: describeDaemonAuthProbeFailure(host, authFailure), - }; - } - - if (state.running) { - return { - connectedDaemon: "unreachable", - localDaemonOverride: "unresponsive", - note: `Local daemon PID is running but websocket at ${host} is not reachable`, - }; - } - return { connectedDaemon: "unreachable" }; - } - - const daemonVersion = client.getLastServerInfoMessage()?.version ?? null; - try { - const statusPayload = await client.getDaemonStatus({ - timeout: DAEMON_STATUS_PROBE_TIMEOUT_MS, - }); - const labelMap = new Map(PROVIDER_BINARIES.map((p) => [p.binary, p.label])); - const daemonProviders = statusPayload.providers.map((p) => ({ - label: labelMap.get(p.provider) ?? p.provider, - path: p.available ? "available" : null, - version: p.available ? null : (p.error ?? null), - source: "daemon" as const, - })); - const relayStatus = - statusPayload.relay == null - ? undefined - : selectRelayStatus({ - persisted: relayConfigFromLocalState(state), - live: statusPayload.relay, - }); - - if (!state.running) { - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - note: state.pidInfo - ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` - : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, - }; - } - - return { - connectedDaemon: "reachable", - daemonVersion: statusPayload.version ?? daemonVersion, - daemonNodeOverride: statusPayload.nodePath, - daemonProviders, - relayStatus, - }; - } catch { - return { - connectedDaemon: "reachable", - daemonVersion, - note: state.running - ? `Local daemon PID is running but daemon detail request to ${host} failed` - : `Connected daemon websocket is reachable at ${host} but daemon status request failed`, - }; - } finally { - await client.close().catch(() => {}); - } -} - -interface ProbeMergeState { - probe: DaemonProbeResult; - connectedDaemon: DaemonStatus["connectedDaemon"]; - localDaemon: DaemonStatus["localDaemon"]; - daemonNode: string; - daemonVersion: string | null; - daemonProviders: ProviderBinaryStatus[] | undefined; - relayStatus: string; - note: string | undefined; -} - -function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "probe"> { - const { probe } = input; - return { - connectedDaemon: probe.connectedDaemon, - localDaemon: probe.localDaemonOverride ?? input.localDaemon, - daemonNode: probe.daemonNodeOverride ?? input.daemonNode, - daemonVersion: probe.daemonVersion !== undefined ? probe.daemonVersion : input.daemonVersion, - daemonProviders: probe.daemonProviders ?? input.daemonProviders, - relayStatus: probe.relayStatus ?? input.relayStatus, - note: probe.note ? appendNote(input.note, probe.note) : input.note, - }; -} - -function resolveServerIdSafely(home: string): { serverId: string | null; error: string | null } { - try { - return { serverId: getOrCreateServerId(home), error: null }; - } catch (error) { - return { - serverId: null, - error: `serverId unavailable: ${shortenMessage(normalizeError(error))}`, - }; - } -} - -async function resolveDaemonNodeLabel( - state: ReturnType<typeof resolveLocalDaemonState>, -): Promise<string> { - if (!state.running) return "-"; - if (!state.pidInfo?.pid) return "unknown (no PID available)"; - const fromPid = await resolveNodePathFromPid(state.pidInfo.pid); - return fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`; -} - -interface RelayStatusConfig { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; -} - -function relayConfigFromLocalState( - state: ReturnType<typeof resolveLocalDaemonState>, -): RelayStatusConfig { - return { - enabled: state.relayEnabled, - endpoint: state.relayEndpoint, - publicEndpoint: state.relayEndpoint, - useTls: state.relayUseTls, - publicUseTls: state.relayPublicUseTls, - }; -} - -export function selectRelayStatus(input: { - persisted: RelayStatusConfig; - live?: RelayStatusConfig; -}): string { - const relay = input.live ?? input.persisted; - if (!relay.enabled) return "disabled"; - const scheme = relay.publicUseTls ? "wss" : "ws"; - return `${scheme}://${relay.publicEndpoint}`; -} - -export type StatusResult = ListResult<StatusRow>; - -export async function runStatusCommand( - options: CommandOptions, - _command: Command, -): Promise<StatusResult> { - const home = typeof options.home === "string" ? options.home : undefined; - const state = resolveLocalDaemonState({ home }); - const daemonTarget = state.listen.trim(); - - const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname); - let daemonNode = await resolveDaemonNodeLabel(state); - const cliNode = process.execPath; - let localDaemon: DaemonStatus["localDaemon"] = state.running ? "running" : "stopped"; - let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; - let daemonVersion: string | null = null; - let daemonProviders: ProviderBinaryStatus[] | undefined; - let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) }); +async function probeDaemonStatus( + target: DaemonTarget, + instance: Awaited<ReturnType<typeof readDaemonInstance>>, + local: Record<string, unknown>, +) { + let connectedDaemon = "unreachable"; let note: string | undefined; - - if (!state.running && state.stalePidFile && state.pidInfo) { - localDaemon = "stale_pid"; - note = `Stale PID file found for PID ${state.pidInfo.pid}`; + let live: Record<string, unknown> = {}; + let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined; + try { + client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 }); + } catch (error) { + const failure = buildDaemonConnectionCommandError({ target, error }); + if (target.kind === "endpoint") throw failure; + connectedDaemon = "unreachable"; + if (failure.code === "AUTH_REQUIRED") connectedDaemon = "auth_required"; + if (failure.code === "AUTH_FAILED") connectedDaemon = "auth_failed"; + note = failure.message; } - - if (daemonTarget) { - const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state }); - ({ - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - } = applyProbeToStatus({ - probe, - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - relayStatus, - note, - })); + if (!client) return { connectedDaemon, note }; + try { + let requestError: unknown; + const status = await client.getDaemonStatus({ timeout: 1_500 }).catch((error: unknown) => { + requestError = error; + return null; + }); + // Recheck even when the RPC times out before combining local and live observations. + const current = target.kind === "instance" ? await readDaemonInstance(target.home) : null; + if (instance && (!current || !isSameDaemonInstance(instance, current))) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + "Supervisor exited or was replaced during status observation.", + ); + const info = client.getLastServerInfoMessage(); + live = { serverId: info?.serverId, daemonVersion: info?.version }; + connectedDaemon = client.isConnected ? "reachable" : "unreachable"; + if (!status) { + const failure = toCommandError(requestError); + note = `Status details unavailable (${failure.code}): ${failure.message}`; + if ( + target.kind === "endpoint" || + !client.isConnected || + !(requestError instanceof DaemonConnectionError) || + requestError.code !== "DAEMON_REQUEST_TIMEOUT" + ) { + throw { ...failure, message: note, details: { ...local, ...live, connectedDaemon } }; + } + } else { + live = { + ...live, + daemonVersion: status.version, + workerPid: status.pid, + daemonNode: status.nodePath, + providers: status.providers, + relay: status.relay, + }; + } + } finally { + await client.close(); } + return { ...live, connectedDaemon, note }; +} - const cliVersion = resolveCliVersion(); - - const serverIdResult = resolveServerIdSafely(state.home); - const serverId = serverIdResult.serverId; - if (serverIdResult.error) { - note = appendNote(note, serverIdResult.error); - } - - const providers = daemonProviders ?? (await checkProviderBinaries()); - - const daemonStatus: DaemonStatus = { - serverId, - localDaemon, - connectedDaemon, - home: state.home, - listen: state.listen, - relay: relayStatus, - hostname: state.pidInfo?.hostname ?? null, - pid: state.pidInfo?.pid ?? null, - startedAt: state.pidInfo?.startedAt ?? null, - owner, - logPath: state.logPath, - daemonNode, - cliNode, - cliVersion, - daemonVersion, - desktopManaged: state.pidInfo?.desktopManaged === true, - providers, - note, - }; - +function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemonInstance>>) { + const config = resolveConfigFromPersisted( + home, + readPersistedConfig(home, { defaultsIfMissing: true }), + { env: {} }, + ); + let localDaemon = "stopped"; + if (instance) localDaemon = instance.listen ? "running" : "not_ready"; return { - type: "list", - data: toStatusRows(daemonStatus), - schema: createStatusSchema(daemonStatus), + home, + pid: instance?.pid ?? null, + startedAt: instance?.startedAt ?? null, + listen: instance?.listen ?? null, + serverId: instance?.serverId ?? undefined, + hostname: instance?.hostname ?? null, + configuredListen: config.listen, + localDaemon, + desktopManaged: instance?.desktopManaged === true, + logPath: daemonLogPath(home), }; } diff --git a/packages/cli/src/commands/daemon/stop.ts b/packages/cli/src/commands/daemon/stop.ts index 76351bb07b..f2bf8b1255 100644 --- a/packages/cli/src/commands/daemon/stop.ts +++ b/packages/cli/src/commands/daemon/stop.ts @@ -1,94 +1,67 @@ -import type { Command } from "commander"; -import { - stopLocalDaemon, - DEFAULT_STOP_TIMEOUT_MS, - DEFAULT_KILL_TIMEOUT_MS, -} from "./local-daemon.js"; -import type { - CommandOptions, - SingleResult, - OutputSchema, - CommandError, -} from "../../output/index.js"; +import { Command } from "commander"; +import { stopDaemonInstance, type DaemonInstance } from "@getpaseo/server/daemon-control"; +import { connectToDaemon } from "../../utils/client.js"; +import { withOutput, type CommandOptions } from "../../output/index.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { describeDaemonTarget } from "../../utils/daemon-target.js"; +import { parseTimeoutMs } from "./local-daemon.js"; -interface StopResult { - action: "stopped" | "not_running"; - home: string; - pid: string; - forced: boolean; - usedLifecycleRpc: boolean; - reason: "not_running" | "lifecycle_shutdown_rpc" | "owner_pid_signal" | "owner_pid_sigkill"; - message: string; +export function daemonStopCommand(): Command { + return addJsonAndDaemonHostOptions(new Command("stop").description("Stop the selected daemon")) + .option("--timeout <seconds>", "Graceful exit deadline (default: 15)") + .option("--force", "Permit forced local process cleanup") + .option("--kill-timeout <seconds>", "Forced exit deadline (default: 3)") + .action(withOutput(runStopCommand)); } -const stopResultSchema: OutputSchema<StopResult> = { - idField: "action", - columns: [ - { - header: "STATUS", - field: "action", - color: (value) => (value === "stopped" ? "green" : "yellow"), +export async function runStopCommand(options: CommandOptions, _command: Command) { + const target = options.daemonTarget; + const timeoutMs = parseTimeoutMs(options.timeout, 15_000); + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(1, deadline - Date.now()); + const requestShutdown = async (instance?: DaemonInstance) => { + const client = await connectToDaemon({ target, timeout: remaining(), instance }); + try { + await client.shutdownServer({ timeout: remaining() }); + } finally { + await client.close(); + } + }; + if (target.kind === "endpoint" && options.force) + throw { + code: "INVALID_OPTIONS", + message: "--force requires a local --home; an endpoint gives no remote process authority.", + }; + const result: { + action: string; + home?: string; + host?: string; + pid?: number | null; + forced?: boolean; + usedLifecycleRpc?: boolean; + } = + target.kind === "instance" + ? { + ...(await stopDaemonInstance(target.home, { + force: options.force === true, + timeoutMs, + killTimeoutMs: parseTimeoutMs(options.killTimeout, 3_000), + requestShutdown, + })), + home: target.home, + } + : (await requestShutdown(), + { action: "shutdown_requested", host: describeDaemonTarget(target) }); + return { + type: "single" as const, + data: result, + schema: { + idField: "action" as const, + columns: [], + renderHuman: () => + target.kind === "endpoint" + ? "Shutdown requested; remote process exit was not verified." + : `${result.action.replaceAll("_", " ")}: ${target.home}`, }, - { header: "HOME", field: "home" }, - { header: "PID", field: "pid" }, - { header: "MESSAGE", field: "message" }, - ], -}; - -export type StopCommandResult = SingleResult<StopResult>; - -function parseSecondsOption(raw: unknown, fallbackMs: number, label: string): number { - if (typeof raw !== "string" || raw.trim().length === 0) { - return fallbackMs; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - const error: CommandError = { - code: "INVALID_TIMEOUT", - message: `Invalid ${label} value: ${raw}`, - details: `${label} must be a positive number of seconds`, - }; - throw error; - } - - return Math.ceil(seconds * 1000); -} - -export async function runStopCommand( - options: CommandOptions, - _command: Command, -): Promise<StopCommandResult> { - const home = typeof options.home === "string" ? options.home : undefined; - const force = options.force === true; - const timeoutMs = parseSecondsOption(options.timeout, DEFAULT_STOP_TIMEOUT_MS, "timeout"); - const killTimeoutMs = parseSecondsOption( - options.killTimeout, - DEFAULT_KILL_TIMEOUT_MS, - "kill-timeout", - ); - - try { - const result = await stopLocalDaemon({ home, force, timeoutMs, killTimeoutMs }); - return { - type: "single", - data: { - action: result.action, - home: result.home, - pid: result.pid === null ? "-" : String(result.pid), - forced: result.forced, - usedLifecycleRpc: result.usedLifecycleRpc, - reason: result.reason, - message: result.message, - }, - schema: stopResultSchema, - }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "STOP_FAILED", - message: `Failed to stop local daemon: ${message}`, - }; - throw error; - } + }; } diff --git a/packages/cli/src/commands/heartbeat/index.ts b/packages/cli/src/commands/heartbeat/index.ts index 7ded358e8f..812af3a847 100644 --- a/packages/cli/src/commands/heartbeat/index.ts +++ b/packages/cli/src/commands/heartbeat/index.ts @@ -64,7 +64,7 @@ async function runCreateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const maxRuns = options.maxRuns ? Number.parseInt(options.maxRuns, 10) : undefined; if (maxRuns !== undefined && (!Number.isSafeInteger(maxRuns) || maxRuns <= 0)) { @@ -105,7 +105,7 @@ async function runUpdateHeartbeat( if (!cron) { throw new Error("--cron is required"); } - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleUpdate({ @@ -133,7 +133,7 @@ async function runDeleteHeartbeat( _command: Command, ): Promise<SingleResult<HeartbeatDeleteRow>> { const agentId = requireCallerAgentId(); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireOwnedHeartbeat(client, id, agentId); const payload = await client.scheduleDelete({ id }); diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts index 3f8c31d855..dd99816b06 100644 --- a/packages/cli/src/commands/hooks.test.ts +++ b/packages/cli/src/commands/hooks.test.ts @@ -17,6 +17,15 @@ function inputFrom(value: string) { }; } +function claudeNotification(notificationType: string) { + return JSON.stringify({ + session_id: "session-1", + hook_event_name: "Notification", + message: "Claude is waiting for your input", + notification_type: notificationType, + }); +} + function ttyInput() { return { isTTY: true, @@ -123,7 +132,7 @@ describe("runHooksCommand", () => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom('{"reason":"idle_prompt"}'), + inputFrom(claudeNotification("idle_prompt")), ); expectPostedState(send, "needs-input"); @@ -131,11 +140,11 @@ describe("runHooksCommand", () => { it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])( "ignores Claude %s notifications", - async (reason) => { + async (notificationType) => { const send = await runHook( claudeProvider.id, claudeProvider.events[4].event, - inputFrom(JSON.stringify({ reason })), + inputFrom(claudeNotification(notificationType)), ); expect(send.calls).toEqual([]); diff --git a/packages/cli/src/commands/hub/client.test.ts b/packages/cli/src/commands/hub/client.test.ts index c211b7d9af..edf86d3dc8 100644 --- a/packages/cli/src/commands/hub/client.test.ts +++ b/packages/cli/src/commands/hub/client.test.ts @@ -117,6 +117,40 @@ describe("Hub HTTP client", () => { assert.deepEqual(requests[0], { url: "/api/v1/triggers", body: "" }); }); + it("validates and installs self-contained organization triggers", async () => { + const requests: Array<{ url: string | undefined; body: string }> = []; + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const origin = await startServer( + (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 1, active: true }, + }, + requests, + ); + const hub = new HubHttpClient(); + const yaml = "name: slack-help\n"; + + assert.deepEqual(await hub.validateTrigger(origin, "secret", yaml), { + name: "slack-help", + valid: true, + }); + assert.deepEqual(await hub.installTrigger(origin, "secret", yaml), { + triggerId, + name: "slack-help", + revisionId, + version: 1, + active: true, + }); + assert.deepEqual(requests, [ + { url: "/api/v1/triggers/validate", body: JSON.stringify({ yaml }) }, + { url: "/api/v1/triggers/install", body: JSON.stringify({ yaml }) }, + ]); + }); + it("reads configuration resources in the Hub's slug vocabulary", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( @@ -134,6 +168,7 @@ describe("Hub HTTP client", () => { ], discord: [{ slug: "paseo", guildName: "Paseo" }], slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [{ slug: "paseo-linear", organizationName: "Paseo" }], }, }), requests, @@ -143,55 +178,10 @@ describe("Hub HTTP client", () => { assert.equal(resources.daemons[0]?.slug, "macbook"); assert.equal(resources.discord[0]?.slug, "paseo"); + assert.equal(resources.linear[0]?.slug, "paseo-linear"); assert.equal(requests[0]?.url, "/api/v1/configuration-resources"); }); - it("reads strict provider-native setup resources", async () => { - const requests: Array<{ url: string | undefined; body: string }> = []; - const origin = await startServer( - () => ({ - status: 200, - body: { - github: [ - { - slug: "getpaseo", - accountLogin: "getpaseo", - accountType: "Organization", - repositories: ["getpaseo/paseo"], - }, - ], - discord: [{ guildId: "guild-123", guildName: "Paseo" }], - slack: [{ teamId: "team-123", teamName: "Paseo" }], - }, - }), - requests, - ); - - const resources = await new HubHttpClient().listSetupResources(origin, "secret"); - - assert.equal(resources.slack[0]?.teamId, "team-123"); - assert.equal(resources.discord[0]?.guildId, "guild-123"); - assert.deepEqual(requests[0], { url: "/api/v1/setup-resources", body: "" }); - }); - - it.each([ - { github: [], discord: [], slack: [{ teamId: "team-123", teamName: "Paseo", slug: "wrong" }] }, - { github: [], discord: [], slack: [{ teamId: 123, teamName: "Paseo" }] }, - ])("rejects malformed or unknown setup resource fields", async (body) => { - const requests: Array<{ url: string | undefined; body: string }> = []; - const origin = await startServer( - () => ({ - status: 200, - body, - }), - requests, - ); - - await assert.rejects(new HubHttpClient().listSetupResources(origin, "secret"), { - code: "HUB_INVALID_RESPONSE", - }); - }); - it("renders file-aware Hub validation issues without exposing credentials or response bodies", async () => { const requests: Array<{ url: string | undefined; body: string }> = []; const origin = await startServer( diff --git a/packages/cli/src/commands/hub/connect.ts b/packages/cli/src/commands/hub/connect.ts index 8f3fcf9bfc..90a4edca36 100644 --- a/packages/cli/src/commands/hub/connect.ts +++ b/packages/cli/src/commands/hub/connect.ts @@ -13,6 +13,7 @@ import { addHubResolutionHelp } from "./help.js"; interface HubConnectOptions { apiKey?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; permission?: readonly string[]; permissions?: readonly string[]; @@ -41,7 +42,7 @@ export async function runHubConnect( const credential = resolveHubCredential({ ...resolution, origin }); const token = await dependencies.hub.issueEnrollmentToken(origin, credential); const permissions = options.permissions ?? options.permission ?? []; - return withHubDaemon(dependencies.daemon, options.host, async (daemon) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => { const response = await daemon.connectHub(origin, token, permissions); if ( response.status.hubOrigin !== null && diff --git a/packages/cli/src/commands/hub/daemon-client.ts b/packages/cli/src/commands/hub/daemon-client.ts index 3f53360ccd..21a2e7bde1 100644 --- a/packages/cli/src/commands/hub/daemon-client.ts +++ b/packages/cli/src/commands/hub/daemon-client.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { connectToDaemon } from "../../utils/client.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; @@ -33,19 +34,19 @@ export interface HubDaemonClient { } export interface HubDaemonConnection { - connect(host: string | undefined): Promise<HubDaemonClient>; + connect(target: DaemonTarget): Promise<HubDaemonClient>; } export const productionHubDaemonConnection: HubDaemonConnection = { - connect: (host) => connectToDaemon({ host }), + connect: (target) => connectToDaemon({ target }), }; export async function withHubDaemon<T>( connection: HubDaemonConnection, - host: string | undefined, + target: DaemonTarget, action: (client: HubDaemonClient) => Promise<T>, ): Promise<T> { - const client = await connection.connect(host); + const client = await connection.connect(target); try { return await action(client); } finally { diff --git a/packages/cli/src/commands/hub/deploy-contract.test.ts b/packages/cli/src/commands/hub/deploy-contract.test.ts index c9c3103e7a..8729312707 100644 --- a/packages/cli/src/commands/hub/deploy-contract.test.ts +++ b/packages/cli/src/commands/hub/deploy-contract.test.ts @@ -17,7 +17,119 @@ afterEach(async () => { await Promise.all(servers.splice(0).map(closeServer)); }); -describe("Hub canonical bundle deployment contract", () => { +describe("Hub deployment contract", () => { + it("validates then installs discovered organization triggers when no project is selected", async () => { + const cwd = await triggerProject(); + const triggerId = "a50e05af-4f20-4c8f-8dcc-58e5ea360663"; + const revisionId = "2de5b143-1c88-42db-8a8d-71ca2af97830"; + const hub = await captureHubRequests(2, (url) => + url === "/api/v1/triggers/validate" + ? { status: 200, body: { name: "slack-help", valid: true } } + : { + status: 201, + body: { triggerId, name: "slack-help", revisionId, version: 3, active: true }, + }, + ); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret" }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual([ + { + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + { + url: "/api/v1/triggers/install", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }, + ]); + expect(result).toMatchObject({ + type: "list", + data: [ + { + triggerId, + name: "slack-help", + revisionId, + version: 3, + active: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("dry-runs organization triggers without installing them", async () => { + const cwd = await triggerProject(); + const hub = await captureHub(200, { name: "slack-help", valid: true }); + + const result = await runHubDeploy( + { hub: hub.origin, apiKey: "operator-secret", dryRun: true }, + { cwd, env: {} }, + ); + + expect(await hub.received).toEqual({ + url: "/api/v1/triggers/validate", + authorization: "Bearer operator-secret", + body: { yaml: trigger }, + }); + expect(result).toMatchObject({ + type: "list", + data: [ + { + name: "slack-help", + valid: true, + path: ".paseo/triggers/slack-help.yml", + origin: hub.origin, + }, + ], + }); + }); + + it("reports triggers installed before a later deployment failure", async () => { + const cwd = await triggerProject(); + const secondTrigger = trigger.replace("slack-help", "z-help"); + await writeFile(path.join(cwd, ".paseo", "triggers", "z-help.yml"), secondTrigger); + const hub = await captureHubRequests(4, (url, requestNumber) => { + if (url === "/api/v1/triggers/validate") { + return { status: 200, body: { name: "valid", valid: true } }; + } + if (requestNumber === 3) { + return { + status: 201, + body: { + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", + version: 1, + active: true, + }, + }; + } + return { status: 500, body: { error: "failed" } }; + }); + + await expect( + runHubDeploy({ hub: hub.origin, apiKey: "operator-secret" }, { cwd, env: {} }), + ).rejects.toMatchObject({ + code: "HUB_TRIGGER_DEPLOY_PARTIAL", + message: "Could not deploy .paseo/triggers/z-help.yml after 1 trigger had been installed.", + details: + "Hub trigger deployment failed with HTTP 500.\nInstalled before the failure:\n- .paseo/triggers/slack-help.yml", + }); + expect((await hub.received).map(({ url }) => url)).toEqual([ + "/api/v1/triggers/validate", + "/api/v1/triggers/validate", + "/api/v1/triggers/install", + "/api/v1/triggers/install", + ]); + }); + it("installs the exact discovered bundle with finite environment and named-agent routing", async () => { const cwd = await canonicalProject(); const hub = await captureHub(201, { @@ -83,6 +195,7 @@ describe("Hub canonical bundle deployment contract", () => { expect(help).not.toContain("[file]"); expect(help).toContain("--project <slug>"); + expect(help).toContain("legacy bundle"); expect(help).toContain("--dry-run"); }); }); @@ -135,6 +248,23 @@ const workflow = [ const partial = "Treat paseo.context as evidence, not hidden prompt text.\n"; +const trigger = [ + "name: slack-help", + "enabled: true", + "on:", + " slack.mention:", + " connection: team", + " filters:", + " from_users: [U123]", + "run:", + " target: { daemon: local, cwd: /workspace/studio }", + " agent: { provider: codex, model: gpt-5, mode: full-access }", + " max_runtime: 90m", + " idle_timeout: 10m", + " prompt: ${{ paseo.prompt }}", + "", +].join("\n"); + function canonicalFiles() { return [ { path: ".paseo/hub.yml", content: resource }, @@ -154,6 +284,15 @@ async function canonicalProject(): Promise<string> { return cwd; } +async function triggerProject(): Promise<string> { + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-hub-trigger-deploy-")); + temporaryDirectories.push(cwd); + const triggers = path.join(cwd, ".paseo", "triggers"); + await mkdir(triggers, { recursive: true }); + await writeFile(path.join(triggers, "slack-help.yml"), trigger); + return cwd; +} + async function captureHub(status: number, responseBody: unknown) { let resolveRequest!: (request: unknown) => void; const received = new Promise<unknown>((resolve) => { @@ -181,6 +320,42 @@ async function captureHub(status: number, responseBody: unknown) { return { origin: `http://127.0.0.1:${address.port}`, received }; } +async function captureHubRequests( + count: number, + responseFor: ( + url: string | undefined, + requestNumber: number, + ) => { status: number; body: unknown }, +) { + const requests: unknown[] = []; + let resolveRequests!: (requests: unknown[]) => void; + const received = new Promise<unknown[]>((resolve) => { + resolveRequests = resolve; + }); + const server = createServer((request, response) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk: string) => { + body += chunk; + }); + request.on("end", () => { + requests.push({ + url: request.url, + authorization: request.headers.authorization, + body: JSON.parse(body), + }); + if (requests.length === count) resolveRequests(requests); + const configured = responseFor(request.url, requests.length); + response.writeHead(configured.status, { "content-type": "application/json" }); + response.end(JSON.stringify(configured.body)); + }); + }); + servers.push(server); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + return { origin: `http://127.0.0.1:${address.port}`, received }; +} + function closeServer(server: ReturnType<typeof createServer>): Promise<void> { server.closeAllConnections(); return new Promise<void>((resolve, reject) => { diff --git a/packages/cli/src/commands/hub/deploy-triggers.test.ts b/packages/cli/src/commands/hub/deploy-triggers.test.ts new file mode 100644 index 0000000000..934dae097f --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.test.ts @@ -0,0 +1,64 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverHubTriggers } from "./deploy-triggers.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("Hub trigger deployment discovery", () => { + it("discovers self-contained triggers in deterministic order", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + await writeFile(path.join(cwd, ".paseo", "triggers", "z.yml"), "name: z\n"); + await writeFile(path.join(cwd, ".paseo", "triggers", "a.yml"), "name: a\n"); + + await expect(discoverHubTriggers(cwd)).resolves.toEqual([ + { path: ".paseo/triggers/a.yml", yaml: "name: a\n" }, + { path: ".paseo/triggers/z.yml", yaml: "name: z\n" }, + ]); + }); + + it("rejects unsupported and unsafe trigger paths", async () => { + const cwd = await temporaryDirectory(); + const directory = path.join(cwd, ".paseo", "triggers"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(cwd, "outside.yml"), "name: outside\n"); + await symlink(path.join(cwd, "outside.yml"), path.join(directory, "linked.yml")); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_TRIGGER_UNSAFE_PATH", + }); + }); + + it("requires at least one trigger", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo", "triggers"), { recursive: true }); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ code: "HUB_TRIGGER_MISSING" }); + }); + + it("directs legacy bundles to the explicit project deployment path", async () => { + const cwd = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "environments: {}\n"); + + await expect(discoverHubTriggers(cwd)).rejects.toMatchObject({ + code: "HUB_PROJECT_REQUIRED", + message: + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project <slug> to deploy it.", + }); + }); +}); + +async function temporaryDirectory(): Promise<string> { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-triggers-")); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/cli/src/commands/hub/deploy-triggers.ts b/packages/cli/src/commands/hub/deploy-triggers.ts new file mode 100644 index 0000000000..e55abb53f4 --- /dev/null +++ b/packages/cli/src/commands/hub/deploy-triggers.ts @@ -0,0 +1,140 @@ +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { HubCommandError } from "./error.js"; + +const TRIGGER_DIRECTORY = ".paseo/triggers"; + +export interface HubDeployTrigger { + path: string; + yaml: string; +} + +export async function discoverHubTriggers(cwd: string): Promise<HubDeployTrigger[]> { + const root = path.resolve(cwd); + const directory = path.join(root, TRIGGER_DIRECTORY); + const paseoDirectory = await readStats(path.join(root, ".paseo"), { + code: "HUB_TRIGGER_DIRECTORY_MISSING", + message: `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + }); + if (paseoDirectory.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + const directoryStats = await readTriggerDirectoryStats(root, directory); + if (directoryStats.isSymbolicLink()) throw unsafeTriggerPath(TRIGGER_DIRECTORY); + if (!directoryStats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${TRIGGER_DIRECTORY} must be a directory.`, + ); + } + + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + const triggers: HubDeployTrigger[] = []; + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const triggerPath = `${TRIGGER_DIRECTORY}/${entry.name}`; + if (entry.isSymbolicLink()) throw unsafeTriggerPath(triggerPath); + if (!entry.isFile() || !entry.name.endsWith(".yml")) { + throw new HubCommandError( + "HUB_TRIGGER_PATH_UNSUPPORTED", + `${triggerPath} must be a direct-child .yml trigger.`, + ); + } + try { + triggers.push({ + path: triggerPath, + yaml: await readFile(path.join(root, triggerPath), "utf8"), + }); + } catch (error) { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(triggerPath); + } + } + if (triggers.length === 0) { + throw new HubCommandError( + "HUB_TRIGGER_MISSING", + `${TRIGGER_DIRECTORY} must contain at least one direct-child .yml trigger.`, + ); + } + return triggers; +} + +async function readTriggerDirectoryStats( + root: string, + directory: string, +): Promise<Awaited<ReturnType<typeof lstat>>> { + try { + return await lstat(directory); + } catch (error) { + if (errorCode(error) !== "ENOENT") { + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } + if (await legacyBundleExists(root)) { + throw new HubCommandError( + "HUB_PROJECT_REQUIRED", + "This directory contains a legacy .paseo/hub.yml bundle. Pass --project <slug> to deploy it.", + ); + } + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_MISSING", + `${TRIGGER_DIRECTORY} does not exist. Run this command from the project root.`, + ); + } +} + +async function legacyBundleExists(root: string): Promise<boolean> { + try { + await lstat(path.join(root, ".paseo/hub.yml")); + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(".paseo/hub.yml"); + } +} + +async function readStats( + target: string, + missing: { code: string; message: string }, +): Promise<Awaited<ReturnType<typeof lstat>>> { + try { + return await lstat(target); + } catch (error) { + if (errorCode(error) === "ENOENT") throw new HubCommandError(missing.code, missing.message); + if (!isExpectedReadError(error)) throw error; + throw unreadableTriggerPath(TRIGGER_DIRECTORY); + } +} + +function unsafeTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + +function unreadableTriggerPath(triggerPath: string): HubCommandError { + return new HubCommandError( + "HUB_TRIGGER_UNREADABLE", + `Could not read ${triggerPath}. Check the file and permissions.`, + ); +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null) return undefined; + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +function isExpectedReadError(error: unknown): boolean { + const code = errorCode(error); + return ( + code === "EACCES" || + code === "EPERM" || + code === "ENOENT" || + code === "ENOTDIR" || + code === "ELOOP" || + code === "EISDIR" + ); +} diff --git a/packages/cli/src/commands/hub/deploy.ts b/packages/cli/src/commands/hub/deploy.ts index d64137879a..b9cde59ae6 100644 --- a/packages/cli/src/commands/hub/deploy.ts +++ b/packages/cli/src/commands/hub/deploy.ts @@ -1,14 +1,23 @@ import type { Command } from "commander"; -import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; +import { + withOutput, + type ListResult, + type OutputSchema, + type SingleResult, +} from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; import { resolveHubCredential, resolveHubOrigin } from "./authority.js"; import { HubHttpClient, type HubInstallResult, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./hub-client/index.js"; import { PrivateHubCredentialStore, type HubCredentialStore } from "./credentials.js"; import { discoverHubBundle, type HubDeployBundle } from "./deploy-bundle.js"; +import { discoverHubTriggers, type HubDeployTrigger } from "./deploy-triggers.js"; +import { HubCommandError } from "./error.js"; import { processHubReporter, reportHubProgress, type HubReporter } from "./reporter.js"; import { addHubResolutionHelp } from "./help.js"; @@ -24,14 +33,20 @@ export interface HubDeployEnvironment { cwd: string; env: Readonly<Record<string, string | undefined>>; credentials?: HubCredentialStore; - hub?: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">; + hub?: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter?: HubReporter; } export interface HubDeployCommandDependencies { env: Readonly<Record<string, string | undefined>>; credentials: HubCredentialStore; - hub: Pick<HubHttpClient, "installConfiguration" | "validateConfiguration">; + hub: Pick< + HubHttpClient, + "installConfiguration" | "validateConfiguration" | "installTrigger" | "validateTrigger" + >; reporter: HubReporter; cwd(): string; } @@ -46,6 +61,16 @@ interface HubDryRunResult extends HubValidationResult { workflows: number; } +interface HubTriggerDeployResult extends HubTriggerInstallationResult { + origin: string; + path: string; +} + +interface HubTriggerDryRunResult extends HubTriggerValidationResult { + origin: string; + path: string; +} + const resultSchema: OutputSchema<HubDeployResult> = { idField: "versionId", columns: [ @@ -68,6 +93,28 @@ const validationSchema: OutputSchema<HubDryRunResult> = { ], }; +const triggerResultSchema: OutputSchema<HubTriggerDeployResult> = { + idField: "triggerId", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VERSION", field: "version" }, + { header: "REVISION ID", field: "revisionId" }, + { header: "ACTIVE", field: "active" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + +const triggerValidationSchema: OutputSchema<HubTriggerDryRunResult> = { + idField: "name", + columns: [ + { header: "TRIGGER", field: "name" }, + { header: "VALID", field: "valid" }, + { header: "PATH", field: "path" }, + { header: "HUB", field: "origin" }, + ], +}; + export async function runHubDeploy( options: HubDeployOptions, environment: HubDeployEnvironment = { @@ -76,14 +123,84 @@ export async function runHubDeploy( credentials: new PrivateHubCredentialStore(), hub: new HubHttpClient(), }, -): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>> { +): Promise< + | SingleResult<HubDeployResult> + | SingleResult<HubDryRunResult> + | ListResult<HubTriggerDeployResult> + | ListResult<HubTriggerDryRunResult> +> { + if (options.project === undefined) { + return runHubDeployTriggers(options, await discoverHubTriggers(environment.cwd), environment); + } const deployInput = await discoverHubBundle({ cwd: environment.cwd, - ...(options.project === undefined ? {} : { project: options.project }), + project: options.project, }); return runHubDeployBundle(options, deployInput, environment); } +export async function runHubDeployTriggers( + options: HubDeployOptions, + triggers: readonly HubDeployTrigger[], + environment: HubDeployEnvironment, +): Promise<ListResult<HubTriggerDeployResult> | ListResult<HubTriggerDryRunResult>> { + const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env); + const resolution = { + options: { origin: options.hub, apiKey: options.apiKey }, + env: environment.env, + credentials, + }; + const origin = resolveHubOrigin(resolution); + const credential = resolveHubCredential({ ...resolution, origin }); + const action = options.dryRun === true ? "Validating" : "Deploying"; + reportHubProgress( + environment.reporter ?? processHubReporter, + options, + `${action} ${String(triggers.length)} trigger${triggers.length === 1 ? "" : "s"} ${ + options.dryRun === true ? "against" : "to" + } ${origin}`, + ); + const hub = environment.hub ?? new HubHttpClient(); + if (options.dryRun === true) { + const data = await Promise.all( + triggers.map(async (trigger) => ({ + ...(await hub.validateTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + })), + ); + return { type: "list", data, schema: triggerValidationSchema }; + } + await Promise.all( + triggers.map((trigger) => hub.validateTrigger(origin, credential, trigger.yaml)), + ); + const data: HubTriggerDeployResult[] = []; + for (const trigger of triggers) { + try { + data.push({ + ...(await hub.installTrigger(origin, credential, trigger.yaml)), + origin, + path: trigger.path, + }); + } catch (error) { + if (data.length === 0) throw error; + throw new HubCommandError( + "HUB_TRIGGER_DEPLOY_PARTIAL", + `Could not deploy ${trigger.path} after ${String(data.length)} trigger${data.length === 1 ? "" : "s"} had been installed.`, + `${errorMessage(error)}\nInstalled before the failure:\n${data.map(({ path: installedPath }) => `- ${installedPath}`).join("\n")}`, + ); + } + } + return { type: "list", data, schema: triggerResultSchema }; +} + +function errorMessage(error: unknown): string { + if (error instanceof HubCommandError) { + return error.details === undefined ? error.message : `${error.message}\n${error.details}`; + } + return error instanceof Error ? error.message : String(error); +} + export async function runHubDeployBundle( options: HubDeployOptions, deployInput: HubDeployBundle, @@ -133,14 +250,17 @@ export function addHubDeployCommand( addHubResolutionHelp( hub .command("deploy") - .description("Discover, validate, and activate the canonical .paseo Hub bundle") - .option("-p, --project <slug>", "Target project slug") + .description("Deploy .paseo organization triggers or a legacy project bundle") + .option("-p, --project <slug>", "Deploy a legacy bundle to this project slug") .option("--hub <origin>", "Paseo Hub origin") .option("--api-key <secret>", "Organization API key") .option("--dry-run", "Validate without installing or activating"), ), ).action( - withOutput<HubDeployResult | HubDryRunResult, unknown[]>(async (...args) => { + withOutput< + HubDeployResult | HubDryRunResult | HubTriggerDeployResult | HubTriggerDryRunResult, + unknown[] + >(async (...args) => { const options = args.at(-2) as HubDeployOptions; return runHubDeploy(options, { cwd: dependencies.cwd(), diff --git a/packages/cli/src/commands/hub/disconnect.ts b/packages/cli/src/commands/hub/disconnect.ts index 867201376f..a7052d4e50 100644 --- a/packages/cli/src/commands/hub/disconnect.ts +++ b/packages/cli/src/commands/hub/disconnect.ts @@ -9,6 +9,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubDisconnectOptions { force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -21,7 +22,7 @@ export function runHubDisconnect( options: HubDisconnectOptions, dependencies: HubDisconnectDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; if (current.hubOrigin !== null) { reportHubProgress( diff --git a/packages/cli/src/commands/hub/hub-client/index.ts b/packages/cli/src/commands/hub/hub-client/index.ts index ab880705d6..857a145afb 100644 --- a/packages/cli/src/commands/hub/hub-client/index.ts +++ b/packages/cli/src/commands/hub/hub-client/index.ts @@ -8,15 +8,17 @@ import { installResponseSchema, projectsResponseSchema, triggersResponseSchema, + triggerInstallationResponseSchema, + triggerValidationResponseSchema, validationResponseSchema, type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, - setupResourcesSchema, - type HubSetupResources, type HubProject, type HubTrigger, + type HubTriggerInstallationResult, + type HubTriggerValidationResult, type HubValidationResult, } from "./internal/contracts.js"; import { requestHub } from "./internal/transport.js"; @@ -26,9 +28,10 @@ export type { CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, - HubSetupResources, HubProject, HubTrigger, + HubTriggerInstallationResult, + HubTriggerValidationResult, HubValidationResult, } from "./internal/contracts.js"; @@ -102,6 +105,40 @@ export class HubHttpClient { return response.triggers; } + validateTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise<HubTriggerValidationResult> { + return requestHub({ + origin, + path: "/api/v1/triggers/validate", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 200, + schema: triggerValidationResponseSchema, + failureMessage: "Hub trigger validation failed", + }); + } + + installTrigger( + origin: string, + apiKey: string, + yaml: string, + ): Promise<HubTriggerInstallationResult> { + return requestHub({ + origin, + path: "/api/v1/triggers/install", + method: "POST", + apiKey, + body: { yaml }, + successStatus: 201, + schema: triggerInstallationResponseSchema, + failureMessage: "Hub trigger deployment failed", + }); + } + listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources> { return requestHub({ origin, @@ -114,18 +151,6 @@ export class HubHttpClient { }); } - listSetupResources(origin: string, apiKey: string): Promise<HubSetupResources> { - return requestHub({ - origin, - path: "/api/v1/setup-resources", - method: "GET", - apiKey, - successStatus: 200, - schema: setupResourcesSchema, - failureMessage: "Hub guided setup resource listing failed", - }); - } - installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult> { return requestHub({ origin: input.origin, diff --git a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts index 059f66cb14..c51127bc4c 100644 --- a/packages/cli/src/commands/hub/hub-client/internal/contracts.ts +++ b/packages/cli/src/commands/hub/hub-client/internal/contracts.ts @@ -48,6 +48,20 @@ const triggerSchema = z export const triggersResponseSchema = z.object({ triggers: z.array(triggerSchema) }).strict(); +export const triggerValidationResponseSchema = z + .object({ name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), valid: z.literal(true) }) + .strict(); + +export const triggerInstallationResponseSchema = z + .object({ + triggerId: z.string().uuid(), + name: z.string().regex(/^[a-z][a-z0-9_-]*$/u), + revisionId: z.string().uuid(), + version: z.number().int().positive(), + active: z.literal(true), + }) + .strict(); + export const configurationResourcesSchema = z .object({ daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()), @@ -63,25 +77,9 @@ export const configurationResourcesSchema = z ), discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()), slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()), - }) - .strict(); - -export const setupResourcesSchema = z - .object({ - github: z.array( - z - .object({ - slug: z.string().min(1), - accountLogin: z.string().min(1), - accountType: z.string().min(1), - repositories: z.array(z.string().min(1)), - }) - .strict(), + linear: z.array( + z.object({ slug: z.string().min(1), organizationName: z.string().min(1) }).strict(), ), - discord: z.array( - z.object({ guildId: z.string().min(1), guildName: z.string().min(1) }).strict(), - ), - slack: z.array(z.object({ teamId: z.string().min(1), teamName: z.string().min(1) }).strict()), }) .strict(); @@ -106,7 +104,8 @@ export type CliAuthorization = z.infer<typeof authorizationSchema>; export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>; export type HubProject = z.infer<typeof projectSchema>; export type HubTrigger = z.infer<typeof triggerSchema>; +export type HubTriggerValidationResult = z.infer<typeof triggerValidationResponseSchema>; +export type HubTriggerInstallationResult = z.infer<typeof triggerInstallationResponseSchema>; export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>; -export type HubSetupResources = z.infer<typeof setupResourcesSchema>; export type HubInstallResult = z.infer<typeof installResponseSchema>; export type HubValidationResult = z.infer<typeof validationResponseSchema>; diff --git a/packages/cli/src/commands/hub/index.ts b/packages/cli/src/commands/hub/index.ts index 6bddbd1e70..650c5a0d53 100644 --- a/packages/cli/src/commands/hub/index.ts +++ b/packages/cli/src/commands/hub/index.ts @@ -59,7 +59,8 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {}) flow: environment.login, reporter: environment.reporter, isInteractive: environment.isInteractive, - continueGuidedSetup: (origin) => continueHubGuidedSetup(origin, environment), + continueGuidedSetup: (origin, daemonTarget) => + continueHubGuidedSetup(origin, { ...environment, daemonTarget }), }); addHubInitCommand(hub, environment); addHubConnectCommand(hub, { @@ -71,8 +72,11 @@ export function createHubCommand(overrides: Partial<HubCommandEnvironment> = {}) }); addJsonAndDaemonHostOptions(hub.command("status")).action( withOutput(async (...args) => { - const options = args.at(-2) as { host?: string }; - return withHubDaemon(environment.daemon, options.host, async (client) => + const options = args.at(-2) as { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + }; + return withHubDaemon(environment.daemon, options.daemonTarget, async (client) => hubStatusResult((await client.getHubStatus()).status), ); }), diff --git a/packages/cli/src/commands/hub/init-flow.test.ts b/packages/cli/src/commands/hub/init-flow.test.ts index 934c47b0f6..74653a6ddb 100644 --- a/packages/cli/src/commands/hub/init-flow.test.ts +++ b/packages/cli/src/commands/hub/init-flow.test.ts @@ -1,5 +1,5 @@ import { strict as assert } from "node:assert"; -import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, it } from "vitest"; @@ -112,7 +112,13 @@ describe("Hub guided setup continuation", () => { continueHubGuidedSetup( origin, setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [], + github: [], + slack: [], + discord: [], + linear: [], + }, }), ), reporter: { progress() {} }, @@ -125,18 +131,77 @@ describe("Hub guided setup continuation", () => { ]); }); - it("keeps hub init's existing replacement confirmation", async () => { + it("preserves an existing legacy bundle while adding the organization trigger", async () => { const cwd = await temporaryDirectory(); await mkdir(path.join(cwd, ".paseo")); - const prompts = new PromptAnswers([false], [], []); + await writeFile(path.join(cwd, ".paseo", "hub.yml"), "legacy: bundle\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }); + + assert.equal(await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), "legacy: bundle\n"); + assert.match( + await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"), + /name: slack-help/u, + ); + assert.deepEqual(prompts.confirmations, []); + }); + + it("asks before replacing only the selected trigger file", async () => { + const cwd = await temporaryDirectory(); + const triggerPath = path.join(cwd, ".paseo", "triggers", "slack-help.yml"); + await mkdir(path.dirname(triggerPath), { recursive: true }); + await writeFile(triggerPath, "name: keep-me\n"); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([false], ["codex", "gpt-5", "full-access"], ["U123"]); + const calls: Array<{ operation: string; origin: string; files?: readonly string[] }> = []; await assert.rejects( - runHubGuidedSetup( - setupEnvironment(cwd, new MemoryCredentials(), new SetupDaemon(), prompts, []), - ), - /Existing .paseo\/ bundle left unchanged/u, + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, calls), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /.paseo\/triggers\/slack-help.yml left unchanged/u, ); - assert.deepEqual(prompts.confirmations, ["Replace the existing .paseo/ Hub bundle?"]); + + assert.equal(await readFile(triggerPath, "utf8"), "name: keep-me\n"); + assert.deepEqual(prompts.confirmations, [ + "Replace the existing .paseo/triggers/slack-help.yml?", + ]); + assert.deepEqual( + calls.map(({ operation }) => operation), + ["resources", "validate-trigger"], + ); + }); + + it("rejects a symlinked trigger directory without writing outside the project", async () => { + const cwd = await temporaryDirectory(); + const outside = await temporaryDirectory(); + await mkdir(path.join(cwd, ".paseo")); + await symlink(outside, path.join(cwd, ".paseo", "triggers")); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const prompts = new PromptAnswers([], ["codex", "gpt-5", "full-access"], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, new SetupDaemon(), prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: false, + }), + (error: unknown) => + error instanceof Error && "code" in error && error.code === "HUB_TRIGGER_UNSAFE_PATH", + ); + + await assert.rejects(readFile(path.join(outside, "slack-help.yml")), { code: "ENOENT" }); }); it("writes the explicitly selected Claude mode when the daemon has no default", async () => { @@ -167,14 +232,14 @@ describe("Hub guided setup continuation", () => { ["Sonnet (suggested)"], ["Auto"], ]); - assert.match( - await readFile(path.join(cwd, ".paseo", "hub.yml"), "utf8"), - /provider: claude\n model: sonnet\n mode: auto/u, - ); + const trigger = await readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml"), "utf8"); + assert.match(trigger, /provider: claude\n model: sonnet\n mode: auto/u); assert.deepEqual( calls.slice(-2).map(({ operation }) => operation), - ["validate", "install"], + ["validate-trigger", "install-trigger"], ); + assert.deepEqual(calls.at(-2)?.files, [trigger]); + assert.deepEqual(calls.at(-1)?.files, [trigger]); }); it("stops before runtime discovery and file writes when no Hub app connection is usable", async () => { @@ -187,15 +252,50 @@ describe("Hub guided setup continuation", () => { await assert.rejects( runHubGuidedSetup( setupEnvironment(cwd, credentials, daemon, prompts, [], { - setupResources: { github: [], slack: [], discord: [] }, + configurationResources: { + daemons: [{ id: "daemon-1", slug: "macbook" }], + github: [], + slack: [], + discord: [], + linear: [], + }, }), { origin: "https://hub.test", daemonId: "daemon-1", deploy: true }, ), - /No Hub app connection is ready for this workflow/u, + /No Hub app connection is ready for this trigger/u, ); assert.equal(daemon.snapshotCwds.length, 0); - await assert.rejects(readFile(path.join(cwd, ".paseo", "hub.yml")), { code: "ENOENT" }); + await assert.rejects(readFile(path.join(cwd, ".paseo", "triggers", "slack-help.yml")), { + code: "ENOENT", + }); + }); + + it("does not offer an agent runtime that cannot author the required execution mode", async () => { + const cwd = await temporaryDirectory(); + const credentials = new MemoryCredentials(); + credentials.save({ origin: "https://hub.test", credential: "secret" }); + const daemon = new SetupDaemon([ + { + provider: "pi", + status: "ready", + enabled: true, + models: [{ provider: "pi", id: "default", label: "Default", isDefault: true }], + modes: [], + }, + ]); + const prompts = new PromptAnswers([], [], ["U123"]); + + await assert.rejects( + runHubGuidedSetup(setupEnvironment(cwd, credentials, daemon, prompts, []), { + origin: "https://hub.test", + daemonId: "daemon-1", + deploy: true, + }), + /No agent runtime with an execution mode is available/u, + ); + + assert.deepEqual(prompts.selections, []); }); it("waits for fresh-daemon provider discovery before offering runtime choices", async () => { @@ -231,7 +331,7 @@ function setupEnvironment( prompts: PromptAnswers, calls: Array<{ operation: string; origin: string; files?: readonly string[] }>, options: { - setupResources?: Awaited<ReturnType<HubHttpClient["listSetupResources"]>>; + configurationResources?: Awaited<ReturnType<HubHttpClient["listConfigurationResources"]>>; } = {}, ): HubGuidedSetupEnvironment { const hub = { @@ -239,42 +339,37 @@ function setupEnvironment( calls.push({ operation: "token", origin }); return "one-time-enrollment-token-with-enough-length"; }, - async listProjects(origin: string) { - calls.push({ operation: "projects", origin }); - return [{ id: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", slug: "studio", name: "Studio" }]; - }, - async listSetupResources(origin: string) { - calls.push({ operation: "setup", origin }); + async listConfigurationResources(origin: string) { + calls.push({ operation: "resources", origin }); return ( - options.setupResources ?? { + options.configurationResources ?? { + daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], - slack: [{ teamId: "T123", teamName: "Paseo" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + linear: [], } ); }, - async listConfigurationResources(origin: string) { - calls.push({ operation: "resources", origin }); - return { daemons: [{ id: "daemon-1", slug: "macbook" }], github: [], discord: [], slack: [] }; - }, - async validateConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + async validateTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "validate", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "validate-trigger", + origin, + files: [yaml], }); - return { projectSlug: "studio", valid: true }; + return { name: "slack-help", valid: true }; }, - async installConfiguration(input: { origin: string; files: readonly { path: string }[] }) { + async installTrigger(origin: string, _apiKey: string, yaml: string) { calls.push({ - operation: "install", - origin: input.origin, - files: input.files.map(({ path: filePath }) => filePath), + operation: "install-trigger", + origin, + files: [yaml], }); return { - projectSlug: "studio", + triggerId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + name: "slack-help", version: 1, - versionId: "a50e05af-4f20-4c8f-8dcc-58e5ea360663", + revisionId: "2de5b143-1c88-42db-8a8d-71ca2af97830", active: true, }; }, diff --git a/packages/cli/src/commands/hub/init-plan.test.ts b/packages/cli/src/commands/hub/init-plan.test.ts index 44538eb3b1..2ec2f85dff 100644 --- a/packages/cli/src/commands/hub/init-plan.test.ts +++ b/packages/cli/src/commands/hub/init-plan.test.ts @@ -1,31 +1,13 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import YAML from "yaml"; -import { afterEach, describe, expect, it } from "vitest"; -import { discoverHubBundle } from "./deploy-bundle.js"; -import { runHubDeploy } from "./deploy.js"; +import { describe, expect, it } from "vitest"; import { createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, type HubInitProvider, } from "./init-plan.js"; import { githubRepositoryFromRemote } from "./init.js"; -const directories: string[] = []; -const project = (slug: string) => ({ - id: "7e950d84-eec0-4e18-b1f9-9c115bdb31e4", - slug, - name: slug.toUpperCase(), -}); - -afterEach(async () => { - await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); -}); - describe("Hub init planning", () => { it("prints direct resumable commands for declined login continuations", () => { expect(hubLoginResumeCommand("connect", "https://hub.test")).toBe( @@ -33,36 +15,6 @@ describe("Hub init planning", () => { ); expect(hubLoginResumeCommand("init", "https://hub.test")).toBe("paseo hub init"); }); - it("includes login only when there is no active login", () => { - expect(planHubInitOpening({ loggedIn: false, paseoDirectoryExists: false })).toEqual({ - replaceExisting: false, - steps: ["login", "connect", "project", "scaffold"], - }); - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: false })).toEqual({ - replaceExisting: false, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("plans a confirmed replacement for an existing .paseo directory", () => { - expect(planHubInitOpening({ loggedIn: true, paseoDirectoryExists: true })).toEqual({ - replaceExisting: true, - steps: ["connect", "project", "scaffold"], - }); - }); - - it("stops, defaults, or asks according to the project count", () => { - expect(resolveHubInitProjects([])).toEqual({ kind: "none" }); - expect(resolveHubInitProjects([project("one")])).toEqual({ - kind: "selected", - project: project("one"), - }); - expect(resolveHubInitProjects([project("one"), project("two")])).toEqual({ - kind: "choose", - projects: [project("one"), project("two")], - }); - }); - it("reuses a connected daemon, waits for reconnect, and rejects a different Hub", () => { const status = { state: "connected", @@ -93,31 +45,15 @@ describe("Hub init planning", () => { }); describe("Hub init scaffold", () => { - it("omits mode for a provider that explicitly exposes none", () => { - const scaffold = createHubInitScaffold({ - cwd: "/workspace", - daemonSlug: "build-studio", - agent: { provider: "pi", model: "default" }, - provider: "slack", - providerFilters: { workspace: "T123456", user: "U123456" }, - }); - - expect(YAML.parse(scaffold.hub)).toMatchObject({ - agents: { starter: { provider: "pi", model: "default" } }, - }); - expect(YAML.parse(scaffold.hub).agents.starter.mode).toBeUndefined(); - }); - it.each([ - ["github", { repo: "getpaseo/paseo", user: "boudra" }], - ["slack", { workspace: "T123456", user: "U123456" }], - ["discord", { guild: "123456789", user: "987654321" }], + ["github", { connection: "github-getpaseo", repo: "getpaseo/paseo", user: "boudra" }], + ["slack", { connection: "slack-paseo", user: "U123456" }], + ["discord", { connection: "discord-paseo", user: "987654321" }], ] satisfies readonly [HubInitProvider, Record<string, string>][])( - "creates a closed %s trigger that the deploy discovery path accepts", - async (provider, providerFilters) => { - const cwd = await temporaryDirectory(); + "creates a self-contained %s organization trigger", + (provider, providerFilters) => { const scaffold = createHubInitScaffold({ - cwd, + cwd: "/workspace", daemonSlug: "build-studio", agent: { provider: "codex", @@ -127,48 +63,48 @@ describe("Hub init scaffold", () => { provider, providerFilters, }); - await mkdir(path.join(cwd, ".paseo", "workflows"), { recursive: true }); - await writeFile(path.join(cwd, ".paseo", "hub.yml"), scaffold.hub); - await writeFile(path.join(cwd, scaffold.workflowPath), scaffold.workflow); - - const bundle = await discoverHubBundle({ cwd, project: "paseo" }); - expect(bundle.workflowCount).toBe(1); - expect(bundle.files.map((file) => file.path)).toEqual([ - ".paseo/hub.yml", - scaffold.workflowPath, - ]); - const parsed = YAML.parse(scaffold.workflow) as { - filters: { from_users: string[]; channels?: string[] }; + expect(scaffold.triggerPath).toBe(`.paseo/triggers/${provider}-help.yml`); + const parsed = YAML.parse(scaffold.trigger) as { + name: string; + enabled: boolean; + max_runtime: string; + on: Record<string, { connection?: string; filters: Record<string, unknown> }>; + run: { + target: { daemon: string; cwd: string }; + agent: { provider: string; model?: string; mode?: string }; + continuation: { mode: string }; + max_runtime: string; + idle_timeout: string; + prompt: string; + outputs?: Record<string, { max: number; required: boolean }>; + }; }; - expect(parsed.filters.from_users).toEqual([providerFilters.user]); - if (provider === "slack") expect(parsed.filters.workspace).toBe("T123456"); - if (provider === "discord") expect(parsed.filters.guild).toBe("123456789"); - expect(parsed.filters.channels).toBeUndefined(); - expect(YAML.parse(scaffold.hub)).toMatchObject({ - agents: { starter: { provider: "codex", model: "gpt-5", mode: "full-access" } }, + expect(parsed).toMatchObject({ + name: `${provider}-help`, + enabled: true, + max_runtime: "2h", }); - - let validatedPaths: readonly string[] = []; - const result = await runHubDeploy( - { project: "paseo", hub: "https://hub.test", dryRun: true }, - { - cwd, - env: { PASEO_HUB_API_KEY: "test-key" }, - reporter: { progress() {} }, - hub: { - async validateConfiguration(input) { - validatedPaths = input.files.map((file) => file.path); - for (const file of input.files) YAML.parse(file.content); - return { projectSlug: input.projectSlug, valid: true }; - }, - async installConfiguration() { - throw new Error("dry-run must not install"); - }, - }, - }, + expect(Object.keys(parsed.on)).toEqual([ + provider === "github" ? "github.issue_comment" : `${provider}.mention`, + ]); + const event = Object.values(parsed.on)[0]!; + expect(event.filters.from_users).toEqual([providerFilters.user]); + if (provider === "github") expect(event.connection).toBe("github-getpaseo"); + if (provider === "slack") expect(event.connection).toBe("slack-paseo"); + if (provider === "discord") expect(event.connection).toBe("discord-paseo"); + expect(event.filters.channels).toBeUndefined(); + expect(parsed.run).toMatchObject({ + target: { daemon: "build-studio", cwd: "/workspace" }, + agent: { provider: "codex", model: "gpt-5", mode: "full-access" }, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + }); + expect(parsed.run.prompt).toContain("hub.finish_execution"); + expect(parsed.run.prompt).toContain("${{ paseo.prompt }}"); + expect(parsed.run.outputs).toEqual( + provider === "github" ? undefined : { [`${provider}.reply`]: { max: 1, required: true } }, ); - expect(result.data).toMatchObject({ projectSlug: "paseo", valid: true, workflows: 1 }); - expect(validatedPaths).toEqual([".paseo/hub.yml", scaffold.workflowPath]); }, ); }); @@ -183,9 +119,3 @@ describe("GitHub origin detection", () => { expect(githubRepositoryFromRemote(remote)).toBe(expected); }); }); - -async function temporaryDirectory(): Promise<string> { - const directory = await mkdtemp(path.join(tmpdir(), "paseo-hub-init-")); - directories.push(directory); - return directory; -} diff --git a/packages/cli/src/commands/hub/init-plan.ts b/packages/cli/src/commands/hub/init-plan.ts index e2764ccbff..bd2077300d 100644 --- a/packages/cli/src/commands/hub/init-plan.ts +++ b/packages/cli/src/commands/hub/init-plan.ts @@ -1,40 +1,27 @@ import path from "node:path"; import YAML from "yaml"; -import type { HubDeployBundle } from "./deploy-bundle.js"; -import type { HubProject } from "./hub-client/index.js"; import type { HubStatus } from "./daemon-client.js"; import type { HubStarterAgentRuntime } from "./starter-agent-runtime.js"; export type HubInitProvider = "github" | "slack" | "discord"; -export type HubInitProjectResolution = - | { kind: "none" } - | { kind: "selected"; project: HubProject } - | { kind: "choose"; projects: readonly HubProject[] }; - export type HubInitConnectionResolution = | { kind: "connected"; daemonId: string } | { kind: "pending"; state: "connecting" | "reconnecting" } | { kind: "connect" } | { kind: "conflict"; origin: string }; -export interface HubInitOpeningPlan { - replaceExisting: boolean; - steps: readonly ("login" | "connect" | "project" | "scaffold")[]; -} - export interface HubInitScaffoldInput { cwd: string; daemonSlug: string; - agent: HubStarterAgentRuntime; + agent: HubStarterAgentRuntime & { mode: string }; provider: HubInitProvider; providerFilters: Readonly<Record<string, string>>; } export interface HubInitScaffold { - hub: string; - workflowPath: string; - workflow: string; + triggerPath: string; + trigger: string; testAction: string; } @@ -42,36 +29,6 @@ export function hubLoginResumeCommand(step: "connect" | "init", origin: string): return step === "connect" ? `paseo hub connect ${origin}` : "paseo hub init"; } -export function planHubInitOpening(input: { - loggedIn: boolean; - paseoDirectoryExists: boolean; -}): HubInitOpeningPlan { - return { - replaceExisting: input.paseoDirectoryExists, - steps: [...(input.loggedIn ? [] : (["login"] as const)), "connect", "project", "scaffold"], - }; -} - -export function createHubInitBundle( - projectSlug: string, - scaffold: HubInitScaffold, -): HubDeployBundle { - return { - projectSlug, - workflowCount: 1, - files: [ - { path: ".paseo/hub.yml", content: scaffold.hub }, - { path: scaffold.workflowPath, content: scaffold.workflow }, - ], - }; -} - -export function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution { - if (projects.length === 0) return { kind: "none" }; - if (projects.length === 1) return { kind: "selected", project: projects[0]! }; - return { kind: "choose", projects }; -} - export function resolveHubInitConnection( status: HubStatus, origin: string, @@ -92,31 +49,21 @@ export function resolveHubInitConnection( } export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold { - const environmentName = input.daemonSlug; - const hub = YAML.stringify( - { - environments: { - [environmentName]: { - kind: "daemon", - daemon: input.daemonSlug, - cwd: path.resolve(input.cwd), - }, - }, - agents: { - starter: { + const provider = providerScaffold(input.provider, input.providerFilters); + return { + triggerPath: `.paseo/triggers/${input.provider}-help.yml`, + trigger: YAML.stringify( + triggerDocument({ + ...provider, + target: { daemon: input.daemonSlug, cwd: path.resolve(input.cwd) }, + agent: { provider: input.agent.provider, model: input.agent.model, - ...(input.agent.mode === undefined ? {} : { mode: input.agent.mode }), + mode: input.agent.mode, }, - }, - }, - { lineWidth: 0 }, - ); - const provider = providerScaffold(input.provider, input.providerFilters, environmentName); - return { - hub, - workflowPath: `.paseo/workflows/${input.provider}-help.yml`, - workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }), + }), + { lineWidth: 0 }, + ), testAction: provider.testAction, }; } @@ -124,80 +71,75 @@ export function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaff function providerScaffold( provider: HubInitProvider, filters: Readonly<Record<string, string>>, - environment: string, -): { workflow: object; testAction: string } { +): { + name: string; + event: string; + connection: string; + filters: object; + reply?: "slack.reply" | "discord.reply"; + testAction: string; +} { + const connection = requireFilter(filters, "connection"); if (provider === "github") { const repo = requireFilter(filters, "repo"); const user = requireFilter(filters, "user"); return { - workflow: workflow({ - name: "github-help", - on: "github.issue_comment", - filters: { repo, contains: "@paseo", from_users: [user] }, - environment, - }), + name: "github-help", + event: "github.issue_comment", + connection, + filters: { repo, contains: "@paseo", from_users: [user] }, testAction: `Comment \`@paseo have a look\` on ${repo}.`, }; } const user = requireFilter(filters, "user"); if (provider === "slack") { - const workspace = requireFilter(filters, "workspace"); return { - workflow: workflow({ - name: "slack-help", - on: "slack.mention", - filters: { workspace, from_users: [user] }, - environment, - reply: "slack.reply", - }), + name: "slack-help", + event: "slack.mention", + connection, + filters: { from_users: [user] }, + reply: "slack.reply", testAction: "Mention `@Paseo have a look` in Slack.", }; } - const guild = requireFilter(filters, "guild"); return { - workflow: workflow({ - name: "discord-help", - on: "discord.mention", - filters: { guild, from_users: [user] }, - environment, - reply: "discord.reply", - }), + name: "discord-help", + event: "discord.mention", + connection, + filters: { from_users: [user] }, + reply: "discord.reply", testAction: "Mention `@Paseo have a look` in Discord.", }; } -function workflow(input: { +function triggerDocument(input: { name: string; - on: string; + event: string; + connection: string; filters: object; - environment: string; + target: { daemon: string; cwd: string }; + agent: { provider: string; model: string; mode?: string }; reply?: "slack.reply" | "discord.reply"; }): object { const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then "; return { name: input.name, - on: input.on, + enabled: true, + on: { [input.event]: { connection: input.connection, filters: input.filters } }, max_runtime: "2h", - filters: input.filters, - steps: [ - { - id: "work", - environment: input.environment, - max_runtime: "90m", - idle_timeout: "10m", - agent: "starter", - prompt: [ - { - text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`, - }, - ], - ...(input.reply === undefined - ? {} - : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }), - }, - ], + run: { + target: input.target, + agent: input.agent, + continuation: { mode: "conversation" }, + max_runtime: "90m", + idle_timeout: "10m", + prompt: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`, + ...(input.reply === undefined + ? {} + : { outputs: { [input.reply]: { max: 1, required: true } } }), + }, }; } diff --git a/packages/cli/src/commands/hub/init.ts b/packages/cli/src/commands/hub/init.ts index c83ce12b09..acd6ef054b 100644 --- a/packages/cli/src/commands/hub/init.ts +++ b/packages/cli/src/commands/hub/init.ts @@ -1,3 +1,6 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { withGlobalOptions } from "../../utils/command-options.js"; +import type { CommandOptions } from "../../output/index.js"; import { cancel, confirm, @@ -12,7 +15,7 @@ import { } from "@clack/prompts"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { link, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { Command } from "commander"; @@ -21,24 +24,14 @@ import type { HubCredentialStore } from "./credentials.js"; import type { HubDaemonConnection } from "./daemon-client.js"; import { withHubDaemon } from "./daemon-client.js"; import { HubCommandError } from "./error.js"; -import type { - HubConfigurationResources, - HubHttpClient, - HubProject, - HubSetupResources, -} from "./hub-client/index.js"; +import type { HubConfigurationResources, HubHttpClient } from "./hub-client/index.js"; import { - createHubInitBundle, createHubInitScaffold, hubLoginResumeCommand, - planHubInitOpening, resolveHubInitConnection, - resolveHubInitProjects, } from "./init-plan.js"; import type { CliLoginFlow } from "./login-flow.js"; import { runHubConnect } from "./connect.js"; -import { runHubDeployBundle } from "./deploy.js"; -import { runHubProjects } from "./projects.js"; import type { HubReporter } from "./reporter.js"; import { normalizeHubOrigin } from "./origin.js"; import { @@ -59,6 +52,7 @@ const DAEMON_READY_POLL_MS = 250; const PROVIDER_READY_TIMEOUT_MS = 60_000; export interface HubGuidedSetupEnvironment { + daemonTarget: DaemonTarget; env: Readonly<Record<string, string | undefined>>; credentials: HubCredentialStore; hub: HubHttpClient; @@ -90,22 +84,27 @@ function initErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function addHubInitCommand(parent: Command, environment: HubGuidedSetupEnvironment): void { +export function addHubInitCommand( + parent: Command, + environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">, +): void { parent .command("init") - .description("Create and optionally deploy a safe starter Hub bundle") - .action(async () => { - try { - await runHubInit(environment); - } catch (error) { - if (error instanceof HubInitCancelledError) { - cancel(error.message); - return; + .description("Create and optionally deploy a safe starter Hub trigger") + .action( + withGlobalOptions(async (options: CommandOptions) => { + try { + await runHubInit({ ...environment, daemonTarget: options.daemonTarget }); + } catch (error) { + if (error instanceof HubInitCancelledError) { + cancel(error.message); + return; + } + cancel(initErrorMessage(error)); + process.exitCode = 1; } - cancel(initErrorMessage(error)); - process.exitCode = 1; - } - }); + }), + ); } export async function runHubInit(environment: HubGuidedSetupEnvironment): Promise<void> { @@ -121,25 +120,19 @@ export async function runHubGuidedSetup( const cwd = environment.cwd(); const activeLogin = environment.credentials.active(); - const opening = planHubInitOpening({ - loggedIn: activeLogin !== null, - paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")), - }); - if ( - opening.replaceExisting && - !(await requiredConfirm(environment, "Replace the existing .paseo/ Hub bundle?", false)) - ) { - throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged."); - } - const origin = state.origin ?? (await ensureLogin(activeLogin?.origin, environment)); const daemonId = state.daemonId ?? (await ensureDaemonConnection(origin, environment)); - const project = await chooseProject(origin, environment); - const resources = await loadSetupResources(origin, environment); + const resources = await loadConfigurationResources(origin, environment); const triggerConnections = await resolveStarterTriggerConnections(resources, cwd); reportStarterTriggerConnections(environment, triggerConnections); const trigger = await chooseStarterTriggerConnection(environment, triggerConnections); - const daemon = await loadDaemonResource(origin, daemonId, environment); + const daemon = resources.daemons.find(({ id }) => id === daemonId); + if (daemon === undefined) { + throw new HubCommandError( + "HUB_DAEMON_RESOURCE_MISSING", + "The connected daemon is not available in this Hub organization. Reconnect it and try again.", + ); + } log.success(`Connected as ${daemon.slug}`); const agent = await chooseStarterAgentRuntime(environment, cwd); const providerFilters = await collectProviderIdentity(trigger, environment); @@ -151,48 +144,50 @@ export async function runHubGuidedSetup( providerFilters, }); - const bundle = createHubInitBundle(project.slug, scaffold); + const credential = resolveHubCredential({ + options: { origin }, + env: environment.env, + credentials: environment.credentials, + origin, + }); - await withSpinner("Validating bundle", async () => { - await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Validating trigger", async () => { + await environment.hub.validateTrigger(origin, credential, scaffold.trigger); }); log.success("Dry run passed"); - await writeScaffold(cwd, scaffold, opening.replaceExisting); - log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`); + const triggerExists = await prepareScaffoldDestination(cwd, scaffold.triggerPath); + if ( + triggerExists && + !(await requiredConfirm(environment, `Replace the existing ${scaffold.triggerPath}?`, false)) + ) { + throw new HubInitCancelledError(`${scaffold.triggerPath} left unchanged.`); + } + await writeScaffold(cwd, scaffold, triggerExists); + log.success(`Created ${scaffold.triggerPath}`); const deploy = state.deploy ?? (await requiredConfirm(environment, "Deploy now?", true)); if (deploy) { - await withSpinner("Deploying bundle", async () => { - await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, { - cwd, - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }); + await withSpinner("Deploying trigger", async () => { + await environment.hub.installTrigger(origin, credential, scaffold.trigger); }); log.success("Deployed"); } else { - reportMessage(environment, `Skipped deployment. Run: paseo hub deploy -p ${project.slug}`); + reportMessage(environment, "Skipped deployment. Run `paseo hub deploy` when ready."); } - const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString(); - note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow"); - outro(deploy ? "Hub is ready" : "Hub bundle is ready"); + const triggersUrl = new URL("/triggers", origin).toString(); + note(`${scaffold.testAction}\nManage it at ${triggersUrl}`, "Test your trigger"); + outro(deploy ? "Hub is ready" : "Hub trigger is ready"); } export async function continueHubGuidedSetup( origin: string, environment: HubGuidedSetupEnvironment, ): Promise<void> { - const currentStatus = await withHubDaemon(environment.daemon, undefined, async (daemon) => - daemon.getHubStatus().then((response) => response.status), + const currentStatus = await withHubDaemon( + environment.daemon, + environment.daemonTarget, + async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const current = resolveHubInitConnection(currentStatus, origin); if (current.kind === "connected") { @@ -203,7 +198,7 @@ export async function continueHubGuidedSetup( }.`, ); } else if (current.kind === "pending") { - await waitForDaemonReady(origin, environment.daemon); + await waitForDaemonReady(origin, environment); } else if (current.kind === "conflict") { reportMessage( environment, @@ -289,7 +284,7 @@ async function ensureDaemonConnection( confirmed = false, permissions: readonly string[] = ["hub.execute"], ): Promise<string> { - const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => + const status = await withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); const connection = resolveHubInitConnection(status, origin); @@ -303,7 +298,7 @@ async function ensureDaemonConnection( return connection.daemonId; } if (connection.kind === "pending") { - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } if (connection.kind === "conflict") { throw new HubCommandError( @@ -319,7 +314,7 @@ async function ensureDaemonConnection( true, )) ) { - throw new HubInitCancelledError("A connected daemon is required to create the bundle."); + throw new HubInitCancelledError("A connected daemon is required to create the trigger."); } return connectDaemon(origin, environment, permissions); } @@ -331,7 +326,7 @@ async function connectDaemon( ): Promise<string> { await runHubConnect( origin, - { permissions }, + { permissions, daemonTarget: environment.daemonTarget }, { env: environment.env, credentials: environment.credentials, @@ -340,15 +335,15 @@ async function connectDaemon( reporter: environment.reporter, }, ); - return waitForDaemonReady(origin, environment.daemon); + return waitForDaemonReady(origin, environment); } async function waitForDaemonReady( origin: string, - connection: HubDaemonConnection, + environment: HubGuidedSetupEnvironment, ): Promise<string> { return withSpinner("Waiting for the daemon to connect", async (reporter) => - withHubDaemon(connection, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS; while (true) { const status = (await daemon.getHubStatus()).status; @@ -379,48 +374,8 @@ async function waitForDaemonReady( ); } -async function chooseProject( - origin: string, - environment: HubGuidedSetupEnvironment, -): Promise<HubProject> { - const result = await runHubProjects( - { hub: origin }, - { - env: environment.env, - credentials: environment.credentials, - hub: environment.hub, - reporter: { progress() {} }, - }, - ); - const resolution = resolveHubInitProjects(result.data.projects); - if (resolution.kind === "none") { - throw new HubInitCancelledError( - `No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`, - ); - } - if (resolution.kind === "selected") { - return resolution.project; - } - const slug = await requiredSelect(environment, { - message: "Project", - options: resolution.projects.map((project) => ({ - value: project.slug, - label: project.name, - hint: project.slug, - })), - }); - const project = resolution.projects.find((candidate) => candidate.slug === slug); - if (project === undefined) { - throw new HubCommandError( - "HUB_PROJECT_SELECTION_INVALID", - "The selected Hub project is unavailable.", - ); - } - return project; -} - async function resolveStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, cwd: string, ): Promise<HubStarterTriggerConnection[]> { const remote = await readCommandValue("git", ["remote", "get-url", "origin"], cwd); @@ -428,7 +383,7 @@ async function resolveStarterTriggerConnections( const connections = availableStarterTriggerConnections(resources, repository); if (connections.length === 0) { throw new HubInitCancelledError( - "No Hub app connection is ready for this workflow.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", + "No Hub app connection is ready for this trigger.\nConnect GitHub, Slack, or Discord in Hub → Apps, then run `paseo hub init` again.", ); } return connections; @@ -440,10 +395,10 @@ function reportStarterTriggerConnections( ): void { const details = `${connections.map(({ label }) => label).join("\n")}\n\nOnly configured connections are shown. To add another, open Hub → Apps, then run \`paseo hub init\` again.`; if (environment.prompts === undefined) { - note(details, "Hub app connections ready for this workflow"); + note(details, "Hub app connections ready for this trigger"); return; } - environment.prompts.message(`Hub app connections ready for this workflow:\n${details}`); + environment.prompts.message(`Hub app connections ready for this trigger:\n${details}`); } async function chooseStarterTriggerConnection( @@ -472,19 +427,19 @@ async function chooseStarterTriggerConnection( async function chooseStarterAgentRuntime( environment: HubGuidedSetupEnvironment, cwd: string, -): Promise<HubStarterAgentRuntime> { +): Promise<HubStarterAgentRuntime & { mode: string }> { const providers = await waitForStarterAgentProviders(environment, cwd); const provider = await chooseStarterAgentProvider(environment, providers); const model = await chooseStarterAgentModel(environment, provider); const mode = await chooseStarterAgentMode(environment, provider); const runtime = selectedStarterAgentRuntime(provider, model, mode); - if (runtime === undefined) { + if (runtime?.mode === undefined) { throw new HubCommandError( "HUB_AGENT_RUNTIME_SELECTION_INVALID", "The selected starter agent runtime is no longer available. Run paseo hub init again.", ); } - return runtime; + return { ...runtime, mode: runtime.mode }; } async function waitForStarterAgentProviders( @@ -492,12 +447,19 @@ async function waitForStarterAgentProviders( cwd: string, ): Promise<readonly HubStarterAgentProvider[]> { return withSpinner("Discovering agent runtimes", async (reporter) => - withHubDaemon(environment.daemon, undefined, async (daemon) => { + withHubDaemon(environment.daemon, environment.daemonTarget, async (daemon) => { const deadline = Date.now() + PROVIDER_READY_TIMEOUT_MS; while (true) { const snapshot = await daemon.getProvidersSnapshot({ cwd }); const state = starterAgentProviderSnapshotState(snapshot.entries); - if (state.kind === "ready") return state.providers; + if (state.kind === "ready") { + const providers = state.providers.filter(({ modes }) => modes.length > 0); + if (providers.length > 0) return providers; + throw new HubCommandError( + "HUB_AGENT_RUNTIME_REQUIRED", + "No agent runtime with an execution mode is available from this daemon. Configure one, then run paseo hub init again.", + ); + } if (state.kind === "unavailable") { throw new HubCommandError( "HUB_AGENT_RUNTIME_REQUIRED", @@ -607,13 +569,13 @@ async function collectProviderIdentity( }; } -async function loadSetupResources( +async function loadConfigurationResources( origin: string, environment: HubGuidedSetupEnvironment, -): Promise<HubSetupResources> { +): Promise<HubConfigurationResources> { try { return await withSpinner("Loading Hub connections", () => - environment.hub.listSetupResources( + environment.hub.listConfigurationResources( origin, resolveHubCredential({ options: { origin }, @@ -624,77 +586,51 @@ async function loadSetupResources( ), ); } catch (error) { - if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") { + if ( + error instanceof HubCommandError && + (error.code === "HUB_NOT_FOUND" || error.code === "HUB_INVALID_RESPONSE") + ) { throw new HubCommandError( "HUB_UPDATE_REQUIRED", - "This Hub needs an update before guided setup can use provider IDs. Update Hub and try again.", + "This Hub needs an update before guided setup can use organization triggers. Update Hub and try again.", ); } throw error; } } -async function loadDaemonResource( - origin: string, - daemonId: string, - environment: HubGuidedSetupEnvironment, -): Promise<HubConfigurationResources["daemons"][number]> { - const resources = await environment.hub.listConfigurationResources( - origin, - resolveHubCredential({ - options: { origin }, - env: environment.env, - credentials: environment.credentials, - origin, - }), - ); - const daemon = resources.daemons.find(({ id }) => id === daemonId); - if (daemon === undefined) { - throw new HubCommandError( - "HUB_DAEMON_RESOURCE_MISSING", - "The connected daemon is not available in this Hub organization. Reconnect it and try again.", - ); - } - return daemon; -} - async function writeScaffold( cwd: string, scaffold: ReturnType<typeof createHubInitScaffold>, replaceExisting: boolean, ): Promise<void> { - const root = path.join(cwd, ".paseo"); - const staging = path.join(cwd, `.paseo-init-${randomUUID()}`); - const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`); - let movedExisting = false; + const destination = path.join(cwd, scaffold.triggerPath); + const temporary = path.join( + path.dirname(destination), + `.${path.basename(destination)}.${process.pid}.${randomUUID()}.tmp`, + ); try { - await mkdir(path.join(staging, "workflows"), { recursive: true }); - await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" }); - await writeFile( - path.join(staging, "workflows", path.basename(scaffold.workflowPath)), - scaffold.workflow, - { - flag: "wx", - }, - ); + await writeFile(temporary, scaffold.trigger, { flag: "wx" }); if (replaceExisting) { - await rename(root, backup); - movedExisting = true; + await rename(temporary, destination); + } else { + await link(temporary, destination); + await rm(temporary); } - await rename(staging, root); - if (movedExisting) await rm(backup, { recursive: true, force: true }); } catch (error) { - await rm(staging, { recursive: true, force: true }); - if (movedExisting) { - await rename(backup, root).catch(() => undefined); - } + await rm(temporary, { force: true }); throw error; } } -async function pathExists(target: string): Promise<boolean> { +async function prepareScaffoldDestination(cwd: string, triggerPath: string): Promise<boolean> { + const root = path.resolve(cwd); + await requireSafeScaffoldDirectory(path.join(root, ".paseo"), ".paseo"); + await requireSafeScaffoldDirectory(path.join(root, ".paseo", "triggers"), ".paseo/triggers"); + const destination = path.join(root, triggerPath); try { - await lstat(target); + const stats = await lstat(destination); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(triggerPath); return true; } catch (error) { if (errorCode(error) === "ENOENT") return false; @@ -702,6 +638,26 @@ async function pathExists(target: string): Promise<boolean> { } } +async function requireSafeScaffoldDirectory(target: string, displayPath: string): Promise<void> { + try { + const stats = await lstat(target); + if (stats.isSymbolicLink()) throw unsafeScaffoldPath(displayPath); + if (!stats.isDirectory()) { + throw new HubCommandError( + "HUB_TRIGGER_DIRECTORY_INVALID", + `${displayPath} must be a directory.`, + ); + } + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + await mkdir(target); + } +} + +function unsafeScaffoldPath(triggerPath: string): HubCommandError { + return new HubCommandError("HUB_TRIGGER_UNSAFE_PATH", `${triggerPath} must not use a symlink.`); +} + function errorCode(error: unknown): string | undefined { return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code @@ -805,7 +761,9 @@ async function requiredSelect<T extends string>( return answer; } -function requireInteractiveTerminal(environment: HubGuidedSetupEnvironment): void { +function requireInteractiveTerminal( + environment: Omit<HubGuidedSetupEnvironment, "daemonTarget">, +): void { if (!(environment.isInteractive?.() ?? (process.stdin.isTTY && process.stdout.isTTY))) { throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY."); } diff --git a/packages/cli/src/commands/hub/login.ts b/packages/cli/src/commands/hub/login.ts index e77329094c..056561deb8 100644 --- a/packages/cli/src/commands/hub/login.ts +++ b/packages/cli/src/commands/hub/login.ts @@ -1,3 +1,5 @@ +import type { CommandOptions } from "../../output/index.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { Command } from "commander"; import { withOutput, type OutputSchema, type SingleResult } from "../../output/index.js"; import { addJsonOption } from "../../utils/command-options.js"; @@ -26,12 +28,12 @@ interface HubLoginDependencies { flow: Pick<CliLoginFlow, "authorize">; reporter: HubReporter; isInteractive?(): boolean; - continueGuidedSetup?(origin: string): Promise<void>; + continueGuidedSetup?(origin: string, daemonTarget: DaemonTarget): Promise<void>; } export async function runHubLogin( originInput: string | undefined, - options: { json?: boolean }, + options: Pick<CommandOptions, "json" | "daemonTarget">, dependencies: HubLoginDependencies, ): Promise<SingleResult<HubLoginResult>> { const origin = resolveHubOrigin({ @@ -48,7 +50,7 @@ export async function runHubLogin( dependencies.isInteractive?.() && dependencies.continueGuidedSetup !== undefined ) { - await dependencies.continueGuidedSetup(origin); + await dependencies.continueGuidedSetup(origin, options.daemonTarget); } return { type: "single", data: { origin, status: "logged_in" }, schema }; } @@ -64,7 +66,7 @@ export function addHubLoginCommand(parent: Command, dependencies: HubLoginDepend ).action( withOutput(async (...args) => { const origin = args[0] as string | undefined; - const options = args.at(-2) as { json?: boolean }; + const options = args.at(-2) as CommandOptions; return runHubLogin(origin, options, dependencies); }), ); diff --git a/packages/cli/src/commands/hub/logout.ts b/packages/cli/src/commands/hub/logout.ts index 28f0837021..8fd6f2bb7c 100644 --- a/packages/cli/src/commands/hub/logout.ts +++ b/packages/cli/src/commands/hub/logout.ts @@ -28,6 +28,7 @@ interface HubLogoutOptions { disconnectDaemon?: boolean; force?: boolean; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -53,7 +54,7 @@ export async function runHubLogout( let daemonStatus: HubStatus | null = null; const shouldInspectDaemon = options.disconnectDaemon === true || mayPrompt; if (shouldInspectDaemon) { - daemonStatus = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => + daemonStatus = await withHubDaemon(dependencies.daemon, options.daemonTarget, async (daemon) => daemon.getHubStatus().then((response) => response.status), ); } @@ -72,9 +73,13 @@ export async function runHubLogout( options, `Disconnecting this daemon from ${active.origin}`, ); - const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => { - return daemon.disconnectHub(options.force ?? false); - }); + const disconnect = await withHubDaemon( + dependencies.daemon, + options.daemonTarget, + async (daemon) => { + return daemon.disconnectHub(options.force ?? false); + }, + ); dependencies.credentials.logoutActive(); return logoutResult({ origin: active.origin, diff --git a/packages/cli/src/commands/hub/permissions.ts b/packages/cli/src/commands/hub/permissions.ts index ca9214b3e3..86f0b4c307 100644 --- a/packages/cli/src/commands/hub/permissions.ts +++ b/packages/cli/src/commands/hub/permissions.ts @@ -8,6 +8,7 @@ import { hubStatusResult } from "./status-output.js"; interface HubPermissionsOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; json?: boolean; } @@ -33,7 +34,7 @@ export function runHubPermissionsList( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ): Promise<ListResult<PermissionRow>> { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const status = (await client.getHubStatus()).status; requireConnectedHub(status); return { @@ -53,7 +54,7 @@ export function runHubPermissionChange( options: HubPermissionsOptions, dependencies: HubPermissionsDependencies, ) { - return withHubDaemon(dependencies.daemon, options.host, async (client) => { + return withHubDaemon(dependencies.daemon, options.daemonTarget, async (client) => { const current = (await client.getHubStatus()).status; requireConnectedHub(current); const response = await client.updateHubPermissions( diff --git a/packages/cli/src/commands/hub/starter-trigger.test.ts b/packages/cli/src/commands/hub/starter-trigger.test.ts index 570ea0005d..f664bc37e2 100644 --- a/packages/cli/src/commands/hub/starter-trigger.test.ts +++ b/packages/cli/src/commands/hub/starter-trigger.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { availableStarterTriggerConnections } from "./starter-trigger.js"; describe("starter trigger connections", () => { - it("returns only concrete connections that can back the generated workflow", () => { + it("returns only concrete connections that can back the generated trigger", () => { expect( availableStarterTriggerConnections( { @@ -14,8 +14,10 @@ describe("starter trigger connections", () => { repositories: ["getpaseo/paseo"], }, ], - slack: [{ teamId: "T123", teamName: "Paseo" }], - discord: [{ guildId: "456", guildName: "Paseo Discord" }], + slack: [{ slug: "paseo", teamName: "Paseo" }], + discord: [{ slug: "paseo-discord", guildName: "Paseo Discord" }], + daemons: [], + linear: [], }, "getpaseo/paseo", ), @@ -24,19 +26,19 @@ describe("starter trigger connections", () => { id: "github:getpaseo/paseo", label: "GitHub — getpaseo/paseo", provider: "github", - filters: { repo: "getpaseo/paseo" }, + filters: { connection: "github-getpaseo", repo: "getpaseo/paseo" }, }, { - id: "slack:T123", + id: "slack:paseo", label: "Slack — Paseo", provider: "slack", - filters: { workspace: "T123" }, + filters: { connection: "paseo" }, }, { - id: "discord:456", + id: "discord:paseo-discord", label: "Discord — Paseo Discord", provider: "discord", - filters: { guild: "456" }, + filters: { connection: "paseo-discord" }, }, ]); }); @@ -55,6 +57,8 @@ describe("starter trigger connections", () => { ], slack: [], discord: [], + daemons: [], + linear: [], }, "getpaseo/paseo", ), diff --git a/packages/cli/src/commands/hub/starter-trigger.ts b/packages/cli/src/commands/hub/starter-trigger.ts index c327beb32a..d5ce8b51ac 100644 --- a/packages/cli/src/commands/hub/starter-trigger.ts +++ b/packages/cli/src/commands/hub/starter-trigger.ts @@ -1,4 +1,4 @@ -import type { HubSetupResources } from "./hub-client/index.js"; +import type { HubConfigurationResources } from "./hub-client/index.js"; import type { HubInitProvider } from "./init-plan.js"; export interface HubStarterTriggerConnection { @@ -9,32 +9,36 @@ export interface HubStarterTriggerConnection { } export function availableStarterTriggerConnections( - resources: HubSetupResources, + resources: HubConfigurationResources, githubRepository?: string, ): HubStarterTriggerConnection[] { return [ - ...(githubRepository !== undefined && - resources.github.some(({ repositories }) => repositories.includes(githubRepository)) - ? [ - { - id: `github:${githubRepository}`, - label: `GitHub — ${githubRepository}`, - provider: "github" as const, - filters: { repo: githubRepository }, - }, - ] - : []), - ...resources.slack.map(({ teamId, teamName }) => ({ - id: `slack:${teamId}`, + ...resources.github.flatMap((connection) => + githubRepository !== undefined && connection.repositories.includes(githubRepository) + ? [ + { + id: `github:${githubRepository}`, + label: `GitHub — ${githubRepository}`, + provider: "github" as const, + filters: { + connection: connection.slug, + repo: githubRepository, + }, + }, + ] + : [], + ), + ...resources.slack.map(({ slug, teamName }) => ({ + id: `slack:${slug}`, label: `Slack — ${teamName}`, provider: "slack" as const, - filters: { workspace: teamId }, + filters: { connection: slug }, })), - ...resources.discord.map(({ guildId, guildName }) => ({ - id: `discord:${guildId}`, + ...resources.discord.map(({ slug, guildName }) => ({ + id: `discord:${slug}`, label: `Discord — ${guildName}`, provider: "discord" as const, - filters: { guild: guildId }, + filters: { connection: slug }, })), ]; } diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 1ce0ac4283..75bcee7478 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -1,17 +1,17 @@ -import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts"; +import { addLocalDaemonOptions } from "../utils/command-options.js"; +import { cancel, confirm, intro, isCancel, log, note, outro } from "@clack/prompts"; import { Command, Option } from "commander"; -import { writeFileSync } from "node:fs"; import path from "node:path"; -import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server"; import { - resolveLocalPaseoHome, - resolveLocalDaemonState, - resolveTcpHostFromListen, - startLocalDaemonDetached, - tailDaemonLog, - type DaemonStartOptions, -} from "./daemon/local-daemon.js"; -import { tryConnectToDaemon } from "../utils/client.js"; + readPersistedConfig as loadPersistedConfig, + savePersistedConfig, + type PersistedConfig, +} from "@getpaseo/server/configuration"; +import { readDaemonInstance, waitForDaemonReady } from "@getpaseo/server/daemon-control"; +import { withGlobalOptions } from "../utils/command-options.js"; +import type { CommandOptions } from "../output/index.js"; +import { launchLocalDaemon, parseTimeoutMs } from "./daemon/local-daemon.js"; +import { connectToDaemon } from "../utils/client.js"; import { formatPairingInstructions } from "../output/pairing.js"; import { confirmRelayPairing, @@ -19,7 +19,12 @@ import { resolveLocalPairingOffer, } from "./daemon/pair.js"; -interface OnboardOptions extends DaemonStartOptions { +interface OnboardOptions extends CommandOptions { + port?: string; + listen?: string; + relay?: boolean; + mcp?: boolean; + hostnames?: string; timeout?: string; voice?: "ask" | "enable" | "disable"; } @@ -39,41 +44,12 @@ type OnboardPersistedConfig = PersistedConfig & { }; }; -const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000; -const READY_PROBE_TIMEOUT_MS = 1200; - class OnboardCancelledError extends Error {} - const plainNoteFormat = (line: string): string => line; - function renderNote(message: string, title: string): void { note(message, title, { format: plainNoteFormat }); } -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function parseTimeoutMs(raw: string | undefined): number { - if (!raw || raw.trim().length === 0) { - return DEFAULT_READY_TIMEOUT_MS; - } - - const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) { - throw new Error(`Invalid timeout value: ${raw}`); - } - - return Math.ceil(seconds * 1000); -} - -function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { - const configPath = path.join(paseoHome, "config.json"); - writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); -} - function applyVoiceSelection( config: OnboardPersistedConfig, enabled: boolean, @@ -135,134 +111,6 @@ async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boo return answer; } -interface DownloadProgress { - modelId: string | null; - pct: number | null; -} - -function parseDownloadProgress(logTail: string): DownloadProgress | null { - const lines = logTail.split("\n").filter(Boolean); - - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]; - if (!line || !line.includes("Downloading model artifact")) { - continue; - } - - const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/); - const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/); - - return { - modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null, - pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null, - }; - } - - return null; -} - -function renderProgressLine(progress: DownloadProgress): string { - const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ""; - if (progress.pct === null) { - return `Downloading speech model${modelSuffix}...`; - } - return `Downloading speech model${modelSuffix}: ${progress.pct}%`; -} - -type ProbeResult = { kind: "ready"; listen: string; host: string | null } | { kind: "pending" }; - -async function probeDaemonReady(home: string, timeoutMs: number): Promise<ProbeResult> { - const state = resolveLocalDaemonState({ home }); - const host = resolveTcpHostFromListen(state.listen); - const deadline = Date.now() + timeoutMs; - const remainingTimeoutMs = () => Math.max(1, deadline - Date.now()); - - if (state.running && host) { - const client = await tryConnectToDaemon({ - host, - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - if (client) { - try { - await client.fetchAgents({ - timeout: Math.min(remainingTimeoutMs(), READY_PROBE_TIMEOUT_MS), - }); - return { kind: "ready", listen: state.listen, host }; - } catch { - // Daemon process is alive but not API-ready yet. - } finally { - await client.close().catch(() => {}); - } - } - } else if (state.running && !host) { - return { kind: "ready", listen: state.listen, host: null }; - } - - return { kind: "pending" }; -} - -interface ProgressState { - lastStatus: string; - lastPrintedAt: number; -} - -function announceProgress( - home: string, - state: ProgressState, - onStatus: ((message: string) => void) | undefined, -): ProgressState { - const progress = parseDownloadProgress(tailDaemonLog(home, 120) ?? ""); - const progressLine = progress ? renderProgressLine(progress) : null; - const statusMessage = progressLine ?? "Waiting for daemon to become ready..."; - - if (statusMessage !== state.lastStatus) { - onStatus?.(statusMessage); - return { lastStatus: statusMessage, lastPrintedAt: Date.now() }; - } - if (!onStatus && Date.now() - state.lastPrintedAt >= 3000) { - console.log(statusMessage); - return { lastStatus: state.lastStatus, lastPrintedAt: Date.now() }; - } - return state; -} - -async function waitForDaemonReady(args: { - home: string; - timeoutMs: number; - onStatus?: (message: string) => void; -}): Promise<{ listen: string; host: string | null }> { - const deadline = Date.now() + args.timeoutMs; - const createTimeoutError = () => { - const recentLogs = tailDaemonLog(args.home, 60); - return new Error( - [ - `Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`, - recentLogs ? `Recent daemon logs:\n${recentLogs}` : null, - ] - .filter(Boolean) - .join("\n\n"), - ); - }; - - async function poll(state: ProgressState): Promise<{ listen: string; host: string | null }> { - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - const probe = await probeDaemonReady(args.home, Math.max(1, deadline - Date.now())); - if (probe.kind === "ready") { - return { listen: probe.listen, host: probe.host }; - } - const nextState = announceProgress(args.home, state, args.onStatus); - if (Date.now() >= deadline) { - throw createTimeoutError(); - } - await sleep(200); - return poll(nextState); - } - - return poll({ lastStatus: "", lastPrintedAt: 0 }); -} - function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void { const daemonLogPath = path.join(paseoHome, "daemon.log"); const nextStepsLines = [ @@ -272,13 +120,13 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo "2. Web app: https://app.paseo.sh", "3. Desktop app: https://github.com/getpaseo/paseo/releases/latest", "4. Docs: https://paseo.sh/docs", - '5. Example: paseo run --output-schema schema.json "extract fields"', + `5. Example: paseo run --home ${JSON.stringify(paseoHome)} --output-schema schema.json "extract fields"`, ]; const quickReferenceLines = [ "1. paseo --help", - "2. paseo ls", - '3. paseo run "your prompt"', - "4. paseo status", + `2. paseo ls --home ${JSON.stringify(paseoHome)}`, + `3. paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`, + `4. paseo status --home ${JSON.stringify(paseoHome)}`, `5. Daemon logs: ${daemonLogPath}`, ]; @@ -301,11 +149,10 @@ function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: bo } export function onboardCommand(): Command { - return new Command("onboard") + return addLocalDaemonOptions(new Command("onboard")) .description("Run first-time setup, start daemon, and print pairing instructions") .option("--listen <listen>", "Listen target (host:port, port, or unix socket path)") .option("--port <port>", "Port to listen on (default: 6767)") - .option("--home <path>", "Paseo home directory (default: ~/.paseo)") .option("--relay", "Enable relay connection without prompting") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") @@ -316,12 +163,15 @@ export function onboardCommand(): Command { .addOption(new Option("--allowed-hosts <hosts>").hideHelp()) .option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)") .option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask") - .action(async (options: RawOnboardOptions) => { - await runOnboard({ - ...options, - hostnames: options.hostnames ?? options.allowedHosts, - }); - }); + .action( + withGlobalOptions(async (options: RawOnboardOptions, command: Command) => { + await runOnboard({ + ...options, + mcp: command.getOptionValueSource("mcp") === "cli" ? options.mcp : undefined, + hostnames: options.hostnames ?? options.allowedHosts, + }); + }), + ); } async function resolveAndPersistVoice( @@ -354,70 +204,30 @@ async function resolveAndPersistVoice( return voiceEnabled; } -async function ensureDaemonStarted(options: OnboardOptions, richUi: boolean): Promise<void> { - const stateBeforeStart = resolveLocalDaemonState({ home: options.home }); - if (stateBeforeStart.running) { - log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`); - return; - } - - const startSpinner = richUi ? spinner() : null; - try { - if (startSpinner) { - startSpinner.start("Starting daemon..."); - } else { - log.message("Starting daemon..."); - } - const startup = await startLocalDaemonDetached(options); - if (startSpinner) { - startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } else { - log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`); - } - log.message(`Logs: ${startup.logPath}`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (startSpinner) { - startSpinner.error(message); - } else { - log.error(message); - } - process.exit(1); - } -} - -async function waitForDaemonReadyWithUi(args: { - home: string; - timeoutMs: number; - richUi: boolean; -}): Promise<{ listen: string; host: string | null }> { - const readySpinner = args.richUi ? spinner() : null; - try { - if (readySpinner) { - readySpinner.start("Waiting for daemon to become ready..."); - } else { - log.message("Waiting for daemon to become ready..."); - } - const readyState = await waitForDaemonReady({ - home: args.home, - timeoutMs: args.timeoutMs, - onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined, - }); - if (readySpinner) { - readySpinner.stop(`Daemon ready on ${readyState.listen}`); - } else { - log.message(`Daemon ready on ${readyState.listen}`); - } - return readyState; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (readySpinner) { - readySpinner.error(message); - } else { - log.error(message); - } - return process.exit(1); +function persistSetupChoices(paseoHome: string, options: OnboardOptions): void { + const persisted = loadPersistedConfig(paseoHome, { defaultsIfMissing: true }); + if (options.listen || options.port) { + persisted.daemon = { + ...persisted.daemon, + listen: options.listen ?? `127.0.0.1:${options.port}`, + }; } + if (options.relay !== undefined) + persisted.daemon = { + ...persisted.daemon, + relay: { ...persisted.daemon?.relay, enabled: options.relay }, + }; + if (options.mcp !== undefined) + persisted.daemon = { + ...persisted.daemon, + mcp: { ...persisted.daemon?.mcp, enabled: options.mcp }, + }; + if (options.hostnames) + persisted.daemon = { + ...persisted.daemon, + hostnames: options.hostnames === "true" ? true : options.hostnames.split(","), + }; + savePersistedConfig(paseoHome, persisted); } export async function runOnboard(options: OnboardOptions): Promise<void> { @@ -431,16 +241,12 @@ export async function runOnboard(options: OnboardOptions): Promise<void> { process.exit(1); } - let timeoutMs = DEFAULT_READY_TIMEOUT_MS; - try { - timeoutMs = parseTimeoutMs(options.timeout); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - cancel(message); - process.exit(1); - } + const timeoutMs = parseTimeoutMs(options.timeout); - const paseoHome = resolveLocalPaseoHome(options.home); + if (options.daemonTarget.kind !== "instance") throw new Error("Onboarding requires a local home"); + const paseoHome = options.daemonTarget.home; + const alreadyRunning = await readDaemonInstance(paseoHome); + persistSetupChoices(paseoHome, options); if (richUi) { renderNote(paseoHome, "Paseo home"); } @@ -452,12 +258,19 @@ export async function runOnboard(options: OnboardOptions): Promise<void> { : "Voice features disabled. Local speech models will not be downloaded.", ); - await ensureDaemonStarted(options, richUi); - await waitForDaemonReadyWithUi({ - home: options.home ?? paseoHome, - timeoutMs, - richUi, - }); + if (alreadyRunning) { + log.message(`Daemon already running (PID ${alreadyRunning.pid}); retaining its supervisor.`); + const client = await connectToDaemon({ target: options.daemonTarget, timeout: timeoutMs }); + try { + log.message(JSON.stringify(await client.reloadDaemonConfig())); + } finally { + await client.close(); + } + } else { + await launchLocalDaemon({ home: paseoHome, timeoutMs }); + } + const ready = await waitForDaemonReady(paseoHome, { timeoutMs }); + log.message(`Daemon ready on ${ready.listen}`); if (options.relay === false) { log.message("Relay pairing skipped because --no-relay was provided."); diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index c815dd36f8..512a677811 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -1,7 +1,7 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; -import { spawnProcess } from "@getpaseo/server"; +import { spawnProcess } from "@getpaseo/server/process"; import { buildAgentDeepLink, type AgentDeepLinkTarget } from "@getpaseo/protocol/agent-deep-link"; function findDesktopApp(): string | null { @@ -95,6 +95,7 @@ export async function openDesktopWithProject(projectPath: string): Promise<void> try { launchDesktop([projectPath]); } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); process.exitCode = 1; diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index 15234c0d01..71bee3f73c 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -1,6 +1,9 @@ import type { Command } from "commander"; -import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission response item for display */ @@ -16,7 +19,7 @@ export interface PermissionResponseItem { export const permitResponseSchema: OutputSchema<PermissionResponseItem> = { idField: "requestId", columns: [ - { header: "REQUEST ID", field: "requestId", width: 12 }, + { header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 }, { header: "AGENT", field: "agentShortId", width: 10 }, { header: "TOOL", field: "name", width: 20 }, { @@ -32,6 +35,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = { ], }; +/** Transform a permission the agent answered to a response item */ +export function toPermissionResponseItem( + agentId: string, + permission: AgentPermissionRequest, + result: "allowed" | "denied", +): PermissionResponseItem { + return { + requestId: permission.id, + agentId, + agentShortId: agentId.slice(0, 7), + name: permission.name, + result, + }; +} + export type PermitAllowResult = ListResult<PermissionResponseItem>; export interface PermitAllowOptions extends CommandOptions { @@ -40,125 +58,136 @@ export interface PermitAllowOptions extends CommandOptions { host?: string; } -export async function runAllowCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitAllowOptions, - _command: Command, -): Promise<PermitAllowResult> { - const host = getDaemonHost({ host: options.host }); +export interface PermitClient { + fetchAgent(options: { agentId: string }): Promise<{ + agent: { id: string; pendingPermissions?: AgentPermissionRequest[] }; + } | null>; + respondToPermissionAndWait( + agentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<unknown>; + close(): Promise<void>; +} - // No validation needed - if no reqId provided, allow all by default +export type PermitClientConnector = ( + options: Parameters<typeof connectToDaemon>[0], +) => Promise<PermitClient>; - // Parse input JSON if provided - let updatedInput: Record<string, unknown> | undefined; - if (options.input) { - try { - updatedInput = JSON.parse(options.input); - } catch (err) { - const error: CommandError = { - code: "INVALID_JSON", - message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, - details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', - }; - throw error; - } - } +export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runAllowCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitAllowOptions, + _command: Command, + ): Promise<PermitAllowResult> { + // No validation needed - if no reqId provided, allow all by default - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } - - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); - const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, - }; - throw error; - } - - // Determine which permissions to allow - let permissionsToAllow: AgentPermissionRequest[]; - if (!reqId || options.all) { - // Default: allow all pending permissions if no req_id specified - // --all flag is kept as an explicit alias for clarity - permissionsToAllow = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); - if (!permission) { - await client.close(); + // Parse input JSON if provided + let updatedInput: Record<string, unknown> | undefined; + if (options.input) { + try { + updatedInput = JSON.parse(options.input); + } catch (err) { const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "INVALID_JSON", + message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`, + details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'', }; throw error; } - permissionsToAllow = [permission]; } - // Allow permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToAllow.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "allow", - ...(updatedInput ? { updatedInput } : {}), - }); - return { - requestId: permission.id.slice(0, 8), - agentId: resolvedAgentId, - agentShortId: resolvedAgentId.slice(0, 7), - name: permission.name, - result: "allowed", + const client = await connect({ target: options.daemonTarget }); + + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { + await client.close(); + const error: CommandError = { + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', }; - }), - ); + throw error; + } + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; - await client.close(); + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + }; + throw error; + } - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; + // Determine which permissions to allow + let permissionsToAllow: AgentPermissionRequest[]; + if (!reqId || options.all) { + // Default: allow all pending permissions if no req_id specified + // --all flag is kept as an explicit alias for clarity + permissionsToAllow = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId)); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToAllow = [permission]; + } + + // Allow permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToAllow.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "allow", + ...(updatedInput ? { updatedInput } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "allowed"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "ALLOW_PERMISSION_FAILED", + message: `Failed to allow permission: ${message}`, + }; + throw error; } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "ALLOW_PERMISSION_FAILED", - message: `Failed to allow permission: ${message}`, - }; - throw error; - } + }; } + +export const runAllowCommand = createAllowCommand(); diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index 94ffe9d571..69a4a35a4d 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -1,8 +1,13 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, CommandError } from "../../output/index.js"; -import { permitResponseSchema, type PermissionResponseItem } from "./allow.js"; +import { + permitResponseSchema, + toPermissionResponseItem, + type PermissionResponseItem, + type PermitClientConnector, +} from "./allow.js"; export type PermitDenyResult = ListResult<PermissionResponseItem>; @@ -13,117 +18,114 @@ export interface PermitDenyOptions extends CommandOptions { host?: string; } -export async function runDenyCommand( - agentIdOrPrefix: string, - reqId: string | undefined, - options: PermitDenyOptions, - _command: Command, -): Promise<PermitDenyResult> { - const host = getDaemonHost({ host: options.host }); - - // Validate arguments - if (!options.all && !reqId) { - const error: CommandError = { - code: "MISSING_ARGUMENT", - message: "Request ID is required unless --all is specified", - details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", - }; - throw error; - } - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } - - try { - const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); - if (!fetchResult) { - await client.close(); +export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) { + return async function runDenyCommand( + agentIdOrPrefix: string, + reqId: string | undefined, + options: PermitDenyOptions, + _command: Command, + ): Promise<PermitDenyResult> { + // Validate arguments + if (!options.all && !reqId) { const error: CommandError = { - code: "AGENT_NOT_FOUND", - message: `Agent not found: ${agentIdOrPrefix}`, - details: 'Use "paseo ls" to list available agents', - }; - throw error; - } - const agent = fetchResult.agent; - const resolvedAgentId = agent.id; - - // Get pending permissions for this agent - const pendingPermissions = agent.pendingPermissions || []; - if (pendingPermissions.length === 0) { - await client.close(); - const error: CommandError = { - code: "NO_PENDING_PERMISSIONS", - message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, + code: "MISSING_ARGUMENT", + message: "Request ID is required unless --all is specified", + details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all", }; throw error; } - // Determine which permissions to deny - let permissionsToDeny: AgentPermissionRequest[]; - if (options.all) { - permissionsToDeny = pendingPermissions; - } else { - // Find permission by ID prefix - const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!)); - if (!permission) { + const client = await connect({ target: options.daemonTarget }); + + try { + const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix }); + if (!fetchResult) { await client.close(); const error: CommandError = { - code: "PERMISSION_NOT_FOUND", - message: `Permission request not found: ${reqId}`, - details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + code: "AGENT_NOT_FOUND", + message: `Agent not found: ${agentIdOrPrefix}`, + details: 'Use "paseo ls" to list available agents', }; throw error; } - permissionsToDeny = [permission]; - } + const agent = fetchResult.agent; + const resolvedAgentId = agent.id; - // Deny permissions - const results: PermissionResponseItem[] = await Promise.all( - permissionsToDeny.map(async (permission) => { - await client.respondToPermission(resolvedAgentId, permission.id, { - behavior: "deny", - ...(options.message ? { message: options.message } : {}), - ...(options.interrupt ? { interrupt: true } : {}), - }); - return { - requestId: permission.id.slice(0, 8), - agentId: resolvedAgentId, - agentShortId: resolvedAgentId.slice(0, 7), - name: permission.name, - result: "denied", + // Get pending permissions for this agent + const pendingPermissions = agent.pendingPermissions || []; + if (pendingPermissions.length === 0) { + await client.close(); + const error: CommandError = { + code: "NO_PENDING_PERMISSIONS", + message: `No pending permissions for agent ${agent.id.slice(0, 7)}`, }; - }), - ); + throw error; + } - await client.close(); + // Determine which permissions to deny + let permissionsToDeny: AgentPermissionRequest[]; + if (options.all) { + permissionsToDeny = pendingPermissions; + } else { + // Find permission by ID prefix + const permission = pendingPermissions.find( + (p) => p.id === reqId || p.id.startsWith(reqId!), + ); + if (!permission) { + await client.close(); + const error: CommandError = { + code: "PERMISSION_NOT_FOUND", + message: `Permission request not found: ${reqId}`, + details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`, + }; + throw error; + } + permissionsToDeny = [permission]; + } - return { - type: "list", - data: results, - schema: permitResponseSchema, - }; - } catch (err) { - await client.close().catch(() => {}); - // Re-throw CommandErrors - if (err && typeof err === "object" && "code" in err) { - throw err; + // Deny permissions + const results: PermissionResponseItem[] = await Promise.all( + permissionsToDeny.map(async (permission) => { + try { + await client.respondToPermissionAndWait(resolvedAgentId, permission.id, { + behavior: "deny", + ...(options.message ? { message: options.message } : {}), + ...(options.interrupt ? { interrupt: true } : {}), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: `Permission response unconfirmed for request ${permission.id}: ${message}`, + details: 'Use "paseo permit ls" to check whether the request is still pending', + }; + throw error; + } + return toPermissionResponseItem(resolvedAgentId, permission, "denied"); + }), + ); + + await client.close(); + + return { + type: "list", + data: results, + schema: permitResponseSchema, + }; + } catch (err) { + await client.close().catch(() => {}); + // Re-throw CommandErrors + if (err && typeof err === "object" && "code" in err) { + throw err; + } + const message = err instanceof Error ? err.message : String(err); + const error: CommandError = { + code: "DENY_PERMISSION_FAILED", + message: `Failed to deny permission: ${message}`, + }; + throw error; } - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DENY_PERMISSION_FAILED", - message: `Failed to deny permission: ${message}`, - }; - throw error; - } + }; } + +export const runDenyCommand = createDenyCommand(); diff --git a/packages/cli/src/commands/permit/ls.ts b/packages/cli/src/commands/permit/ls.ts index adeaa70ff1..ca4543d6cf 100644 --- a/packages/cli/src/commands/permit/ls.ts +++ b/packages/cli/src/commands/permit/ls.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Permission list item for display */ @@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = { idField: "id", columns: [ { header: "AGENT", field: "agentShortId", width: 12 }, - { header: "REQ_ID", field: "id", width: 12 }, + { header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 }, { header: "TOOL", field: "name", width: 20 }, { header: "DESCRIPTION", field: "description", width: 50 }, ], @@ -30,7 +30,7 @@ function toListItem( permission: AgentPermissionRequest, ): PermissionListItem { return { - id: permission.id.slice(0, 8), + id: permission.id, agentId: agent.id, agentShortId: agent.id.slice(0, 7), name: permission.name, @@ -40,6 +40,22 @@ function toListItem( export type PermitLsResult = ListResult<PermissionListItem>; +/** Collect the pending permissions of every agent */ +export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult { + const items: PermissionListItem[] = []; + for (const agent of agents) { + for (const permission of agent.pendingPermissions ?? []) { + items.push(toListItem(agent, permission)); + } + } + + return { + type: "list", + data: items, + schema: permitLsSchema, + }; +} + export interface PermitLsOptions extends CommandOptions { host?: string; } @@ -48,41 +64,14 @@ export async function runLsCommand( options: PermitLsOptions, _command: Command, ): Promise<PermitLsResult> { - const host = getDaemonHost({ host: options.host }); - - let client; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); await client.close(); - // Collect all pending permissions from all agents - const items: PermissionListItem[] = []; - for (const agent of agents) { - if (agent.pendingPermissions && agent.pendingPermissions.length > 0) { - for (const permission of agent.pendingPermissions) { - items.push(toListItem(agent, permission)); - } - } - } - - return { - type: "list", - data: items, - schema: permitLsSchema, - }; + return listPendingPermissions(agents); } catch (err) { await client.close().catch(() => {}); const message = err instanceof Error ? err.message : String(err); diff --git a/packages/cli/src/commands/permit/permit-output.test.ts b/packages/cli/src/commands/permit/permit-output.test.ts new file mode 100644 index 0000000000..b77989f697 --- /dev/null +++ b/packages/cli/src/commands/permit/permit-output.test.ts @@ -0,0 +1,45 @@ +import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; +import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; +import { describe, expect, it } from "vitest"; +import { render } from "../../output/index.js"; +import { permitResponseSchema, toPermissionResponseItem } from "./allow.js"; +import { listPendingPermissions } from "./ls.js"; + +const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f"; + +function permissionRequest(id: string): AgentPermissionRequest { + return { id, provider: "claude", name: "Write", kind: "tool" }; +} + +const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783"); +const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb"); + +const agent = { + id: agentId, + pendingPermissions: [writeRequest, editRequest], +} as AgentSnapshotPayload; + +describe("permit ls output", () => { + it("emits full request ids that permit allow accepts", () => { + const result = listPendingPermissions([agent]); + + const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>; + expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]); + expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]); + }); +}); + +describe("permit allow and deny output", () => { + it("emits the full id of the request that was answered", () => { + const result = { + type: "list" as const, + data: [toPermissionResponseItem(agentId, writeRequest, "allowed")], + schema: permitResponseSchema, + }; + + const answered = JSON.parse(render(result, { format: "json" })) as Array<{ + requestId: string; + }>; + expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]); + }); +}); diff --git a/packages/cli/src/commands/permit/permit-response.test.ts b/packages/cli/src/commands/permit/permit-response.test.ts new file mode 100644 index 0000000000..5328b6ecb8 --- /dev/null +++ b/packages/cli/src/commands/permit/permit-response.test.ts @@ -0,0 +1,177 @@ +import { setImmediate as waitForCommandContinuations } from "node:timers/promises"; +import { Command } from "commander"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, +} from "@getpaseo/protocol/agent-types"; +import { describe, expect, it } from "vitest"; +import { createAllowCommand, type PermitClient } from "./allow.js"; +import { createDenyCommand } from "./deny.js"; + +const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f"; +const requests: AgentPermissionRequest[] = [ + { + id: "permission-write", + provider: "codex", + name: "CodexBash", + kind: "tool", + }, + { id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" }, +]; + +class PermissionClient implements PermitClient { + readonly pending = [...requests]; + readonly responses = new Map<string, AgentPermissionResponse>(); + closed = false; + private notifyReceived = () => {}; + private readonly confirmations = new Map< + string, + { + resolve: () => void; + reject: (error: Error) => void; + } + >(); + + async fetchAgent() { + return { agent: { id: agentId, pendingPermissions: this.pending } }; + } + + async respondToPermission( + _agentId: string, + requestId: string, + response: AgentPermissionResponse, + ) { + this.responses.set(requestId, response); + this.notifyReceived(); + } + + async respondToPermissionAndWait( + responseAgentId: string, + requestId: string, + response: AgentPermissionResponse, + ): Promise<void> { + const confirmation = new Promise<void>((resolve, reject) => { + this.confirmations.set(requestId, { resolve, reject }); + }); + await this.respondToPermission(responseAgentId, requestId, response); + await confirmation; + } + + async close() { + this.closed = true; + } + + async waitForResponses(count: number) { + while (this.responses.size < count) { + await new Promise<void>((resolve) => { + this.notifyReceived = resolve; + }); + } + } + + confirm(requestId: string) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + this.pending.splice( + this.pending.findIndex((request) => request.id === requestId), + 1, + ); + confirmation.resolve(); + } + + fail(requestId: string, error: Error) { + const confirmation = this.confirmations.get(requestId); + if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`); + confirmation.reject(error); + } +} + +const options = { + daemonTarget: { kind: "endpoint" as const, host: "test.invalid" }, +}; +const actions = [ + { name: "allow", create: createAllowCommand, result: "allowed" }, + { name: "deny", create: createDenyCommand, result: "denied" }, +]; +for (const action of actions) { + describe(`permit ${action.name}`, () => { + it("returns success and closes only after confirmation", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(1); + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + expect(client.pending).toEqual(requests); + client.confirm(requests[0].id); + const result = await command; + expect(result.data).toEqual([ + { + requestId: requests[0].id, + agentId, + agentShortId: agentId.slice(0, 7), + name: "CodexBash", + result: action.result, + }, + ]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([requests[1]]); + }); + + it("waits for every confirmation with --all", async () => { + const client = new PermissionClient(); + let completed = false; + const command = action.create(async () => client)( + agentId, + undefined, + { ...options, all: true }, + new Command(), + ); + void command.then( + () => (completed = true), + () => false, + ); + await client.waitForResponses(2); + client.confirm(requests[0].id); + // Wait beyond the first reply's promise continuations while withholding the second. + await waitForCommandContinuations(); + expect(completed).toBe(false); + expect(client.closed).toBe(false); + client.confirm(requests[1].id); + const result = await command; + expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]); + expect(client.closed).toBe(true); + expect(client.pending).toEqual([]); + }); + + it("rejects an unconfirmed response without returning success", async () => { + const client = new PermissionClient(); + const command = action.create(async () => client)( + agentId, + requests[0].id, + options, + new Command(), + ); + const failure = expect(command).rejects.toMatchObject({ + code: "PERMISSION_RESPONSE_UNCONFIRMED", + message: expect.stringContaining(requests[0].id), + details: 'Use "paseo permit ls" to check whether the request is still pending', + }); + await client.waitForResponses(1); + client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)")); + await failure; + expect(client.closed).toBe(true); + expect(client.pending).toEqual(requests); + }); + }); +} diff --git a/packages/cli/src/commands/plugin/index.test.ts b/packages/cli/src/commands/plugin/index.test.ts index 11e80480f4..8ea6de9dfb 100644 --- a/packages/cli/src/commands/plugin/index.test.ts +++ b/packages/cli/src/commands/plugin/index.test.ts @@ -1,6 +1,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const listPlugins = vi.fn(async () => []); +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + +const listPlugins = vi.fn(async () => [ + { + id: "git-plugin", + path: "/plugins/git-plugin", + enabled: true, + status: "running" as const, + source: "git" as const, + commit: "1557a34c91e2abcdef", + ref: "main", + installation: { + identity: { + kind: "git" as const, + remote: "https://example.test/plugin.git", + pluginPath: ".", + }, + currentRevision: "1557a34c91e2abcdef", + }, + }, + { + id: "legacy-plugin", + path: "/plugins/legacy-plugin", + enabled: true, + status: "failed" as const, + error: "This plugin was made for an older version of Paseo", + }, +]); const getPluginLogs = vi.fn(async () => [ { sequence: 1, @@ -9,23 +36,26 @@ const getPluginLogs = vi.fn(async () => [ message: "ready", }, ]); -const installDirectoryPlugin = vi.fn(async () => ({ - id: "trusted-plugin", - path: "/plugins/trusted-plugin", - enabled: true, - status: "running" as const, -})); const installPluginSource = vi.fn(async () => ({ id: "trusted-plugin", path: "/plugins/trusted-plugin", enabled: true, status: "running" as const, })); +const installDirectoryPlugin = vi.fn(async () => ({ + id: "legacy-plugin", + path: "/plugins/legacy-plugin", + enabled: true, + status: "running" as const, +})); +const previewPluginUpdates = vi.fn(async () => []); const close = vi.fn(async () => undefined); const features: { pluginManagement?: boolean; pluginLogs?: boolean; pluginGitManagement?: boolean; + pluginSourceInstallation?: boolean; + pluginSourceUpdates?: boolean; } = {}; vi.mock("../../utils/client.js", () => ({ @@ -35,23 +65,34 @@ vi.mock("../../utils/client.js", () => ({ getPluginLogs, installDirectoryPlugin, installPluginSource, + previewPluginUpdates, close, })), })); import { render } from "../../output/index.js"; -import { createPluginCommand, runPluginListCommand, runPluginLogsCommand } from "./index.js"; +import { + createPluginCommand, + runPluginInstallCommand, + runPluginUpdateCommand, + runPluginListCommand, + runPluginLogsCommand, +} from "./index.js"; describe("plugin management commands", () => { beforeEach(() => { features.pluginManagement = false; features.pluginLogs = false; features.pluginGitManagement = false; + features.pluginSourceInstallation = false; + features.pluginSourceUpdates = false; vi.clearAllMocks(); }); it("requires host support before attempting a management RPC", async () => { - await expect(runPluginListCommand({}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginListCommand(undefined, { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to use plugin management.", }); @@ -59,8 +100,41 @@ describe("plugin management commands", () => { expect(close).toHaveBeenCalledTimes(1); }); + it("keeps status as a hidden alias for the network-free plugin list", async () => { + features.pluginManagement = true; + const command = createPluginCommand(); + + await command.parseAsync(["status"], { from: "user" }); + + expect(listPlugins).toHaveBeenCalledTimes(1); + expect(command.helpInformation()).not.toContain("status"); + }); + + it("lists runtime state and the installed commit without an upstream commit", async () => { + features.pluginManagement = true; + + const result = await runPluginListCommand(undefined, { daemonTarget }, {} as never); + const output = render(result, { noColor: true }); + + expect(output).toContain("SOURCE"); + expect(output).toContain("REVISION"); + expect(output).not.toContain("LATEST"); + expect(output).toContain("1557a34c91e2"); + expect(output).toContain("This plugin was made for an older version of Paseo"); + }); + + it("filters the shared ls and status command by plugin ID", async () => { + features.pluginManagement = true; + + const result = await runPluginListCommand("legacy-plugin", { daemonTarget }, {} as never); + + expect(result.data.map((plugin) => plugin.id)).toEqual(["legacy-plugin"]); + }); + it("requires plugin log support before attempting the RPC", async () => { - await expect(runPluginLogsCommand("example", {}, {} as never)).rejects.toMatchObject({ + await expect( + runPluginLogsCommand("example", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to view plugin logs.", }); @@ -69,7 +143,7 @@ describe("plugin management commands", () => { it("returns readable and JSON plugin log output", async () => { features.pluginLogs = true; - const result = await runPluginLogsCommand("example", {}, {} as never); + const result = await runPluginLogsCommand("example", { daemonTarget }, {} as never); expect(getPluginLogs).toHaveBeenCalledWith("example"); expect(render(result, { noColor: true })).toContain("ready"); @@ -92,36 +166,107 @@ describe("plugin management commands", () => { }); it("prints the trust acknowledgement before installing", async () => { - features.pluginManagement = true; + features.pluginSourceInstallation = true; const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); await command.parseAsync(["install", "/plugins/trusted-plugin"], { from: "user" }); expect(stderr).toHaveBeenCalledWith( - expect.stringContaining("Git build commands run unsandboxed on the daemon host"), + expect.stringContaining("preparation commands run unsandboxed on the daemon host"), ); - expect(installDirectoryPlugin).toHaveBeenCalledWith("/plugins/trusted-plugin", undefined); + expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/trusted-plugin" }); stderr.mockRestore(); }); - it("folds the legacy --path option into the plugin source reference", async () => { + it("requires source support for directory installs without using the legacy RPC", async () => { + features.pluginManagement = true; features.pluginGitManagement = true; const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await expect( + runPluginInstallCommand("/plugins/trusted-plugin", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to install plugin sources.", + }); + expect(installPluginSource).not.toHaveBeenCalled(); + expect(installDirectoryPlugin).not.toHaveBeenCalled(); + stderr.mockRestore(); + }); + + it.each([undefined, false, true])( + "installs with source support and Git support=%s", + async (gitSupport) => { + features.pluginSourceInstallation = true; + features.pluginGitManagement = gitSupport; + await runPluginInstallCommand("npm:@acme/review@^1.0.0", { daemonTarget }, {} as never); + expect(installPluginSource).toHaveBeenCalledWith({ source: "npm:@acme/review@^1.0.0" }); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + + it.each([undefined, false])( + "rejects installs before an RPC when source support=%s despite Git support", + async (sourceSupport) => { + features.pluginGitManagement = true; + features.pluginSourceInstallation = sourceSupport; + await expect( + runPluginInstallCommand("owner/review", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to install plugin sources.", + }); + expect(installPluginSource).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + + it.each([undefined, false, true])( + "reviews updates with source install support=%s", + async (sourceSupport) => { + features.pluginSourceUpdates = true; + features.pluginSourceInstallation = sourceSupport; + await runPluginUpdateCommand("review", { daemonTarget }, {} as never); + expect(previewPluginUpdates).toHaveBeenCalledWith({ pluginId: "review", target: undefined }); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + + it.each([undefined, false])( + "rejects reviewed updates when support=%s despite legacy Git support", + async (gitSupport) => { + features.pluginSourceInstallation = true; + features.pluginGitManagement = true; + features.pluginSourceUpdates = gitSupport; + await expect( + runPluginUpdateCommand("review", { daemonTarget }, {} as never), + ).rejects.toMatchObject({ + code: "DAEMON_UPDATE_REQUIRED", + message: "Update the host to review plugin updates.", + }); + expect(previewPluginUpdates).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }, + ); + + it("folds the legacy --path option into the plugin source reference", async () => { + features.pluginSourceInstallation = true; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); - await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], { + await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], { from: "user", }); expect(installPluginSource).toHaveBeenCalledWith({ - source: "owner/monorepo:plugins/review", + source: "git:owner/monorepo:plugins/review", }); stderr.mockRestore(); }); it("keeps an absolute monorepo path as one plugin source reference", async () => { - features.pluginGitManagement = true; + features.pluginSourceInstallation = true; const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const command = createPluginCommand(); @@ -130,7 +275,113 @@ describe("plugin management commands", () => { expect(installPluginSource).toHaveBeenCalledWith({ source: "/plugins/monorepo:plugins/review", }); - expect(installDirectoryPlugin).not.toHaveBeenCalled(); stderr.mockRestore(); }); }); + +import { reviewPluginUpdates, type UpdateInteraction } from "./update.js"; +import type { + PluginUpdatePreview, + PluginUpdateProposal, + PluginUpdateResult, +} from "@getpaseo/protocol/messages"; +function reviewFixture(answer = true, outcome: "update" | "installed-newer" = "update") { + const proposal: PluginUpdateProposal = { + id: "review", + expected: { + identity: { kind: "npm", packageName: "review", pluginPath: "." }, + installationRoot: "/owned/one", + revision: "1.0.0", + }, + target: { + kind: "npm", + version: "1.1.0", + resolved: "https://registry.npmjs.org/review.tgz", + integrity: "sha512-test", + }, + }; + const preview: PluginUpdatePreview = { + id: "review", + outcome, + links: ["https://npmjs.com/package/review/v/1.1.0"], + current: { identity: proposal.expected.identity, currentRevision: "1.0.0" }, + target: proposal.target, + ...(outcome === "update" ? { proposal } : {}), + }; + const applied: PluginUpdateProposal[][] = []; + const questions: string[] = []; + const output: string[] = []; + const client = { + previewPluginUpdates: async () => [preview], + applyPluginUpdates: async ( + proposals: PluginUpdateProposal[], + ): Promise<PluginUpdateResult[]> => { + applied.push(proposals); + return [{ id: "review", outcome: "updated" }]; + }, + }; + const interaction: UpdateInteraction = { + interactive: true, + structured: false, + write: (text) => { + output.push(text); + }, + confirm: async (question) => { + questions.push(question); + return answer; + }, + }; + return { client, interaction, proposal, applied, questions, output }; +} +describe("reviewed plugin update workflow", () => { + it("prints target and links, then declines without applying", async () => { + const f = reviewFixture(false); + expect(await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction)).toEqual([ + { id: "review", outcome: "declined" }, + ]); + expect(f.output.join("")).toContain("1.0.0 → 1.1.0"); + expect(f.output.join("")).toContain("Review: https://"); + expect(f.questions).toEqual(["Update review? [y/N] "]); + expect(f.applied).toEqual([]); + }); + it("applies exactly the displayed proposal after approval", async () => { + const f = reviewFixture(); + await reviewPluginUpdates(f.client, { pluginId: "review" }, f.interaction); + expect(f.applied).toEqual([[f.proposal]]); + }); + it.each([{ yes: true }, { version: "stable" }, { ref: "v1" }])( + "skips questions for %j", + async (options) => { + const f = reviewFixture(); + await reviewPluginUpdates(f.client, { pluginId: "review", ...options }, f.interaction); + expect(f.questions).toEqual([]); + expect(f.applied).toEqual([[f.proposal]]); + }, + ); + it("check takes precedence over yes and explicit targets", async () => { + const f = reviewFixture(); + await reviewPluginUpdates( + f.client, + { pluginId: "review", check: true, yes: true, version: "1.1.0" }, + f.interaction, + ); + expect(f.applied).toEqual([]); + expect(f.questions).toEqual([]); + }); + it("yes never applies an ordinary downgrade", async () => { + const f = reviewFixture(true, "installed-newer"); + await reviewPluginUpdates(f.client, { all: true, yes: true }, f.interaction); + expect(f.applied).toEqual([]); + expect(f.questions).toEqual([]); + }); + it.each([{ interactive: false }, { structured: true }])( + "requires explicit approval for %j", + async (flags) => { + const f = reviewFixture(); + await expect( + reviewPluginUpdates(f.client, { pluginId: "review" }, { ...f.interaction, ...flags }), + ).rejects.toThrow("Confirmation required"); + expect(f.applied).toEqual([]); + }, + ); +}); diff --git a/packages/cli/src/commands/plugin/index.ts b/packages/cli/src/commands/plugin/index.ts index 016938d947..86dc5889b8 100644 --- a/packages/cli/src/commands/plugin/index.ts +++ b/packages/cli/src/commands/plugin/index.ts @@ -1,14 +1,13 @@ -import { Command } from "commander"; import path from "node:path"; -import type { - PluginListItem, - PluginLogEntry, - PluginSourceStatusItem, - PluginSourceUpdateItem, -} from "@getpaseo/protocol/messages"; +import { expandUserPath } from "../../classify.js"; +import { createInterface } from "node:readline/promises"; +import { reviewPluginUpdates, type UpdateOutcome } from "./update.js"; +import { Command } from "commander"; +import type { PluginListItem, PluginLogEntry } from "@getpaseo/protocol/messages"; import { formatPluginSourceReference, parsePluginSourceReference, + formatPluginIdentity, } from "@getpaseo/protocol/plugin-source-reference"; import type { CommandOptions, ListResult, OutputSchema, SingleResult } from "../../output/index.js"; import { withOutput } from "../../output/index.js"; @@ -16,6 +15,7 @@ import { addJsonAndDaemonHostOptions, addJsonOption } from "../../utils/command- import { scaffoldPluginDirectory, type PluginScaffold } from "./scaffold.js"; import { withPluginLogsClient, + withPluginUpdateClient, withPluginManagementClient, withPluginSourceClient, } from "./shared.js"; @@ -26,6 +26,9 @@ interface PluginOptions extends CommandOptions { ref?: string; path?: string; all?: boolean; + version?: string; + check?: boolean; + yes?: boolean; } const pluginSchema: OutputSchema<PluginListItem> = { @@ -34,6 +37,17 @@ const pluginSchema: OutputSchema<PluginListItem> = { { header: "PLUGIN", field: "id", width: 20 }, { header: "STATUS", field: "status", width: 10 }, { header: "ENABLED", field: (plugin) => (plugin.enabled ? "yes" : "no"), width: 8 }, + { + header: "SOURCE", + field: (plugin) => + plugin.installation ? formatPluginIdentity(plugin.installation.identity) : "-", + width: 40, + }, + { + header: "REVISION", + field: (plugin) => plugin.installation?.currentRevision ?? "-", + width: 16, + }, { header: "DIRECTORY", field: "path", width: 40 }, { header: "ERROR", field: (plugin) => plugin.error ?? "", width: 40 }, ], @@ -56,30 +70,17 @@ const pluginLogsSchema: OutputSchema<PluginLogEntry> = { ], }; -function shortCommit(commit: string | undefined): string { - return commit?.slice(0, 12) ?? "-"; -} - -const pluginStatusSchema: OutputSchema<PluginSourceStatusItem> = { +const pluginUpdateSchema: OutputSchema<UpdateOutcome> = { idField: "id", columns: [ - { header: "PLUGIN", field: "id", width: 20 }, - { header: "SOURCE", field: "source", width: 10 }, - { header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 }, - { header: "LATEST", field: (plugin) => shortCommit(plugin.latestCommit), width: 14 }, - { header: "COMMITS", field: (plugin) => String(plugin.commitsBehind ?? 0), width: 8 }, - { header: "REF", field: (plugin) => plugin.ref ?? "-", width: 24 }, - ], -}; - -const pluginUpdateSchema: OutputSchema<PluginSourceUpdateItem> = { - idField: "id", - columns: [ - { header: "PLUGIN", field: "id", width: 20 }, - { header: "PREVIOUS", field: (plugin) => shortCommit(plugin.previousCommit), width: 14 }, - { header: "CURRENT", field: (plugin) => shortCommit(plugin.currentCommit), width: 14 }, - { header: "COMMITS", field: (plugin) => String(plugin.commits), width: 8 }, - { header: "UPDATED", field: (plugin) => (plugin.updated ? "yes" : "no"), width: 8 }, + { header: "PLUGIN", field: "id", width: 24 }, + { header: "RESULT", field: "outcome", width: 18 }, + { + header: "DETAIL", + field: (item) => + item.error ?? item.warning ?? item.plugin?.installation?.currentRevision ?? "", + width: 50, + }, ], }; @@ -96,10 +97,15 @@ export async function runPluginInitCommand( } export async function runPluginListCommand( + pluginId: string | undefined, options: PluginOptions, _command: Command, ): Promise<ListResult<PluginListItem>> { - const data = await withPluginManagementClient(options.host, (client) => client.listPlugins()); + const plugins = await withPluginManagementClient(options.daemonTarget, (client) => + client.listPlugins(), + ); + const data = pluginId ? plugins.filter((plugin) => plugin.id === pluginId) : plugins; + if (pluginId && data.length === 0) throw new Error(`Plugin is not configured: ${pluginId}`); return { type: "list", data, schema: pluginSchema }; } @@ -108,66 +114,71 @@ export async function runPluginLogsCommand( options: PluginOptions, _command: Command, ): Promise<ListResult<PluginLogEntry>> { - const data = await withPluginLogsClient(options.host, (client) => client.getPluginLogs(pluginId)); + const data = await withPluginLogsClient(options.daemonTarget, (client) => + client.getPluginLogs(pluginId), + ); return { type: "list", data, schema: pluginLogsSchema }; } -async function install( +export async function runPluginInstallCommand( source: string, options: PluginOptions, _command: Command, ): Promise<SingleResult<PluginListItem>> { process.stderr.write( - "Trusting plugin code: server code and Git build commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", + "Trusting plugin code: server code and preparation commands run unsandboxed on the daemon host; client code runs inside Paseo. Dependencies and future updates are part of the codebase you trust.\n", + ); + const reference = parsePluginSourceReference(source); + let resolvedSource = source; + if (reference.kind === "directory") { + const directory = expandUserPath(reference.source); + // An absolute path can target a daemon on a different operating system. + const absoluteDirectory = + path.posix.isAbsolute(directory) || path.win32.isAbsolute(directory) + ? directory + : path.resolve(directory); + resolvedSource = formatPluginSourceReference(absoluteDirectory, reference.pluginPath); + } + const sourceReference = formatPluginSourceReference(resolvedSource, options.path); + const data = await withPluginSourceClient(options.daemonTarget, (client) => + client.installPluginSource({ + source: sourceReference, + ...(options.id ? { id: options.id } : {}), + ...(options.ref ? { ref: options.ref } : {}), + }), ); - const isExplicitPath = - path.isAbsolute(source) || - source === "." || - source === ".." || - source.startsWith("./") || - source.startsWith("../") || - source.startsWith(".\\") || - source.startsWith("..\\"); - const hasPluginPathSuffix = parsePluginSourceReference(source).pluginPath !== undefined; - const canUseLegacyDirectoryInstall = - isExplicitPath && !hasPluginPathSuffix && !options.ref && !options.path; - const sourceReference = formatPluginSourceReference(source, options.path); - const data = canUseLegacyDirectoryInstall - ? await withPluginManagementClient(options.host, (client) => - client.installDirectoryPlugin(source, options.id), - ) - : await withPluginSourceClient(options.host, (client) => - client.installPluginSource({ - source: sourceReference, - ...(options.id ? { id: options.id } : {}), - ...(options.ref ? { ref: options.ref } : {}), - }), - ); return { type: "single", data, schema: pluginSchema }; } -async function status( +export async function runPluginUpdateCommand( pluginId: string | undefined, options: PluginOptions, _command: Command, -): Promise<ListResult<PluginSourceStatusItem>> { - const data = await withPluginSourceClient(options.host, (client) => - client.getPluginSourceStatus(pluginId), - ); - return { type: "list", data, schema: pluginStatusSchema }; -} - -async function update( - pluginId: string | undefined, - options: PluginOptions, - _command: Command, -): Promise<ListResult<PluginSourceUpdateItem>> { - if ((pluginId === undefined) === (options.all !== true)) { - throw new Error("Choose one plugin ID or pass --all"); - } - const data = await withPluginSourceClient(options.host, (client) => - client.updatePluginSources(pluginId), +): Promise<ListResult<UpdateOutcome>> { + const data = await withPluginUpdateClient(options.daemonTarget, (client) => + reviewPluginUpdates( + client, + { ...options, pluginId }, + { + interactive: process.stdin.isTTY === true, + structured: + options.json === true || + ["json", "yaml"].includes(options.format?.trim().toLowerCase() ?? ""), + write: (text) => process.stderr.write(text), + confirm: async (message) => { + const prompt = createInterface({ input: process.stdin, output: process.stderr }); + try { + return /^(y|yes)$/i.test((await prompt.question(message)).trim()); + } catch { + return false; + } finally { + prompt.close(); + } + }, + }, + ), ); + if (data.some((item) => item.outcome === "error")) process.exitCode = 1; return { type: "list", data, schema: pluginUpdateSchema }; } @@ -176,7 +187,7 @@ async function act( pluginId: string, options: PluginOptions, ): Promise<SingleResult<PluginListItem>> { - const data = await withPluginManagementClient(options.host, (client) => + const data = await withPluginManagementClient(options.daemonTarget, (client) => client[`${action}Plugin`](pluginId), ); return { type: "single", data, schema: pluginSchema }; @@ -187,7 +198,7 @@ async function remove( options: PluginOptions, _command: Command, ): Promise<SingleResult<PluginListItem>> { - const data = await withPluginManagementClient(options.host, async (client) => { + const data = await withPluginManagementClient(options.daemonTarget, async (client) => { const current = (await client.listPlugins()).find((plugin) => plugin.id === pluginId); if (!current) throw new Error(`Plugin is not configured: ${pluginId}`); await client.removePlugin(pluginId); @@ -205,7 +216,10 @@ export function createPluginCommand(): Command { .argument("<directory>") .option("--id <id>", "Manifest plugin ID (defaults to the directory name)"), ).action(withOutput(runPluginInitCommand)); - addJsonAndDaemonHostOptions(plugin.command("ls").description("List configured plugins")).action( + addJsonAndDaemonHostOptions( + plugin.command("ls").description("List configured plugins").argument("[id]"), + ).action(withOutput(runPluginListCommand)); + addJsonAndDaemonHostOptions(plugin.command("status", { hidden: true }).argument("[id]")).action( withOutput(runPluginListCommand), ); addJsonAndDaemonHostOptions( @@ -215,22 +229,28 @@ export function createPluginCommand(): Command { plugin .command("install") .alias("add") - .description("Trust and install a plugin from a directory or Git repository") - .argument("<source>", "Host directory, Git source, or Git source:plugin/path") + .description( + "Trust and install a plugin from a registry, directory, Git repository, or npm package", + ) + .argument( + "<source>", + "Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source", + ) .option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)") .option("--ref <ref>", "Git branch, tag, or commit") .option("--path <path>", "Legacy form of the :plugin/path source suffix"), - ).action(withOutput(install)); - addJsonAndDaemonHostOptions( - plugin.command("status").description("Check plugin source updates").argument("[id]"), - ).action(withOutput(status)); + ).action(withOutput(runPluginInstallCommand)); addJsonAndDaemonHostOptions( plugin .command("update") - .description("Update a Git-managed plugin") + .description("Review and update installed plugins") .argument("[id]") - .option("--all", "Update every Git-managed plugin"), - ).action(withOutput(update)); + .option("--all", "Review all configured plugins") + .option("--check", "Show available updates without installing") + .option("--yes", "Apply displayed updates without asking") + .option("--ref <ref>", "Apply a Git branch, tag, or commit without asking") + .option("--version <version>", "Apply an npm version, tag, or range without asking"), + ).action(withOutput(runPluginUpdateCommand)); for (const action of ["reload", "enable", "disable"] as const) { addJsonAndDaemonHostOptions( plugin.command(action).description(`${action} a plugin`).argument("<id>"), diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index 79db0e2878..a3350ecb8c 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -1,4 +1,4 @@ -import { execFile } from "node:child_process"; +import { exec, execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js"; const directories: string[] = []; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc"); async function typecheckPlugin(directory: string): Promise<void> { @@ -28,6 +29,47 @@ afterEach(async () => { }); describe("plugin scaffold", () => { + it("creates an author overview beside the manifest", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "hello-plugin"); + await scaffoldPluginDirectory(directory); + + const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8"); + const comments = overview.match(/<!--[\s\S]*?-->/g) ?? []; + expect(comments).toHaveLength(1); + expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe(""); + expect(overview).toContain("required to list"); + expect(overview).toContain("README"); + expect(overview).toContain("choosing a plugin in Paseo"); + expect(overview).toContain("Length follows complexity"); + expect(overview).toContain("1. Describe what your plugin is and does"); + expect(overview).toContain("2. Explain how it works only when it is not obvious"); + expect(overview).toContain("3. Explain setup when needed"); + expect(overview).toContain("4. Explain capabilities and settings"); + expect(overview).toContain("permissions"); + expect(overview).toContain("known limits"); + expect(overview).not.toContain("This plugin entry was imported"); + const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(packageJson.files).toContain("OVERVIEW.md"); + expect(await readdir(directory)).toContain("paseo-plugin.json"); + }); + + it("includes the author overview in the packed npm artifact", async () => { + const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "packed-plugin"); + await scaffoldPluginDirectory(directory); + + const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", { + cwd: directory, + }); + const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[]; + expect(artifact.files.map((file) => file.path)).toEqual( + expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]), + ); + }); + it( "creates a standalone split-runtime project that typechecks", { timeout: 15_000 }, @@ -45,18 +87,32 @@ describe("plugin scaffold", () => { expect(config.compilerOptions.lib).toEqual(["ES2023"]); expect(config.compilerOptions.types).toEqual(["react"]); await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); - expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( - { - id: "hello-plugin", - }, - ); const cliPackageJson = JSON.parse( await readFile(new URL("../../../package.json", import.meta.url), "utf8"), ) as { version: string }; + expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( + { + id: "hello-plugin", + $comment: expect.stringContaining('"icon": "assets/icon.png"'), + media: [], + requirements: { paseo: `>=${cliPackageJson.version}` }, + }, + ); expect(JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"))).toEqual({ name: "hello-plugin", private: true, version: "0.0.0", + files: [ + "paseo-plugin.json", + "OVERVIEW.md", + "index.client.ts", + "index.client.tsx", + "index.server.ts", + "index.server.tsx", + "client/", + "server/", + "shared/", + ], scripts: { typecheck: "tsc --noEmit" }, devDependencies: { "@getpaseo/plugin": cliPackageJson.version, @@ -140,16 +196,8 @@ export async function inspectConfig( path.join(directory, "client", "main.tsx"), `import React from "react"; import { Text } from "react-native"; -import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native"; -import { - type PluginAgentPanelProps, - type PluginClientContext, - type PluginComposerPillProps, - type PluginSurfaceProps, - useAgent, - usePaseo, - useWorkspace, -} from "@getpaseo/plugin"; +import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native"; +import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client"; import { inspect } from "../shared/inspect"; export function Surface({ navigation }: PluginSurfaceProps) { @@ -160,6 +208,8 @@ export function Surface({ navigation }: PluginSurfaceProps) { }); navigation?.openAgent({ agentId: "agent-1" }); navigation?.openWorkspace({ workspaceId: "workspace-1" }); + navigation?.openAgent({ serverId: "server-2", agentId: "agent-2" }); + navigation?.openWorkspace({ serverId: "server-2", workspaceId: "workspace-2" }); void createWorkspace; return <><Icon name="Settings" size={18} color="#123456" /><Text onPress={() => toast.show("Ready")}>Paseo API</Text><Modal title="Example" icon={<Icon name="Settings" />} open={false} onOpenChange={() => {}}><Modal.Content><Text>Modal</Text></Modal.Content></Modal></>; } @@ -178,33 +228,52 @@ export function AgentPanel({ workspaceId, agentId }: PluginAgentPanelProps) { return <Text>{workspaceName}: {agentTitle}</Text>; } -export function ComposerPill({ workspaceId, agentId }: PluginComposerPillProps) { - return <Text>{workspaceId}: {agentId}</Text>; -} - export function contributeClient(client: PluginClientContext) { - return client.addComposerPill({ - id: "open-review", - title: "Open review", + const header = client.addHeaderButton({ + id: "review-tools", workspaceId: "workspace-a", - agentId: "agent-a", - Component: ComposerPill, - async onPress() { - await client.rpc(inspect, {}); - client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" }); + button: { + title: "Review tools", + icon: "Scan", + label: "Review", + behavior: { + kind: "menu", + items: [{ kind: "item", id: "refresh", title: "Refresh review", behavior: { + kind: "action", async onPress() { await client.rpc(inspect, {}); }, + } }], + }, }, }); + const pill = client.addComposerPill({ + id: "open-review", + workspaceId: "workspace-a", + agentId: "agent-a", + button: { + title: "Open review", + icon: "Scan", + behavior: { + kind: "action", + async onPress() { + await client.rpc(inspect, {}); + client.openPanel("review", { workspaceId: "workspace-a", agentId: "agent-a" }); + }, + }, + }, + }); + header.update({ visible: false }); + pill.update({ disabled: true }); + return () => { header.remove(); pill.remove(); }; } `, ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { AgentPanel, contributeClient, Surface } from "./client/main"; import { inspect } from "./shared/inspect"; export default function contribute(client: PluginClientContext) { - client.addSurface("main", Surface); + client.addScreen({ id: "main", title: "Review", Component: Surface }); client.addWorkspacePanel({ id: "review", title: "Review", @@ -229,7 +298,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectConfig } from "./server/inspect"; import { inspect } from "./shared/inspect"; @@ -244,6 +313,39 @@ export default function contribute(server: PluginServerContext) { await expect(typecheckPlugin(directory)).resolves.toBeUndefined(); }, 20_000); + it("reports TypeScript errors for the old composer pill shape and cleanup", async () => { + const parent = await mkdtemp(path.join(process.cwd(), ".plugin-scaffold-")); + directories.push(parent); + const directory = path.join(parent, "old-composer-pill"); + await scaffoldPluginDirectory(directory); + await writeFile( + path.join(directory, "index.client.tsx"), + ` +import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin/client"; + +export default function contribute(client: PluginClientContext) { + const oldPill = { + id: "review", workspaceId: "workspace-a", agentId: "agent-a", + title: "Review", Component: () => null, onPress() {}, + }; + client.addComposerPill(oldPill); + const pill = client.addComposerPill({ + id: "review", workspaceId: "workspace-a", agentId: "agent-a", + button: { title: "Review", icon: "Scan", behavior: { kind: "action", onPress() {} } }, + }); + pill(); + return () => pill.remove(); +} +`, + ); + const check = typecheckPlugin(directory); + await expect(check).rejects.toThrow( + /has no exported member (named )?'PluginComposerPillProps'/, + ); + await expect(check).rejects.toThrow("Property 'button' is missing"); + await expect(check).rejects.toThrow("has no call signatures"); + }, 20_000); + it("refuses to write into a non-empty directory", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-")); directories.push(directory); diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index e57e316215..b1eb53a6ef 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -3,6 +3,34 @@ import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { resolveCliVersion } from "../../version.js"; +const OVERVIEW = `<!-- +OVERVIEW.md is required to list your plugin in the registry. Keep it beside +paseo-plugin.json in your repository at the pinned commit and in your npm package. +Replace this comment with useful facts before publishing. + +This overview is for people choosing a plugin in Paseo, on the plugin page under +its install command. README assumes a GitHub audience and includes technical +details, installation instructions, and badges. Long, AI-generated READMEs leave +people reading past that material to understand what the plugin does. + +Write these parts in order, including only what helps someone decide to install: + +1. Describe what your plugin is and does in plain terms first. +2. Explain how it works only when it is not obvious. +3. Explain setup when needed: settings, accounts, tokens, providers, tools, or + other plugins. Include applicable daemon version and operating system + requirements. Setup guidance is allowed; installation instructions are not. +4. Explain capabilities and settings worth understanding, what each option does, + what the plugin reads or sends and where, permissions, and known limits. + +Length follows complexity; a theme needs one paragraph. Choose headings only +when they help. Use sentence case and plain factual language, with no em dashes. +Omit installation commands, badges, changelog, contributing or license sections, +marketing, and unsupported claims. Avoid implementation filler such as empty +cleanup functions, theme-token field lists, or lists of absent features. +--> +`; + const TSCONFIG = { compilerOptions: { target: "ES2020", @@ -20,22 +48,28 @@ const TSCONFIG = { include: ["**/*.ts", "**/*.tsx"], }; -const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin"; -import { GreetingSurface } from "./client/greeting"; +const CLIENT_ENTRY = `import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client"; +import { SidebarRow } from "@getpaseo/plugin/client/ui"; +import { GreetingScreen } from "./client/greeting"; + +function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) { + return ( + <SidebarRow + icon="MessageCircle" + active={currentScreen?.screenId === "greeting"} + onPress={() => openScreen({ screenId: "greeting" })} + /> + ); +} export default function contribute(client: PluginClientContext) { - client.addSurface("greeting", GreetingSurface); - client.addSidebarItem({ - id: "greeting", - title: "Greeting", - icon: "MessageCircle", - surface: "greeting", - }); + client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen }); + client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem }); return () => {}; } `; -const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin"; +const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createGreeting } from "./server/greeting"; import { greetingRpc } from "./shared/greeting"; @@ -63,15 +97,15 @@ export function createGreeting({ name }: RpcInput<typeof greetingRpc>) { } `; -const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin"; -import { useRpc } from "@getpaseo/plugin"; +const CLIENT_GREETING = `import type { PluginScreenProps } from "@getpaseo/plugin/client"; +import { useRpc } from "@getpaseo/plugin/client"; import { useMutation } from "@tanstack/react-query"; import { useMemo } from "react"; import { Pressable, Text, View } from "react-native"; import { greetingRpc } from "../shared/greeting"; import { openExternal } from "./web"; -export function GreetingSurface({ theme, layout }: PluginSurfaceProps) { +export function GreetingScreen({ theme, layout }: PluginScreenProps) { const createGreeting = useRpc(greetingRpc); const greeting = useMutation({ mutationFn: createGreeting }); const styles = useMemo( @@ -142,13 +176,25 @@ export async function scaffoldPluginDirectory( throw new Error(`Plugin directory must be empty: ${directory}`); } + const version = resolveCliVersion(); const packageJson = { name: id, private: true, version: "0.0.0", + files: [ + "paseo-plugin.json", + "OVERVIEW.md", + "index.client.ts", + "index.client.tsx", + "index.server.ts", + "index.server.tsx", + "client/", + "server/", + "shared/", + ], scripts: { typecheck: "tsc --noEmit" }, devDependencies: { - "@getpaseo/plugin": resolveCliVersion(), + "@getpaseo/plugin": version, "@tanstack/react-query": "^5.90.11", "@types/react": "~19.2.0", react: "19.1.0", @@ -158,7 +204,21 @@ export async function scaffoldPluginDirectory( }, }; const files = new Map<string, string>([ - ["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`], + [ + "paseo-plugin.json", + `${JSON.stringify( + { + id, + $comment: + 'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.', + media: [], + requirements: { paseo: `>=${version}` }, + }, + null, + 2, + )}\n`, + ], + ["OVERVIEW.md", OVERVIEW], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`], ["index.client.tsx", CLIENT_ENTRY], diff --git a/packages/cli/src/commands/plugin/shared.ts b/packages/cli/src/commands/plugin/shared.ts index c85a8d7e32..af87bdb82b 100644 --- a/packages/cli/src/commands/plugin/shared.ts +++ b/packages/cli/src/commands/plugin/shared.ts @@ -1,16 +1,21 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { CommandError } from "../../output/index.js"; import { connectToDaemon } from "../../utils/client.js"; -type PluginFeature = "pluginManagement" | "pluginLogs" | "pluginGitManagement"; +type PluginFeature = + | "pluginManagement" + | "pluginLogs" + | "pluginSourceUpdates" + | "pluginSourceInstallation"; async function withPluginClient<T>( - host: string | undefined, + target: DaemonTarget, feature: PluginFeature, updateMessage: string, run: (client: DaemonClient) => Promise<T>, ): Promise<T> { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); if (client.getLastServerInfoMessage()?.features?.[feature] !== true) { await client.close().catch(() => undefined); throw { @@ -26,12 +31,12 @@ async function withPluginClient<T>( } export async function withPluginManagementClient<T>( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise<T>, ): Promise<T> { // COMPAT(pluginManagement): added in v0.3.1, remove gate after 2027-08-14. return withPluginClient( - host, + target, "pluginManagement", "Update the host to use plugin management.", run, @@ -39,22 +44,35 @@ export async function withPluginManagementClient<T>( } export async function withPluginLogsClient<T>( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise<T>, ): Promise<T> { // COMPAT(pluginLogs): added in v0.4.0, remove gate after 2027-08-16. - return withPluginClient(host, "pluginLogs", "Update the host to view plugin logs.", run); + return withPluginClient(target, "pluginLogs", "Update the host to view plugin logs.", run); } export async function withPluginSourceClient<T>( - host: string | undefined, + target: DaemonTarget, run: (client: DaemonClient) => Promise<T>, ): Promise<T> { - // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. + // COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers. return withPluginClient( - host, - "pluginGitManagement", - "Update the host to install and update Git plugins.", + target, + "pluginSourceInstallation", + "Update the host to install plugin sources.", + run, + ); +} + +export async function withPluginUpdateClient<T>( + target: DaemonTarget, + run: (client: DaemonClient) => Promise<T>, +): Promise<T> { + // COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates. + return withPluginClient( + target, + "pluginSourceUpdates", + "Update the host to review plugin updates.", run, ); } diff --git a/packages/cli/src/commands/plugin/update.ts b/packages/cli/src/commands/plugin/update.ts new file mode 100644 index 0000000000..d55edd0dbd --- /dev/null +++ b/packages/cli/src/commands/plugin/update.ts @@ -0,0 +1,102 @@ +import type { + PluginUpdatePreview, + PluginUpdateResult, + PluginUpdateSelection, + PluginUpdateProposal, +} from "@getpaseo/protocol/messages"; + +interface UpdateClient { + previewPluginUpdates(input: { + pluginId?: string; + target?: PluginUpdateSelection; + }): Promise<PluginUpdatePreview[]>; + applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]>; +} +export interface UpdateOptions { + pluginId?: string; + all?: boolean; + ref?: string; + version?: string; + check?: boolean; + yes?: boolean; +} +export interface UpdateInteraction { + interactive: boolean; + structured: boolean; + write(text: string): void; + confirm(message: string): Promise<boolean>; +} +export interface UpdateOutcome { + id: string; + outcome: PluginUpdatePreview["outcome"] | PluginUpdateResult["outcome"] | "declined"; + current?: PluginUpdatePreview["current"]; + target?: PluginUpdatePreview["target"]; + links?: string[]; + proposal?: PluginUpdateProposal; + plugin?: PluginUpdateResult["plugin"]; + error?: string; + warning?: string; +} + +export async function reviewPluginUpdates( + client: UpdateClient, + options: UpdateOptions, + interaction: UpdateInteraction, +): Promise<UpdateOutcome[]> { + if ((options.pluginId === undefined) === (options.all !== true)) + throw new Error("Choose one plugin ID or pass --all"); + if (options.ref && options.version) throw new Error("Choose --ref or --version, not both"); + let target: PluginUpdateSelection | undefined; + if (options.ref) target = { kind: "git", ref: options.ref }; + if (options.version) target = { kind: "npm", version: options.version }; + if (target && options.all) + throw new Error("An explicit target requires one plugin ID, without --all"); + const preview = await client.previewPluginUpdates({ pluginId: options.pluginId, target }); + if (!interaction.structured) interaction.write(formatUpdatePreview(preview)); + if (options.check) return preview; + const proposals = preview.flatMap((item) => (item.proposal ? [item.proposal] : [])); + if (!proposals.length) return preview; + if (!options.yes && !target) { + if (interaction.structured || !interaction.interactive) + throw new Error("Confirmation required; rerun with --yes."); + if ( + !(await interaction.confirm( + options.all ? "Update these plugins? [y/N] " : `Update ${options.pluginId}? [y/N] `, + )) + ) { + return preview.map((item) => (item.proposal ? { id: item.id, outcome: "declined" } : item)); + } + } + const applied = new Map( + (await client.applyPluginUpdates(proposals)).map((item) => [item.id, item]), + ); + return preview.map((item) => applied.get(item.id) ?? item); +} + +export function formatUpdatePreview(items: PluginUpdatePreview[]): string { + return items + .map((item) => { + const current = item.current?.currentRevision ?? "unknown"; + const target = item.target?.kind === "git" ? item.target.commit : item.target?.version; + let text: string; + switch (item.outcome) { + case "update": + text = `${current} → ${target}`; + break; + case "current": + text = `up to date (${current})`; + break; + case "installed-newer": + text = `installed ${current} is newer than latest ${target}; keeping installed version`; + break; + case "local": + text = "local directory; use Reload after editing"; + break; + case "error": + text = item.error ?? "Check failed"; + break; + } + return `${item.id}: ${text}\n${item.links.map((link) => `Review: ${link}\n`).join("")}`; + }) + .join("\n"); +} diff --git a/packages/cli/src/commands/project/create.ts b/packages/cli/src/commands/project/create.ts index 2210e6c214..b141ea7f18 100644 --- a/packages/cli/src/commands/project/create.ts +++ b/packages/cli/src/commands/project/create.ts @@ -31,10 +31,10 @@ export async function runCreateCommand( const projectPath = resolveProjectPath({ pathArg, cwd: process.cwd(), - daemonTarget: options.host ?? process.env.PASEO_HOST, + daemonTarget: options.daemonTarget.kind === "endpoint" ? options.daemonTarget.host : undefined, }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -44,6 +44,7 @@ export async function runCreateCommand( } return { type: "single", data: toProjectRow(payload.project), schema: projectSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts index 05dd657ef1..2c4a20ddc2 100644 --- a/packages/cli/src/commands/project/delete.ts +++ b/packages/cli/src/commands/project/delete.ts @@ -24,8 +24,8 @@ export async function runDeleteCommand( options: CommandOptions, _command: Command, ): Promise<SingleResult<ProjectDeleteResult>> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -36,6 +36,7 @@ export async function runDeleteCommand( schema: projectDeleteSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_DELETE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/project/ls.ts b/packages/cli/src/commands/project/ls.ts index 750428881f..acd9a21935 100644 --- a/packages/cli/src/commands/project/ls.ts +++ b/packages/cli/src/commands/project/ls.ts @@ -7,8 +7,8 @@ export async function runLsCommand( options: CommandOptions, _command: Command, ): Promise<ListResult<ProjectRow>> { - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { diff --git a/packages/cli/src/commands/project/project.test.ts b/packages/cli/src/commands/project/project.test.ts index 47ea1cecf5..301c7a8a65 100644 --- a/packages/cli/src/commands/project/project.test.ts +++ b/packages/cli/src/commands/project/project.test.ts @@ -47,7 +47,11 @@ describe("project commands", () => { }); it("creates a project from an absolute directory path", async () => { - const result = await runCreateCommand("relative/project", {}, {} as never); + const result = await runCreateCommand( + "relative/project", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(addProject).toHaveBeenCalledWith(path.resolve("relative/project")); expect(result.data).toEqual({ @@ -77,7 +81,10 @@ describe("project commands", () => { }); it("lists projects", async () => { - const result = await runLsCommand({}, {} as never); + const result = await runLsCommand( + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(listProjects).toHaveBeenCalled(); expect(result.data).toHaveLength(1); @@ -88,7 +95,12 @@ describe("project commands", () => { expect(resolveProjectName({ name: " New name " })).toBe("New name"); expect(resolveProjectName({ reset: true })).toBeNull(); - const result = await runRenameCommand("project-1", " New name ", {}, {} as never); + const result = await runRenameCommand( + "project-1", + " New name ", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(renameProject).toHaveBeenCalledWith("project-1", "New name"); expect(result.data).toEqual({ projectId: "project-1", name: "New name" }); }); @@ -98,7 +110,11 @@ describe("project commands", () => { }); it("deletes a project", async () => { - const result = await runDeleteCommand("project-1", {}, {} as never); + const result = await runDeleteCommand( + "project-1", + { daemonTarget: { kind: "instance", home: "/tmp/project-test" } }, + {} as never, + ); expect(removeProject).toHaveBeenCalledWith("project-1"); expect(result.data).toEqual({ diff --git a/packages/cli/src/commands/project/rename.ts b/packages/cli/src/commands/project/rename.ts index b00fdb06be..42bf21bd90 100644 --- a/packages/cli/src/commands/project/rename.ts +++ b/packages/cli/src/commands/project/rename.ts @@ -44,8 +44,8 @@ export async function runRenameCommand( _command: Command, ): Promise<SingleResult<ProjectRenameResult>> { const name = resolveProjectName({ name: nameArg, reset: options.reset }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { - throw buildDaemonConnectionCommandError({ host: options.host, error }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { + throw buildDaemonConnectionCommandError({ target: options.daemonTarget, error }); }); try { @@ -56,6 +56,7 @@ export async function runRenameCommand( schema: projectRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "PROJECT_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/provider/diagnostic.ts b/packages/cli/src/commands/provider/diagnostic.ts index 0ff05556a1..cbc13bd670 100644 --- a/packages/cli/src/commands/provider/diagnostic.ts +++ b/packages/cli/src/commands/provider/diagnostic.ts @@ -28,7 +28,7 @@ export async function runDiagnosticCommand( options: ProviderDiagnosticOptions, _command: Command, ): Promise<SingleResult<ProviderDiagnosticResult>> { - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.getProviderDiagnostic(provider.trim().toLowerCase()); return { diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 6b902383db..1e56b60f73 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest"; -import { tryConnectToDaemon } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; export interface ProviderListItem { provider: ProviderSnapshotEntry["provider"]; @@ -13,20 +12,6 @@ export interface ProviderListItem { modes: string; } -/** Derive provider list from the manifest — single source of truth */ -const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ - provider: def.id, - label: def.label, - status: "available", - enabled: def.enabledByDefault === false ? "Disabled" : "Enabled", - defaultMode: def.defaultModeId ?? "-", - modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-", -})); - -function getStaticProviders(): ProviderListItem[] { - return PROVIDERS; -} - /** Schema for provider ls output */ export const providerLsSchema: OutputSchema<ProviderListItem> = { idField: "provider", @@ -59,15 +44,7 @@ export async function runLsCommand( options: ProviderLsOptions, _command: Command, ): Promise<ProviderLsResult> { - const client = await tryConnectToDaemon({ host: options.host }); - - if (!client) { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const snapshot = await client.getProvidersSnapshot(); @@ -83,12 +60,6 @@ export async function runLsCommand( })), schema: providerLsSchema, }; - } catch { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; } finally { await client.close().catch(() => {}); } diff --git a/packages/cli/src/commands/provider/models.ts b/packages/cli/src/commands/provider/models.ts index e5dde2d136..1d8fd3f7bb 100644 --- a/packages/cli/src/commands/provider/models.ts +++ b/packages/cli/src/commands/provider/models.ts @@ -62,7 +62,7 @@ export async function runModelsCommand( ): Promise<ProviderModelsResult> { const normalizedProvider = provider.toLowerCase(); - const client = await connectToDaemon({ host: options.host }); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const result = await client.listProviderModels(normalizedProvider); diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts index 471765876d..8b77ea392f 100644 --- a/packages/cli/src/commands/schedule/create.ts +++ b/packages/cli/src/commands/schedule/create.ts @@ -43,12 +43,12 @@ export async function runCreateCommand( mode: options.mode, thinking: options.thinking, cwd: options.cwd, - host: options.host, + daemonTarget: options.daemonTarget, maxRuns: options.maxRuns, expiresIn: options.expiresIn, runNow, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleCreate(input); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/delete.ts b/packages/cli/src/commands/schedule/delete.ts index f1f4ba2cae..f46ecf5d1f 100644 --- a/packages/cli/src/commands/schedule/delete.ts +++ b/packages/cli/src/commands/schedule/delete.ts @@ -25,7 +25,7 @@ export async function runDeleteCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<SingleResult<ScheduleDeleteRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleDelete({ id }); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/inspect.ts b/packages/cli/src/commands/schedule/inspect.ts index d020f24089..8a94a82e23 100644 --- a/packages/cli/src/commands/schedule/inspect.ts +++ b/packages/cli/src/commands/schedule/inspect.ts @@ -16,7 +16,7 @@ export async function runInspectCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<ListResult<ScheduleInspectRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleInspect({ id }); if (payload.error || !payload.schedule) { diff --git a/packages/cli/src/commands/schedule/logs.ts b/packages/cli/src/commands/schedule/logs.ts index c9565b5c0d..7609851766 100644 --- a/packages/cli/src/commands/schedule/logs.ts +++ b/packages/cli/src/commands/schedule/logs.ts @@ -13,7 +13,7 @@ export async function runLogsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<ListResult<ScheduleLogRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleLogs({ id }); diff --git a/packages/cli/src/commands/schedule/ls.ts b/packages/cli/src/commands/schedule/ls.ts index 501c86653e..c3dfb6db28 100644 --- a/packages/cli/src/commands/schedule/ls.ts +++ b/packages/cli/src/commands/schedule/ls.ts @@ -13,7 +13,7 @@ export async function runLsCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<ListResult<ScheduleRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { const payload = await client.scheduleList(); if (payload.error) { diff --git a/packages/cli/src/commands/schedule/pause.ts b/packages/cli/src/commands/schedule/pause.ts index ed8e12108d..07929516fc 100644 --- a/packages/cli/src/commands/schedule/pause.ts +++ b/packages/cli/src/commands/schedule/pause.ts @@ -15,7 +15,7 @@ export async function runPauseCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<SingleResult<ScheduleRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.schedulePause({ id }); diff --git a/packages/cli/src/commands/schedule/resume.ts b/packages/cli/src/commands/schedule/resume.ts index efac2d6340..8d2855b397 100644 --- a/packages/cli/src/commands/schedule/resume.ts +++ b/packages/cli/src/commands/schedule/resume.ts @@ -15,7 +15,7 @@ export async function runResumeCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<SingleResult<ScheduleRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleResume({ id }); diff --git a/packages/cli/src/commands/schedule/run-once.ts b/packages/cli/src/commands/schedule/run-once.ts index 2818afc5db..beb8f65b29 100644 --- a/packages/cli/src/commands/schedule/run-once.ts +++ b/packages/cli/src/commands/schedule/run-once.ts @@ -15,7 +15,7 @@ export async function runRunOnceCommand( options: ScheduleCommandOptions, _command: Command, ): Promise<SingleResult<ScheduleRow>> { - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleRunOnce({ id }); diff --git a/packages/cli/src/commands/schedule/shared.test.ts b/packages/cli/src/commands/schedule/shared.test.ts index 0777134b0e..bac537e4fe 100644 --- a/packages/cli/src/commands/schedule/shared.test.ts +++ b/packages/cli/src/commands/schedule/shared.test.ts @@ -1,4 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; + +import { selectDaemonTarget } from "../../utils/daemon-target.js"; import { compileEveryPresetToCron, @@ -7,32 +9,25 @@ import { } from "./shared.js"; const baseOptions = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", every: "5m", provider: "claude", }; const baseCron = { + daemonTarget: selectDaemonTarget({ home: process.cwd() }, {}), prompt: "do the thing", cron: "0 9 * * *", provider: "claude", }; describe("parseScheduleCreateInput cwd/host validation", () => { - beforeEach(() => { - vi.spyOn(process, "cwd").mockReturnValue("/local/project"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - test("no host, no cwd → defaults to process.cwd()", () => { const input = parseScheduleCreateInput(baseOptions); expect(input.target).toEqual({ type: "new-agent", - config: { provider: "claude", cwd: "/local/project" }, + config: { provider: "claude", cwd: process.cwd() }, }); }); @@ -47,7 +42,7 @@ describe("parseScheduleCreateInput cwd/host validation", () => { test("host with cwd → uses provided cwd", () => { const input = parseScheduleCreateInput({ ...baseOptions, - host: "dev:6767", + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), cwd: "/remote/project", }); expect(input.target).toEqual({ @@ -57,7 +52,12 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("host without cwd → throws MISSING_CWD", () => { - expect(() => parseScheduleCreateInput({ ...baseOptions, host: "dev:6767" })).toThrow( + expect(() => + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + }), + ).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required when --host is specified"), @@ -66,9 +66,9 @@ describe("parseScheduleCreateInput cwd/host validation", () => { }); test("PASEO_HOST without cwd → throws MISSING_CWD", () => { - vi.stubEnv("PASEO_HOST", "dev:6767"); + const daemonTarget = selectDaemonTarget({}, { PASEO_HOST: "dev:12345" }); - expect(() => parseScheduleCreateInput(baseOptions)).toThrow( + expect(() => parseScheduleCreateInput({ ...baseOptions, daemonTarget })).toThrow( expect.objectContaining({ code: "MISSING_CWD", message: expect.stringContaining("--cwd is required"), @@ -76,9 +76,21 @@ describe("parseScheduleCreateInput cwd/host validation", () => { ); }); + test("explicit home beats PASEO_HOST and defaults cwd locally", () => { + const daemonTarget = selectDaemonTarget({ home: process.cwd() }, { PASEO_HOST: "dev:12345" }); + expect(parseScheduleCreateInput({ ...baseOptions, daemonTarget }).target).toEqual({ + type: "new-agent", + config: { provider: "claude", cwd: process.cwd() }, + }); + }); + test("host with whitespace-only cwd → throws MISSING_CWD", () => { expect(() => - parseScheduleCreateInput({ ...baseOptions, host: "dev:6767", cwd: " " }), + parseScheduleCreateInput({ + ...baseOptions, + daemonTarget: selectDaemonTarget({ host: "dev:12345" }, {}), + cwd: " ", + }), ).toThrow(expect.objectContaining({ code: "MISSING_CWD" })); }); }); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index beae80acc0..7506596092 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -1,4 +1,5 @@ -import { connectToDaemon, getDaemonHost, getExplicitDaemonHost } from "../../utils/client.js"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; import type { CreateScheduleInput, @@ -19,15 +20,16 @@ export interface ScheduleCommandOptions extends CommandOptions { } export async function connectScheduleClient( - host: string | undefined, + target: DaemonTarget, ): Promise<{ client: ScheduleDaemonClient; host: string }> { - const resolvedHost = getDaemonHost({ host }); + const resolvedHost = getDaemonHost({ target }); try { const client = (await connectToDaemon({ - host, + target, })) as unknown as ScheduleDaemonClient; return { client, host: resolvedHost }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -147,6 +149,7 @@ export function parseScheduleCreateInput(options: { thinking?: string; cwd?: string; host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; maxRuns?: string; expiresIn?: string; runNow?: boolean; @@ -168,7 +171,7 @@ export function parseScheduleCreateInput(options: { } const cwdInput = options.cwd?.trim(); - if (getExplicitDaemonHost(options.host) !== undefined && !cwdInput) { + if (options.daemonTarget.kind === "endpoint" && !cwdInput) { throw { code: "MISSING_CWD", message: diff --git a/packages/cli/src/commands/schedule/update.ts b/packages/cli/src/commands/schedule/update.ts index 30873853c1..7d0079e269 100644 --- a/packages/cli/src/commands/schedule/update.ts +++ b/packages/cli/src/commands/schedule/update.ts @@ -23,10 +23,8 @@ export interface ScheduleUpdateOptions extends ScheduleCommandOptions { model?: string; mode?: string; cwd?: string; - maxRuns?: string; - noMaxRuns?: boolean; - expiresIn?: string; - noExpiresIn?: boolean; + maxRuns?: string | false; + expiresIn?: string | false; } export async function runUpdateCommand( @@ -45,12 +43,12 @@ export async function runUpdateCommand( model: options.model, mode: options.mode, cwd: options.cwd, - maxRuns: options.maxRuns, - expiresIn: options.expiresIn, - clearMaxRuns: options.noMaxRuns, - clearExpires: options.noExpiresIn, + maxRuns: options.maxRuns === false ? undefined : options.maxRuns, + expiresIn: options.expiresIn === false ? undefined : options.expiresIn, + clearMaxRuns: options.maxRuns === false, + clearExpires: options.expiresIn === false, }); - const { client } = await connectScheduleClient(options.host); + const { client } = await connectScheduleClient(options.daemonTarget); try { await requireNewAgentSchedule(client, id); const payload = await client.scheduleUpdate(input); diff --git a/packages/cli/src/commands/script/ls.ts b/packages/cli/src/commands/script/ls.ts index cd7d98e9f8..e8271d0bef 100644 --- a/packages/cli/src/commands/script/ls.ts +++ b/packages/cli/src/commands/script/ls.ts @@ -12,7 +12,7 @@ export async function runLsCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise<ListResult<WorkspaceScriptRow>> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.listWorkspaceScripts(workspaceId); diff --git a/packages/cli/src/commands/script/shared.ts b/packages/cli/src/commands/script/shared.ts index 4e3308bdfb..b5b2326b6c 100644 --- a/packages/cli/src/commands/script/shared.ts +++ b/packages/cli/src/commands/script/shared.ts @@ -1,3 +1,4 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; import { resolve } from "node:path"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; @@ -9,10 +10,10 @@ export interface WorkspaceScriptCommandOptions extends CommandOptions { workspace?: string; } -export async function connectWorkspaceScriptClient(host?: string): Promise<DaemonClient> { - const daemonHost = getDaemonHost({ host }); +export async function connectWorkspaceScriptClient(target: DaemonTarget): Promise<DaemonClient> { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); + const client = await connectToDaemon({ target }); // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. if (!client.getLastServerInfoMessage()?.features?.workspaceScriptManagement) { await client.close().catch(() => {}); diff --git a/packages/cli/src/commands/script/start.ts b/packages/cli/src/commands/script/start.ts index 1b377267fa..0883f52720 100644 --- a/packages/cli/src/commands/script/start.ts +++ b/packages/cli/src/commands/script/start.ts @@ -13,7 +13,7 @@ export async function runStartCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise<SingleResult<WorkspaceScriptRow>> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.startWorkspaceScriptWithStatus(workspaceId, scriptName); diff --git a/packages/cli/src/commands/script/stop.ts b/packages/cli/src/commands/script/stop.ts index 6855656057..21dbf21371 100644 --- a/packages/cli/src/commands/script/stop.ts +++ b/packages/cli/src/commands/script/stop.ts @@ -13,7 +13,7 @@ export async function runStopCommand( options: WorkspaceScriptCommandOptions, _command: Command, ): Promise<SingleResult<WorkspaceScriptRow>> { - const client = await connectWorkspaceScriptClient(options.host); + const client = await connectWorkspaceScriptClient(options.daemonTarget); try { const workspaceId = await resolveWorkspaceScriptWorkspaceId(client, options); const payload = await client.stopWorkspaceScript(workspaceId, scriptName); diff --git a/packages/cli/src/commands/terminal/capture.ts b/packages/cli/src/commands/terminal/capture.ts index 2dc8f2180d..94ff1726ee 100644 --- a/packages/cli/src/commands/terminal/capture.ts +++ b/packages/cli/src/commands/terminal/capture.ts @@ -16,11 +16,9 @@ export interface TerminalCaptureOptions extends TerminalCommandOptions { export async function runCaptureCommand( terminalId: string, - _options: TerminalCaptureOptions, - command: Command, + options: TerminalCaptureOptions, + _command: Command, ): Promise<void> { - const options = command.optsWithGlobals() as TerminalCaptureOptions; - try { const payload = await executeCaptureCommand(terminalId, options); if (options.json) { @@ -55,7 +53,7 @@ async function executeCaptureCommand( terminalId: string, options: TerminalCaptureOptions, ): Promise<{ terminalId: string; lines: string[]; totalLines: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -70,7 +68,7 @@ async function executeCaptureCommand( const start = options.scrollback ? 0 : parseLineNumber("--start", options.start); const end = parseLineNumber("--end", options.end); - return await client.captureTerminal(resolvedId, { + return await client.terminals.ref(resolvedId).capture({ ...(start === undefined ? {} : { start }), ...(end === undefined ? {} : { end }), stripAnsi: !options.ansi, @@ -78,7 +76,7 @@ async function executeCaptureCommand( } catch (err) { throw toTerminalCommandError("TERMINAL_CAPTURE_FAILED", "capture terminal output", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/create.ts b/packages/cli/src/commands/terminal/create.ts index b71833b871..be41d877e0 100644 --- a/packages/cli/src/commands/terminal/create.ts +++ b/packages/cli/src/commands/terminal/create.ts @@ -1,13 +1,14 @@ import type { Command } from "commander"; -import type { SingleResult, CommandError } from "../../output/index.js"; +import type { SingleResult } from "../../output/index.js"; import { connectTerminalClient, toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalCreateOptions extends TerminalCommandOptions { + workspace?: string; cwd?: string; name?: string; } @@ -16,37 +17,26 @@ export async function runCreateCommand( options: TerminalCreateOptions, _command: Command, ): Promise<SingleResult<TerminalRow>> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.cwd ?? process.cwd(); - + const { client, close } = await connectTerminalClient(options.daemonTarget); try { - const opened = await client.openProject(cwd); - if (!opened.workspace) { - const error: CommandError = { - code: "WORKSPACE_OPEN_FAILED", - message: opened.error ?? "Failed to open workspace", - }; - throw error; - } - - const payload = await client.createTerminal(cwd, options.name, undefined, { - workspaceId: opened.workspace.id, + const cwd = options.cwd ?? (options.workspace ? undefined : process.cwd()); + const workspaceId = + options.workspace ?? (await client.workspaces.open(options.cwd ?? process.cwd())).id; + const terminal = await client.terminals.create({ + workspaceId, + cwd, + name: options.name, }); - if (!payload.terminal) { - const error: CommandError = { - code: "TERMINAL_CREATE_FAILED", - message: payload.error ?? "Failed to create terminal", - }; - throw error; - } + const snapshot = terminal.current(); + if (!snapshot) throw new Error("The daemon did not create a terminal"); return { type: "single", - data: toTerminalRow(payload.terminal), + data: snapshot, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_CREATE_FAILED", "create terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/index.ts b/packages/cli/src/commands/terminal/index.ts index ce647f78e6..22ffc39d31 100644 --- a/packages/cli/src/commands/terminal/index.ts +++ b/packages/cli/src/commands/terminal/index.ts @@ -1,4 +1,4 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import { withOutput } from "../../output/index.js"; import { addDaemonHostOption, @@ -18,15 +18,19 @@ export function createTerminalCommand(): Command { terminal .command("ls") .description("List terminals") - .option("--all", "List terminals across all workspaces") - .option("--cwd <path>", "Workspace directory"), + .addOption( + new Option("--all", "List terminals across all workspaces").conflicts(["cwd", "workspace"]), + ) + .option("--workspace <id>", "Workspace ID") + .option("--cwd <path>", "Working directory"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( terminal .command("create") .description("Create a terminal") - .option("--cwd <path>", "Workspace directory") + .option("--workspace <id>", "Workspace ID") + .option("--cwd <path>", "Working directory") .option("--name <name>", "Terminal name"), ).action(withOutput(runCreateCommand)); diff --git a/packages/cli/src/commands/terminal/kill.ts b/packages/cli/src/commands/terminal/kill.ts index 0a574e16d8..685c8ecaf3 100644 --- a/packages/cli/src/commands/terminal/kill.ts +++ b/packages/cli/src/commands/terminal/kill.ts @@ -13,23 +13,23 @@ export async function runKillCommand( options: TerminalCommandOptions, _command: Command, ): Promise<SingleResult<TerminalKillRow>> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await requireTerminalId(client, terminalId); - const payload = await client.killTerminal(resolvedId); + await client.terminals.ref(resolvedId).kill(); return { type: "single", data: { - terminalId: payload.terminalId, - success: payload.success, + terminalId: resolvedId, + success: true, }, schema: terminalKillSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_KILL_FAILED", "kill terminal", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/ls.ts b/packages/cli/src/commands/terminal/ls.ts index bcb6d90d48..23b1a182a6 100644 --- a/packages/cli/src/commands/terminal/ls.ts +++ b/packages/cli/src/commands/terminal/ls.ts @@ -5,11 +5,10 @@ import { toTerminalCommandError, type TerminalCommandOptions, } from "./shared.js"; -import { terminalSchema, type TerminalRow, toTerminalRow } from "./schema.js"; - -type TerminalListEntry = Parameters<typeof toTerminalRow>[0]; +import { terminalSchema, type TerminalRow } from "./schema.js"; export interface TerminalLsOptions extends TerminalCommandOptions { + workspace?: string; all?: boolean; cwd?: string; } @@ -18,22 +17,19 @@ export async function runLsCommand( options: TerminalLsOptions, _command: Command, ): Promise<ListResult<TerminalRow>> { - const { client } = await connectTerminalClient(options.host); - const cwd = options.all ? undefined : (options.cwd ?? process.cwd()); + const { client, close } = await connectTerminalClient(options.daemonTarget); + const cwd = options.all || options.workspace ? undefined : (options.cwd ?? process.cwd()); try { - const payload = - cwd === undefined ? await client.listTerminals() : await client.listTerminals(cwd); + const payload = await client.terminals.list({ cwd, workspaceId: options.workspace }); return { type: "list", - data: payload.terminals.map((terminal: TerminalListEntry) => - toTerminalRow(terminal, payload.cwd ?? cwd), - ), + data: payload.entries, schema: terminalSchema, }; } catch (err) { throw toTerminalCommandError("TERMINAL_LIST_FAILED", "list terminals", err); } finally { - await client.close().catch(() => {}); + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/schema.ts b/packages/cli/src/commands/terminal/schema.ts index 9b777ad196..def9c3c455 100644 --- a/packages/cli/src/commands/terminal/schema.ts +++ b/packages/cli/src/commands/terminal/schema.ts @@ -1,10 +1,7 @@ +import type { PaseoTerminal } from "@getpaseo/client"; import type { OutputSchema } from "../../output/index.js"; -export interface TerminalRow { - id: string; - name: string; - cwd: string; -} +export type TerminalRow = PaseoTerminal; export interface TerminalKillRow { terminalId: string; @@ -17,6 +14,7 @@ export const terminalSchema: OutputSchema<TerminalRow> = { { header: "ID", field: (row) => row.id.slice(0, 8), width: 8 }, { header: "NAME", field: "name", width: 24 }, { header: "CWD", field: "cwd", width: 48 }, + { header: "WORKSPACE", field: "workspaceId", width: 24 }, ], }; @@ -27,18 +25,3 @@ export const terminalKillSchema: OutputSchema<TerminalKillRow> = { { header: "SUCCESS", field: "success", width: 8 }, ], }; - -export function toTerminalRow( - terminal: { - id: string; - name: string; - cwd?: string; - }, - cwd?: string, -): TerminalRow { - return { - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd ?? cwd ?? "-", - }; -} diff --git a/packages/cli/src/commands/terminal/send-keys.ts b/packages/cli/src/commands/terminal/send-keys.ts index f109cece64..8b1a274204 100644 --- a/packages/cli/src/commands/terminal/send-keys.ts +++ b/packages/cli/src/commands/terminal/send-keys.ts @@ -14,11 +14,9 @@ export interface TerminalSendKeysOptions extends TerminalCommandOptions { export async function runSendKeysCommand( terminalId: string, keys: string[], - _options: TerminalSendKeysOptions, - command: Command, + options: TerminalSendKeysOptions, + _command: Command, ): Promise<void> { - const options = command.optsWithGlobals() as TerminalSendKeysOptions; - try { const payload = await executeSendKeysCommand(terminalId, keys, options); if (options.json) { @@ -39,7 +37,7 @@ async function executeSendKeysCommand( keys: string[], options: TerminalSendKeysOptions, ): Promise<{ terminalId: string; keysSent: number }> { - const { client } = await connectTerminalClient(options.host); + const { client, close } = await connectTerminalClient(options.daemonTarget); try { const resolvedId = await resolveTerminalId(client, terminalId); @@ -51,49 +49,16 @@ async function executeSendKeysCommand( }; } - const data = keys.map((key) => resolveKeyToken(key, options.literal === true)).join(""); - client.sendTerminalInput(resolvedId, { type: "input", data }); + const terminal = client.terminals.ref(resolvedId); + const keysSent = options.literal ? terminal.write(keys.join("")) : terminal.sendKeys(keys); return { terminalId: resolvedId, - keysSent: data.length, + keysSent, }; } catch (err) { throw toTerminalCommandError("TERMINAL_SEND_KEYS_FAILED", "send terminal keys", err); } finally { - await client.close().catch(() => {}); - } -} - -function resolveKeyToken(key: string, literal: boolean): string { - if (literal) { - return key; - } - - switch (key) { - case "Enter": - return "\r"; - case "Tab": - return "\t"; - case "Escape": - return "\u001b"; - case "Space": - return " "; - case "BSpace": - return "\u007f"; - case "C-c": - return "\u0003"; - case "C-d": - return "\u0004"; - case "C-z": - return "\u001a"; - case "C-l": - return "\u000c"; - case "C-a": - return "\u0001"; - case "C-e": - return "\u0005"; - default: - return key; + await close().catch(() => {}); } } diff --git a/packages/cli/src/commands/terminal/shared.ts b/packages/cli/src/commands/terminal/shared.ts index 6e04936575..0fcb9ef64d 100644 --- a/packages/cli/src/commands/terminal/shared.ts +++ b/packages/cli/src/commands/terminal/shared.ts @@ -1,3 +1,5 @@ +import type { DaemonTarget } from "../../utils/daemon-target.js"; +import { createPaseoApi } from "@getpaseo/client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, CommandOptions } from "../../output/index.js"; @@ -10,12 +12,13 @@ interface TerminalLike { name?: string | null; } -export async function connectTerminalClient(host?: string) { - const daemonHost = getDaemonHost({ host }); +export async function connectTerminalClient(target: DaemonTarget) { + const daemonHost = getDaemonHost({ target }); try { - const client = await connectToDaemon({ host }); - return { client, daemonHost }; + const client = await connectToDaemon({ target }); + return { client: createPaseoApi(client), daemonHost, close: () => client.close() }; } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", @@ -44,11 +47,11 @@ export function toTerminalCommandError(code: string, action: string, err: unknow } export async function resolveTerminalId( - client: Awaited<ReturnType<typeof connectToDaemon>>, + client: Awaited<ReturnType<typeof connectTerminalClient>>["client"], idOrName: string, ): Promise<string | null> { - const payload = await client.listTerminals(); - return resolveTerminalIdentifier(idOrName, payload.terminals); + const payload = await client.terminals.list(); + return resolveTerminalIdentifier(idOrName, payload.entries); } function resolveTerminalIdentifier(idOrName: string, terminals: TerminalLike[]): string | null { diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts new file mode 100644 index 0000000000..9c2e3e8bbf --- /dev/null +++ b/packages/cli/src/commands/usage/index.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it } from "vitest"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { render } from "../../output/index.js"; +import { createCli } from "../../cli.js"; +import { runUsageCommand } from "./index.js"; + +const options = { daemonTarget: { kind: "endpoint" as const, host: "example.test:1234" } }; +const reports: UsageReportEntry[] = [ + { + id: "plugin.source:work.account", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: { label: "Work" }, + fetchedAt: "2026-10-09T12:00:00Z", + icon: "<svg />", + report: { + status: "available", + planLabel: "Pro", + windows: [ + { + id: "5h", + label: "Five hours", + usedPct: 12.5, + remainingPct: 87.5, + resetsAt: "2026-10-09T17:00:00Z", + runsOutAt: "2026-10-09T16:00:00Z", + shortfallPct: 4.25, + }, + { id: "week", label: "Weekly", usedPct: null, remainingPct: 0 }, + { id: "extra", label: "Extra", usedPct: 0 }, + ], + balances: [ + { + id: "credit", + label: "Credit", + used: 0, + remaining: 2.5, + limit: 10, + unit: "usd", + resetsAt: "2026-11-01T00:00:00Z", + }, + { id: "requests", label: "Requests", remaining: null, unit: "requests" }, + ], + details: [{ id: "tier", label: "Tier", value: "Enterprise" }], + }, + }, + { + id: "plugin.source:personal", + sourceId: "plugin.source", + sourceLabel: "Plugin Source", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + loginErrors: [ + { + harness: "Claude Code", + report: { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-10-08T00:00:00Z", refreshedBy: "claude" }, + }, + }, + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex login" }, + }, + }, + { harness: "Other", report: { status: "error", error: "Login service failed" } }, + ], + }, + { + id: "other:broken", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { status: "error", error: "Source failed" }, + }, + { + id: "other:noquota", + sourceId: "other", + sourceLabel: "Other", + account: {}, + fetchedAt: "2026-10-09T12:00:00Z", + report: { + status: "unavailable", + problem: { kind: "no_quota", detail: "No quota for this plan" }, + }, + }, +]; + +function host(entries = reports, supported = true) { + const requests: Array<Parameters<NonNullable<Parameters<typeof runUsageCommand>[2]>>[0]> = []; + const queries: Array< + { agentId?: string; forceRefresh?: boolean; reportIds?: string[] } | undefined + > = []; + let closed = 0; + return { + requests, + queries, + get closed() { + return closed; + }, + connect: async (input: (typeof requests)[number]) => { + requests.push(input); + return { + getLastServerInfoMessage: () => ({ features: { usageSources: supported } }), + listUsageReports: async (query?: (typeof queries)[number]) => { + queries.push(query); + const selected = query?.reportIds + ? entries.filter((entry) => query.reportIds!.includes(entry.id)) + : entries; + return { requestId: "request", reports: selected }; + }, + close: async () => { + closed++; + }, + }; + }, + }; +} + +it("preserves complete JSON entries, numeric values and quiet full IDs", async () => { + const daemon = host(); + const result = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports); + expect(render(result, { quiet: true })).toBe(reports.map((entry) => entry.id).join("\n")); + expect(daemon.requests).toEqual([{ target: options.daemonTarget }]); + expect(daemon.closed).toBe(1); +}); + +it("lists all windows, balances, missing values, failures and remedies", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host().connect); + const text = render(result, { noColor: true }); + for (const expected of [ + "REPORT ID", + "SOURCE", + "ACCOUNT", + "STATUS", + "USAGE", + "FETCHED", + "Five hours: 12.5% used, 87.5% remaining", + "Weekly: - used, 0% remaining", + "Extra: 0% used, - remaining", + "Credit: 0 used, 2.5 remaining, 10 limit (usd)", + "Requests: - used, - remaining, - limit (requests)", + "Login expired at 2026-10-08T00:00:00Z. Run claude", + "Login rejected (HTTP 401). Run codex login", + "Other: Login service failed", + "Source failed", + "No quota for this plan", + ]) + expect(text).toContain(expected); + expect(render(result, { noHeaders: true })).not.toContain("REPORT ID"); +}); + +it("inspects exact opaque IDs and includes resets, forecasts, details and login failures", async () => { + const daemon = host(); + const result = await runUsageCommand( + options, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports[0]); + const text = render(result); + for (const expected of [ + "Source ID: plugin.source", + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Resets: -", + "Resets: 2026-11-01T00:00:00Z", + "Tier: Enterprise", + ]) + expect(text).toContain(expected); + const failure = await runUsageCommand( + options, + { kind: "inspect", id: reports[1]!.id }, + daemon.connect, + ); + expect(render(failure)).toContain("Login failures:\n Claude Code: Login expired at"); + expect(render(failure)).toContain(" Other: Login service failed"); +}); + +it("discovers before inspecting, then refreshes only the selected report", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, refresh: true }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ); + expect(daemon.queries).toEqual([undefined, { reportIds: [reports[0]!.id], forceRefresh: true }]); +}); + +it("rejects agent selection combined with inspect before connecting", async () => { + const daemon = host(); + await expect( + runUsageCommand( + { ...options, agent: "agent-A" }, + { kind: "inspect", id: reports[0]!.id }, + daemon.connect, + ), + ).rejects.toMatchObject({ code: "INVALID_OPTIONS" }); + expect(daemon.requests).toEqual([]); +}); + +it("passes agent account selection and refresh without converting it to token consumption", async () => { + const daemon = host(); + await runUsageCommand( + { ...options, agent: "agent-123", refresh: true }, + { kind: "list" }, + daemon.connect, + ); + expect(daemon.queries).toEqual([{ agentId: "agent-123", forceRefresh: true }]); +}); + +it("returns an empty array in JSON and explains empty discovery in human output", async () => { + const result = await runUsageCommand(options, { kind: "list" }, host([]).connect); + expect(render(result, { format: "json" })).toBe("[]"); + expect(render(result)).toBe("No usage reports returned"); + expect(render(result, { quiet: true })).toBe(""); +}); + +it("rejects unknown IDs and prefixes and closes the client", async () => { + const daemon = host(); + await expect( + runUsageCommand(options, { kind: "inspect", id: "plugin.source:work" }, daemon.connect), + ).rejects.toEqual({ + code: "REPORT_NOT_FOUND", + message: "Unknown usage report: plugin.source:work", + }); + expect(daemon.closed).toBe(1); +}); + +it("gates unsupported hosts before any request and closes the connection", async () => { + const daemon = host(reports, false); + await expect(runUsageCommand(options, { kind: "list" }, daemon.connect)).rejects.toMatchObject({ + code: "UNSUPPORTED_HOST", + }); + expect(daemon.queries).toEqual([]); + expect(daemon.closed).toBe(1); +}); + +it("propagates connection and request failures", async () => { + await expect( + runUsageCommand(options, { kind: "list" }, async () => { + throw new Error("Connection failed"); + }), + ).rejects.toThrow("Connection failed"); + let closed = false; + const connect = async () => ({ + getLastServerInfoMessage: () => ({ features: { usageSources: true } }), + listUsageReports: async () => { + throw new Error("Request failed"); + }, + close: async () => { + closed = true; + }, + }); + await expect(runUsageCommand(options, { kind: "list" }, connect)).rejects.toThrow( + "Request failed", + ); + expect(closed).toBe(true); +}); + +describe("usage CLI registration", () => { + it("registers default, ls and inspect with the accepted selectors", () => { + const usage = createCli().commands.find((command) => command.name() === "usage")!; + expect(usage.commands.map((command) => command.name())).toEqual(["ls", "inspect"]); + expect(usage.helpInformation()).toContain("--agent <id>"); + expect(usage.commands[0]!.helpInformation()).toContain("--refresh"); + expect(usage.commands[1]!.helpInformation()).toContain("<report-id>"); + }); +}); diff --git a/packages/cli/src/commands/usage/index.ts b/packages/cli/src/commands/usage/index.ts new file mode 100644 index 0000000000..ab96fc659c --- /dev/null +++ b/packages/cli/src/commands/usage/index.ts @@ -0,0 +1,95 @@ +import { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { UsageReportEntry } from "@getpaseo/protocol/messages"; +import { connectToDaemon, type ConnectOptions } from "../../utils/client.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; +import { withOutput, type CommandOptions, type CommandResult } from "../../output/index.js"; +import { usageSchema } from "./presentation.js"; + +interface UsageOptions extends CommandOptions { + agent?: string; + refresh?: boolean; +} + +type UsageSelection = { kind: "list" } | { kind: "inspect"; id: string }; +interface UsageClient { + getLastServerInfoMessage(): { features?: { usageSources?: boolean } } | null; + listUsageReports: DaemonClient["listUsageReports"]; + close: DaemonClient["close"]; +} +type ConnectUsage = (options: ConnectOptions) => Promise<UsageClient>; + +export async function runUsageCommand( + options: UsageOptions, + selection: UsageSelection, + connect: ConnectUsage = connectToDaemon, +): Promise<CommandResult<UsageReportEntry>> { + const conflictingSelectors = selection.kind === "inspect" && options.agent !== undefined; + if (conflictingSelectors) { + throw { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }; + } + const client = await connect({ target: options.daemonTarget }); + try { + if (client.getLastServerInfoMessage()?.features?.usageSources !== true) { + throw { code: "UNSUPPORTED_HOST", message: "Update the host to use usage reports." }; + } + if (selection.kind === "list") { + const { reports } = await client.listUsageReports({ + agentId: options.agent, + forceRefresh: options.refresh, + }); + return { type: "list", data: reports, schema: usageSchema }; + } + // Report-ID queries only select known accounts. Discover before selecting, including on cold hosts. + const { reports } = await client.listUsageReports(); + let entry = reports.find((report) => report.id === selection.id); + if (entry && options.refresh) { + const refreshed = await client.listUsageReports({ + reportIds: [selection.id], + forceRefresh: true, + }); + entry = refreshed.reports.find((report) => report.id === selection.id); + } + if (!entry) { + throw { code: "REPORT_NOT_FOUND", message: `Unknown usage report: ${selection.id}` }; + } + return { type: "single", data: entry, schema: usageSchema }; + } finally { + await client.close().catch(() => {}); + } +} + +function addListOptions(command: Command): Command { + return addJsonAndDaemonHostOptions(command) + .option( + "--agent <id>", + "Show account quota associated with this agent (not agent token consumption)", + ) + .option("--refresh", "Refresh usage instead of using the host cache"); +} + +export function createUsageCommand(): Command { + const usage = addListOptions(new Command("usage").description("Show account usage reports")); + const list = (options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "list" }); + usage.action(withOutput(list)); + addListOptions(usage.command("ls").description("List account quotas and balances")).action( + withOutput(list), + ); + addJsonAndDaemonHostOptions( + usage + .command("inspect") + .description("Show a complete account usage report") + .argument("<report-id>", "Exact opaque Report ID") + .option("--refresh", "Refresh the selected report instead of using the host cache"), + ).action( + withOutput((id: string, options: CommandOptions, _command: Command) => + runUsageCommand(options, { kind: "inspect", id }), + ), + ); + return usage; +} diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts new file mode 100644 index 0000000000..ecc4aba405 --- /dev/null +++ b/packages/cli/src/commands/usage/presentation.ts @@ -0,0 +1,137 @@ +import type { + UsageReportEntry, + UsageReport, + UsageProblem, + ProviderUsageWindow, + ProviderUsageBalance, +} from "@getpaseo/protocol/messages"; +import { renderTable, type OutputSchema } from "../../output/index.js"; + +function value(input: string | number | null | undefined): string { + return input == null ? "-" : String(input); +} + +function percent(input: number | null | undefined): string { + return input == null ? "-" : `${input}%`; +} + +function problemText(problem: UsageProblem): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh login.` + : "Sign in again."; + if (problem.kind === "expired") return `Login expired at ${problem.expiresAt}. ${remedy}`; + return `Login rejected (HTTP ${problem.status}). ${remedy}`; +} + +function failureText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return problemText(report.problem); + return "-"; +} + +function windowText(window: ProviderUsageWindow): string { + return `${window.label}: ${percent(window.usedPct)} used, ${percent(window.remainingPct)} remaining`; +} + +function balanceText(balance: ProviderUsageBalance): string { + return `${balance.label}: ${value(balance.used)} used, ${value(balance.remaining)} remaining, ${value(balance.limit)} limit (${balance.unit})`; +} + +function summary(entry: UsageReportEntry): string[] { + const { report } = entry; + if (report.status !== "available") { + const lines = [failureText(report)]; + for (const login of entry.loginErrors ?? []) + lines.push(`${login.harness}: ${failureText(login.report)}`); + return lines; + } + const lines = report.windows.map(windowText); + lines.push(...(report.balances ?? []).map(balanceText)); + return lines.length ? lines : ["-"]; +} + +interface SummaryRow { + id: string; + source: string; + account: string; + status: string; + usage: string; + fetched: string; +} + +const summarySchema: OutputSchema<SummaryRow> = { + idField: "id", + columns: [ + { header: "REPORT ID", field: "id" }, + { header: "SOURCE", field: "source" }, + { header: "ACCOUNT", field: "account" }, + { header: "STATUS", field: "status" }, + { header: "USAGE", field: "usage" }, + { header: "FETCHED", field: "fetched" }, + ], +}; + +function inspect(entry: UsageReportEntry): string { + const lines = [ + `Report ID: ${entry.id}`, + `Source: ${entry.sourceLabel}`, + `Source ID: ${entry.sourceId}`, + `Account: ${value(entry.account.label)}`, + `Status: ${entry.report.status}`, + `Fetched: ${value(entry.fetchedAt)}`, + ]; + const { report } = entry; + if (report.status === "available") { + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + } else { + lines.push(`Reason: ${failureText(report)}`); + } + if (entry.loginErrors?.length) { + lines.push("Login failures:"); + for (const login of entry.loginErrors) + lines.push(` ${login.harness}: ${failureText(login.report)}`); + } + return lines.join("\n"); +} + +export const usageSchema: OutputSchema<UsageReportEntry> = { + idField: "id", + columns: [], + renderHuman(result, options) { + if (result.type === "single") return inspect(result.data); + if (!result.data.length) return "No usage reports returned"; + const rows: SummaryRow[] = []; + for (const entry of result.data) { + for (const [index, usage] of summary(entry).entries()) { + const first = index === 0; + rows.push({ + id: first ? entry.id : "", + source: first ? entry.sourceLabel : "", + account: first ? value(entry.account.label) : "", + status: first ? entry.report.status : "", + usage, + fetched: first ? value(entry.fetchedAt) : "", + }); + } + } + return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options); + }, +}; diff --git a/packages/cli/src/commands/workspace/archive.ts b/packages/cli/src/commands/workspace/archive.ts index 958d0451d0..a9dad90e91 100644 --- a/packages/cli/src/commands/workspace/archive.ts +++ b/packages/cli/src/commands/workspace/archive.ts @@ -19,11 +19,11 @@ const workspaceArchiveSchema: OutputSchema<WorkspaceArchiveResult> = { export async function runArchiveCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise<SingleResult<WorkspaceArchiveResult>> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -44,6 +44,7 @@ export async function runArchiveCommand( schema: workspaceArchiveSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_ARCHIVE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/create.ts b/packages/cli/src/commands/workspace/create.ts index 078eefb06e..04d9e10ef0 100644 --- a/packages/cli/src/commands/workspace/create.ts +++ b/packages/cli/src/commands/workspace/create.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveCallerAgentId } from "../../utils/caller-agent.js"; import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js"; import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; @@ -15,6 +16,7 @@ export interface WorkspaceCreateOptions extends CommandOptions { branch?: string; prNumber?: string; forge?: string; + background?: boolean; } interface WorktreeSourceBase { @@ -134,8 +136,8 @@ export async function runCreateCommand( options: WorkspaceCreateOptions, _command: Command, ): Promise<SingleResult<WorkspaceRow>> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -144,15 +146,19 @@ export async function runCreateCommand( }); try { + const callerAgentId = await resolveCallerAgentId(client); const payload = await client.createWorkspace({ source: buildWorkspaceSource(options), ...(options.title ? { title: options.title } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), + ...(callerAgentId ? { callerAgentId } : {}), }); if (!payload.workspace) { throw new Error(payload.error ?? "Workspace creation failed"); } return { type: "single", data: toWorkspaceRow(payload.workspace), schema: workspaceSchema }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_CREATE_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index 19d640d252..7d15ef26f3 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -34,12 +34,17 @@ export function createWorkspaceCommand(): Command { .option("--base <ref>", "Base ref (--mode branch-off)") .option("--branch <name>", "Existing branch (--mode checkout-branch)") .option("--pr-number <n>", "Pull request or change request number (--mode checkout-pr)") - .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)"), + .option("--forge <forge>", "Forge for --mode checkout-pr (default: source checkout)") + .option("--background", "Create a background workspace") + .option("--no-background", "Create a visible workspace instead of inheriting background"), ).action(withOutput(runCreateCommand)); - addJsonAndDaemonHostOptions(workspace.command("ls").description("List active workspaces")).action( - withOutput(runLsCommand), - ); + addJsonAndDaemonHostOptions( + workspace + .command("ls") + .description("List active workspaces") + .option("--background", "Include background workspaces"), + ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( workspace diff --git a/packages/cli/src/commands/workspace/ls.ts b/packages/cli/src/commands/workspace/ls.ts index 7ea9e8f56d..680bb06e1c 100644 --- a/packages/cli/src/commands/workspace/ls.ts +++ b/packages/cli/src/commands/workspace/ls.ts @@ -1,14 +1,26 @@ import type { Command } from "commander"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandError, ListResult } from "../../output/index.js"; -import { toWorkspaceRow, workspaceSchema, type WorkspaceRow } from "./shared.js"; +import { + toWorkspaceRow, + workspaceSchema, + workspaceWithBackgroundSchema, + type WorkspaceRow, +} from "./shared.js"; + +export interface WorkspaceLsOptions { + host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; + /** --background: Include background workspaces */ + background?: boolean; +} export async function runLsCommand( - options: { host?: string }, + options: WorkspaceLsOptions, _command: Command, ): Promise<ListResult<WorkspaceRow>> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -20,12 +32,17 @@ export async function runLsCommand( let cursor: string | undefined; do { const payload = await client.fetchWorkspaces({ + ...(options.background ? { filter: { includeBackground: true } } : {}), page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); workspaces.push(...payload.entries.map(toWorkspaceRow)); cursor = payload.pageInfo.nextCursor ?? undefined; } while (cursor); - return { type: "list", data: workspaces, schema: workspaceSchema }; + return { + type: "list", + data: workspaces, + schema: options.background ? workspaceWithBackgroundSchema : workspaceSchema, + }; } finally { await client.close().catch(() => undefined); } diff --git a/packages/cli/src/commands/workspace/rename.ts b/packages/cli/src/commands/workspace/rename.ts index fd5857682b..85fa5f5ff4 100644 --- a/packages/cli/src/commands/workspace/rename.ts +++ b/packages/cli/src/commands/workspace/rename.ts @@ -18,6 +18,7 @@ const workspaceRenameSchema: OutputSchema<WorkspaceRenameResult> = { export interface WorkspaceRenameOptions { host?: string; + daemonTarget: import("../../utils/daemon-target.js").DaemonTarget; reset?: boolean; } @@ -55,8 +56,8 @@ export async function runRenameCommand( ): Promise<SingleResult<WorkspaceRenameResult>> { const title = resolveWorkspaceTitle({ title: titleArg, reset: options.reset }); - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); throw { code: "DAEMON_NOT_RUNNING", @@ -72,6 +73,7 @@ export async function runRenameCommand( schema: workspaceRenameSchema, }; } catch (error) { + if (error && typeof error === "object" && "code" in error) throw error; const message = error instanceof Error ? error.message : String(error); throw { code: "WORKSPACE_RENAME_FAILED", message } satisfies CommandError; } finally { diff --git a/packages/cli/src/commands/workspace/setup.ts b/packages/cli/src/commands/workspace/setup.ts index ce15d10f26..0d9e99821a 100644 --- a/packages/cli/src/commands/workspace/setup.ts +++ b/packages/cli/src/commands/workspace/setup.ts @@ -17,11 +17,11 @@ const workspaceSetupSchema: OutputSchema<WorkspaceSetupResult> = { export async function runSetupCommand( workspaceId: string, - options: { host?: string }, + options: { host?: string; daemonTarget: import("../../utils/daemon-target.js").DaemonTarget }, _command: Command, ): Promise<SingleResult<WorkspaceSetupResult>> { - const host = getDaemonHost({ host: options.host }); - const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => { + const host = getDaemonHost({ target: options.daemonTarget }); + const client = await connectToDaemon({ target: options.daemonTarget }).catch((error: unknown) => { throw { code: "DAEMON_NOT_RUNNING", message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/cli/src/commands/workspace/shared.ts b/packages/cli/src/commands/workspace/shared.ts index cd800c9f10..db047e57b3 100644 --- a/packages/cli/src/commands/workspace/shared.ts +++ b/packages/cli/src/commands/workspace/shared.ts @@ -7,6 +7,7 @@ export interface WorkspaceRow { name: string; isolation: "local" | "worktree"; cwd: string; + background: boolean; } export const workspaceSchema: OutputSchema<WorkspaceRow> = { @@ -20,6 +21,15 @@ export const workspaceSchema: OutputSchema<WorkspaceRow> = { ], }; +/** `--background` listings add a column so hidden workspaces stand out. */ +export const workspaceWithBackgroundSchema: OutputSchema<WorkspaceRow> = { + ...workspaceSchema, + columns: [ + ...workspaceSchema.columns, + { header: "BACKGROUND", field: (row) => (row.background ? "yes" : ""), width: 8 }, + ], +}; + export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): WorkspaceRow { return { workspaceId: workspace.id, @@ -27,5 +37,6 @@ export function toWorkspaceRow(workspace: WorkspaceDescriptorPayload): Workspace name: workspace.name, isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local", cwd: workspace.workspaceDirectory, + background: workspace.background === true, }; } diff --git a/packages/cli/src/commands/worktree/archive.test.ts b/packages/cli/src/commands/worktree/archive.test.ts index 5314be144d..f591aaa6a7 100644 --- a/packages/cli/src/commands/worktree/archive.test.ts +++ b/packages/cli/src/commands/worktree/archive.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { runArchiveCommandWithDeps } from "./archive.js"; +const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" }; + function createFakeDaemonClient( overrides: Partial< Pick<DaemonClient, "getPaseoWorktreeList" | "archivePaseoWorktree" | "close"> @@ -60,9 +62,12 @@ describe("runArchiveCommand", () => { const result = await runArchiveCommandWithDeps( "feature", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -111,9 +116,12 @@ describe("runArchiveCommand", () => { await runArchiveCommandWithDeps( "feature-x", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ); @@ -134,9 +142,12 @@ describe("runArchiveCommand", () => { await expect( runArchiveCommandWithDeps( "missing", - {}, + { daemonTarget }, { - connectToDaemon: async () => fakeClient, + connectToDaemon: async (options) => { + expect(options.target).toEqual(daemonTarget); + return fakeClient; + }, }, ), ).rejects.toMatchObject({ diff --git a/packages/cli/src/commands/worktree/archive.ts b/packages/cli/src/commands/worktree/archive.ts index 711711dced..5d2bc60f33 100644 --- a/packages/cli/src/commands/worktree/archive.ts +++ b/packages/cli/src/commands/worktree/archive.ts @@ -2,6 +2,7 @@ import path from "path"; import type { Command } from "commander"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, SingleResult, @@ -31,6 +32,7 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = { export interface WorktreeArchiveOptions extends CommandOptions { host?: string; + cwd?: string; } export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>; @@ -48,7 +50,8 @@ export async function runArchiveCommandWithDeps( options: WorktreeArchiveOptions, deps: { connectToDaemon: typeof connectToDaemon }, ): Promise<WorktreeArchiveCommandResult> { - const host = getDaemonHost({ host: options.host }); + const host = getDaemonHost({ target: options.daemonTarget }); + const cwd = resolveRepositoryDirectory(options); // Validate arguments if (!nameArg || nameArg.trim().length === 0) { @@ -62,8 +65,9 @@ export async function runArchiveCommandWithDeps( let client: DaemonClient; try { - client = await deps.connectToDaemon({ host: options.host }); + client = await deps.connectToDaemon({ target: options.daemonTarget }); } catch (err) { + if (err && typeof err === "object" && "code" in err) throw err; const message = err instanceof Error ? err.message : String(err); const error: CommandError = { code: "DAEMON_NOT_RUNNING", @@ -75,7 +79,7 @@ export async function runArchiveCommandWithDeps( try { // Get the list of worktrees first to resolve the name - const listResponse = await client.getPaseoWorktreeList({}); + const listResponse = await client.getPaseoWorktreeList({ cwd }); if (listResponse.error) { const error: CommandError = { diff --git a/packages/cli/src/commands/worktree/create.ts b/packages/cli/src/commands/worktree/create.ts index 257fadc679..da7d873b5a 100644 --- a/packages/cli/src/commands/worktree/create.ts +++ b/packages/cli/src/commands/worktree/create.ts @@ -1,7 +1,6 @@ import path from "node:path"; import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { connectToDaemon } from "../../utils/client.js"; import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js"; import { buildCreateWorktreeRequest, type WorktreeCreateOptions } from "./create-input.js"; @@ -31,18 +30,7 @@ export async function runCreateCommand( const cwd = options.cwd ?? process.cwd(); const request = buildCreateWorktreeRequest(options, cwd); - const host = getDaemonHost({ host: options.host }); - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw cmdError( - "DAEMON_NOT_RUNNING", - `Cannot connect to daemon at ${host}: ${message}`, - "Start the daemon with: paseo daemon start", - ); - } + const client = await connectToDaemon({ target: options.daemonTarget }); try { const response = await client.createPaseoWorktree(request); diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts index f35f001a38..37405e2630 100644 --- a/packages/cli/src/commands/worktree/index.ts +++ b/packages/cli/src/commands/worktree/index.ts @@ -9,7 +9,10 @@ export function createWorktreeCommand(): Command { const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees"); addJsonAndDaemonHostOptions( - worktree.command("ls").description("List Paseo-managed git worktrees"), + worktree + .command("ls") + .description("List Paseo-managed git worktrees") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runLsCommand)); addJsonAndDaemonHostOptions( @@ -31,7 +34,8 @@ export function createWorktreeCommand(): Command { worktree .command("archive") .description("Archive a worktree (removes worktree and associated branch)") - .argument("<name>", "Worktree name or branch name"), + .argument("<name>", "Worktree name or branch name") + .option("--cwd <path>", "Repository directory (default: current)"), ).action(withOutput(runArchiveCommand)); return worktree; diff --git a/packages/cli/src/commands/worktree/ls.ts b/packages/cli/src/commands/worktree/ls.ts index 10f1344e17..43940131ce 100644 --- a/packages/cli/src/commands/worktree/ls.ts +++ b/packages/cli/src/commands/worktree/ls.ts @@ -1,8 +1,7 @@ import type { Command } from "commander"; -import { homedir } from "node:os"; -import { basename, join, sep } from "node:path"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; +import { basename } from "node:path"; +import { connectToDaemon } from "../../utils/client.js"; +import { resolveRepositoryDirectory } from "./repository-directory.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; /** Worktree list item for display */ @@ -27,19 +26,6 @@ function extractWorktreeName(path: string): string { return basename(path); } -export function resolvePaseoHomePath(): string { - return process.env.PASEO_HOME ?? join(homedir(), ".paseo"); -} - -export function resolvePaseoWorktreesDir(): string { - return join(resolvePaseoHomePath(), "worktrees"); -} - -function isAgentInManagedWorktree(agentCwd: string): boolean { - const worktreesDir = resolvePaseoWorktreesDir(); - return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep); -} - /** Schema for worktree ls output */ export const worktreeLsSchema: OutputSchema<WorktreeListItem> = { idField: "name", @@ -55,33 +41,22 @@ export type WorktreeLsResult = ListResult<WorktreeListItem>; export interface WorktreeLsOptions extends CommandOptions { host?: string; + cwd?: string; } export async function runLsCommand( options: WorktreeLsOptions, _command: Command, ): Promise<WorktreeLsResult> { - const host = getDaemonHost({ host: options.host }); - - let client: DaemonClient; - try { - client = await connectToDaemon({ host: options.host }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const error: CommandError = { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: "Start the daemon with: paseo daemon start", - }; - throw error; - } + const cwd = resolveRepositoryDirectory(options); + const client = await connectToDaemon({ target: options.daemonTarget }); try { const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } }); const agents = agentsPayload.entries.map((entry) => entry.agent); // Get worktree list from daemon - const response = await client.getPaseoWorktreeList({}); + const response = await client.getPaseoWorktreeList({ cwd }); await client.close(); @@ -96,9 +71,7 @@ export async function runLsCommand( // Build a map of worktree paths to agent IDs const worktreeAgentMap = new Map<string, string>(); for (const agent of agents) { - if (isAgentInManagedWorktree(agent.cwd)) { - worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); - } + worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7)); } const items: WorktreeListItem[] = response.worktrees.map((wt) => ({ diff --git a/packages/cli/src/commands/worktree/repository-directory.ts b/packages/cli/src/commands/worktree/repository-directory.ts new file mode 100644 index 0000000000..3c241703c0 --- /dev/null +++ b/packages/cli/src/commands/worktree/repository-directory.ts @@ -0,0 +1,13 @@ +import { resolve } from "node:path"; +import type { DaemonTarget } from "../../utils/daemon-target.js"; + +export function resolveRepositoryDirectory(options: { + cwd?: string; + daemonTarget: DaemonTarget; +}): string { + const cwd = options.cwd ?? process.cwd(); + if (options.daemonTarget.kind === "instance") { + return resolve(cwd); + } + return cwd; +} diff --git a/packages/cli/src/output/pairing.test.ts b/packages/cli/src/output/pairing.test.ts index 89fc31d85e..71cf630069 100644 --- a/packages/cli/src/output/pairing.test.ts +++ b/packages/cli/src/output/pairing.test.ts @@ -30,4 +30,17 @@ describe("formatPairingInstructions", () => { expect(output).toContain("terminal width could not be detected"); expect(output.split("\n")).toContain(URL); }); + + it("redacts a password in a printed connection URI", () => { + const output = formatPairingInstructions({ + qr: null, + url: URL, + connectionUri: "relay://relay.paseo.sh:443/srv_test?key=public&password=private-value", + }); + + expect(output).toContain( + "relay://relay.paseo.sh:443/srv_test?key=public&password=%5Bredacted%5D", + ); + expect(output).not.toContain("private-value"); + }); }); diff --git a/packages/cli/src/output/pairing.ts b/packages/cli/src/output/pairing.ts index 741c8fd056..a5bbe7659d 100644 --- a/packages/cli/src/output/pairing.ts +++ b/packages/cli/src/output/pairing.ts @@ -3,6 +3,7 @@ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g"); interface PairingInstructions { url: string; qr: string | null; + connectionUri?: string | null; columns?: number; } @@ -32,6 +33,23 @@ function formatQr(qr: string | null, columns: number | undefined): string { return qr; } -export function formatPairingInstructions({ url, qr, columns }: PairingInstructions): string { - return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`; +function redactConnectionUri(uri: string): string { + try { + const parsed = new URL(uri); + if (parsed.searchParams.has("password")) { + parsed.searchParams.set("password", "[redacted]"); + } + return parsed.toString(); + } catch { + return "Invalid connection URI"; + } +} + +export function formatPairingInstructions({ + url, + qr, + columns, + connectionUri, +}: PairingInstructions): string { + return `\nScan to pair:\n${formatQr(qr, columns)}\n\nPairing link:\n${url}${connectionUri ? `\n\nConnection URI:\n${redactConnectionUri(connectionUri)}` : ""}\n\nTreat this pairing link like a password. Anyone with it can access this daemon.\n`; } diff --git a/packages/cli/src/output/render.test.ts b/packages/cli/src/output/render.test.ts new file mode 100644 index 0000000000..bc7dced1ca --- /dev/null +++ b/packages/cli/src/output/render.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { renderError, toCommandError } from "./index.js"; + +it("preserves the message of an Error with an RPC code in JSON output", () => { + const error = Object.assign(new Error("Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2."), { + code: "handler_error", + requestId: "request-1", + }); + expect(JSON.parse(renderError(toCommandError(error), { format: "json" }))).toEqual({ + error: { + code: "handler_error", + requestId: "request-1", + message: "Plugin requires Paseo >=0.8.0. Your daemon is 0.7.2.", + }, + }); +}); diff --git a/packages/cli/src/output/render.ts b/packages/cli/src/output/render.ts index f3eb9f8aec..d21e53344a 100644 --- a/packages/cli/src/output/render.ts +++ b/packages/cli/src/output/render.ts @@ -50,7 +50,8 @@ export function render<T>( /** Convert an unknown error to a CommandError */ export function toCommandError(error: unknown): CommandError { if (isCommandError(error)) { - return error; + // Error.message and class getters are not enumerable; materialize the output contract. + return { ...error, code: error.code, message: error.message, details: error.details }; } if (error instanceof Error) { diff --git a/packages/cli/src/output/with-output.ts b/packages/cli/src/output/with-output.ts index ad0f631988..723b6c15d5 100644 --- a/packages/cli/src/output/with-output.ts +++ b/packages/cli/src/output/with-output.ts @@ -11,6 +11,8 @@ import { withGlobalOptions } from "../utils/command-options.js"; /** Options that include output settings from global options */ export interface CommandOptions extends Partial<OutputOptions> { + daemonTarget: import("../utils/daemon-target.js").DaemonTarget; + home?: string; /** Daemon host target from --host option */ host?: string; /** JSON output flag from --json option */ diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 8a0a685829..651719e806 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -1,10 +1,59 @@ +import { spawn } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { createCli } from "./cli"; import { createCliParseArgv } from "./run"; +const runModuleUrl = new URL("./run.ts", import.meta.url).href; + +// Runs the CLI in a child process whose stdout pipe has no reader, as when the +// program that launched it has already closed its end. +async function runCliWithClosedStdout( + argv: string[], +): Promise<{ code: number | null; stderr: string }> { + const script = ` + const { runCli } = await import(${JSON.stringify(runModuleUrl)}); + process.exitCode = await runCli(${JSON.stringify(argv)}); + await new Promise((resolve) => setTimeout(resolve, 100)); + process.stderr.write("still running\\n"); + `; + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { + cwd: path.dirname(fileURLToPath(import.meta.url)), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.destroy(); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const code = await new Promise<number | null>((resolve) => child.on("close", resolve)); + return { code, stderr }; +} + describe("runCli", () => { + it("lets plugin update own --version while preserving global output options", async () => { + const cli = createCli() + .exitOverride() + .configureOutput({ writeOut: () => {} }); + const update = cli.commands + .find((command) => command.name() === "plugin")! + .commands.find((command) => command.name() === "update")!; + let received: Record<string, unknown> | undefined; + update.action((_id, options) => { + received = options; + }); + await cli.parseAsync( + ["plugin", "update", "example", "--version", "1.2.0", "--format", "json", "--no-color"], + { from: "user" }, + ); + expect(received).toMatchObject({ version: "1.2.0" }); + expect(update.optsWithGlobals()).toMatchObject({ format: "json", color: false }); + }); + it("defaults an empty CLI invocation to onboard", () => { expect( createCliParseArgv({ @@ -52,6 +101,19 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]); }); + it("finishes the command quietly when stdout is closed before it writes its output", async () => { + const home = mkdtempSync(path.join(tmpdir(), "paseo-cli-closed-stdout-")); + + try { + const result = await runCliWithClosedStdout(["daemon", "status", "--json", "--home", home]); + + expect(result.stderr).toBe("still running\n"); + expect(result.code).toBe(0); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); + it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const project = path.join(root, "repository"); diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index 4b679c5298..ac67e80745 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -1,3 +1,4 @@ +import { renderError, toCommandError, defaultOutputOptions } from "./output/render.js"; import { createCli } from "./cli.js"; import { classifyInvocation } from "./classify.js"; import { openDesktopWithProject } from "./commands/open.js"; @@ -35,7 +36,21 @@ export function createCliParseArgv(input: { return [...nodeArgv, ...cliArgv]; } +// The program that started the CLI can close its end of stdout before the output +// is written, as `paseo ls | head -1` or a launcher that discards output does. +// Drop the output nobody reads and let the command finish, instead of surfacing +// the failed write as an uncaught error. +function ignoreClosedStdout(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } +} + export async function runCli(argv: string[], options: RunCliOptions = {}): Promise<number> { + if (!process.stdout.listeners("error").includes(ignoreClosedStdout)) { + process.stdout.on("error", ignoreClosedStdout); + } + const parseArgv = createCliParseArgv({ argv, cwd: options.cwd ?? process.cwd(), @@ -48,6 +63,16 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi } const program = createCli(); - await program.parseAsync(parseArgv, { from: "node" }); + try { + await program.parseAsync(parseArgv, { from: "node" }); + } catch (error) { + process.stderr.write( + renderError(toCommandError(error), { + ...defaultOutputOptions, + format: argv.includes("--json") ? "json" : "table", + }) + "\n", + ); + return 1; + } return typeof process.exitCode === "number" ? process.exitCode : 0; } diff --git a/packages/cli/src/utils/caller-agent.ts b/packages/cli/src/utils/caller-agent.ts new file mode 100644 index 0000000000..3bc2ee6223 --- /dev/null +++ b/packages/cli/src/utils/caller-agent.ts @@ -0,0 +1,21 @@ +import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client"; + +interface CallerLookupClient { + fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>; +} + +// PASEO_AGENT_ID belongs to the daemon that launched this shell. Commands +// targeting another daemon (--host or --home) have no caller on that daemon. +export async function resolveCallerAgentId( + client: CallerLookupClient, + env: { PASEO_AGENT_ID?: string } = process.env, +): Promise<string | undefined> { + const agentId = env.PASEO_AGENT_ID?.trim(); + if (!agentId) return undefined; + const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => { + // A missing agent is a daemon answer; a lost or timed-out connection is not. + if (error instanceof DaemonConnectionError) throw error; + return null; + }); + return caller?.agent.id === agentId ? agentId : undefined; +} diff --git a/packages/cli/src/utils/client-id.ts b/packages/cli/src/utils/client-id.ts index 13863e0945..84a04b3951 100644 --- a/packages/cli/src/utils/client-id.ts +++ b/packages/cli/src/utils/client-id.ts @@ -1,14 +1,7 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { ensurePrivateDirectory } from "@getpaseo/server/daemon-control"; +import { readFile, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; -import { dirname, join } from "node:path"; -import { homedir } from "node:os"; - -const CLIENT_SESSION_KEY_FILE = join( - process.env.PASEO_HOME ?? join(homedir(), ".paseo"), - "cli-client-id", -); - -let cachedClientId: string | null = null; +import { join } from "node:path"; function normalizeClientId(value: string): string | null { const trimmed = value.trim(); @@ -19,15 +12,12 @@ function generateClientId(): string { return `cid_${randomUUID().replace(/-/g, "")}`; } -export async function getOrCreateCliClientId(): Promise<string> { - if (cachedClientId) { - return cachedClientId; - } +export async function getOrCreateCliClientId(home: string): Promise<string> { + const clientSessionKeyFile = join(home, "cli-client-id"); try { - const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8")); + const existing = normalizeClientId(await readFile(clientSessionKeyFile, "utf8")); if (existing) { - cachedClientId = existing; return existing; } } catch (error) { @@ -38,8 +28,7 @@ export async function getOrCreateCliClientId(): Promise<string> { } const nextValue = generateClientId(); - await mkdir(dirname(CLIENT_SESSION_KEY_FILE), { recursive: true }); - await writeFile(CLIENT_SESSION_KEY_FILE, nextValue, { mode: 0o600 }); - cachedClientId = nextValue; + ensurePrivateDirectory(home); + await writeFile(clientSessionKeyFile, nextValue, { mode: 0o600 }); return nextValue; } diff --git a/packages/cli/src/utils/client.ssh.test.ts b/packages/cli/src/utils/client.ssh.test.ts index 4311db71b5..19b720a893 100644 --- a/packages/cli/src/utils/client.ssh.test.ts +++ b/packages/cli/src/utils/client.ssh.test.ts @@ -47,7 +47,9 @@ describe("CLI SSH transport", () => { }); it("routes an SSH host through a local tunnel", async () => { - await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" }); + await connectToDaemon({ + target: { kind: "endpoint", host: "ssh://deploy@build-box:2222?daemonPort=7777" }, + }); expect(mocks.createSshTunnel).toHaveBeenCalledWith({ host: "deploy@build-box", diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index bd774ddaff..92d91ea944 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -1,5 +1,11 @@ -import { existsSync, readFileSync } from "node:fs"; -import { loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { + waitForDaemonReady, + resolvePaseoHome, + type DaemonInstance, + readLocalCredentialForTarget, +} from "@getpaseo/server/daemon-control"; +import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js"; +export type { DaemonTarget } from "./daemon-target.js"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, @@ -13,60 +19,60 @@ import { } from "@getpaseo/protocol/connection-offer"; import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport"; import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; -import path from "node:path"; import { WebSocket } from "ws"; import { getOrCreateCliClientId } from "./client-id.js"; import { resolveCliVersion } from "../version.js"; import { createSshTunnel } from "../ssh/ssh-tunnel.js"; export interface ConnectOptions { - host?: string; + target: DaemonTarget; timeout?: number; + instance?: DaemonInstance; } - -export interface DaemonConnectionCommandError { - code: "DAEMON_NOT_RUNNING"; - message: string; - details: string; +export function resolveClientPaseoHome( + target: DaemonTarget, + env: Readonly<Record<string, string | undefined>> = process.env, +): string { + return target.kind === "instance" ? target.home : resolvePaseoHome(env); } - -const DEFAULT_HOST = "localhost:6767"; const DEFAULT_TIMEOUT = 15000; -const PID_FILENAME = "paseo.pid"; +type TransportTarget = + | { type: "tcp"; url: string } + | { type: "ipc"; url: string; socketPath: string }; -type DaemonTarget = - | { - type: "tcp"; - url: string; - } - | { - type: "ipc"; - url: string; - socketPath: string; - }; - -/** - * Get the daemon host from environment or options - */ -export function getDaemonHost(options?: ConnectOptions): string { - return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST; +export function getDaemonHost(options: ConnectOptions): string { + return describeDaemonTarget(options.target); } -export function buildDaemonConnectionCommandError(options: { - host?: string; - error: unknown; -}): DaemonConnectionCommandError { - const host = getDaemonHost({ host: options.host }); - const message = options.error instanceof Error ? options.error.message : String(options.error); +export function buildDaemonConnectionCommandError(options: ConnectOptions & { error: unknown }) { + const error = options.error; + let message = error instanceof Error ? error.message : String(error); + if (error && typeof error === "object" && "message" in error) message = String(error.message); + if (options.target.kind === "endpoint") + message = message.replaceAll(options.target.host, describeDaemonTarget(options.target)); + if (message.startsWith("Cannot connect to daemon at ")) + return error as { code: string; message: string; details: string }; + let code = "DAEMON_UNREACHABLE"; + if (typeof error === "object" && error !== null && "code" in error) code = String(error.code); + else if (message === "Password required") code = "AUTH_REQUIRED"; + else if (message === "Incorrect password") code = "AUTH_FAILED"; return { - code: "DAEMON_NOT_RUNNING", - message: `Cannot connect to daemon at ${host}: ${message}`, - details: host.trim().startsWith("ssh://") - ? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it." - : "Start the daemon with: paseo daemon start", + code, + message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`, + details: describeConnectionRemedy(code, options.target), }; } +function describeConnectionRemedy(code: string, target: DaemonTarget): string { + if (code === "AUTH_REQUIRED") + return "The daemon requires a password. Set PASEO_PASSWORD and retry."; + if (code === "AUTH_FAILED") + return "The daemon rejected the password. Check PASEO_PASSWORD and retry."; + if (target.kind === "instance") + return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`; + return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon."; +} + export function normalizeDaemonHost(raw: string): string | null { const trimmed = raw.trim(); if (!trimmed) { @@ -118,100 +124,13 @@ export function normalizeDaemonHost(raw: string): string | null { return trimmed.includes(":") ? trimmed : null; } -export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string { - return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST; -} - -function isIpcDaemonHost(host: string | null): host is string { - return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://")); -} - -function isTcpDaemonHost(host: string | null): host is string { - return host !== null && !isIpcDaemonHost(host); -} - -function readPidSocketTarget(paseoHome: string): string | null { - const pidPath = path.join(paseoHome, PID_FILENAME); - if (!existsSync(pidPath)) { - return null; - } - - try { - const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as { - listen?: unknown; - sockPath?: unknown; - }; - if (typeof parsed.listen === "string") return parsed.listen; - if (typeof parsed.sockPath === "string") return parsed.sockPath; - return null; - } catch { - return null; - } -} - -function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? ""); - if (isIpcDaemonHost(directEnvHost)) { - return directEnvHost; - } - - const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? ""); - if (isIpcDaemonHost(pidHost)) { - return pidHost; - } - - const config = loadConfig(paseoHome, { env }); - const configuredHost = normalizeDaemonHost(config.listen); - return isIpcDaemonHost(configuredHost) ? configuredHost : null; -} - -function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null { - const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen); - if (!isTcpDaemonHost(configuredHost)) { - return null; - } - return configuredHost === "127.0.0.1:6767" ? null : configuredHost; -} - -export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] { - const paseoHome = resolvePaseoHome(env); - const candidates: string[] = []; - const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome); - if (configuredIpcHost) { - candidates.push(configuredIpcHost); - } - const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome); - if (configuredTcpHost) { - candidates.push(configuredTcpHost); - } - candidates.push(DEFAULT_HOST); - return Array.from(new Set(candidates)); -} - -function resolveDaemonHostCandidates(options?: ConnectOptions): string[] { - const explicitHost = getExplicitDaemonHost(options?.host); - if (explicitHost) { - return [explicitHost]; - } - - return resolveDefaultDaemonHosts(); -} - -export function getExplicitDaemonHost( - host: string | undefined, - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - const explicitHost = host ?? env.PASEO_HOST; - return explicitHost?.trim() ? explicitHost : undefined; -} - function stripIpcPrefix(trimmed: string): string { if (trimmed.startsWith("unix://")) return trimmed.slice("unix://".length).trim(); if (trimmed.startsWith("pipe://")) return trimmed.slice("pipe://".length).trim(); return trimmed; } -export function resolveDaemonTarget(host: string): DaemonTarget { +export function resolveDaemonTarget(host: string): TransportTarget { const trimmed = normalizeDaemonHost(host); if (!trimmed) { throw new Error(`Invalid daemon target: ${host}`); @@ -260,6 +179,16 @@ export function resolveDaemonPassword(host: string): string | undefined { return fromEnv && fromEnv.length > 0 ? fromEnv : undefined; } +export function resolveDaemonCredential( + host: string, + home: string, +): { kind: "password"; password: string } | { kind: "localCredential"; token: string } | null { + const password = resolveDaemonPassword(host); + if (password) return { kind: "password", password }; + const token = readLocalCredentialForTarget(home, host); + return token ? { kind: "localCredential", token } : null; +} + /** * Create a WebSocket factory that works in Node.js */ @@ -281,7 +210,8 @@ function createNodeWebSocketFactory() { */ async function tryConnectHost( host: string, - password: string | undefined, + credential: ReturnType<typeof resolveDaemonCredential>, + home: string, clientId: string, timeout: number, nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>, @@ -292,7 +222,10 @@ async function tryConnectHost( clientId, clientType: "cli", appVersion: resolveCliVersion(), - password, + ...(credential?.kind === "password" ? { password: credential.password } : {}), + ...(credential?.kind === "localCredential" + ? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined } + : {}), connectTimeoutMs: timeout, webSocketFactory: ( url: string, @@ -363,24 +296,45 @@ function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null } } -export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> { - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - const clientId = await getOrCreateCliClientId(); +async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> { + const timeout = options.timeout ?? DEFAULT_TIMEOUT; + const deadline = Date.now() + timeout; + const explicitHost = + options.target.kind === "endpoint" + ? options.target.host + : ( + await waitForDaemonReady(options.target.home, { + timeoutMs: timeout, + instance: options.instance, + }) + ).listen; + const home = resolveClientPaseoHome(options.target); + const clientId = await getOrCreateCliClientId(home); const nodeWebSocketFactory = createNodeWebSocketFactory(); - const explicitHost = getExplicitDaemonHost(options?.host); if (explicitHost?.trim().startsWith("ssh://")) { const target = parseSshTransportUri(explicitHost.trim()); const tunnel = await createSshTunnel(target); const password = resolveDaemonPassword(explicitHost); const result = await tryConnectHost( tunnel.endpoint, - password, + password ? { kind: "password", password } : null, + home, clientId, - timeout, + Math.max(1, deadline - Date.now()), nodeWebSocketFactory, ); - if ("client" in result) return result.client; + if ("client" in result) { + const close = result.client.close.bind(result.client); + result.client.close = async () => { + try { + await close(); + } finally { + tunnel.close(); + } + }; + return result.client; + } const failure = tunnel.failureDetail(); tunnel.close(); @@ -389,32 +343,38 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC } const offer = parseHostOfferOrNull(explicitHost); if (offer) { - return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory); + return connectViaRelayOffer( + offer, + clientId, + Math.max(1, deadline - Date.now()), + nodeWebSocketFactory, + ); } - const hosts = resolveDaemonHostCandidates(options); + const result = await tryConnectHost( + explicitHost, + resolveDaemonCredential(explicitHost, home), + home, + clientId, + Math.max(1, deadline - Date.now()), + nodeWebSocketFactory, + ); + if ("client" in result) return result.client; + throw result.error; +} - async function tryNext(index: number, lastError: unknown): Promise<DaemonClient> { - if (index >= hosts.length) { - if (lastError instanceof Error) throw lastError; - throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`); - } - const host = hosts[index]; - const password = resolveDaemonPassword(host); - const result = await tryConnectHost(host, password, clientId, timeout, nodeWebSocketFactory); - if ("client" in result) { - return result.client; - } - return tryNext(index + 1, result.error); +export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> { + try { + return await connectSelectedDaemon(options); + } catch (error) { + throw buildDaemonConnectionCommandError({ ...options, error }); } - - return tryNext(0, null); } /** * Try to connect to the daemon, returns null if connection fails */ -export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> { +export async function tryConnectToDaemon(options: ConnectOptions): Promise<DaemonClient | null> { try { return await connectToDaemon(options); } catch { diff --git a/packages/cli/src/utils/command-options.test.ts b/packages/cli/src/utils/command-options.test.ts index 652fe9356b..3fdb9ede18 100644 --- a/packages/cli/src/utils/command-options.test.ts +++ b/packages/cli/src/utils/command-options.test.ts @@ -1,16 +1,19 @@ import { Command } from "commander"; import { describe, expect, it } from "vitest"; -import { addDaemonHostOption, withGlobalOptions } from "./command-options.js"; +import { createCli } from "../cli.js"; +import { withGlobalOptions } from "./command-options.js"; async function parseHost(argv: string[]): Promise<string | undefined> { - const program = new Command().exitOverride().option("--host <host>"); + const program = createCli().exitOverride(); let receivedHost: string | undefined; - addDaemonHostOption(program.command("ls")).action( - withGlobalOptions((options: { host?: string }) => { - receivedHost = options.host; - }), - ); + program.commands + .find((command) => command.name() === "ls")! + .action( + withGlobalOptions((options: { host?: string }) => { + receivedHost = options.host; + }), + ); await program.parseAsync(argv, { from: "user" }); return receivedHost; @@ -25,10 +28,13 @@ describe("global command options", () => { await expect(parseHost(["ls", "--host", "local:6767"])).resolves.toBe("local:6767"); }); - it("uses the last explicit host", async () => { - await expect(parseHost(["--host", "first:6767", "ls", "--host", "last:6767"])).resolves.toBe( - "last:6767", - ); + it("rejects conflicting parent and command selectors", async () => { + await expect( + parseHost(["--host", "first:6767", "ls", "--host", "last:6767"]), + ).rejects.toMatchObject({ + code: "TARGET_AMBIGUOUS", + message: "Conflicting duplicate --host selectors.", + }); }); it("lets local-only commands ignore a global host", async () => { diff --git a/packages/cli/src/utils/command-options.ts b/packages/cli/src/utils/command-options.ts index b02b4abdb1..2274785cc4 100644 --- a/packages/cli/src/utils/command-options.ts +++ b/packages/cli/src/utils/command-options.ts @@ -1,8 +1,17 @@ import type { Command } from "commander"; +import { selectDaemonTarget } from "./daemon-target.js"; const JSON_OPTION_DESCRIPTION = "Output in JSON format"; const DAEMON_HOST_OPTION_DESCRIPTION = - "Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)"; + "Explicit daemon endpoint: host:port, TCP, socket, SSH, or pairing URL (default: local home)"; + +const localCommands = new WeakSet<Command>(); + +export function addLocalDaemonOptions<T extends Command>(command: T): T { + localCommands.add(command); + command.option("--home <path>", "Local daemon home (default: ~/.paseo)"); + return command; +} export function collectMultiple(value: string, previous: string[]): string[] { return previous.concat([value]); @@ -15,6 +24,8 @@ export function addJsonOption<T extends Command>(command: T): T { export function addDaemonHostOption<T extends Command>(command: T): T { command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION); + if (!command.options.some((option) => option.long === "--home")) + command.option("--home <path>", "Select a local daemon home (default: ~/.paseo)"); return command; } @@ -28,7 +39,21 @@ export function withGlobalOptions<Args extends unknown[], Result>( return (...args) => { const command = args.at(-1) as Command; const mergedArgs = [...args]; - mergedArgs[mergedArgs.length - 2] = command.optsWithGlobals(); + const options = command.optsWithGlobals(); + const selectors = { home: new Set<string>(), host: new Set<string>() }; + for (let current: Command | null = command; current; current = current.parent) { + for (const key of ["home", "host"] as const) { + const value = current.opts()[key]; + if (typeof value === "string") selectors[key].add(value); + } + } + for (const key of ["home", "host"] as const) { + if (selectors[key].size > 1) + throw { code: "TARGET_AMBIGUOUS", message: `Conflicting duplicate --${key} selectors.` }; + } + const localOnly = localCommands.has(command); + options.daemonTarget = selectDaemonTarget(options, process.env, localOnly); + mergedArgs[mergedArgs.length - 2] = options; return handler(...(mergedArgs as Args)); }; } diff --git a/packages/cli/src/utils/daemon-target.test.ts b/packages/cli/src/utils/daemon-target.test.ts new file mode 100644 index 0000000000..fc989ab476 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.test.ts @@ -0,0 +1,69 @@ +import { test, expect } from "vitest"; +import { selectDaemonTarget, describeDaemonTarget } from "./daemon-target.js"; +import { resolveDaemonCredential, resolveClientPaseoHome } from "./client.js"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +test("explicit selectors win over both environment selectors", () => { + const env = { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" }; + expect(selectDaemonTarget({ home: "/tmp/b" }, env)).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(selectDaemonTarget({ host: "chosen:23456" }, env)).toEqual({ + kind: "endpoint", + host: "chosen:23456", + }); + expect(() => selectDaemonTarget({}, env)).toThrow(); + expect(() => selectDaemonTarget({ home: "/tmp/b", host: "chosen:23456" }, {})).toThrow(); +}); + +test("endpoint connections resolve local credentials from PASEO_HOME", () => { + expect( + resolveClientPaseoHome( + { kind: "endpoint", host: "localhost:6767" }, + { PASEO_HOME: "/tmp/custom-home" }, + ), + ).toBe("/tmp/custom-home"); +}); + +test("local operations ignore routing environment but reject an explicit endpoint", () => { + expect( + selectDaemonTarget({}, { PASEO_HOME: "/tmp/b", PASEO_HOST: "unused:12345" }, true), + ).toEqual({ kind: "instance", home: "/tmp/b" }); + expect(() => selectDaemonTarget({ host: "chosen:23456" }, {}, true)).toThrow(); +}); + +test("endpoint descriptions redact pairing material and credentials", () => { + expect( + describeDaemonTarget({ + kind: "endpoint", + host: "tcp://user:private@example.test:23456?password=secret", + }), + ).not.toMatch(/private|secret/); + expect( + describeDaemonTarget({ kind: "endpoint", host: "https://app.paseo.sh/#offer=private" }), + ).not.toContain("private"); +}); + +test("CLI selects an explicit password before a matching local credential and never sends the latter remotely", async () => { + const home = await mkdtemp(join(tmpdir(), "paseo-cli-credential-")); + const previousPassword = process.env.PASEO_PASSWORD; + delete process.env.PASEO_PASSWORD; + try { + const token = "a".repeat(43); + await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" })); + await writeFile(join(home, "local-credential"), token); + expect(resolveDaemonCredential("tcp://localhost:6767", home)).toEqual({ + kind: "localCredential", + token, + }); + expect(resolveDaemonCredential("tcp://remote.example:6767", home)).toBeNull(); + expect(resolveDaemonCredential("tcp://localhost:6767?password=explicit", home)).toEqual({ + kind: "password", + password: "explicit", + }); + } finally { + if (previousPassword === undefined) delete process.env.PASEO_PASSWORD; + else process.env.PASEO_PASSWORD = previousPassword; + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/utils/daemon-target.ts b/packages/cli/src/utils/daemon-target.ts new file mode 100644 index 0000000000..cfe8db8a15 --- /dev/null +++ b/packages/cli/src/utils/daemon-target.ts @@ -0,0 +1,51 @@ +import { resolvePaseoHome } from "@getpaseo/server/daemon-control"; + +export type DaemonTarget = { kind: "instance"; home: string } | { kind: "endpoint"; host: string }; + +export function selectDaemonTarget( + options: { home?: string; host?: string }, + env: NodeJS.ProcessEnv = process.env, + localOnly = false, +): DaemonTarget { + for (const key of ["home", "host"] as const) { + if (options[key] !== undefined && !options[key]!.trim()) + throw { code: "TARGET_INVALID", message: `--${key} requires a non-empty value.` }; + } + if (options.home !== undefined && options.host !== undefined) + throw { code: "TARGET_AMBIGUOUS", message: "Choose either --home or --host, not both." }; + if (localOnly) { + if (options.host !== undefined) + throw { + code: "LOCAL_OPERATION", + message: "This is a local operation; use --home. --host is not supported.", + }; + return { + kind: "instance", + home: resolvePaseoHome({ PASEO_HOME: options.home ?? env.PASEO_HOME }), + }; + } + if (options.home !== undefined) + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: options.home }) }; + if (options.host !== undefined) return { kind: "endpoint", host: options.host }; + if (env.PASEO_HOME && env.PASEO_HOST) + throw { + code: "TARGET_AMBIGUOUS", + message: "PASEO_HOME and PASEO_HOST are both set. Choose --home or --host explicitly.", + }; + if (env.PASEO_HOST) return { kind: "endpoint", host: env.PASEO_HOST }; + return { kind: "instance", home: resolvePaseoHome({ PASEO_HOME: env.PASEO_HOME }) }; +} + +export function describeDaemonTarget(target: DaemonTarget): string { + if (target.kind === "instance") return `home ${target.home}`; + try { + const url = new URL(target.host); + if (url.password) url.password = "REDACTED"; + for (const key of url.searchParams.keys()) + if (/password|token|secret/i.test(key)) url.searchParams.set(key, "REDACTED"); + if (url.hash) url.hash = "REDACTED"; + return url.toString(); + } catch { + return target.host.replace(/([?&](?:password|token|secret)=)[^&]*/gi, "$1REDACTED"); + } +} diff --git a/packages/cli/src/utils/timeline.ts b/packages/cli/src/utils/timeline.ts index 7e84757da0..90b67c9f8e 100644 --- a/packages/cli/src/utils/timeline.ts +++ b/packages/cli/src/utils/timeline.ts @@ -7,6 +7,7 @@ interface FetchProjectedTimelineItemsInput { client: DaemonClient; agentId: string; timeoutMs?: number; + sinceTimestampMs?: number; } export async function fetchProjectedTimelineItems( @@ -18,5 +19,10 @@ export async function fetchProjectedTimelineItems( projection: "projected", timeout: input.timeoutMs, }); - return timeline.entries.map((entry) => entry.item); + let entries = timeline.entries; + const sinceTimestampMs = input.sinceTimestampMs; + if (sinceTimestampMs !== undefined) { + entries = entries.filter((entry) => Date.parse(entry.timestamp) >= sinceTimestampMs); + } + return entries.map((entry) => entry.item); } diff --git a/packages/cli/src/utils/wait-for-stop.ts b/packages/cli/src/utils/wait-for-stop.ts new file mode 100644 index 0000000000..8430c39726 --- /dev/null +++ b/packages/cli/src/utils/wait-for-stop.ts @@ -0,0 +1,24 @@ +// Commands that stream until stopped (`paseo agent logs --follow`, `paseo agent +// attach`) stop on Ctrl+C, a termination signal, or when the program reading +// their output closes its end, as `| head` does. A closed reader shows up as +// EPIPE on the next write to stdout, so the command stops at its next line of +// output. +export function waitForStop(): Promise<void> { + return new Promise((resolve) => { + const stop = () => { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + process.stdout.off("error", stopOnClosedStdout); + resolve(); + }; + const stopOnClosedStdout = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + stop(); + } + }; + + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + process.stdout.on("error", stopOnClosedStdout); + }); +} diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index aa84c4caae..d395bdc6fe 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -11,7 +11,7 @@ * - daemon status reports stopped when daemon not running * - daemon status --json outputs valid JSON * - daemon stop handles daemon not running gracefully - * - daemon restart starts the daemon and can be cleaned up + * - daemon restart refuses to start a stopped daemon * - daemon status probes the live relay state over local IPC */ @@ -140,7 +140,7 @@ try { const result = await daemonCommand(["status"]); assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped"); const output = result.stdout.toLowerCase(); - assert(output.includes("local daemon"), "status table should include Local Daemon row"); + assert(output.includes("localdaemon"), "status table should include Local Daemon row"); assert(output.includes("stopped"), "status should report stopped"); console.log("✓ daemon status reports stopped when not running\n"); } @@ -164,7 +164,7 @@ try { const result = await daemonCommand(["status", "--json"]); assert.strictEqual(result.exitCode, 0, "--json status should succeed"); const status = JSON.parse(result.stdout); - assert.strictEqual(typeof status.serverId, "string", "json status should include serverId"); + assert.strictEqual(status.serverId, undefined, "stopped observation must not invent identity"); assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped"); assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home"); assert.strictEqual( @@ -175,20 +175,12 @@ try { console.log("✓ daemon status --json outputs valid JSON\n"); } - // Global hosts do not change local daemon lifecycle commands. { - console.log("Test 5b: daemon status ignores a global --host"); - const result = await runLocalPaseo(["--host", "localhost:1", "daemon", "status", "--json"], { + const result = await runLocalPaseo(["--host", "127.0.0.1:1", "daemon", "status", "--json"], { PASEO_HOME: paseoHome, }); - assert.strictEqual(result.exitCode, 0, `daemon status should stay local: ${result.stderr}`); - const parsed = JSON.parse(result.stdout); - assert.strictEqual( - parsed.localDaemon, - "stopped", - "daemon should report the local stopped state", - ); - console.log("✓ daemon status ignores a global --host\n"); + assert.strictEqual(result.exitCode, 1, "an explicit endpoint must not report local status"); + assert.match(result.stderr, /127.0.0.1:1/); } // Test 6: daemon stop handles daemon not running gracefully @@ -205,16 +197,12 @@ try { console.log("✓ daemon stop succeeds gracefully when daemon not running\n"); } - // Test 7: daemon restart starts daemon and can be stopped { - console.log("Test 7: daemon restart starts daemon and can be stopped"); - const result = await daemonCommand(["restart", "--port", String(port)]); - assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped"); - assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart"); - - const cleanup = await daemonCommand(["stop", "--force"]); - assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart"); - console.log("✓ daemon restart starts and stop cleanup succeeds\n"); + const result = await daemonCommand(["restart", "--json"]); + assert.strictEqual(result.exitCode, 1, "restart must not start a stopped daemon"); + assert.match(result.stderr, /DAEMON_NOT_RUNNING/); + const configured = await daemonCommand(["config", "set", "daemon.listen", `127.0.0.1:${port}`]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); } // Test 8: status uses the running daemon as relay authority over local IPC @@ -256,10 +244,10 @@ try { const relayProbe = await retryWhileWorkerRuns( worker, async () => { - const statusResult = await daemonCommand(["status", "--json"]); + const statusResult = await daemonCommand(["status", "--host", listen, "--json"]); const statusPayload = statusResult.exitCode === 0 ? JSON.parse(statusResult.stdout) : null; - const pairingResult = await daemonCommand(["pair", "--json"]); + const pairingResult = await daemonCommand(["pair", "--host", listen, "--json"]); return { statusResult, statusPayload, pairingResult }; }, (result) => @@ -280,8 +268,8 @@ try { assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); assert.strictEqual( payload.localDaemon, - "stopped", - "missing PID should report stopped locally", + undefined, + "endpoint status must not invent local PID state", ); assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); assert.strictEqual( @@ -303,6 +291,7 @@ try { const nestedReload = await daemonCommand(["reload", "--host", listen, "--json"]); assert.strictEqual(nestedReload.exitCode, 0, nestedReload.stderr); assert.deepStrictEqual(JSON.parse(nestedReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: ["daemon.listen"], @@ -315,6 +304,7 @@ try { }); assert.strictEqual(aliasReload.exitCode, 0, aliasReload.stderr); assert.deepStrictEqual(JSON.parse(aliasReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: ["daemon.browserTools.enabled"], restartRequiredPaths: [], overrideControlledPaths: [], @@ -323,6 +313,7 @@ try { const yamlReload = await daemonCommand(["reload", "--host", listen, "--format", "yaml"]); assert.strictEqual(yamlReload.exitCode, 0, yamlReload.stderr); assert.deepStrictEqual(YAML.parse(yamlReload.stdout), { + restartCommand: `paseo daemon restart --host ${JSON.stringify(listen)}`, appliedPaths: [], restartRequiredPaths: [], overrideControlledPaths: [], @@ -335,31 +326,16 @@ try { try { await writeFile( join(foreignHome, "config.json"), - `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + `${JSON.stringify({ daemon: { listen, relay: { enabled: false } } }, null, 2)}\n`, "utf-8", ); - const foreignPairing = await retryWhileWorkerRuns( - worker, - () => - runLocalPaseo(["daemon", "pair", "--home", foreignHome, "--json"], { - PASEO_HOME: foreignHome, - }), - (result) => result.stderr.includes("different Paseo home"), - (result) => `Pairing did not report the daemon identity mismatch: ${result.stderr}`, - ); - assert.notStrictEqual( - foreignPairing.exitCode, - 0, - "pairing should reject a daemon owned by another home", - ); - assert( - foreignPairing.stderr.includes("different Paseo home"), - "pairing should explain the daemon identity mismatch", - ); - assert( - !foreignPairing.stdout.includes("#offer="), - "pairing should not expose another offer", + const foreignPairing = await runLocalPaseo( + ["daemon", "pair", "--home", foreignHome, "--json"], + { PASEO_HOME: foreignHome }, ); + assert.strictEqual(foreignPairing.exitCode, 1); + assert.match(foreignPairing.stderr, /RELAY_DISABLED/); + assert(!foreignPairing.stdout.includes("#offer=")); } finally { await rm(foreignHome, { recursive: true, force: true }); } @@ -369,14 +345,14 @@ try { console.log("✓ daemon status probes live relay state over local IPC\n"); } - // Test 9: --relay accepts an already-enabled persisted relay while stopped + // Test 9: explicit offline relay consent persists for subsequent pairing { - console.log("Test 9: daemon pair --relay accepts persisted relay while stopped"); + console.log("Test 9: daemon pair --relay persists offline relay consent"); const configPath = join(paseoHome, "config.json"); const config = JSON.parse(await readFile(configPath, "utf-8")); config.daemon = { ...config.daemon, - relay: { ...config.daemon?.relay, enabled: true }, + relay: { ...config.daemon?.relay, enabled: false }, }; await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); @@ -390,7 +366,13 @@ try { const payload = JSON.parse(pairing.stdout); assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state"); assert.match(payload.url, /#offer=/, "pairing should include the offline offer"); - console.log("✓ daemon pair --relay accepts persisted relay while stopped\n"); + const persistedRelay = await daemonCommand(["config", "get", "daemon.relay.enabled", "--json"]); + assert.strictEqual(persistedRelay.exitCode, 0, persistedRelay.stderr); + assert.strictEqual(JSON.parse(persistedRelay.stdout).value, true); + const subsequentPairing = await daemonCommand(["pair", "--json"]); + assert.strictEqual(subsequentPairing.exitCode, 0, subsequentPairing.stderr); + assert.match(JSON.parse(subsequentPairing.stdout).url, /#offer=/); + console.log("✓ daemon pair --relay persists offline consent for subsequent pairing\n"); } } finally { // Best-effort daemon cleanup in case assertions fail before explicit stop. diff --git a/packages/cli/tests/04-agent-ls.test.ts b/packages/cli/tests/04-agent-ls.test.ts index 363eba5694..65b3bec0bc 100644 --- a/packages/cli/tests/04-agent-ls.test.ts +++ b/packages/cli/tests/04-agent-ls.test.ts @@ -23,12 +23,13 @@ import assert from "node:assert"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { getAvailablePort } from "./helpers/network.ts"; import { runLocalPaseo } from "./helpers/local-cli.ts"; console.log("=== LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -62,7 +63,6 @@ try { console.log("Test 3: paseo ls handles daemon not running"); const result = await runLocalPaseo(["ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -74,8 +74,8 @@ try { assert(hasError, "error message should mention connection issue"); assert.match( output, - /--host <host:port>.*PASEO_HOST/s, - "the recovery message should explain both remote connection inputs", + /Check the selected endpoint and credentials/, + "the recovery message should explain how to check the selected endpoint", ); console.log("✓ paseo ls handles daemon not running\n"); } @@ -85,7 +85,6 @@ try { console.log("Test 4: paseo ls --json handles errors"); const result = await runLocalPaseo(["ls", "--json"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); @@ -109,7 +108,6 @@ try { console.log("Test 5: paseo ls -a flag is accepted"); const result = await runLocalPaseo(["ls", "-a"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); // Will fail due to no daemon, but flag should be parsed without error // (no "unknown option" error) @@ -124,7 +122,6 @@ try { console.log("Test 6: paseo ls -g flag is accepted"); const result = await runLocalPaseo(["ls", "-g"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -g flag"); @@ -137,7 +134,6 @@ try { console.log("Test 7: paseo ls -ag combined flags are accepted"); const result = await runLocalPaseo(["ls", "-ag"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -ag flags"); @@ -150,7 +146,6 @@ try { console.log("Test 8: -q (quiet) flag is accepted"); const result = await runLocalPaseo(["-q", "ls"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); @@ -163,7 +158,6 @@ try { console.log("Test 9: paseo ls --ui is rejected"); const result = await runLocalPaseo(["ls", "--ui"], { PASEO_HOST: `localhost:${port}`, - PASEO_HOME: paseoHome, }); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; @@ -185,17 +179,23 @@ try { console.log("✓ global --host targets the requested daemon\n"); } - // Test 11: the last explicit --host wins + // Test 11: conflicting explicit --host selectors are rejected { - console.log("Test 11: the last explicit --host wins"); + console.log("Test 11: conflicting explicit --host selectors are rejected"); const firstHost = `localhost:${port}`; - const lastHost = `localhost:${port + 1}`; + const lastHost = `localhost:${await getAvailablePort()}`; const result = await runLocalPaseo(["--host", firstHost, "ls", "--host", lastHost]); const output = result.stdout + result.stderr; - assert.notStrictEqual(result.exitCode, 0, "should fail when the selected daemon is absent"); - assert(output.includes(lastHost), "connection error should name the last explicit host"); - assert(!output.includes(firstHost), "the earlier host should be fully overridden"); - console.log("✓ the last explicit --host wins\n"); + assert.notStrictEqual(result.exitCode, 0, "should reject conflicting explicit selectors"); + assert( + output.includes("Conflicting duplicate --host selectors."), + "should report the selector conflict", + ); + assert( + !output.includes("Cannot connect"), + "must reject the selectors before attempting a connection", + ); + console.log("✓ conflicting explicit --host selectors are rejected\n"); } } finally { // Clean up temp directory diff --git a/packages/cli/tests/05-agent-run.test.ts b/packages/cli/tests/05-agent-run.test.ts index 0af2a6ec7c..6948e4c008 100644 --- a/packages/cli/tests/05-agent-run.test.ts +++ b/packages/cli/tests/05-agent-run.test.ts @@ -21,6 +21,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -30,8 +31,8 @@ $.verbose = false; console.log("=== Run Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const schemaPath = join(paseoHome, "run-output-schema.json"); await writeFile( @@ -52,8 +53,12 @@ try { console.log("Test 1: run --help shows options"); const result = await $`npx paseo run --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "run --help should exit 0"); - assert(result.stdout.includes("-d"), "help should mention -d flag"); - assert(result.stdout.includes("--background"), "help should mention --background flag"); + assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag"); + assert( + !result.stdout.includes("--internal"), + "help should not mention removed --internal flag", + ); + assert(!result.stdout.includes("--background"), "help should hide legacy --background syntax"); assert(!result.stdout.includes("--detach"), "help should hide legacy --detach syntax"); assert(result.stdout.includes("--title"), "help should mention --title option"); assert(result.stdout.includes("--provider"), "help should mention --provider option"); @@ -72,7 +77,7 @@ try { { console.log("Test 2: run requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -88,7 +93,7 @@ try { { console.log("Test 3: run handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider claude "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider claude "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -104,7 +109,7 @@ try { { console.log("Test 4: run -d flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -d flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -115,7 +120,7 @@ try { { console.log("Test 5: run --name flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --name "test-agent" "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --name flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -126,7 +131,7 @@ try { { console.log("Test 6: run --provider flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --provider flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +142,7 @@ try { { console.log("Test 6b: run --provider provider/model syntax is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept provider/model syntax"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -148,7 +153,7 @@ try { { console.log("Test 7: run --mode flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --mode bypass "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --mode flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -159,7 +164,7 @@ try { { console.log("Test 8: run --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --cwd /tmp "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -170,32 +175,32 @@ try { { console.log("Test 9: run --output-schema flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --output-schema ${schemaPath} "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --output-schema flag"); assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ run --output-schema flag is accepted\n"); } - // Test 10: run --output-schema cannot be used with --background + // Test 10: run --output-schema cannot be used with --no-wait { - console.log("Test 10: run --output-schema cannot be used with --background"); + console.log("Test 10: run --output-schema cannot be used with --no-wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --background --output-schema ${schemaPath} "test prompt"`.nothrow(); - assert.notStrictEqual(result.exitCode, 0, "should fail with --background and --output-schema"); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --no-wait --output-schema ${schemaPath} "test prompt"`.nothrow(); + assert.notStrictEqual(result.exitCode, 0, "should fail with --no-wait and --output-schema"); const output = result.stdout + result.stderr; assert( - output.includes("--output-schema cannot be used with --background"), + output.includes("--output-schema cannot be used with --no-wait"), "error should explain background incompatibility", ); - console.log("✓ run --output-schema cannot be used with --background\n"); + console.log("✓ run --output-schema cannot be used with --no-wait\n"); } // Test 11: -q (quiet) flag is accepted with run { console.log("Test 11: -q (quiet) flag is accepted with run"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -206,7 +211,7 @@ try { { console.log("Test 12: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -217,7 +222,7 @@ try { { console.log("Test 12b: conflicting provider/model syntax is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting model inputs"); const output = result.stdout + result.stderr; assert( @@ -240,7 +245,7 @@ try { { console.log("Test 14: run --ui is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --ui "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --ui"); @@ -251,7 +256,7 @@ try { { console.log("Test 15: run --new-workspace is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --new-workspace local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --new-workspace local "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --new-workspace"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -262,7 +267,7 @@ try { { console.log("Test 16: run --isolation is rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --isolation local "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} run --isolation local "test prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for removed --isolation flag"); const output = result.stdout + result.stderr; assert(output.includes("unknown option"), "should report unknown option for --isolation"); diff --git a/packages/cli/tests/06-agent-send.test.ts b/packages/cli/tests/06-agent-send.test.ts index 60fd9aac72..ca28e26f56 100644 --- a/packages/cli/tests/06-agent-send.test.ts +++ b/packages/cli/tests/06-agent-send.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Send Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); const promptFilePath = join(paseoHome, "send-prompt.txt"); await writeFile(promptFilePath, "prompt from file"); @@ -58,7 +59,7 @@ try { { console.log("Test 2: send requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -74,7 +75,7 @@ try { { console.log("Test 3: send requires prompt argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without prompt"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -90,7 +91,7 @@ try { { console.log("Test 4: send handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "test prompt"`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -106,7 +107,7 @@ try { { console.log("Test 5: send --no-wait flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --no-wait flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 5b: send --prompt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt "test prompt" abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -128,7 +129,7 @@ try { { console.log("Test 5c: send --prompt-file flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send --prompt-file ${promptFilePath} abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --prompt-file flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -150,7 +151,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with send"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "test prompt"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -161,7 +162,7 @@ try { { console.log("Test 8: Combined flags work together"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q send --no-wait abc123 "Run the linter"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept all combined flags"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -172,7 +173,7 @@ try { { console.log("Test 8b: conflicting prompt sources are rejected"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} send abc123 "positional prompt" --prompt "flag prompt"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting prompt sources"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/07-agent-stop.test.ts b/packages/cli/tests/07-agent-stop.test.ts index 0b20028ec0..9416884a7b 100644 --- a/packages/cli/tests/07-agent-stop.test.ts +++ b/packages/cli/tests/07-agent-stop.test.ts @@ -18,6 +18,8 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +29,8 @@ $.verbose = false; console.log("=== Stop Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +50,7 @@ try { { console.log("Test 2: stop requires ID, --all, or --cwd"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd"); const output = result.stdout + result.stderr; const hasError = @@ -64,7 +66,7 @@ try { { console.log("Test 3: stop handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -80,7 +82,7 @@ try { { console.log("Test 4: stop --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -91,7 +93,7 @@ try { { console.log("Test 5: stop --cwd flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop --cwd /tmp`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --cwd flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +104,7 @@ try { { console.log("Test 6: stop with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} stop abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -122,12 +124,34 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with stop"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q stop abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with stop\n"); } + + { + console.log("Test 9: stop reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "stop", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "stop should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ stop reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { // Clean up temp directory await rm(paseoHome, { recursive: true, force: true }); diff --git a/packages/cli/tests/08-agent-logs.test.ts b/packages/cli/tests/08-agent-logs.test.ts index f857bffdff..672f4a2dd0 100644 --- a/packages/cli/tests/08-agent-logs.test.ts +++ b/packages/cli/tests/08-agent-logs.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Logs Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: logs requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -67,7 +68,7 @@ try { { console.log("Test 3: logs handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -84,7 +85,7 @@ try { console.log("Test 4: logs -f (follow) flag is accepted"); // Use timeout to avoid hanging on follow mode const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs -f abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -f flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -95,7 +96,7 @@ try { { console.log("Test 5: logs --follow flag is accepted"); const result = - await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow(); + await $`timeout 1 bash -c 'PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --follow abc123' || true`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --follow flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -106,7 +107,7 @@ try { { console.log("Test 6: logs --tail flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs --tail 50 abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --tail flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -117,7 +118,7 @@ try { { console.log("Test 7: logs with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} logs abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -137,7 +138,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with logs"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q logs abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/09-agent-inspect.test.ts b/packages/cli/tests/09-agent-inspect.test.ts index 0ae33343d7..9c449971bd 100644 --- a/packages/cli/tests/09-agent-inspect.test.ts +++ b/packages/cli/tests/09-agent-inspect.test.ts @@ -18,6 +18,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -27,8 +28,8 @@ $.verbose = false; console.log("=== Inspect Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -48,7 +49,7 @@ try { { console.log("Test 2: inspect requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -64,7 +65,7 @@ try { { console.log("Test 3: inspect handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -91,7 +92,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -102,7 +103,7 @@ try { { console.log("Test 6: --json flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} inspect abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -113,7 +114,7 @@ try { { console.log("Test 7: --format yaml flag is accepted with inspect"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml inspect abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/10-agent-mode.test.ts b/packages/cli/tests/10-agent-mode.test.ts index 0aa492ea4f..e44898c03e 100644 --- a/packages/cli/tests/10-agent-mode.test.ts +++ b/packages/cli/tests/10-agent-mode.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Mode Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -47,7 +48,7 @@ try { { console.log("Test 2: agent mode requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -63,7 +64,7 @@ try { { console.log("Test 3: agent mode handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 bypass`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -79,7 +80,7 @@ try { { console.log("Test 4: agent mode --list flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode --list abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --list flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -90,7 +91,7 @@ try { { console.log("Test 5: agent mode with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123 plan --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -110,7 +111,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent mode"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent mode abc123 bypass`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -121,7 +122,7 @@ try { { console.log("Test 8: agent mode requires mode argument when not using --list"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent mode abc123`.nothrow(); // Should fail because mode is required unless --list is specified assert.notStrictEqual(result.exitCode, 0, "should fail without mode argument"); const output = result.stdout + result.stderr; diff --git a/packages/cli/tests/11-agent-archive.test.ts b/packages/cli/tests/11-agent-archive.test.ts index 7493395a2b..0685e2948b 100644 --- a/packages/cli/tests/11-agent-archive.test.ts +++ b/packages/cli/tests/11-agent-archive.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Agent Archive Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -46,7 +47,7 @@ try { { console.log("Test 2: agent archive requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -61,7 +62,7 @@ try { { console.log("Test 3: agent archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -77,7 +78,7 @@ try { { console.log("Test 4: agent archive --force flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --force`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --force flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -88,7 +89,7 @@ try { { console.log("Test 5: agent archive with ID and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent archive abc123 --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 7: -q (quiet) flag is accepted with agent archive"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q agent archive abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/11-agent-wait.test.ts b/packages/cli/tests/11-agent-wait.test.ts index 8984bc0c4e..198fa08910 100644 --- a/packages/cli/tests/11-agent-wait.test.ts +++ b/packages/cli/tests/11-agent-wait.test.ts @@ -19,6 +19,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -28,8 +29,8 @@ $.verbose = false; console.log("=== Wait Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -51,7 +52,7 @@ try { { console.log("Test 2: wait requires id argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; // Commander should complain about missing argument @@ -67,7 +68,7 @@ try { { console.log("Test 3: wait handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -105,7 +106,7 @@ try { { console.log("Test 6: -q (quiet) flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -116,7 +117,7 @@ try { { console.log("Test 7: --json flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} wait abc123 --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -127,7 +128,7 @@ try { { console.log("Test 8: --format yaml flag is accepted with wait"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} --format yaml wait abc123`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --format yaml flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/12-permit-ls.test.ts b/packages/cli/tests/12-permit-ls.test.ts index c4549ae6a7..44b9a720af 100644 --- a/packages/cli/tests/12-permit-ls.test.ts +++ b/packages/cli/tests/12-permit-ls.test.ts @@ -17,6 +17,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -26,8 +27,8 @@ $.verbose = false; console.log("=== Permit LS Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -55,7 +56,7 @@ try { { console.log("Test 3: permit ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -71,7 +72,7 @@ try { { console.log("Test 4: permit ls --json handles errors"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit ls --json`.nothrow(); // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); // But output should be valid JSON if present @@ -93,7 +94,7 @@ try { { console.log("Test 5: -q (quiet) flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q permit ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); diff --git a/packages/cli/tests/13-permit-allow-deny.test.ts b/packages/cli/tests/13-permit-allow-deny.test.ts index 51773da185..a44113726f 100644 --- a/packages/cli/tests/13-permit-allow-deny.test.ts +++ b/packages/cli/tests/13-permit-allow-deny.test.ts @@ -22,6 +22,7 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; @@ -31,8 +32,8 @@ $.verbose = false; console.log("=== Permit Allow/Deny Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -65,7 +66,7 @@ try { { console.log("Test 3: permit allow handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -81,7 +82,7 @@ try { { console.log("Test 4: permit deny handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -97,7 +98,7 @@ try { { console.log("Test 5: permit allow --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -108,7 +109,7 @@ try { { console.log("Test 6: permit deny --all flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 --all`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --all flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -119,7 +120,7 @@ try { { console.log("Test 7: permit deny --message flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --message "Not allowed"`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --message flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -130,7 +131,7 @@ try { { console.log("Test 8: permit deny --interrupt flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123 req456 --interrupt`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --interrupt flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -141,7 +142,7 @@ try { { console.log("Test 9: permit allow --input flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123 req456 --input '{"key":"value"}'`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --input flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -152,7 +153,7 @@ try { { console.log("Test 10: permit allow requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit allow abc123`.nothrow(); // This might fail due to daemon not running first, or due to missing argument // The important thing is it doesn't crash with an unhandled error assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); @@ -163,7 +164,7 @@ try { { console.log("Test 11: permit deny requires req_id or --all"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} permit deny abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all"); console.log("✓ permit deny requires req_id or --all\n"); } diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index d1b30a541d..027cfbaa2b 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -19,8 +19,12 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, rm, mkdir } from "fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { tmpdir } from "os"; import { join } from "path"; @@ -28,8 +32,8 @@ $.verbose = false; console.log("=== Worktree Command Tests ===\n"); -// Get random port that's definitely not in use (never 6767) -const port = 10000 + Math.floor(Math.random() * 50000); +// Allocate an unused endpoint for connection-error and argument-validation checks. +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-")); try { @@ -49,6 +53,7 @@ try { const result = await $`npx paseo worktree ls --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0"); assert(result.stdout.includes("--host"), "help should mention --host option"); + assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); console.log("✓ worktree ls --help shows options\n"); } @@ -56,7 +61,7 @@ try { { console.log("Test 3: worktree ls handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -72,7 +77,7 @@ try { { console.log("Test 4: worktree ls with --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -85,6 +90,7 @@ try { const result = await $`npx paseo worktree archive --help`.nothrow(); assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0"); assert(result.stdout.includes("--host"), "help should mention --host option"); + assert(result.stdout.includes("--cwd"), "help should mention --cwd option"); assert(result.stdout.includes("<name>"), "help should mention required name argument"); console.log("✓ worktree archive --help shows options\n"); } @@ -93,7 +99,7 @@ try { { console.log("Test 6: worktree archive requires name argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without name"); const output = result.stdout + result.stderr; const hasError = @@ -108,7 +114,7 @@ try { { console.log("Test 7: worktree archive handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree`.nothrow(); // Should fail because daemon not running assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; @@ -124,7 +130,7 @@ try { { console.log("Test 8: worktree archive with name and --host flag is accepted"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree archive test-worktree --host localhost:${port}`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --host flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -135,7 +141,7 @@ try { { console.log("Test 9: -q (quiet) flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} -q worktree ls`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -146,7 +152,7 @@ try { { console.log("Test 10: --json flag is accepted with worktree ls"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} worktree ls --json`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --json flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -166,4 +172,66 @@ try { await rm(paseoHome, { recursive: true, force: true }); } +// Exercise repository selection through the real CLI and daemon. An empty +// request used to fail before Git could inspect even a valid repository. +const daemon = await startTestDaemon(); +const git = promisify(execFile); +try { + const repo = join(daemon.workDir, "repository"); + await mkdir(repo); + await git("git", ["init", repo]); + await git("git", [ + "-C", + repo, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "Initial commit", + ]); + + for (const args of [ + ["worktree", "ls", "--json"], + ["worktree", "ls", "--cwd", repo, "--json"], + ["worktree", "ls", "--cwd", "./repository", "--json"], + ["worktree", "ls", "--host", `127.0.0.1:${daemon.port}`, "--cwd", ".", "--json"], + ]) { + const explicit = args.includes("--cwd"); + const result = await runPaseoCli(daemon, args, { + cwd: explicit ? daemon.workDir : repo, + env: { FORCE_COLOR: undefined }, + }); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(result.stdout), []); + } + + for (const args of [ + ["worktree", "archive", "missing", "--json"], + ["worktree", "archive", "missing", "--cwd", repo, "--json"], + ["worktree", "archive", "missing", "--cwd", "./repository", "--json"], + [ + "worktree", + "archive", + "missing", + "--host", + `127.0.0.1:${daemon.port}`, + "--cwd", + ".", + "--json", + ], + ]) { + const result = await runPaseoCli(daemon, args, { + cwd: args.includes("--cwd") ? daemon.workDir : repo, + env: { FORCE_COLOR: undefined }, + }); + assert.notStrictEqual(result.exitCode, 0); + assert.strictEqual(JSON.parse(result.stderr).error.code, "WORKTREE_NOT_FOUND", result.stderr); + } +} finally { + await daemon.stop(); +} + console.log("=== All worktree tests passed ==="); diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index a1d302e9c4..3a4f33031c 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -52,10 +52,15 @@ interface ProviderDiagnostic { } const EXPECTED_CLAUDE_MODELS = [ + { + id: "claude-opus-5-5", + model: "Opus 5.5", + descriptionFragment: "Latest release", + }, { id: "claude-opus-5", model: "Opus 5", - descriptionFragment: "Latest release", + descriptionFragment: "Previous release", }, { id: "claude-fable-5-1", @@ -77,10 +82,15 @@ const EXPECTED_CLAUDE_MODELS = [ model: "Opus 4.8", descriptionFragment: "Previous release", }, + { + id: "claude-sonnet-5-5", + model: "Sonnet 5.5", + descriptionFragment: "Best for everyday tasks", + }, { id: "claude-sonnet-5", model: "Sonnet 5", - descriptionFragment: "Best for everyday tasks", + descriptionFragment: "Previous release", }, { id: "claude-opus-4-7[1m]", @@ -112,10 +122,15 @@ const EXPECTED_CLAUDE_MODELS = [ model: "Opus 4.6", descriptionFragment: "Most capable", }, + { + id: "claude-haiku-5-5", + model: "Haiku 5.5", + descriptionFragment: "Fastest", + }, { id: "claude-haiku-4-5", model: "Haiku 4.5", - descriptionFragment: "Fastest", + descriptionFragment: "Previous release", }, ] as const; @@ -349,10 +364,6 @@ try { ids.every((id) => id.startsWith("gpt-")), "all codex model IDs should be from the gpt family", ); - assert( - ids.some((id) => id.includes("codex")), - "codex model list should include at least one codex-optimized model", - ); assert( data.every((m) => m.model && m.id && m.description), "every codex model should have model, id, and description fields", @@ -425,8 +436,8 @@ try { "--quiet should print the same ordered model IDs returned by --json", ); assert( - claudeModelsFromJson.some((m) => m.id === "claude-sonnet-5"), - "captured --json output should include the current Claude everyday model id", + claudeModelsFromJson.some((m) => m.id === "claude-haiku-5-5"), + "captured --json output should include the current Claude quick-answer model id", ); console.log("✓ provider models --quiet outputs model IDs only\n"); } diff --git a/packages/cli/tests/16-agent-update.test.ts b/packages/cli/tests/16-agent-update.test.ts index 8e84ebf7b8..8ae54323bc 100644 --- a/packages/cli/tests/16-agent-update.test.ts +++ b/packages/cli/tests/16-agent-update.test.ts @@ -11,11 +11,11 @@ */ import assert from "node:assert"; +import { getAvailablePort } from "./helpers/network.ts"; import { $ } from "zx"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -43,7 +43,7 @@ try { { console.log("Test 2: agent update requires ID argument"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update --name "New Name"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without id"); const output = result.stdout + result.stderr; const hasError = @@ -59,7 +59,7 @@ try { { console.log("Test 3: agent update requires update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail without --name/--label"); const output = result.stdout + result.stderr; const hasError = @@ -74,7 +74,7 @@ try { { console.log("Test 4: agent update handles daemon not running"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name "Renamed Agent"`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running"); const output = result.stdout + result.stderr; const hasError = @@ -89,7 +89,7 @@ try { { console.log("Test 5: agent update accepts multi-label syntax"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept --label flag"); assert(!output.includes("error: option"), "should not have option parsing error"); @@ -109,7 +109,7 @@ try { { console.log("Test 7: agent update accepts thinking as an update field"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --thinking high`.nothrow(); const output = result.stdout + result.stderr; assert(!output.includes("Nothing to update"), "should treat --thinking as an update field"); console.log("✓ agent update accepts thinking as an update field\n"); @@ -119,7 +119,7 @@ try { { console.log("Test 8: thinking cannot be combined with metadata updates"); const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow(); + await $`PASEO_HOME=${paseoHome} npx paseo --host localhost:${port} agent update abc123 --name renamed --thinking high`.nothrow(); assert.notStrictEqual(result.exitCode, 0, "should reject a combined update"); const output = result.stdout + result.stderr; assert( diff --git a/packages/cli/tests/17-onboard.test.ts b/packages/cli/tests/17-onboard.test.ts index 3d8e641a26..a8b25148a2 100644 --- a/packages/cli/tests/17-onboard.test.ts +++ b/packages/cli/tests/17-onboard.test.ts @@ -5,6 +5,7 @@ import { readFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -15,9 +16,19 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-")); const port = await getAvailablePort(); try { + const configured = await runLocalPaseo([ + "daemon", + "config", + "set", + "daemon.listen", + `127.0.0.1:${port}`, + "--home", + paseoHome, + ]); + assert.strictEqual(configured.exitCode, 0, configured.stderr); + console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing"); - const onboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); + const onboard = await $`PASEO_HOME=${paseoHome} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); assert.strictEqual( onboard.exitCode, @@ -37,8 +48,8 @@ try { assert(onboard.stdout.includes("paseo --help"), "onboard output should include --help shortcut"); assert(onboard.stdout.includes("paseo ls"), "onboard output should include ls shortcut"); assert( - onboard.stdout.includes('paseo run "your prompt"'), - "onboard output should include run shortcut", + onboard.stdout.includes(`paseo run --home ${JSON.stringify(paseoHome)} "your prompt"`), + "onboard output should include a run shortcut for the selected home", ); assert(onboard.stdout.includes("paseo status"), "onboard output should include status shortcut"); assert( @@ -58,8 +69,7 @@ try { assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`); assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer"); - const noRelayOnboard = - await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow(); + const noRelayOnboard = await $`PASEO_HOME=${paseoHome} npx paseo --no-relay`.nothrow(); assert.strictEqual( noRelayOnboard.exitCode, 0, diff --git a/packages/cli/tests/18-local-daemon-utils.test.ts b/packages/cli/tests/18-local-daemon-utils.test.ts deleted file mode 100644 index 89780a6731..0000000000 --- a/packages/cli/tests/18-local-daemon-utils.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env npx tsx - -/** - * Phase 18: Local daemon utility tests. - * - * Tests pure helpers that do not require a running daemon. - */ - -import assert from "node:assert"; -import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js"; - -console.log("=== Local Daemon Utility Helpers ===\n"); - -{ - console.log("Test 1: resolves numeric listen values to localhost host:port"); - assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767"); - assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777"); - console.log("✓ resolves numeric listen values\n"); -} - -{ - console.log("Test 2: preserves explicit host:port listen values"); - assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767"); - assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080"); - console.log("✓ preserves explicit host:port values\n"); -} - -{ - console.log("Test 3: rejects unix socket listen values"); - assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null); - assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null); - console.log("✓ rejects unix socket listen values\n"); -} - -{ - console.log("Test 4: rejects empty and non-host listen values"); - assert.strictEqual(resolveTcpHostFromListen(""), null); - assert.strictEqual(resolveTcpHostFromListen(" "), null); - assert.strictEqual(resolveTcpHostFromListen("localhost"), null); - console.log("✓ rejects empty and non-host listen values\n"); -} - -{ - console.log("Test 5: rejects Windows absolute paths (not TCP endpoints)"); - assert.strictEqual(resolveTcpHostFromListen("C:\\Users\\foo\\.paseo\\paseo.sock"), null); - assert.strictEqual(resolveTcpHostFromListen("D:\\project\\socket"), null); - assert.strictEqual(resolveTcpHostFromListen("C:\\paseo.sock"), null); - console.log("✓ rejects Windows absolute paths\n"); -} - -console.log("=== All local daemon utility tests passed ==="); diff --git a/packages/cli/tests/20-worktree-ls-paths.test.ts b/packages/cli/tests/20-worktree-ls-paths.test.ts deleted file mode 100644 index d73cb5ca5e..0000000000 --- a/packages/cli/tests/20-worktree-ls-paths.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env npx tsx - -import assert from "node:assert"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js"; - -console.log("=== Worktree LS Path Helper Tests ===\n"); - -const originalPaseoHome = process.env.PASEO_HOME; - -try { - { - console.log("Test 1: resolves explicit PASEO_HOME when set"); - process.env.PASEO_HOME = "/tmp/paseo-explicit-home"; - - assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home"); - assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees"); - console.log("\u2713 explicit PASEO_HOME is respected\n"); - } - - { - console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset"); - delete process.env.PASEO_HOME; - - assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo")); - assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees")); - console.log("\u2713 fallback home path is derived from os.homedir()\n"); - } -} finally { - if (originalPaseoHome === undefined) { - delete process.env.PASEO_HOME; - } else { - process.env.PASEO_HOME = originalPaseoHome; - } -} - -console.log("=== All worktree ls path helper tests passed ==="); diff --git a/packages/cli/tests/22-daemon-stop-supervisor.test.ts b/packages/cli/tests/22-daemon-stop-supervisor.test.ts index f6687703fa..979e866003 100644 --- a/packages/cli/tests/22-daemon-stop-supervisor.test.ts +++ b/packages/cli/tests/22-daemon-stop-supervisor.test.ts @@ -213,8 +213,9 @@ try { ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( - capturedSupervisorLogs.includes('"msg":"Worker requested shutdown"') && - capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"'), + process.platform === "win32" + ? capturedSupervisorLogs.includes('"reason":"client_shutdown_rpc"') + : capturedSupervisorLogs.includes("supervisor_received_SIGTERM"), `stop should log lifecycle shutdown reason from daemon worker, logs:\n${capturedSupervisorLogs}`, ); assert( diff --git a/packages/cli/tests/25-daemon-restart-supervisor.test.ts b/packages/cli/tests/25-daemon-restart-supervisor.test.ts index 040fba7f2d..d3ae10e19c 100644 --- a/packages/cli/tests/25-daemon-restart-supervisor.test.ts +++ b/packages/cli/tests/25-daemon-restart-supervisor.test.ts @@ -6,17 +6,22 @@ */ import assert from "node:assert"; -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { spawn, type ChildProcess } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { $ } from "zx"; -import { tryConnectToDaemon } from "../src/utils/client.ts"; +import { WebSocket } from "ws"; +import { + DaemonClient, + DaemonConnectionError, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; +import { readDaemonInstance, isSameDaemonInstance } from "@getpaseo/server/daemon-control"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { getAvailablePort } from "./helpers/network.ts"; -$.verbose = false; - const pollIntervalMs = 100; +const daemonReadyTimeoutMs = 120_000; const testEnv = { PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0", PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0", @@ -40,60 +45,6 @@ function isProcessRunning(pid: number): boolean { } } -function readWorkerPid(supervisorPid: number): number | null { - if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) { - return null; - } - - const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" }); - if (result.status !== 0 || result.error) { - return null; - } - - for (const line of result.stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const [pidToken, ppidToken] = trimmed.split(/\s+/); - const pid = Number.parseInt(pidToken ?? "", 10); - const ppid = Number.parseInt(ppidToken ?? "", 10); - if (!Number.isInteger(pid) || !Number.isInteger(ppid)) { - continue; - } - if (ppid === supervisorPid && pid > 0) { - return pid; - } - } - - return null; -} - -interface DaemonStatus { - localDaemon: string | null; - pid: number | null; -} - -async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> { - const result = - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow(); - if (result.exitCode !== 0) { - return { localDaemon: null, pid: null }; - } - - try { - const parsed = JSON.parse(result.stdout) as { localDaemon?: unknown; pid?: unknown }; - const localDaemon = typeof parsed.localDaemon === "string" ? parsed.localDaemon : null; - const pid = - typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0 - ? parsed.pid - : null; - return { localDaemon, pid }; - } catch { - return { localDaemon: null, pid: null }; - } -} - async function readCapturedSupervisorLogs(paseoHome: string, recentLogs: string): Promise<string> { const durableLogs = await readFile(join(paseoHome, "daemon.log"), "utf8").catch(() => ""); return `${recentLogs}\n${durableLogs}`; @@ -125,8 +76,30 @@ const host = `127.0.0.1:${port}`; let supervisorProcess: ChildProcess | null = null; let recentSupervisorLogs = ""; +let client: DaemonClient | undefined; +const availabilityLog = join(paseoHome, "availability.log"); try { + if (process.platform !== "win32") { + const provider = join(paseoHome, "slow-provider"); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(availabilityLog)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(paseoHome, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + } console.log("Test 1: start supervisor-entrypoint in dev mode with isolated PASEO_HOME"); supervisorProcess = spawn( @@ -135,7 +108,11 @@ try { { cwd: cliRoot, env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: paseoHome, + USERPROFILE: paseoHome, ...testEnv, PASEO_HOME: paseoHome, PASEO_LISTEN: host, @@ -153,80 +130,91 @@ try { recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000); }); + let supervisor = await readDaemonInstance(paseoHome); await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return ( - status.localDaemon === "running" && status.pid !== null && isProcessRunning(status.pid) - ); + supervisor = await readDaemonInstance(paseoHome); + return supervisor?.pid === supervisorProcess?.pid && Boolean(supervisor?.listen); }, - 120000, - "daemon did not become running in time", + daemonReadyTimeoutMs, + "daemon did not publish its bound endpoint in time", ); - - const statusBeforeRestart = await readDaemonStatus(paseoHome); - const supervisorPid = statusBeforeRestart.pid; - assert.strictEqual( - statusBeforeRestart.localDaemon, - "running", - "daemon should be running before restart", - ); - assert(supervisorPid !== null, "supervisor pid should exist once daemon starts"); + assert(supervisor?.listen, "owned supervisor should publish its bound endpoint"); + const supervisorPid = supervisor.pid; + assert.strictEqual(supervisorPid, supervisorProcess.pid, "PID lock should identify this launch"); assert(isProcessRunning(supervisorPid), "supervisor process should be running"); - const workerPidBeforeRestart = readWorkerPid(supervisorPid); - assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart"); + + client = new DaemonClient({ + url: `ws://${supervisor.listen}/ws`, + clientId: "supervisor-restart-test", + connectTimeoutMs: 5000, + webSocketFactory: (url) => new WebSocket(url) as unknown as WebSocketLike, + }); + await client.connect(); + // The app reads worker identity through the full RPC, not the bounded CLI observation. + const statusBeforeRestart = await client.getDaemonStatus(); + const workerPidBeforeRestart = statusBeforeRestart.pid; assert( isProcessRunning(workerPidBeforeRestart), "worker process should be running before restart", ); + if (process.platform !== "win32") { + assert.match( + await readFile(availabilityLog, "utf8"), + /\["--version"\]/, + "the configured slow provider was actually probed", + ); + } console.log( `✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`, ); console.log("Test 2: app-style restart request should restart worker and keep daemon healthy"); - const client = await tryConnectToDaemon({ host, timeout: 5000 }); - assert(client, "daemon client should connect"); - try { - const restartAck = await client.restartServer("settings_update"); - assert.strictEqual( - restartAck.status, - "restart_requested", - "restart request should be acknowledged", - ); - } finally { - await client?.close().catch(() => undefined); - } - - await waitFor( - () => { - const workerPid = readWorkerPid(supervisorPid); - return ( - workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid) - ); - }, - 20000, - "worker pid did not change after restart request", + const restartAck = await client.restartServer("settings_update"); + assert.strictEqual( + restartAck.status, + "restart_requested", + "restart request should be acknowledged", ); - const workerPidAfterRestart = readWorkerPid(supervisorPid); - assert(workerPidAfterRestart !== null, "worker process should exist after restart"); + // A restart includes worker startup and client reconnection, as initial readiness does. + const deadline = Date.now() + daemonReadyTimeoutMs; + let statusAfterRestart = statusBeforeRestart; + await waitFor( + async () => { + try { + const observed = await client!.getDaemonStatus({ + timeout: Math.max(1, deadline - Date.now()), + }); + if (observed.pid === workerPidBeforeRestart || !isProcessRunning(observed.pid)) + return false; + statusAfterRestart = observed; + return true; + } catch (error) { + if (error instanceof DaemonConnectionError) return false; + throw error; + } + }, + daemonReadyTimeoutMs, + "worker pid did not change after restart request", + ); assert.notStrictEqual( - workerPidAfterRestart, + statusAfterRestart.pid, workerPidBeforeRestart, "worker pid should change after restart", ); - - const statusAfterRestart = await readDaemonStatus(paseoHome); + assert(isProcessRunning(statusAfterRestart.pid), "replacement worker should remain running"); + const current = await readDaemonInstance(paseoHome); + assert(current?.listen, "daemon should remain bound after restart"); assert.strictEqual( - statusAfterRestart.localDaemon, - "running", - "daemon should stay running after restart", - ); - assert.strictEqual( - statusAfterRestart.pid, + current.pid, supervisorPid, "supervisor pid should remain stable across restart", ); + assert( + isSameDaemonInstance(supervisor, current), + "supervisor start time should remain stable across restart", + ); const capturedSupervisorLogs = await readCapturedSupervisorLogs(paseoHome, recentSupervisorLogs); assert( capturedSupervisorLogs.includes('"msg":"Worker requested restart"') && @@ -243,6 +231,7 @@ try { ); console.log("✓ app-style restart keeps daemon healthy and restarts worker\n"); } finally { + await client?.close(); if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) { supervisorProcess.kill("SIGTERM"); await waitFor( @@ -254,7 +243,7 @@ try { }); } - await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow(); + await runLocalPaseo(["daemon", "stop", "--home", paseoHome, "--force"]); await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/cli/tests/27-agent-delete.test.ts b/packages/cli/tests/27-agent-delete.test.ts index 64ab4007bf..349125109b 100644 --- a/packages/cli/tests/27-agent-delete.test.ts +++ b/packages/cli/tests/27-agent-delete.test.ts @@ -11,27 +11,24 @@ */ import assert from "node:assert"; -import { $ } from "zx"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { getAvailablePort } from "./helpers/network.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; import { mkdtemp, rm } from "fs/promises"; import { tmpdir } from "os"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; - -$.verbose = false; +import { join } from "path"; console.log("=== Delete Command Tests ===\n"); -const cliRoot = dirname(fileURLToPath(import.meta.url)); -const repoRoot = join(cliRoot, "..", "..", ".."); -const port = 10000 + Math.floor(Math.random() * 50000); +const port = await getAvailablePort(); const paseoHome = await mkdtemp(join(tmpdir(), "paseo-delete-test-home-")); async function runCli(args: string[]) { - return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow(); + return runLocalPaseo(["--host", `localhost:${port}`, ...args], { PASEO_HOME: paseoHome }); } async function runDelete(args: string[]) { - return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow(); + return runCli(["delete", ...args]); } try { @@ -110,13 +107,34 @@ try { { console.log("Test 8: -q (quiet) flag is accepted with delete"); - const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow(); + const result = await runCli(["-q", "delete", "abc123"]); const output = result.stdout + result.stderr; assert(!output.includes("unknown option"), "should accept -q flag"); assert(!output.includes("error: option"), "should not have option parsing error"); console.log("✓ -q (quiet) flag is accepted with delete\n"); } + + { + console.log("Test 9: delete reports AGENT_NOT_FOUND for an unknown ID"); + const daemon = await startTestDaemon(); + try { + const result = await runPaseoCli(daemon, [ + "agent", + "delete", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + assert.notStrictEqual(result.exitCode, 0, "delete should fail for an unknown ID"); + const { error } = JSON.parse(result.stderr); + assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr); + assert.match(error.details, /paseo ls/); + } finally { + await daemon.stop(); + } + console.log("✓ delete reports AGENT_NOT_FOUND for an unknown ID\n"); + } } finally { await rm(paseoHome, { recursive: true, force: true }); } diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts index 24ceb9a632..3072927df0 100644 --- a/packages/cli/tests/28-client-ipc-targets.test.ts +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -1,16 +1,13 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { getDaemonHost, normalizeDaemonHost, resolveDaemonPassword, resolveDaemonTarget, - resolveDefaultDaemonHosts, } from "../src/utils/client.js"; +import { selectDaemonTarget } from "../src/utils/daemon-target.js"; import { resolveCliVersion } from "../src/version.js"; console.log("=== CLI IPC Target Helpers ===\n"); @@ -81,48 +78,13 @@ console.log("=== CLI IPC Target Helpers ===\n"); } { - console.log("Test 6: default host resolution tries local IPC first, then localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }), - ); - assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [ - "unix:///tmp/paseo-from-pid.sock", - "localhost:6767", - ]); - const previousHome = process.env.PASEO_HOME; - const previousHost = process.env.PASEO_HOST; - process.env.PASEO_HOME = paseoHome; - delete process.env.PASEO_HOST; - assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock"); - if (previousHome === undefined) delete process.env.PASEO_HOME; - else process.env.PASEO_HOME = previousHome; - if (previousHost === undefined) delete process.env.PASEO_HOST; - else process.env.PASEO_HOST = previousHost; - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ default host resolution tries local IPC first, then localhost fallback\n"); -} - -{ - console.log("Test 7: configured TCP host is preserved before the localhost fallback"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-")); - try { - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ configured TCP host is preserved before the localhost fallback\n"); + const target = selectDaemonTarget( + { home: "/tmp/selected-home" }, + { PASEO_HOST: "ignored:12345", PASEO_LISTEN: "ignored:23456" }, + ); + assert.deepStrictEqual(target, { kind: "instance", home: "/tmp/selected-home" }); + assert.strictEqual(getDaemonHost({ target }), "home /tmp/selected-home"); + assert.throws(() => selectDaemonTarget({}, { PASEO_HOME: "/tmp/a", PASEO_HOST: "unused:12345" })); } { @@ -131,28 +93,6 @@ console.log("=== CLI IPC Target Helpers ===\n"); console.log("✓ CLI app version resolves for daemon hello compatibility\n"); } -{ - console.log("Test 9: local IPC still takes priority over configured TCP hosts"); - const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-")); - try { - mkdirSync(paseoHome, { recursive: true }); - writeFileSync( - path.join(paseoHome, "paseo.pid"), - JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }), - ); - assert.deepStrictEqual( - resolveDefaultDaemonHosts({ - PASEO_HOME: paseoHome, - PASEO_LISTEN: "127.0.0.1:7777", - }), - ["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"], - ); - } finally { - rmSync(paseoHome, { recursive: true, force: true }); - } - console.log("✓ local IPC still takes priority over configured TCP hosts\n"); -} - { console.log("Test 10: daemon password resolution prefers TCP URI query, falls back to env"); const previousEnv = process.env.PASEO_PASSWORD; diff --git a/packages/cli/tests/31-schedule.test.ts b/packages/cli/tests/31-schedule.test.ts index 64cd6ca64b..8ecd098a07 100644 --- a/packages/cli/tests/31-schedule.test.ts +++ b/packages/cli/tests/31-schedule.test.ts @@ -130,7 +130,102 @@ try { } { - console.log("Test 1d: compatibility agent-target schedules remain deletable"); + console.log("Test 1d: schedule update clears limits with negated flags"); + const created = await ctx.paseo([ + "schedule", + "create", + "Review scheduled work", + "--cron", + "0 0 1 1 *", + "--provider", + "claude", + "--cwd", + ctx.workDir, + "--max-runs", + "3", + "--expires-in", + "1h", + "--json", + ]); + assert.strictEqual(created.exitCode, 0, created.stderr); + const { id } = JSON.parse(created.stdout); + + const inspect = async () => { + const result = await ctx.paseo(["schedule", "inspect", id, "--json"]); + assert.strictEqual(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); + }; + const initial = await inspect(); + assert.strictEqual(initial.maxRuns, 3); + assert.strictEqual(typeof initial.expiresAt, "string"); + + const renamed = await ctx.paseo([ + "schedule", + "update", + id, + "--name", + "renamed-review", + "--json", + ]); + assert.strictEqual(renamed.exitCode, 0, renamed.stderr); + const afterRename = await inspect(); + assert.strictEqual(afterRename.name, "renamed-review"); + assert.strictEqual(afterRename.maxRuns, initial.maxRuns); + assert.strictEqual(afterRename.expiresAt, initial.expiresAt); + + const clearedMaxRuns = await ctx.paseo(["schedule", "update", id, "--no-max-runs", "--json"]); + assert.strictEqual(clearedMaxRuns.exitCode, 0, clearedMaxRuns.stderr); + const withoutMaxRuns = await inspect(); + assert.strictEqual(withoutMaxRuns.maxRuns, null); + assert.strictEqual(withoutMaxRuns.expiresAt, initial.expiresAt); + + const clearedExpiration = await ctx.paseo([ + "schedule", + "update", + id, + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedExpiration.exitCode, 0, clearedExpiration.stderr); + const withoutExpiration = await inspect(); + assert.strictEqual(withoutExpiration.maxRuns, null); + assert.strictEqual(withoutExpiration.expiresAt, null); + + const updatedLimits = await ctx.paseo([ + "schedule", + "update", + id, + "--max-runs", + "5", + "--expires-in", + "2h", + "--json", + ]); + assert.strictEqual(updatedLimits.exitCode, 0, updatedLimits.stderr); + const withLimits = await inspect(); + assert.strictEqual(withLimits.maxRuns, 5); + assert.strictEqual(typeof withLimits.expiresAt, "string"); + + const clearedBoth = await ctx.paseo([ + "schedule", + "update", + id, + "--no-max-runs", + "--no-expires-in", + "--json", + ]); + assert.strictEqual(clearedBoth.exitCode, 0, clearedBoth.stderr); + const withoutLimits = await inspect(); + assert.strictEqual(withoutLimits.maxRuns, null); + assert.strictEqual(withoutLimits.expiresAt, null); + + const deleted = await ctx.paseo(["schedule", "delete", id, "--json"]); + assert.strictEqual(deleted.exitCode, 0, deleted.stderr); + console.log("schedule update clears limits\n"); + } + + { + console.log("Test 1e: compatibility agent-target schedules remain deletable"); const created = await ctx.paseo( [ "schedule", diff --git a/packages/cli/tests/32-daemon-set-password.test.ts b/packages/cli/tests/32-daemon-set-password.test.ts index 2dab1d9561..d40e79d5a0 100644 --- a/packages/cli/tests/32-daemon-set-password.test.ts +++ b/packages/cli/tests/32-daemon-set-password.test.ts @@ -5,7 +5,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Command } from "commander"; -import { isBearerTokenValid } from "@getpaseo/server"; +import { isBearerTokenValidAsync } from "@getpaseo/server/auth"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { runSetPasswordCommand, setDaemonPasswordInConfig, @@ -51,13 +52,19 @@ try { const config = JSON.parse(await readFile(join(paseoHome, "config.json"), "utf-8")); assert.strictEqual(result.configPath, join(paseoHome, "config.json")); - assert.strictEqual(result.restartCommand, "paseo daemon restart"); + assert.strictEqual( + result.restartCommand, + `paseo daemon restart --home ${JSON.stringify(paseoHome)}`, + ); assert.strictEqual(config.daemon.listen, "127.0.0.1:9999"); assert.strictEqual(config.daemon.relay.enabled, false); assert.notStrictEqual(config.daemon.auth.password, "shared-secret"); assert.match(config.daemon.auth.password, /^\$2[aby]\$12\$/); assert.strictEqual( - isBearerTokenValid({ password: config.daemon.auth.password, token: "shared-secret" }), + await isBearerTokenValidAsync({ + password: config.daemon.auth.password, + token: "shared-secret", + }), true, ); console.log("✓ set-password writes bcrypt hash without clobbering config\n"); @@ -68,6 +75,7 @@ try { const result = await runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["new-secret", "new-secret"]), }, {} as Command, @@ -76,7 +84,7 @@ try { assert.strictEqual(result.data.action, "password_set"); assert.strictEqual( - isBearerTokenValid({ password: config.daemon.auth.password, token: "new-secret" }), + await isBearerTokenValidAsync({ password: config.daemon.auth.password, token: "new-secret" }), true, ); console.log("✓ command accepts matching confirmation\n"); @@ -88,6 +96,7 @@ try { runSetPasswordCommand( { home: paseoHome, + daemonTarget: { kind: "instance", home: paseoHome }, promptPassword: promptSequence(["first-secret", "second-secret"]), }, {} as Command, @@ -100,6 +109,27 @@ try { ); console.log("✓ command refuses password mismatch\n"); } + + { + console.log("Test 4: piped stdin fails with a message instead of exiting silently"); + const pipedHome = join(root, "piped"); + await mkdir(pipedHome, { recursive: true }); + const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]); + run.stdin.end("x\nx\n"); + const result = await run; + + assert.strictEqual(result.exitCode, 1, result.stderr); + const error = JSON.parse(result.stderr).error as { + code: string; + message: string; + details: string; + }; + assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED"); + assert.match(error.message, /needs a terminal/); + assert.match(error.details, /PASEO_PASSWORD/); + await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8")); + console.log("✓ piped stdin reports that a terminal is required\n"); + } } finally { await rm(root, { recursive: true, force: true }); } diff --git a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts index 5ee8af7b61..3a290d91b2 100644 --- a/packages/cli/tests/33-daemon-stop-tree-kill.test.ts +++ b/packages/cli/tests/33-daemon-stop-tree-kill.test.ts @@ -118,6 +118,8 @@ try { join(paseoHome, "paseo.pid"), JSON.stringify({ pid: ownerProcess.pid, + hostname: "test", + uid: process.getuid?.() ?? -1, listen: "127.0.0.1:1", startedAt: new Date().toISOString(), }), @@ -153,15 +155,11 @@ try { action: parsed.action, forced: parsed.forced, usedLifecycleRpc: parsed.usedLifecycleRpc, - reason: parsed.reason, - message: parsed.message, }, { action: "stopped", forced: true, usedLifecycleRpc: false, - reason: "owner_pid_sigkill", - message: "Daemon owner process was force-stopped", }, `stop should report forced tree cleanup: ${stopResult.stdout}`, ); diff --git a/packages/cli/tests/34-daemon-status-auth.test.ts b/packages/cli/tests/34-daemon-status-auth.test.ts index bb683ed027..2c3bdca851 100644 --- a/packages/cli/tests/34-daemon-status-auth.test.ts +++ b/packages/cli/tests/34-daemon-status-auth.test.ts @@ -1,6 +1,9 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; import { startTestDaemon } from "./helpers/test-daemon.ts"; @@ -12,7 +15,7 @@ const daemon = await startTestDaemon({ try { { - console.log("Test 1: status reports password requirement without marking daemon unreachable"); + console.log("Test 1: local status uses the daemon's local credential"); const result = await runLocalPaseo(["daemon", "status", "--json"], { PASEO_HOME: daemon.paseoHome, PASEO_HOST: "", @@ -23,12 +26,11 @@ try { const status = JSON.parse(result.stdout); assert.strictEqual(status.localDaemon, "running"); - assert.strictEqual(status.connectedDaemon, "auth_required"); + assert.strictEqual(status.connectedDaemon, "reachable"); assert(!("runningAgents" in status), "status should not fetch agent counts"); assert(!("idleAgents" in status), "status should not fetch agent counts"); - assert.match(status.note, /requires a password/i); - assert.doesNotMatch(status.note, /not reachable/i); - console.log("✓ missing password reports auth_required\n"); + assert.doesNotMatch(status.note ?? "", /Password required|not reachable/i); + console.log("✓ missing password uses local credential\n"); } { @@ -44,7 +46,7 @@ try { assert.strictEqual(status.localDaemon, "running"); assert.strictEqual(status.connectedDaemon, "auth_failed"); - assert.match(status.note, /password was rejected/i); + assert.match(status.note, /Incorrect password/i); assert.doesNotMatch(status.note, /not reachable/i); console.log("✓ wrong password reports auth_failed\n"); } @@ -70,4 +72,78 @@ try { await daemon.stop(); } +// POSIX executable probing executes --version; Windows resolves executables differently. +if (process.platform !== "win32") { + const root = await mkdtemp(join(tmpdir(), "paseo status slow provider ")); + const home = join(root, "daemon"); + const workDir = join(root, "work"); + const provider = join(root, "slow-provider"); + const marker = join(root, "availability.log"); + let slowDaemon: Awaited<ReturnType<typeof startTestDaemon>> | undefined; + try { + await mkdir(home); + await mkdir(workDir); + await writeFile( + provider, + `#!${process.execPath} +import('node:fs').then(({appendFileSync}) => { + appendFileSync(${JSON.stringify(marker)}, JSON.stringify(process.argv.slice(2)) + "\\n"); + setTimeout(() => console.log('provider 1.0.0'), 1800); +}); +`, + { mode: 0o700 }, + ); + await writeFile( + join(home, "config.json"), + JSON.stringify({ + version: 1, + agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } }, + }), + ); + slowDaemon = await startTestDaemon({ + paseoHome: home, + workDir, + env: { PASEO_PASSWORD: "shared-secret" }, + }); + console.log("Test 4: local status separates authenticated reachability from slow details"); + const local = await runLocalPaseo(["daemon", "status", "--home", home, "--json"], { + PASEO_PASSWORD: "shared-secret", + }); + assert.strictEqual(local.exitCode, 0, local.stderr); + const status = JSON.parse(local.stdout); + assert.strictEqual(status.localDaemon, "running"); + assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status)); + assert.strictEqual(typeof status.serverId, "string"); + assert.match(status.note, /DAEMON_REQUEST_TIMEOUT/); + assert.match(status.note, /Status details unavailable/); + assert(!("workerPid" in status), "must not invent worker facts"); + assert(!("providers" in status), "must not invent provider facts"); + assert.match( + await readFile(marker, "utf8"), + /\["--version"\]/, + "the public provider executable was actually probed", + ); + console.log("✓ local authenticated connection remains reachable when details time out\n"); + + console.log("Test 5: explicit endpoint status remains an error when details time out"); + const remote = await runLocalPaseo( + ["daemon", "status", "--host", `127.0.0.1:${slowDaemon.port}`, "--json"], + { PASEO_PASSWORD: "shared-secret" }, + ); + assert.notStrictEqual(remote.exitCode, 0); + const { error } = JSON.parse(remote.stderr); + assert.strictEqual(error.code, "DAEMON_REQUEST_TIMEOUT"); + assert.match(error.message, /Status details unavailable/); + assert.strictEqual(error.details.connectedDaemon, "reachable"); + assert.strictEqual(error.details.serverId, status.serverId); + assert(!("home" in error.details), "endpoint observation must not invent local ownership"); + assert(!("workerPid" in error.details)); + assert(!("providers" in error.details)); + console.log("✓ failed remote query retains its error and authenticated connection fact\n"); + } finally { + await slowDaemon?.stop(); + await rm(root, { recursive: true, force: true }); + } +} + console.log("=== Daemon Status Auth Tests Passed ==="); diff --git a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts index 91d789d1da..aaba355f67 100644 --- a/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts +++ b/packages/cli/tests/34-daemon-stop-stale-reachable.test.ts @@ -1,8 +1,8 @@ #!/usr/bin/env npx tsx /** - * Regression: `paseo daemon stop` must stop a reachable daemon even when the - * local pid file points at a dead supervisor owner. + * Regression: `paseo daemon stop` must leave a reachable daemon alone when the + * selected home points at a dead supervisor owner. */ import assert from "node:assert"; @@ -12,6 +12,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { $ } from "zx"; +import { connectToDaemon } from "../src/utils/client.js"; import { getAvailablePort } from "./helpers/network.ts"; $.verbose = false; @@ -146,21 +147,24 @@ try { await waitFor( async () => { - const status = await readDaemonStatus(paseoHome); - return status.localDaemon === "stale_pid" && status.connectedDaemon === "reachable"; + try { + const client = await connectToDaemon({ target: { kind: "endpoint", host }, timeout: 500 }); + await client.close(); + return true; + } catch { + return false; + } }, 120000, - "daemon did not enter stale_pid + reachable state in time", + "unowned worker did not become reachable in time", ); const statusBeforeStop = await readDaemonStatus(paseoHome); - assert.strictEqual(statusBeforeStop.pid, stalePid, "status should report the stale owner pid"); + assert.strictEqual(statusBeforeStop.pid, null, "status should not claim a dead owner is running"); assert(workerProcess.pid && isProcessRunning(workerProcess.pid), "worker should be running"); console.log(`✓ fixture has stale pid ${stalePid} and live worker ${workerProcess.pid}\n`); - console.log( - "Test 2: `paseo daemon stop` should stop reachable worker instead of saying not_running", - ); + console.log("Test 2: home-selected stop leaves the unowned reachable worker running"); const stopResult = await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow(); assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`); @@ -169,22 +173,11 @@ try { pid?: unknown; message?: unknown; }; - assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action"); - assert.strictEqual( - stopJson.pid, - String(stalePid), - "stop should report the stale pid it recovered from", - ); - assert.strictEqual( - stopJson.message, - "Daemon stopped gracefully", - "stop should route through lifecycle shutdown", - ); - - await waitFor( - () => !isProcessRunning(workerProcess?.pid ?? -1), - 15000, - "worker remained running after stop", + assert.strictEqual(stopJson.action, "not_running"); + assert.strictEqual(stopJson.pid, stalePid); + assert( + workerProcess.pid && isProcessRunning(workerProcess.pid), + "stop must not contact the stale endpoint", ); assert.strictEqual(existsSync(pidPath), false, "stale pid file should be removed after stop"); console.log("✓ stop recovered stale supervisor pid state\n"); diff --git a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts index 313ec5c4c6..f7872b310b 100644 --- a/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts +++ b/packages/cli/tests/36-daemon-status-windows-process-lookup.test.ts @@ -1,43 +1,12 @@ #!/usr/bin/env npx tsx import assert from "node:assert"; -import { spawn } from "node:child_process"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getAvailablePort } from "./helpers/network.ts"; -const CLI_ENTRY = join(import.meta.dirname, "..", "dist", "index.js"); - -interface CommandResult { - exitCode: number | null; - stdout: string; - stderr: string; -} - -function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv): Promise<CommandResult> { - return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [CLI_ENTRY, ...args], { - env: { ...process.env, ...env }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (exitCode) => { - resolve({ exitCode, stdout, stderr }); - }); - }); -} - if (process.platform !== "win32") { console.log("Skipping Windows daemon status process lookup regression on non-Windows."); process.exit(0); @@ -55,7 +24,16 @@ const env = { }; try { - const start = await runLocalPaseo(["daemon", "restart", "--port", String(port)], env); + for (const [field, value] of [ + ["daemon.listen", `127.0.0.1:${port}`], + ["daemon.relay.enabled", "false"], + ["features.dictation.enabled", "false"], + ["features.voiceMode.enabled", "false"], + ]) { + const saved = await runLocalPaseo(["daemon", "config", "set", field!, value!], env); + assert.equal(saved.exitCode, 0, saved.stderr); + } + const start = await runLocalPaseo(["daemon", "start"], env); assert.strictEqual( start.exitCode, 0, diff --git a/packages/cli/tests/38-workspace-rename.test.ts b/packages/cli/tests/38-workspace-rename.test.ts index 50d3b4ce82..9fed12aa4c 100644 --- a/packages/cli/tests/38-workspace-rename.test.ts +++ b/packages/cli/tests/38-workspace-rename.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { basename } from "node:path"; +import { createPaseoClient } from "@getpaseo/client"; import { createE2ETestContext } from "./helpers/test-daemon.ts"; console.log("=== Workspace Rename Command ===\n"); @@ -51,8 +52,61 @@ try { )?.name, basename(ctx.workDir), ); + + await verifyWorkspaceCallerContext(); } finally { await ctx.stop(); } console.log("=== Workspace Rename Command Tests Passed ==="); + +async function verifyWorkspaceCallerContext() { + const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } }); + try { + await sdk.connect(); + const parentWorkspace = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + background: true, + }); + // Creation without a prompt stays idle; this visibility test runs no provider turn. + const parent = await parentWorkspace.agents.create({ config: { provider: "claude/haiku" } }); + const cases = [ + { caller: parent.id, background: undefined, expected: true }, + { caller: parent.id, background: false, expected: false }, + { caller: parent.id, background: true, expected: true }, + { caller: "foreign-or-missing-agent", background: undefined, expected: false }, + { caller: "foreign-or-missing-agent", background: true, expected: true }, + { caller: "foreign-or-missing-agent", background: false, expected: false }, + { caller: "", background: undefined, expected: false }, + { caller: "", background: true, expected: true }, + ]; + for (const entry of cases) { + const result = await ctx.paseo( + [ + "workspace", + "create", + "--host", + `127.0.0.1:${ctx.port}`, + "--isolation", + "local", + "--path", + ctx.workDir, + ...(entry.background === undefined + ? [] + : [entry.background ? "--background" : "--no-background"]), + "--json", + ], + { env: { PASEO_AGENT_ID: entry.caller } }, + ); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual( + JSON.parse(result.stdout).background, + entry.expected, + JSON.stringify(entry), + ); + } + console.log("✓ workspace create caller inheritance, foreign/blank defaults, and overrides"); + } finally { + await sdk.close(); + } +} diff --git a/packages/cli/tests/39-terminal-workspace.test.ts b/packages/cli/tests/39-terminal-workspace.test.ts new file mode 100644 index 0000000000..b1221962f3 --- /dev/null +++ b/packages/cli/tests/39-terminal-workspace.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { createPaseoClient } from "@getpaseo/client"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { waitForTerminalOutput } from "./helpers/terminal.ts"; + +const ctx = await createE2ETestContext({ timeout: 30_000 }); +const sdk = createPaseoClient({ url: `${ctx.wsUrl}/ws`, reconnect: { enabled: false } }); + +async function cli(args: string[]) { + const result = await ctx.paseo([...args, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + return JSON.parse(result.stdout); +} + +try { + await sdk.connect(); + const first = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "main", + }); + const second = await sdk.workspaces.create({ + source: { kind: "directory", path: ctx.workDir }, + title: "feature-work", + }); + const main = await cli(["terminal", "create", "--cwd", ctx.workDir, "--name", "Main"]); + assert.equal(main.workspaceId, first.id); + const feature = await cli(["terminal", "create", "--workspace", second.id, "--name", "Feature"]); + assert.deepEqual(feature, { + id: feature.id, + name: "Feature", + cwd: ctx.workDir, + workspaceId: second.id, + }); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), [feature]); + assert.deepEqual(await cli(["terminal", "ls", "--cwd", ctx.workDir]), [main, feature]); + assert.deepEqual(await cli(["terminal", "ls", "--all"]), [main, feature]); + const invalid = await ctx.paseo([ + "terminal", + "create", + "--workspace", + "wks_missing", + "--cwd", + ctx.workDir, + "--json", + ]); + assert.notEqual(invalid.exitCode, 0); + assert.match(invalid.stderr, /not active or does not exist/); + const conflict = await ctx.paseo(["terminal", "ls", "--all", "--workspace", second.id]); + assert.notEqual(conflict.exitCode, 0); + + const terminal = await second.terminals.create({ + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "READY"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "-l", "Enter"]), { + terminalId: terminal.id, + keysSent: 5, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e746572"); + assert.deepEqual(await cli(["terminal", "send-keys", terminal.id, "Enter"]), { + terminalId: terminal.id, + keysSent: 1, + }); + await waitForTerminalOutput(ctx.paseo, terminal.id, "HEX:456e7465720d"); + assert.deepEqual(await cli(["terminal", "kill", terminal.id]), { + terminalId: terminal.id, + success: true, + }); + await cli(["terminal", "kill", feature.id]); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", second.id]), []); + assert.deepEqual(await cli(["terminal", "ls", "--workspace", first.id]), [main]); + console.log("Terminal workspace CLI: ownership, lists, input, capture, kill, and errors passed"); +} finally { + await sdk.close(); + await ctx.stop(); +} diff --git a/packages/cli/tests/40-daemon-stale-boot-lock.test.ts b/packages/cli/tests/40-daemon-stale-boot-lock.test.ts new file mode 100644 index 0000000000..ae8b2b990c --- /dev/null +++ b/packages/cli/tests/40-daemon-stale-boot-lock.test.ts @@ -0,0 +1,96 @@ +#!/usr/bin/env npx tsx + +/** + * Regression: a reboot reassigns the supervisor's PID to an unrelated process. The lock + * left behind must not make the daemon look like it is running, and must never be treated + * as a handle on whatever now holds that PID. + */ + +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir, uptime } from "node:os"; +import { join } from "node:path"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; + +console.log("=== Daemon lock left behind by a reboot ===\n"); + +const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stale-boot-lock-")); +const env = { + PASEO_HOME: paseoHome, + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0", + PASEO_DICTATION_ENABLED: "0", + PASEO_VOICE_MODE_ENABLED: "0", +}; + +// Stands in for the process that inherits the supervisor's PID after a reboot. It records a +// signal rather than dying of it, so a late delivery cannot slip past the assertion. +const signalMarker = join(paseoHome, "bystander-signalled"); +const bystander = spawn( + process.execPath, + [ + "-e", + `process.on("SIGTERM", () => require("node:fs").writeFileSync(${JSON.stringify(signalMarker)}, "SIGTERM"));` + + `setTimeout(() => {}, 120_000);`, + ], + { stdio: "ignore" }, +); +let bystanderExited = false; +bystander.once("exit", () => { + bystanderExited = true; +}); +assert(bystander.pid, "bystander process should have a pid"); + +try { + const beforeThisBoot = new Date(Date.now() - uptime() * 1000 - 60 * 60_000); + await writeFile( + join(paseoHome, "paseo.pid"), + JSON.stringify({ + pid: bystander.pid, + startedAt: beforeThisBoot.toISOString(), + hostname: "before-reboot", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }), + ); + + const statusResult = await runLocalPaseo( + ["daemon", "status", "--home", paseoHome, "--json"], + env, + ); + assert.strictEqual( + statusResult.exitCode, + 0, + `daemon status should succeed:\nstdout:\n${statusResult.stdout}\nstderr:\n${statusResult.stderr}`, + ); + const status = JSON.parse(statusResult.stdout) as { localDaemon?: string; pid?: number | null }; + assert.strictEqual( + status.localDaemon, + "stopped", + `daemon left behind by a reboot should read as stopped, got: ${statusResult.stdout}`, + ); + assert.strictEqual(status.pid, null, "status should not report the reused pid as the daemon"); + console.log("✓ daemon status reports stopped\n"); + + const stopResult = await runLocalPaseo(["daemon", "stop", "--home", paseoHome], env); + assert.strictEqual( + stopResult.exitCode, + 0, + `daemon stop should succeed:\nstdout:\n${stopResult.stdout}\nstderr:\n${stopResult.stderr}`, + ); + assert.strictEqual( + existsSync(signalMarker), + false, + "daemon stop must not signal the process that inherited the pid", + ); + assert.strictEqual(bystanderExited, false, "the process that inherited the pid should survive"); + console.log("✓ daemon stop leaves the process holding that pid alone\n"); +} finally { + bystander.kill("SIGKILL"); + await rm(paseoHome, { recursive: true, force: true }); +} + +console.log("=== Daemon lock left behind by a reboot passed ==="); diff --git a/packages/cli/tests/41-daemon-auth-command-errors.test.ts b/packages/cli/tests/41-daemon-auth-command-errors.test.ts new file mode 100644 index 0000000000..37fb7e511f --- /dev/null +++ b/packages/cli/tests/41-daemon-auth-command-errors.test.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { runLocalPaseo } from "./helpers/local-cli.ts"; +import { startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Daemon Auth Command Errors ===\n"); + +const daemon = await startTestDaemon({ + env: { PASEO_PASSWORD: "shared-secret" }, +}); + +async function lsError(password: string, remoteClient = false) { + const result = await runLocalPaseo(["ls", "--json"], { + PASEO_HOME: remoteClient ? "" : daemon.paseoHome, + PASEO_HOST: remoteClient ? `127.0.0.1:${daemon.port}` : "", + PASEO_PASSWORD: password, + }); + assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password"); + return JSON.parse(result.stderr).error as { + code: string; + message: string; + details: string; + }; +} + +try { + { + console.log("Test 1: a client outside the daemon home needs PASEO_PASSWORD"); + const error = await lsError("", true); + assert.strictEqual(error.code, "AUTH_REQUIRED"); + assert.match(error.message, /Password required/); + assert.match(error.details, /PASEO_PASSWORD/); + assert.doesNotMatch(error.details, /daemon start/); + console.log("✓ client without the local credential is asked for PASEO_PASSWORD\n"); + } + + { + console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start"); + const error = await lsError("wrong-secret"); + assert.strictEqual(error.code, "AUTH_FAILED"); + assert.match(error.message, /Incorrect password/); + assert.match(error.details, /PASEO_PASSWORD/); + assert.doesNotMatch(error.details, /daemon start/); + console.log("✓ wrong password points at PASEO_PASSWORD\n"); + } +} finally { + await daemon.stop(); +} + +console.log("=== Daemon Auth Command Errors Tests Passed ==="); diff --git a/packages/cli/tests/42-agent-open.test.ts b/packages/cli/tests/42-agent-open.test.ts new file mode 100644 index 0000000000..d468dfe16d --- /dev/null +++ b/packages/cli/tests/42-agent-open.test.ts @@ -0,0 +1,52 @@ +#!/usr/bin/env npx tsx + +import assert from "node:assert"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Agent Open Command Tests ===\n"); + +if (process.platform !== "linux") { + console.log("Skipped: the fake Desktop app is a Linux AppImage path."); + process.exit(0); +} + +const daemon = await startTestDaemon(); + +// runPaseoCli sets HOME to the daemon's home, where the CLI looks for +// ~/Applications/Paseo.AppImage on Linux. The fake app records its launch. +const launchRecord = join(daemon.paseoHome, "desktop-launches.txt"); +const fakeDesktop = join(daemon.paseoHome, "Applications", "Paseo.AppImage"); +await mkdir(join(daemon.paseoHome, "Applications"), { recursive: true }); +await writeFile(fakeDesktop, `#!/bin/sh\necho "$@" >> "${launchRecord}"\n`); +await chmod(fakeDesktop, 0o755); + +try { + { + console.log("Test 1: an unknown agent ID fails without opening Desktop"); + const result = await runPaseoCli(daemon, [ + "agent", + "open", + "does-not-exist", + "--host", + `127.0.0.1:${daemon.port}`, + "--json", + ]); + + assert.notStrictEqual(result.exitCode, 0, "open should fail for an unknown agent"); + assert.match(JSON.parse(result.stderr).error.message, /Agent not found: does-not-exist/); + // Give a wrongly spawned detached app time to write its record. + await new Promise((resolve) => setTimeout(resolve, 500)); + assert.ok( + !existsSync(launchRecord), + `Desktop should not open, but was launched with: ${existsSync(launchRecord) ? await readFile(launchRecord, "utf8") : ""}`, + ); + console.log("✓ unknown agent ID fails without opening Desktop\n"); + } +} finally { + await daemon.stop(); +} + +console.log("=== All agent open tests passed ==="); diff --git a/packages/cli/tests/43-agent-follow-closed-stdout.test.ts b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts new file mode 100644 index 0000000000..0191867438 --- /dev/null +++ b/packages/cli/tests/43-agent-follow-closed-stdout.test.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env npx tsx + +/** + * Follow commands stop when nobody reads their output + * + * `paseo agent logs --follow` and `paseo agent attach` stream until they are + * stopped. A program that closes its end of their stdout, as `| head` or a + * launcher that discards output does, stops them the same way Ctrl+C does. + */ + +import assert from "node:assert"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts"; + +console.log("=== Follow Commands With Closed Stdout Tests ===\n"); + +const daemon = await startTestDaemon(); +const host = `127.0.0.1:${daemon.port}`; + +try { + // An agent created without a prompt stays idle, so no provider is called. + const client = await connectToDaemon({ target: { kind: "endpoint", host } }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.workDir, + title: "closed stdout", + }); + await client.close(); + + for (const args of [ + ["agent", "logs", agent.id, "--follow"], + ["agent", "attach", agent.id], + ]) { + const label = `paseo ${args.slice(0, 2).join(" ")}`; + console.log(`Test: ${label} exits when its stdout reader is gone`); + const result = await runPaseoCli(daemon, [...args, "--host", host], { + closedStdout: true, + timeout: 20_000, + }); + + assert.strictEqual(result.stderr, "", `${label} should exit quietly`); + assert.strictEqual(result.exitCode, 0, `${label} should exit with code 0`); + console.log(`✓ ${label} exits when its stdout reader is gone\n`); + } +} finally { + await daemon.stop(); +} + +console.log("=== All follow commands with closed stdout tests passed ==="); diff --git a/packages/cli/tests/44-usage.test.ts b/packages/cli/tests/44-usage.test.ts new file mode 100644 index 0000000000..2db20e2824 --- /dev/null +++ b/packages/cli/tests/44-usage.test.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env npx tsx +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; +import { connectToDaemon } from "../src/utils/client.ts"; +import { resolveCliVersion } from "../src/version.ts"; + +const context = await createE2ETestContext(); +try { + const directory = join(context.workDir, "usage-plugin"); + await mkdir(directory); + await writeFile( + join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "cli-usage", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile( + join(directory, "index.server.ts"), + ` +import { z } from 'zod'; +export default function contribute(server) { + const counts = new Map(); + server.registerUsageSource({ + id: 'cli-usage', label: 'CLI usage fixture', input: z.object({ key: z.string() }), + discover: async () => [ + { key: 'work.account', label: 'Work', input: { key: 'work' } }, + { key: 'personal', input: { key: 'personal' } }, + { key: 'broken', harness: 'Harness A', input: { key: 'rejected' } }, + { key: 'broken', harness: 'Harness B', input: { key: 'expired' } }, + { key: 'error', input: { key: 'error' } }, + ], + fetch: async ({key}) => { + counts.set(key, (counts.get(key) ?? 0) + 1); + if (key === 'rejected') return {status: 'unavailable', problem: {kind: 'rejected', status: 401, refreshedBy: 'codex login'}}; + if (key === 'expired') return {status: 'unavailable', problem: {kind: 'expired', expiresAt: '2026-10-08T00:00:00Z', refreshedBy: 'claude'}}; + if (key === 'error') throw new Error('Quota service failed'); + return { status: 'available', planLabel: 'Pro', windows: [ + { id: 'session', label: 'Session', usedPct: counts.get(key), remainingPct: 99, resetsAt: '2026-10-09T17:00:00Z', runsOutAt: '2026-10-09T16:00:00Z', shortfallPct: 4.25 }, + { id: 'weekly', label: 'Weekly', usedPct: null, remainingPct: 0 }, + ], balances: [ + { id: 'credits', label: 'Credits', remaining: 2.5, unit: 'credits' }, + { id: 'tokens', label: 'Tokens', used: 0, limit: 100, unit: 'tokens' }, + ], details: [{ id: 'tier', label: 'Tier', value: 'Enterprise' }] }; + }, + }); + return () => {}; +}`, + ); + const client = await connectToDaemon({ target: { kind: "instance", home: context.paseoHome } }); + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory, "cli-usage"); + } finally { + await client.close(); + } + async function ok(args: string[]) { + const result = await context.paseo(args); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; + } + // The first usage request is inspect: no earlier usage discovery has populated known IDs. + const cold = JSON.parse(await ok(["usage", "inspect", "cli-usage:work.account", "--json"])); + assert.equal(cold.id, "cli-usage:work.account"); + assert.equal(cold.report.windows[0].usedPct, 1); + for (const args of [ + ["usage", "--agent", "agent-A", "inspect", cold.id, "--json"], + ["usage", "inspect", cold.id, "--agent", "agent-A", "--json"], + ]) { + const conflicting = await context.paseo(args); + assert.equal(conflicting.exitCode, 1, `Expected selector rejection: ${args.join(" ")}`); + assert.equal(conflicting.stdout, ""); + assert.deepEqual(JSON.parse(conflicting.stderr).error, { + code: "INVALID_OPTIONS", + message: + "--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.", + }); + } + + const list = JSON.parse(await ok(["usage", "ls", "--json"])); + const defaultList = JSON.parse(await ok(["usage", "--json"])); + assert.deepEqual(defaultList, list); + assert.deepEqual(list.map((entry: { id: string }) => entry.id).sort(), [ + "cli-usage:broken", + "cli-usage:error", + "cli-usage:personal", + "cli-usage:work.account", + ]); + const work = list.find((entry: { id: string }) => entry.id === cold.id); + assert.deepEqual(work, cold); + const table = await ok(["usage", "ls", "--no-color"]); + for (const text of [ + "REPORT ID", + "Credits: - used, 2.5 remaining, - limit (credits)", + "Tokens: 0 used, - remaining, 100 limit (tokens)", + "Weekly: - used, 0% remaining", + "Harness A: Login rejected (HTTP 401). Run codex login", + "Harness B: Login expired at", + "Quota service failed", + ]) + assert.ok(table.includes(text), text); + const quiet = await ok(["usage", "ls", "--quiet"]); + assert.deepEqual( + quiet.trim().split("\n").sort(), + list.map((entry: { id: string }) => entry.id).sort(), + ); + assert.ok(!(await ok(["usage", "ls", "--no-headers"])).includes("REPORT ID")); + const detail = await ok(["usage", "inspect", cold.id]); + for (const text of [ + "Plan: Pro", + "Resets: 2026-10-09T17:00:00Z", + "Runs out: 2026-10-09T16:00:00Z", + "Shortfall: 4.25%", + "Tier: Enterprise", + ]) + assert.ok(detail.includes(text), text); + const refreshed = JSON.parse(await ok(["usage", "inspect", cold.id, "--refresh", "--json"])); + assert.equal(refreshed.report.windows[0].usedPct, 2); + const personal = JSON.parse(await ok(["usage", "inspect", "cli-usage:personal", "--json"])); + assert.equal(personal.report.windows[0].usedPct, 1); + const allRefreshed = JSON.parse(await ok(["usage", "ls", "--refresh", "--json"])); + assert.equal( + allRefreshed.find((entry: { id: string }) => entry.id === cold.id).report.windows[0].usedPct, + 3, + ); + for (const id of ["cli-usage:work", "missing"]) { + const unknown = await context.paseo(["usage", "inspect", id, "--json"]); + assert.equal(unknown.exitCode, 1); + assert.equal(unknown.stdout, ""); + assert.equal(JSON.parse(unknown.stderr).error.code, "REPORT_NOT_FOUND"); + } + const agentFailure = await context.paseo(["usage", "ls", "--agent", "missing-agent", "--json"]); + assert.equal(agentFailure.exitCode, 1); + assert.equal(agentFailure.stdout, ""); + assert.match(JSON.parse(agentFailure.stderr).error.message, /Unknown agent/); + const remove = await context.paseo(["plugin", "remove", "cli-usage", "--json"]); + assert.equal(remove.exitCode, 0, remove.stderr); + assert.equal((await ok(["usage", "ls", "--json"])).trim(), "[]"); + assert.equal((await ok(["usage"])).trim(), "No usage reports returned"); + const connection = await context.paseo(["usage", "ls", "--host", "127.0.0.1:1", "--json"]); + assert.equal(connection.exitCode, 1); + assert.equal(connection.stdout, ""); + assert.equal(JSON.parse(connection.stderr).error.code, "DAEMON_UNREACHABLE"); + console.log( + "Usage CLI: cold inspect, default/ls, complete JSON, mixed statuses, all windows/balances, inspect details, refresh, IDs, errors and empty discovery passed.", + ); +} finally { + await context.stop(); +} diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index afc28c551c..3e71b6b608 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -1,12 +1,20 @@ #!/usr/bin/env npx tsx +import { resolveCliVersion } from "../../src/version.js"; +import { readPluginManifest } from "../../../server/src/server/plugins/manifest.js"; +import { + startNpmRegistry, + npmPluginPackages, +} from "../../../../scripts/test-support/npm-registry.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { once } from "node:events"; +import { WebSocket, WebSocketServer } from "ws"; import { connectToDaemon } from "../../src/utils/client.ts"; import { createE2ETestContext } from "../helpers/test-daemon.ts"; @@ -24,25 +32,130 @@ async function git(cwd: string, args: string[]): Promise<void> { async function main(): Promise<void> { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-")); - const context = await createE2ETestContext({ timeout: 45_000 }); + const registry = await startNpmRegistry(npmPluginPackages()); + const context = await createE2ETestContext({ timeout: 45_000, env: registry.env }); try { - await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" })); + const scaffold = path.join(context.workDir, "authored-plugin"); + const init = await context.paseo(["plugin", "init", scaffold, "--json"]); + assert.equal(init.exitCode, 0, init.stderr); + const manifestPath = path.join(scaffold, "paseo-plugin.json"); + const manifest = await readPluginManifest(scaffold); + assert.deepEqual(manifest.requirements, { paseo: `>=${resolveCliVersion()}` }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }), + ); await writeFile(path.join(directory, "index.server.ts"), pluginSource); + // Observe real CLI requests while forwarding them to the isolated daemon. + const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const sources: string[] = []; + proxy.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const message = JSON.parse(data.toString()); + if ( + message.type === "session" && + message.message.type === "plugin.source.install.request" + ) { + sources.push(message.message.source); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(proxy, "listening"); + const address = proxy.address(); + assert.ok(address && typeof address !== "string"); + const sibling = path.join(context.workDir, "x"); + const caller = path.join(context.workDir, "caller"); + await mkdir(caller); + await mkdir(path.join(sibling, "sub"), { recursive: true }); + for (const target of [caller, path.join(sibling, "sub"), context.paseoHome]) { + await writeFile( + path.join(target, "paseo-plugin.json"), + JSON.stringify({ + id: "relative-cli", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(target, "index.server.ts"), pluginSource); + } + assert.notEqual(caller, process.cwd()); // The daemon inherits the test runner's cwd. + try { + for (const [source, expectedSource, expectedDirectory, extraArgs] of [ + [".", caller, caller, []], + ["../x:sub", `${sibling}:sub`, path.join(sibling, "sub"), []], + ["../x", `${sibling}:sub`, path.join(sibling, "sub"), ["--path", "sub"]], + ["~", context.paseoHome, context.paseoHome, []], + ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], + ] as const) { + sources.length = 0; + const result = await context.paseo( + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [expectedSource]); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).path, expectedDirectory); + const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + // An absolute path belongs to the daemon's platform, which can differ from the CLI's. + const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; + sources.length = 0; + const missing = await context.paseo( + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + { cwd: caller }, + ); + assert.deepEqual(sources, [windowsSource]); + assert.equal(missing.exitCode, 1); + assert.match(missing.stderr, /Plugin directory does not exist/); + } finally { + for (const socket of proxy.clients) socket.close(); + await promisify(proxy.close.bind(proxy))(); + } + const install = await context.paseo(["plugin", "install", directory, "--json"]); assert.equal(install.exitCode, 0, install.stderr); assert.equal(JSON.parse(install.stdout).id, "cli-e2e"); - const client = await connectToDaemon({ host: `127.0.0.1:${context.port}` }); + const client = await connectToDaemon({ + target: { kind: "endpoint", host: `127.0.0.1:${context.port}` }, + }); await client.patchDaemonConfig({ pluginsEnabled: true }); await client.close(); + await writeFile( + manifestPath, + JSON.stringify({ ...manifest, requirements: { paseo: ">=999.0.0" } }), + ); + const incompatibleInstall = await context.paseo(["plugin", "install", scaffold, "--json"]); + assert.equal(incompatibleInstall.exitCode, 1); + assert.match(incompatibleInstall.stderr, /requires Paseo >=999.0.0/); + const afterRejection = await context.paseo(["plugin", "ls", "--json"]); + assert.equal(afterRejection.exitCode, 0, afterRejection.stderr); + assert.deepEqual( + JSON.parse(afterRejection.stdout).map((plugin: { id: string }) => plugin.id), + ["cli-e2e"], + ); + await writeFile(manifestPath, JSON.stringify(manifest)); + const scaffoldInstall = await context.paseo(["plugin", "install", scaffold, "--json"]); + assert.equal(scaffoldInstall.exitCode, 0, scaffoldInstall.stderr); + assert.equal(JSON.parse(scaffoldInstall.stdout).status, "running"); + await git(gitDirectory, ["init", "-b", "main"]); await git(gitDirectory, ["config", "user.name", "Paseo Tests"]); await git(gitDirectory, ["config", "user.email", "paseo@example.test"]); await writeFile( path.join(gitDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "git-cli-e2e" }), + JSON.stringify({ id: "git-cli-e2e", requirements: { paseo: `>=${resolveCliVersion()}` } }), ); await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource); await git(gitDirectory, ["add", "-A"]); @@ -51,7 +164,7 @@ async function main(): Promise<void> { const gitInstall = await context.paseo([ "plugin", "add", - pathToFileURL(gitDirectory).href, + `git:${pathToFileURL(gitDirectory).href}`, "--json", ]); assert.equal(gitInstall.exitCode, 0, gitInstall.stderr); @@ -65,11 +178,57 @@ async function main(): Promise<void> { await git(gitDirectory, ["commit", "-m", "update"]); const status = await context.paseo(["plugin", "status", "git-cli-e2e", "--json"]); assert.equal(status.exitCode, 0, status.stderr); - assert.equal(JSON.parse(status.stdout)[0].updateAvailable, true); + assert.equal(JSON.parse(status.stdout)[0].status, "running"); + assert.equal(JSON.parse(status.stdout)[0].commit, JSON.parse(gitInstall.stdout).commit); - const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--json"]); + const update = await context.paseo(["plugin", "update", "git-cli-e2e", "--yes", "--json"]); assert.equal(update.exitCode, 0, update.stderr); - assert.equal(JSON.parse(update.stdout)[0].updated, true); + assert.equal(JSON.parse(update.stdout)[0].outcome, "updated"); + + const installedCommit = JSON.parse(update.stdout)[0].plugin.installation.currentRevision; + const buildMarker = path.join(context.workDir, "incompatible-build-ran"); + await writeFile( + path.join(gitDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "git-cli-e2e", + requirements: { paseo: ">=999.0.0" }, + build: [ + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync(process.argv[1], "ran")', + buildMarker, + ], + ], + }), + ); + await git(gitDirectory, ["add", "-A"]); + await git(gitDirectory, ["commit", "-m", "requires a future Paseo"]); + const incompatibleUpdate = await context.paseo([ + "plugin", + "update", + "git-cli-e2e", + "--yes", + "--json", + ]); + assert.equal(incompatibleUpdate.exitCode, 1); + assert.match(JSON.parse(incompatibleUpdate.stdout)[0].error, /requires Paseo >=999.0.0/); + await assert.rejects(readFile(buildMarker), { code: "ENOENT" }); + const retained = await context.paseo(["plugin", "ls", "git-cli-e2e", "--json"]); + assert.equal(retained.exitCode, 0, retained.stderr); + assert.equal(JSON.parse(retained.stdout)[0].commit, installedCommit); + assert.equal(JSON.parse(retained.stdout)[0].status, "running"); + const incompatibleAdd = await context.paseo([ + "plugin", + "add", + pathToFileURL(gitDirectory).href, + "--id", + "future-plugin", + "--json", + ]); + assert.equal(incompatibleAdd.exitCode, 1); + assert.match(incompatibleAdd.stderr, /requires Paseo >=999.0.0/); + await assert.rejects(readFile(buildMarker), { code: "ENOENT" }); const reload = await context.paseo(["plugin", "reload", "cli-e2e", "--json"]); assert.equal(reload.exitCode, 0, reload.stderr); @@ -87,11 +246,56 @@ async function main(): Promise<void> { assert.equal(remove.exitCode, 0, remove.stderr); const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]); assert.equal(removeGit.exitCode, 0, removeGit.stderr); + const removeScaffold = await context.paseo(["plugin", "remove", "authored-plugin", "--json"]); + assert.equal(removeScaffold.exitCode, 0, removeScaffold.stderr); + for (const source of ["npm:paseo-fixture-plugin@^1.0.0", "npm:@paseo-fixture/review@2.0.0"]) { + const npmInstall = await context.paseo([ + "plugin", + "install", + source, + "--path", + ".", + "--json", + ]); + assert.equal(npmInstall.exitCode, 0, npmInstall.stderr); + assert.equal(JSON.parse(npmInstall.stdout).id, "npm-review"); + assert.equal(JSON.parse(npmInstall.stdout).status, "running"); + assert.equal( + JSON.parse(npmInstall.stdout).installation.currentRevision, + source.includes("@paseo-fixture") ? "2.0.0" : "1.1.0", + ); + const npmDisabled = await context.paseo(["plugin", "disable", "npm-review", "--json"]); + assert.equal(npmDisabled.exitCode, 0, npmDisabled.stderr); + const restart = await context.paseo(["daemon", "restart", "--timeout", "45", "--json"], { + timeout: 60_000, + }); + assert.equal(restart.exitCode, 0, restart.stderr); + assert.notEqual( + JSON.parse(restart.stdout).workerPid, + JSON.parse(restart.stdout).previousWorkerPid, + ); + const persisted = await context.paseo(["plugin", "ls", "npm-review", "--json"]); + assert.equal(persisted.exitCode, 0, persisted.stderr); + assert.equal(JSON.parse(persisted.stdout)[0].status, "disabled"); + assert.equal(JSON.parse(persisted.stdout)[0].path, JSON.parse(npmInstall.stdout).path); + assert.deepEqual( + JSON.parse(persisted.stdout)[0].installation, + JSON.parse(npmInstall.stdout).installation, + ); + const npmEnabled = await context.paseo(["plugin", "enable", "npm-review", "--json"]); + assert.equal(npmEnabled.exitCode, 0, npmEnabled.stderr); + assert.equal(JSON.parse(npmEnabled.stdout).status, "running"); + const npmReload = await context.paseo(["plugin", "reload", "npm-review", "--json"]); + assert.equal(npmReload.exitCode, 0, npmReload.stderr); + const npmRemove = await context.paseo(["plugin", "remove", "npm-review", "--json"]); + assert.equal(npmRemove.exitCode, 0, npmRemove.stderr); + } const list = await context.paseo(["plugin", "ls", "--json"]); assert.equal(list.exitCode, 0, list.stderr); assert.deepEqual(JSON.parse(list.stdout), []); } finally { await context.stop(); + await registry.close(); await rm(directory, { recursive: true, force: true }); await rm(gitDirectory, { recursive: true, force: true }); } diff --git a/packages/cli/tests/e2e/relay-host.test.ts b/packages/cli/tests/e2e/relay-host.test.ts index 1c5e4bd8b9..bfba930f7d 100644 --- a/packages/cli/tests/e2e/relay-host.test.ts +++ b/packages/cli/tests/e2e/relay-host.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer"; -import { generateLocalPairingOffer } from "@getpaseo/server"; +import { generateLocalPairingOffer } from "@getpaseo/server/pairing"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { WebSocket } from "ws"; import { getAvailablePort } from "../helpers/network.ts"; diff --git a/packages/cli/tests/helpers/local-cli.ts b/packages/cli/tests/helpers/local-cli.ts index ad7f3a4524..e6d22bb0ff 100644 --- a/packages/cli/tests/helpers/local-cli.ts +++ b/packages/cli/tests/helpers/local-cli.ts @@ -1,5 +1,10 @@ import { $, ProcessPromise } from "zx"; import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const cliHome = mkdtempSync(join(tmpdir(), "paseo-test-cli-os-home-")); +process.once("exit", () => rmSync(cliHome, { recursive: true, force: true })); const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js"); @@ -11,6 +16,13 @@ export function runLocalPaseo( $.verbose = false; return $({ cwd, - env: { ...process.env, ...env }, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow(); } diff --git a/packages/cli/tests/helpers/terminal.ts b/packages/cli/tests/helpers/terminal.ts new file mode 100644 index 0000000000..75a0bf258d --- /dev/null +++ b/packages/cli/tests/helpers/terminal.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import type { createE2ETestContext } from "./test-daemon.ts"; + +export async function waitForTerminalOutput( + paseo: Awaited<ReturnType<typeof createE2ETestContext>>["paseo"], + terminalId: string, + text: string, +): Promise<void> { + const deadline = Date.now() + 10_000; + let lines: string[] = []; + while (Date.now() < deadline) { + const result = await paseo(["terminal", "capture", terminalId, "--json"]); + assert.equal(result.exitCode, 0, result.stderr); + lines = JSON.parse(result.stdout).lines; + if (lines.some((line) => line.includes(text))) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Missing terminal output: ${text}\nLast capture:\n${lines.join("\n")}`); +} diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 8607b1cfb5..5b5513d1a7 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -169,19 +169,23 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by running `paseo agent ls` * This connects via WebSocket and ensures the daemon is responsive */ -async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise<boolean> { +async function probeDaemonReady( + port: number, + paseoHome: string, + env?: NodeJS.ProcessEnv, +): Promise<boolean> { try { const { exitCode } = await runPaseoCli( { port, wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`, - paseoHome: "", + paseoHome, workDir: "", process: null, isReady: false, stop: async () => {}, }, - ["agent", "ls"], + ["agent", "ls", "--host", `${TEST_DAEMON_HOST}:${port}`], { env }, ); return exitCode === 0; @@ -192,13 +196,14 @@ async function probeDaemonReady(port: number, env?: NodeJS.ProcessEnv): Promise< async function waitForDaemonReady( port: number, + paseoHome: string, timeout = 30000, env?: NodeJS.ProcessEnv, ): Promise<void> { const deadline = Date.now() + timeout; async function poll(): Promise<void> { - if (await probeDaemonReady(port, env)) return; + if (await probeDaemonReady(port, paseoHome, env)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`); } @@ -240,23 +245,23 @@ export async function startTestDaemon(options?: { const cliSrcPath = join(cliDir, "src", "index.ts"); // Start daemon process using tsx to run TypeScript directly - const daemonProcess = spawn( - process.execPath, - [TSX_ENTRY, cliSrcPath, "daemon", "start", "--foreground"], - { - env: { - ...process.env, - ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOME: paseoHome, - PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, - // Force no TTY to prevent QR code output - CI: "true", - ...options?.env, - }, - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", + const daemonProcess = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, "daemon", "run"], { + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...TEST_DAEMON_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`, + // Force no TTY to prevent QR code output + CI: "true", + ...options?.env, + HOME: paseoHome, + USERPROFILE: paseoHome, }, - ); + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }); const stdout = createOutputCapture(); const stderr = createOutputCapture(); @@ -319,7 +324,7 @@ export async function startTestDaemon(options?: { // Wait for daemon to be ready try { - await waitForDaemonReady(port, timeout, options?.env); + await waitForDaemonReady(port, paseoHome, timeout, options?.env); ctx.isReady = true; } catch (err) { // Daemon failed to start - clean up and rethrow @@ -347,6 +352,8 @@ export async function runPaseoCli( timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; + /** Close the reading end of the CLI's stdout, as a launcher that discards output does. */ + closedStdout?: boolean; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -358,11 +365,14 @@ export async function runPaseoCli( return new Promise((resolve, reject) => { const proc = spawn(process.execPath, [TSX_ENTRY, cliSrcPath, ...args], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), ...TEST_DAEMON_ENV_DEFAULTS, - PASEO_HOST: `${TEST_DAEMON_HOST}:${ctx.port}`, PASEO_HOME: ctx.paseoHome, ...options?.env, + HOME: ctx.paseoHome, + USERPROFILE: ctx.paseoHome, }, cwd, stdio: ["ignore", "pipe", "pipe"], @@ -372,6 +382,10 @@ export async function runPaseoCli( const stdout = createOutputCapture(); const stderr = createOutputCapture(); + if (options?.closedStdout) { + proc.stdout?.destroy(); + } + proc.stdout?.on("data", (data) => { appendOutputCapture(stdout, data); }); diff --git a/packages/cli/tests/run-all.ts b/packages/cli/tests/run-all.ts index d3d4cc8b2e..7303f5408e 100644 --- a/packages/cli/tests/run-all.ts +++ b/packages/cli/tests/run-all.ts @@ -196,12 +196,17 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> { const testName = testFile.replace(/\.test\.ts$/, ""); const startedAt = Date.now(); const npmCache = await mkdtemp(join(tmpdir(), "paseo-cli-test-npm-cache-")); + const osHome = await mkdtemp(join(tmpdir(), "paseo-cli-test-os-home-")); try { return await new Promise<TestOutcome>((resolve) => { const proc = spawn("npx", ["tsx", testPath], { env: { - ...process.env, + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + HOME: osHome, + USERPROFILE: osHome, PATH: [rootNodeModulesBin, process.env.PATH].filter(Boolean).join(delimiter), npm_config_cache: npmCache, PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD, @@ -249,6 +254,7 @@ async function runSingleTest(testFile: string): Promise<TestOutcome> { }); } finally { await rm(npmCache, { recursive: true, force: true }); + await rm(osHome, { recursive: true, force: true }); } } diff --git a/packages/cli/tests/setup.ts b/packages/cli/tests/setup.ts index 94f872300f..5b860cf6c2 100644 --- a/packages/cli/tests/setup.ts +++ b/packages/cli/tests/setup.ts @@ -20,6 +20,16 @@ const TEST_ENV_DEFAULTS = { PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0", }; +function testEnvironment(paseoHome: string): NodeJS.ProcessEnv { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), + ...TEST_ENV_DEFAULTS, + PASEO_HOME: paseoHome, + HOME: paseoHome, + USERPROFILE: paseoHome, + }; +} + function killPidTree(pid: number, signal: NodeJS.Signals): void { if (!Number.isInteger(pid) || pid <= 0) { return; @@ -83,19 +93,25 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st * Wait for daemon to be ready by testing WebSocket connection * Uses `paseo agent ls` which connects via WebSocket */ -async function probeDaemon(port: number): Promise<boolean> { +async function probeDaemon(port: number, paseoHome: string): Promise<boolean> { try { - const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow(); + const result = await $({ + env: testEnvironment(paseoHome), + })`paseo agent ls --host localhost:${port}`.nothrow(); return result.exitCode === 0; } catch { return false; } } -export async function waitForDaemon(port: number, timeout = 30000): Promise<void> { +export async function waitForDaemon( + port: number, + paseoHome: string, + timeout = 30000, +): Promise<void> { const deadline = Date.now() + timeout; async function poll(): Promise<void> { - if (await probeDaemon(port)) return; + if (await probeDaemon(port, paseoHome)) return; if (Date.now() >= deadline) { throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`); } @@ -110,8 +126,14 @@ export async function waitForDaemon(port: number, timeout = 30000): Promise<void */ export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> { $.verbose = false; - const daemon = - $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow(); + const daemon = $({ + env: { + ...testEnvironment(paseoHome), + PASEO_LISTEN: `127.0.0.1:${port}`, + PASEO_RELAY_ENABLED: "false", + CI: "true", + }, + })`paseo daemon run`.nothrow(); return daemon; } @@ -125,7 +147,7 @@ export async function createTestContext(): Promise<TestContext> { // Helper to run CLI commands against test daemon const paseo = (args: string[]): ProcessPromise => { $.verbose = false; - return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow(); + return $({ env: testEnvironment(paseoHome) })`paseo --home ${paseoHome} ${args}`.nothrow(); }; // Cleanup function @@ -162,7 +184,7 @@ export async function createTestContext(): Promise<TestContext> { export async function createTestContextWithDaemon(): Promise<TestContext> { const ctx = await createTestContext(); ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome); - await waitForDaemon(ctx.port); + await waitForDaemon(ctx.port, ctx.paseoHome); return ctx; } diff --git a/packages/client/README.md b/packages/client/README.md index 444f1ddc7d..29bed77a81 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -26,7 +26,7 @@ await client.close(); The public API is the package root. Imports under `@getpaseo/client/internal/*` are unsupported implementation details used by Paseo's own packages. -Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). +Read the [SDK documentation](https://paseo.sh/docs/sdk) for agents, workspaces, terminals, provider discovery, events, recipes, and the API reference. Runnable TypeScript patterns also live in [`examples/`](./examples/README.md). ## Runtime @@ -34,6 +34,11 @@ The client needs a WebSocket implementation. Modern browsers and Node.js 22 prov Use a WebSocket URL ending in `/ws`, such as `ws://127.0.0.1:6767/ws`. Pass `password` when the daemon requires authentication. +The client advertises its supported protocol capabilities by default. Optional `capabilities` +overrides extend or override that declaration; browser hosting must be supplied by the caller. +Connecting alone does not subscribe to agent timelines or catalog events. See the +[event guide](https://paseo.sh/docs/sdk/events) for subscription lifetimes and timeline replacements. + ## Stability The high-level API exported from `@getpaseo/client` is the supported SDK surface. The SDK and daemon remain protocol-compatible across versions, but newly added capabilities can require a newer daemon. diff --git a/packages/client/examples/agents-and-providers.ts b/packages/client/examples/agents-and-providers.ts index c27ca1871e..e1ebfe021c 100644 --- a/packages/client/examples/agents-and-providers.ts +++ b/packages/client/examples/agents-and-providers.ts @@ -58,10 +58,23 @@ export async function runFollowUp(url: string, agentId: string): Promise<string try { await client.connect(); - const result = await client.agents.ref(agentId).run("Summarize your progress and next step."); + const result = await client.agents.ref(agentId).run("Summarize your progress and next step.", { + activeTurnBehavior: "interrupt", + }); if (result.status !== "idle") throw new Error(result.error ?? result.status); return result.lastMessage; } finally { await client.close(); } } + +export async function steerAgent(url: string, agentId: string, prompt: string): Promise<void> { + const client = createClient(url); + + try { + await client.connect(); + await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" }); + } finally { + await client.close(); + } +} diff --git a/packages/client/examples/events-and-timeline.ts b/packages/client/examples/events-and-timeline.ts index 84855426de..f0269f87b9 100644 --- a/packages/client/examples/events-and-timeline.ts +++ b/packages/client/examples/events-and-timeline.ts @@ -10,13 +10,12 @@ export async function subscribeToEvents( url: string, agentId: string, workspaceId: string, -): Promise<() => void> { +): Promise<() => Promise<void>> { const client = createClient(url); await client.connect(); - await client.workspaces.list({ - subscribe: { subscriptionId: `workspace-${workspaceId}` }, - }); + const agents = await client.agents.list({ subscribe: {} }); + const workspaces = await client.workspaces.list({ subscribe: {} }); const unsubscribeAgentUpdates = client.agents.subscribe((update) => { if (update.kind === "upsert" && update.agent.id === agentId) { @@ -31,14 +30,24 @@ export async function subscribeToEvents( }); const unsubscribeTimeline = client.agents.ref(agentId).timeline.subscribe((event) => { - void event.event; + if (event.event.type === "subscription_restored") { + console.log("Live subscription restored; request any missed history explicitly."); + } else if (event.event.type === "error") { + console.error(event.event.error); + } }); - return () => { + await unsubscribeTimeline.ready; + + return async () => { unsubscribeTimeline(); unsubscribeWorkspaceUpdates(); unsubscribeAgentUpdates(); - void client.close(); + try { + await Promise.all([agents.subscription.release(), workspaces.subscription.release()]); + } finally { + await client.close(); + } }; } diff --git a/packages/client/package.json b/packages/client/package.json index d23d727fcb..937190e0d0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo client SDK package", "files": [ "dist", @@ -31,7 +31,7 @@ }, "scripts": { "clean": "node ../../scripts/clean-package-dist.mjs", - "build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false", + "build": "npm run build --workspace=@getpaseo/protocol && npm run build --workspace=@getpaseo/relay && tsc -p tsconfig.json --incremental false", "build:clean": "npm run clean && npm run build", "prepack": "npm run build:clean", "typecheck": "tsgo --noEmit", @@ -39,8 +39,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/protocol": "0.11.2", + "@getpaseo/relay": "0.11.2", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/client/src/compat/normalize-provider-models.ts b/packages/client/src/compat/normalize-provider-models.ts index 65d4addcbb..b2662c0d21 100644 --- a/packages/client/src/compat/normalize-provider-models.ts +++ b/packages/client/src/compat/normalize-provider-models.ts @@ -59,19 +59,28 @@ export function normalizeListProviderModelsPayload( export function normalizeProvidersSnapshotPayload< T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload, ->(payload: T): T { - const entries = payload.compactSnapshot +>(payload: T, expand = true): T { + if (payload.compactSnapshot && !expand) return payload; + const decoded = payload.compactSnapshot ? expandProviderSnapshot(payload.compactSnapshot) : normalizeProviderSnapshotEntries(payload.entries); + const freshness = payload.fetchedAt; + const entries = + freshness && expand + ? decoded.map((entry) => + freshness[entry.provider] ? { ...entry, fetchedAt: freshness[entry.provider] } : entry, + ) + : decoded; return entries === payload.entries ? payload : { ...payload, entries }; } export function normalizeProviderSnapshotUpdateMessage( msg: SessionOutboundMessage, + expand = true, ): SessionOutboundMessage { if (msg.type !== "providers_snapshot_update") { return msg; } - return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) }; + return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload, expand) }; } diff --git a/packages/client/src/connection.test.ts b/packages/client/src/connection.test.ts new file mode 100644 index 0000000000..a3d12e48f6 --- /dev/null +++ b/packages/client/src/connection.test.ts @@ -0,0 +1,1029 @@ +import { expect, test } from "vitest"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { DaemonClient, type DaemonTransport } from "./daemon-client"; + +function connection( + options: { + acknowledgeSubscriptions?: boolean; + ownedSubscriptions?: boolean; + ownedCapability?: boolean; + workspaceMultiplicity?: boolean; + broadcasts?: boolean; + browserHost?: { hostKind: string; supportedCommands: string[] }; + acknowledgeTimelineReads?: boolean; + } = {}, +) { + const sent: Array<{ + type: string; + capabilities?: Record<string, unknown>; + message?: { + type: string; + requestId?: string; + agentIds?: string[]; + events?: string[]; + subscriptionId?: string; + }; + }> = []; + let receive = (_data: unknown) => {}; + let open = () => {}; + let closed = (_event?: unknown) => {}; + const transport: DaemonTransport = { + send(data) { + const frame = JSON.parse(String(data)); + sent.push(frame); + if (frame.type === "hello") { + receive( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "test", + hostname: null, + version: null, + features: { + ...(options.ownedSubscriptions === false ? {} : { ownedSubscriptions: true }), + workspaceMultiplicity: options.workspaceMultiplicity ?? true, + selectiveAgentTimeline: !options.broadcasts, + explicitEventSubscriptions: !options.broadcasts, + }, + }, + }, + }), + ); + } else if ( + options.acknowledgeSubscriptions !== false && + frame.type === "session" && + frame.message.type.endsWith("set_subscription.request") + ) { + receive( + JSON.stringify({ + type: "session", + message: { + type: frame.message.type.replace(".request", ".response"), + payload: { + requestId: frame.message.requestId, + agentIds: frame.message.agentIds, + subscriptionId: `server-${sent.length}`, + }, + }, + }), + ); + } + if ( + frame.message?.type === "fetch_agent_timeline_request" && + options.acknowledgeTimelineReads !== false + ) { + receive( + JSON.stringify({ + type: "session", + message: timelinePage(frame.message.requestId, "epoch", 0), + }), + ); + } + if (frame.message?.type === "subscription.release.request") { + receive( + JSON.stringify({ + type: "session", + message: { + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }, + }), + ); + } + }, + close() {}, + onMessage(handler) { + receive = (data) => handler(data, typeof data !== "string"); + return () => {}; + }, + onOpen(handler) { + open = handler; + return () => {}; + }, + onClose(handler) { + closed = handler; + return () => {}; + }, + onError() { + return () => {}; + }, + }; + const client = new DaemonClient({ + url: "ws://test", + clientId: "test", + capabilities: { + [CLIENT_CAPS.browserHost]: options.browserHost, + ...(options.ownedCapability === undefined + ? {} + : { [CLIENT_CAPS.ownedSubscriptions]: options.ownedCapability }), + }, + transportFactory: () => transport, + reconnect: { enabled: false }, + }); + return { + client, + sent, + open: () => open(), + disconnect: () => closed(), + receive: (message: unknown) => receive(JSON.stringify({ type: "session", message })), + }; +} + +test("a plain client advertises every protocol capability and no browser host", async () => { + const h = connection(); + try { + const ready = h.client.connect(); + h.open(); + await ready; + const { browserHost: _browser, ...protocolCapabilities } = CLIENT_CAPS; + // Every new capability needs a deliberate default or a host-owned exception. + expect(h.sent).toHaveLength(1); + expect(h.sent[0].capabilities).toEqual( + Object.fromEntries(Object.values(protocolCapabilities).map((key) => [key, true])), + ); + } finally { + await h.client.close(); + } +}); + +test("SDK timelines have independent lifetimes and fresh IDs on reconnect", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection(); + const api = createPaseoApi(h.client); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const a = api.agents.ref("a").timeline.subscribe(() => {}); + const same = api.agents.ref("a").timeline.subscribe(() => {}); + const b = api.agents.ref("b").timeline.subscribe(() => {}); + await Promise.all([a.ready, same.ready, b.ready]); + expect(new Set([a.subscriptionId, same.subscriptionId, b.subscriptionId]).size).toBe(3); + const memberships = () => + h.sent + .filter((frame) => frame.message?.type === "agent.timeline.set_subscription.request") + .map((frame) => frame.message?.agentIds); + expect(memberships()).toEqual([["a"], ["a"], ["b"]]); + await a.release(); + await same.release(); + const previousId = b.subscriptionId; + h.disconnect(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + await expect.poll(() => b.subscriptionId).not.toBe(previousId); + expect(memberships()).toEqual([["a"], ["a"], ["b"], ["b"]]); + await b.release(); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("SDK subscribers receive timeline replacement instead of silently losing history", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection(); + const received: unknown[] = []; + try { + const ready = h.client.connect(); + h.open(); + await ready; + const off = createPaseoApi(h.client) + .agents.ref("agent") + .timeline.subscribe((event) => received.push(event)); + await off.ready; + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", epoch: "next", subscriptionId: off.subscriptionId }, + }); + expect(received).toEqual([{ agentId: "agent", event: { type: "replacement", epoch: "next" } }]); + off(); + } finally { + await h.client.close(); + } +}); + +test("local listeners create no demand before or after reconnect", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const off = h.client.on("project.update", () => {}); + h.disconnect(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + off(); + } finally { + await h.client.close(); + } +}); + +test("provider reference hydration preserves independent owners and drops released work", async () => { + const h = connection(); + const updates: string[] = []; + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const a = h.client.observeEvents(["providers_snapshot_update"]); + const b = h.client.observeEvents(["providers_snapshot_update"]); + await Promise.all([a.ready, b.ready]); + for (const owner of [a, b]) + owner.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "providers_snapshot_update") + updates.push(message.payload.subscriptionId!); + }, + }); + const payload = { + entries: [], + snapshotHash: "catalog", + generatedAt: "2026-09-08T00:00:00.000Z", + }; + for (const owner of [a, b]) + h.receive({ + type: "providers_snapshot_update", + payload: { ...payload, subscriptionId: owner.subscriptionId }, + }); + const requests = h.sent.filter( + (frame) => frame.message?.type === "get_providers_snapshot_request", + ); + expect(requests).toHaveLength(2); + await a.release(); + for (const request of requests) + h.receive({ + type: "get_providers_snapshot_response", + payload: { + ...payload, + requestId: request.message!.requestId, + compactSnapshot: { entries: [], thinkingSets: [] }, + }, + }); + await expect.poll(() => updates).toEqual([b.subscriptionId]); + await b.release(); + } finally { + await h.client.close(); + } +}); + +test("timeline readiness and updates belong to the acknowledged handle", async () => { + const h = connection({ acknowledgeSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const a = h.client.subscribeAgentTimeline("agent", () => {}); + const b = h.client.subscribeAgentTimeline("agent", () => {}); + const requests = h.sent.filter( + (frame) => frame.message?.type === "agent.timeline.set_subscription.request", + ); + let bReady = false; + void b.ready.then(() => { + bReady = true; + return undefined; + }); + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: requests[0].message!.requestId, + agentIds: ["agent"], + subscriptionId: "a", + }, + }); + await a.ready; + expect(bReady).toBe(false); + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + requestId: requests[1].message!.requestId, + agentIds: ["agent"], + subscriptionId: "b", + }, + }); + await b.ready; + await a.release(); + await b.release(); + } finally { + await h.client.close(); + } +}); + +test("timeline readiness remains pending before connect and rejects when released", async () => { + const h = connection(); + try { + const release = h.client.subscribeAgentTimeline("agent", () => {}); + const canceled = h.client.subscribeAgentTimeline("canceled", () => {}); + canceled(); + await expect(canceled.ready).rejects.toThrow("released"); + let established = false; + void release.ready.then(() => { + return (established = true); + }); + await Promise.resolve(); + expect(established).toBe(false); + expect(h.sent).toEqual([]); + const connect = h.client.connect(); + h.open(); + await connect; + await release.ready; + expect(established).toBe(true); + release(); + } finally { + await h.client.close(); + } +}); + +test("passive provider listeners never hydrate unowned references", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const off = h.client.on("providers_snapshot_update", () => {}); + h.receive({ + type: "providers_snapshot_update", + payload: { entries: [], snapshotHash: "updated", generatedAt: "2026-09-08T00:00:00.000Z" }, + }); + h.disconnect(); + off(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + expect(h.sent.filter((frame) => frame.type === "session")).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("failed terminal bootstrap reports the domain error without reconnecting or retaining a route", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const terminal = h.client.observeTerminal("missing-terminal", () => {}); + const request = h.sent.at(-1)!.message!; + h.receive({ + type: "subscribe_terminal_response", + payload: { + requestId: request.requestId, + terminalId: "missing-terminal", + error: "Terminal not found", + }, + }); + await expect(terminal.ready).rejects.toThrow("Terminal not found"); + await terminal.release(); + expect(terminal.subscriptionId).toBeNull(); + expect(h.client.isConnected).toBe(true); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("older hosts share event demand on one connection and release only their own listeners", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const first = h.client.observeEvents(["project.update"]); + const second = h.client.observeEvents(["project.update"]); + await Promise.all([first.ready, second.ready]); + const received: string[] = []; + first.subscribe({ snapshot: () => {}, update: () => received.push("first") }); + second.subscribe({ snapshot: () => {}, update: () => received.push("second") }); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second"]); + await first.release(); + h.receive({ type: "project.update", payload: { kind: "remove", projectId: "project" } }); + expect(received).toEqual(["first", "second", "second"]); + expect(h.sent.at(-1)?.message?.events).toEqual(["project.update"]); + await second.release(); + expect(h.sent.at(-1)?.message?.events).toEqual([]); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + expect( + h.sent.filter((frame) => frame.message?.type === "subscription.release.request"), + ).toEqual([]); + } finally { + await h.client.close(); + } +}); + +test("invalid observation input rejects without reconnecting the transport", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const invalid = h.client.observeAgents({ page: { limit: -1 } }); + await expect(invalid.ready).rejects.toThrow(); + await invalid.release(); + expect(h.client.isConnected).toBe(true); + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( + 0, + ); + } finally { + await h.client.close(); + } +}); + +test("a timed-out subscription rejects and cannot replay on reconnect", async () => { + const h = connection(); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeAgents({ timeout: 10 }); + await expect(observation.ready).rejects.toThrow(/timed out|timeout/i); + await observation.release(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + expect(h.sent.filter((frame) => frame.message?.type === "fetch_agents_request")).toHaveLength( + 1, + ); + } finally { + await h.client.close(); + } +}); + +function timelinePage(requestId: string | undefined, epoch: string, seq: number) { + return { + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent", + agent: null, + direction: "before", + projection: "projected", + epoch, + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: seq, nextSeq: seq + 1 }, + startCursor: { epoch, seq }, + endCursor: { epoch, seq }, + hasOlder: seq > 1, + hasNewer: false, + error: null, + entries: seq + ? [ + { + provider: "codex", + item: { type: "user_message", text: `persisted-${epoch}-${seq}` }, + timestamp: new Date(0).toISOString(), + seqStart: seq, + seqEnd: seq, + sourceSeqRanges: [{ startSeq: seq, endSeq: seq }], + collapsed: [], + }, + ] + : [], + }, + }; +} + +test.each([ + { name: "modern", ownedSubscriptions: true, broadcasts: false }, + { name: "legacy selective", ownedSubscriptions: false, broadcasts: false }, + { name: "legacy broadcast", ownedSubscriptions: false, broadcasts: true }, +])("$name timelines restore live delivery without requesting history", async (mode) => { + const { createPaseoApi } = await import("./index"); + const h = connection(mode); + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[][] = [[], [], []]; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owners = received.map((events) => + api.agents.ref("agent").timeline.subscribe((event) => events.push(event)), + ); + await Promise.all(owners.map((owner) => owner.ready)); + expect(received).toEqual([[], [], []]); + for (let cycle = 0; cycle < 2; cycle++) { + const oldIds = owners.map((owner) => owner.subscriptionId); + h.disconnect(); + await owners[2].release(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => owners[0].subscriptionId).not.toBe(oldIds[0]); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toEqual([]); + for (const index of [0, 1]) { + expect(owners[index].subscriptionId).not.toBeNull(); + expect(received[index].at(-1)).toEqual({ + agentId: "agent", + subscriptionId: owners[index].subscriptionId, + event: { type: "subscription_restored" }, + }); + } + const live = { + agentId: "agent", + timestamp: new Date(0).toISOString(), + event: { type: "turn_completed", provider: "codex" }, + }; + if (mode.ownedSubscriptions) { + for (const owner of owners.slice(0, 2)) + h.receive({ + type: "agent_stream", + payload: { ...live, subscriptionId: owner.subscriptionId }, + }); + } else h.receive({ type: "agent_stream", payload: live }); + for (const events of received.slice(0, 2)) + expect(events.map((update) => update.event.type)).toEqual( + Array.from({ length: cycle + 1 }, () => [ + "subscription_restored", + "turn_completed", + ]).flat(), + ); + expect(received[2]).toEqual([]); + } + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("a consumer chooses its recovery cursor and a failed read leaves live delivery active", async () => { + const { createPaseoApi } = await import("./index"); + const h = connection({ acknowledgeTimelineReads: false }); + const api = createPaseoApi(h.client); + const received: import("./index").PaseoAgentTimelineEvent[] = []; + let read: Promise<unknown> | undefined; + try { + const connected = h.client.connect(); + h.open(); + await connected; + const timeline = api.agents.ref("agent").timeline; + const owner = timeline.subscribe((message) => { + received.push(message); + if (message.event.type === "subscription_restored") { + read = timeline.refetch({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + void read.catch(() => {}); + } + }); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + await expect.poll(() => read !== undefined).toBe(true); + const requests = h.sent.filter( + (frame) => frame.message?.type === "fetch_agent_timeline_request", + ); + expect(requests).toHaveLength(1); + expect(requests[0].message).toMatchObject({ + direction: "after", + cursor: { epoch: "cached", seq: 42 }, + limit: 7, + }); + // A slow consumer-owned history read must not buffer or filter live events. + for (let seq = 43; seq < 173; seq++) + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + subscriptionId: owner.subscriptionId, + seq, + epoch: "cached", + timestamp: new Date(0).toISOString(), + event: { type: "turn_completed", provider: "codex" }, + }, + }); + expect(received).toHaveLength(131); + h.receive({ + type: "agent.timeline.replacement", + payload: { agentId: "agent", subscriptionId: owner.subscriptionId, epoch: "new" }, + }); + expect(received.at(-1)?.event).toEqual({ type: "replacement", epoch: "new" }); + const page = timelinePage(requests[0].message?.requestId, "cached", 42); + h.receive({ ...page, payload: { ...page.payload, error: "History unavailable" } }); + await expect(read).rejects.toThrow("History unavailable"); + expect(owner.subscriptionId).not.toBeNull(); + expect(received.some((message) => message.event.type === "error")).toBe(false); + expect( + h.sent.filter((frame) => frame.message?.type === "fetch_agent_timeline_request"), + ).toHaveLength(1); + await owner.release(); + } finally { + await api.dispose(); + await h.client.close(); + } +}); + +test("subscription-restored notification waits for the new membership acknowledgement", async () => { + const h = connection({ acknowledgeSubscriptions: false }); + const received: unknown[] = []; + const acknowledge = (id: string) => + h.receive({ + type: "agent.timeline.set_subscription.response", + payload: { + agentIds: ["agent"], + requestId: h.sent.at(-1)?.message?.requestId, + subscriptionId: id, + }, + }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const owner = h.client.subscribeAgentTimeline("agent", (message) => received.push(message)); + acknowledge("first"); + await owner.ready; + h.disconnect(); + const reconnected = h.client.connect(); + h.open(); + await reconnected; + expect(received).toEqual([]); + acknowledge("second"); + expect(received).toEqual([ + { + type: "agent.timeline.subscription_restored", + payload: { agentId: "agent", subscriptionId: "second" }, + }, + ]); + await owner.release(); + } finally { + await h.client.close(); + } +}); + +function legacyAgent(input: { + id: string; + cwd: string; + status?: "idle" | "running"; + updatedAt?: string; + projectRoot?: string; +}) { + const updatedAt = input.updatedAt ?? "2026-06-18T10:00:00.000Z"; + return { + agent: { + id: input.id, + provider: "mock", + cwd: input.cwd, + model: null, + createdAt: updatedAt, + updatedAt, + lastUserMessageAt: null, + status: input.status ?? "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }, + project: { + projectKey: "/repo", + projectName: "repo", + workspaceName: "app", + checkout: { + cwd: input.cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "git@example.com:repo/app.git", + worktreeRoot: input.cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: input.projectRoot ?? "/repo", + }, + }, + }; +} + +test.each([ + { cwd: "/repo/app", id: "/repo/app", root: "/repo" }, + { cwd: "C:\\repo\\app", id: "C:/repo/app", root: "C:\\repo" }, + { cwd: "C:\\", id: "C:", root: "C:\\" }, + { cwd: "C:/", id: "C:", root: "C:/" }, + { cwd: "\\\\server\\share\\", id: "//server/share", root: "\\\\server\\share\\" }, + { cwd: "\\\\?\\C:\\repo\\app", id: "//?/C:/repo/app", root: "\\\\?\\C:\\repo" }, + { cwd: "/repo/trailing space ", id: "/repo/trailing space", root: "/repo/ " }, + { cwd: "/repo/back\\slash", id: "/repo/back/slash", root: "/repo" }, +])( + "old workspaces preserve daemon filesystem paths ($cwd) and stable IDs", + async ({ cwd, id, root }) => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + const request = h.sent.at(-1)!.message!; + expect(request.type).toBe("fetch_agents_request"); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: request.requestId, + entries: [legacyAgent({ id: "agent", cwd, projectRoot: root, status: "idle" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }, + }); + expect((await workspaces.ready).entries).toEqual([ + expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + projectId: "/repo", + status: "done", + }), + ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + const changed = legacyAgent({ id: "agent", cwd, projectRoot: root, status: "running" }); + h.receive({ type: "agent_update", payload: { kind: "upsert", ...changed } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ + id, + workspaceDirectory: cwd, + projectRootPath: root, + status: "running", + }), + }), + }), + ]); + const fetching = h.client.fetchAgent("agent"); + const detail = h.sent.at(-1)!.message!; + h.receive({ + type: "fetch_agent_response", + payload: { requestId: detail.requestId, ...changed, error: null }, + }); + expect((await fetching).agent?.workspaceId).toBe(id); + expect(h.sent.filter((frame) => frame.type === "hello")).toHaveLength(1); + await workspaces.release(); + } finally { + await h.client.close(); + } + }, +); + +test("legacy workspace pages contain only that page's groups and retain earlier live state", async () => { + const h = connection({ ownedSubscriptions: false, workspaceMultiplicity: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const workspaces = h.client.observeWorkspaces(); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "first", cwd: "/first", status: "running" })], + pageInfo: { hasMore: true, nextCursor: "next-page", prevCursor: null }, + }, + }); + const first = await workspaces.ready; + const next = h.client.fetchWorkspaces({ + page: { cursor: first.pageInfo.nextCursor!, limit: 1 }, + }); + h.receive({ + type: "fetch_agents_response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + entries: [legacyAgent({ id: "second", cwd: "/second" })], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: "previous-page" }, + }, + }); + const second = await next; + expect([...first.entries, ...second.entries].map((workspace) => workspace.id)).toEqual([ + "/first", + "/second", + ]); + const updates: unknown[] = []; + workspaces.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + // Earlier pages must remain in the aggregate: this idle sibling cannot + // replace the running status of the first workspace. + h.receive({ + type: "agent_update", + payload: { kind: "upsert", ...legacyAgent({ id: "sibling", cwd: "/first" }) }, + }); + expect(updates).toEqual([]); + h.receive({ type: "agent_update", payload: { kind: "remove", agentId: "first" } }); + expect(updates).toEqual([ + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ id: "/first", status: "done" }), + }), + }), + ]); + await workspaces.release(); + } finally { + await h.client.close(); + } +}); + +test("old attention stream events reach the current notification interface", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connecting = h.client.connect(); + h.open(); + await connecting; + const observation = h.client.observeEvents(["agent_attention_required"]); + await observation.ready; + const received: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => received.push(message) }); + h.receive({ + type: "agent_stream", + payload: { + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { + type: "attention_required", + provider: "mock", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + }, + }, + }); + expect(received).toEqual([ + { + type: "agent_attention_required", + payload: { + agentId: "agent", + reason: "finished", + timestamp: "2026-09-11T00:00:00Z", + shouldNotify: true, + subscriptionId: observation.subscriptionId, + }, + }, + ]); + await observation.release(); + } finally { + await h.client.close(); + } +}); + +test("broadcast-only hosts expose local timeline readiness without sending an unsupported RPC", async () => { + const h = connection({ ownedSubscriptions: false, broadcasts: true }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeTimeline(["agent"]); + expect(await observation.ready).toEqual({ + agentIds: ["agent"], + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + expect(h.sent).toHaveLength(1); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old browser hosting requires the registration sent in hello", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost({ + hostKind: "test", + supportedCommands: ["list_tabs"], + }); + await expect(observation.ready).rejects.toThrow("browser_host"); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("old timeline observers retain shared membership and restore surviving interests", async () => { + const h = connection({ ownedSubscriptions: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const app = h.client.observeTimeline(["app"]); + const plugin = h.client.observeTimeline(["plugin"]); + await Promise.all([app.ready, plugin.ready]); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app", "plugin"]); + await plugin.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + h.disconnect(); + const reconnecting = h.client.connect(); + h.open(); + await reconnecting; + await expect.poll(() => h.sent.at(-1)?.message?.agentIds).toEqual(["app"]); + await app.release(); + expect(h.sent.at(-1)?.message?.agentIds).toEqual([]); + expect(h.sent.some((frame) => frame.message?.type === "subscription.release.request")).toBe( + false, + ); + } finally { + await h.client.close(); + } +}); + +test("old browser hosts attach to their hello registration without another connection", async () => { + const registration = { + hostKind: "desktop app", + supportedCommands: ["list_tabs"] as ["list_tabs"], + }; + const h = connection({ ownedSubscriptions: false, browserHost: registration }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.registerBrowserHost(registration); + expect(await observation.ready).toEqual({ + requestId: expect.any(String), + subscriptionId: observation.subscriptionId, + }); + await observation.release(); + expect(h.sent).toHaveLength(1); + } finally { + await h.client.close(); + } +}); + +test("delivery follows the negotiated client capability as well as the server feature", async () => { + const h = connection({ ownedCapability: false }); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const observation = h.client.observeEvents(["project.update"]); + expect((await observation.ready).subscriptionId).toMatch(/^legacy:/); + await observation.release(); + expect(h.sent.at(-1)?.message).toMatchObject({ + type: "session.events.set_subscription.request", + events: [], + }); + } finally { + await h.client.close(); + } +}); + +test("subscribeFile returns its initial version without calling the change callback", async () => { + const h = connection(); + try { + const connected = h.client.connect(); + h.open(); + await connected; + const changes: unknown[] = []; + const initial = { status: "missing" as const, cwd: "/repo", path: "file.txt" }; + const subscribing = h.client.subscribeFile({ cwd: "/repo", path: "file.txt" }, (value) => + changes.push(value), + ); + h.receive({ + type: "fs.file.subscribe.response", + payload: { + requestId: h.sent.at(-1)!.message!.requestId, + subscriptionId: "file-owner", + initial, + }, + }); + const file = await subscribing; + expect(file.initial).toEqual(initial); + expect(changes).toEqual([]); + const version = { + status: "ready", + cwd: "/repo", + path: "file.txt", + size: 1, + modifiedAt: "2026-09-11T00:00:00Z", + }; + h.receive({ type: "fs.file.update", payload: { subscriptionId: "file-owner", version } }); + expect(changes).toEqual([version]); + await file.unsubscribe(); + } finally { + await h.client.close(); + } +}); diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts new file mode 100644 index 0000000000..d939b3c72b --- /dev/null +++ b/packages/client/src/connection/index.ts @@ -0,0 +1,152 @@ +export { OwnedSubscriptions, type OwnedSubscription, type SubscriptionObserver } from "./owned.js"; +import { + OwnedSubscriptions, + type OwnedSubscription, + type OwnedSubscriptionsHost, +} from "./owned.js"; +import { LegacySubscriptions, type ObservationRequest } from "./legacy.js"; +import { + type SessionInboundMessage, + type SessionOutboundMessage, + type ServerInfoStatusPayload, +} from "@getpaseo/protocol/messages"; +import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; + +export class ConnectionSubscriptions extends OwnedSubscriptions { + private legacy: LegacySubscriptions | null = null; + + constructor( + private readonly connection: OwnedSubscriptionsHost & { + send(message: ObservationRequest): Promise<void>; + }, + ) { + super({ + ...connection, + release: async (id) => { + if (!this.legacy) return connection.release(id); + const message = this.legacy.release(id); + if (message) await connection.send(message); + }, + }); + } + + override restore( + info?: ServerInfoStatusPayload, + capabilities?: Partial<Record<ClientCapability, unknown>>, + ): void { + if (info) { + const clientCapabilities = capabilities ?? DEFAULT_CLIENT_CAPABILITIES; + const owned = + info.features?.ownedSubscriptions === true && + clientCapabilities[CLIENT_CAPS.ownedSubscriptions] === true; + this.legacy = null; + if (!owned) { + this.legacy = new LegacySubscriptions( + info, + this.connection.send, + capabilities?.[CLIENT_CAPS.browserHost], + ); + } + } + super.restore(); + } + + override disconnected(): void { + super.disconnected(); + this.legacy = null; + } + + observeRequest<T>( + query: ObservationRequest, + request: (query: ObservationRequest, accept: (snapshot: T) => void) => Promise<T>, + signal?: AbortSignal, + failed?: () => void, + ): OwnedSubscription<T> { + return super.observe( + async (accept) => { + const legacy = this.legacy; + if (!legacy) return request(query, accept); + const interest = legacy.start(query); + const snapshot = (payload: T): T & { subscriptionId: string } => ({ + ...payload, + subscriptionId: interest.id, + }); + try { + const message = legacy.request(interest); + if (!message) { + // Broadcast-era hosts have no acknowledgement. Readiness means the + // local listener is attached; no server membership is being claimed. + const value = snapshot({ + requestId: interest.id, + ...(query.type === "agent.timeline.set_subscription.request" + ? { agentIds: query.agentIds } + : {}), + } as T); + accept(value); + return value; + } + return snapshot(await request(message, (payload) => accept(snapshot(payload)))); + } catch (error) { + const release = legacy.release(interest.id); + if (release && this.legacy === legacy) + await this.connection.send(release).catch(this.connection.failed); + throw error; + } + }, + signal, + failed, + ); + } + + override owns(message: SessionOutboundMessage): boolean { + return this.legacy ? this.legacy.owns(message) : super.owns(message); + } + + override receive(message: SessionOutboundMessage): void { + if (this.legacy) this.legacy.receive(message, (update) => super.receive(update)); + else super.receive(message); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.legacy?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage) { + return ( + this.legacy?.prepareRequest(message) ?? { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + } + ); + } +} + +// Protocol support belongs to the installed client. Only browser hosting needs +// a resource supplied by the caller. Keep this exhaustive as the protocol evolves. +export const DEFAULT_CLIENT_CAPABILITIES = { + [CLIENT_CAPS.helloRejection]: true, + [CLIENT_CAPS.ownedSubscriptions]: true, + [CLIENT_CAPS.allProviders]: true, + [CLIENT_CAPS.selectiveAgentTimeline]: true, + [CLIENT_CAPS.reasoningMergeEnum]: true, + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.terminalReflowableSnapshot]: true, + [CLIENT_CAPS.providerSubagents]: true, + [CLIENT_CAPS.projectedSubagentTimeline]: true, + [CLIENT_CAPS.projectUpdates]: true, + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + [CLIENT_CAPS.timelineReplacementInvalidation]: true, + [CLIENT_CAPS.timelineNotifications]: true, + [CLIENT_CAPS.pluginTimelineItems]: true, + [CLIENT_CAPS.workspaceSetupBlocked]: true, + [CLIENT_CAPS.explicitEventSubscriptions]: true, +} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>; + +/** Calling releases demand; ready waits for membership, or local attachment on broadcast hosts. */ +export type TimelineSubscription = (() => void) & { + readonly ready: Promise<void>; + readonly subscriptionId: string | null; + release(): Promise<void>; +}; diff --git a/packages/client/src/connection/legacy-workspaces.ts b/packages/client/src/connection/legacy-workspaces.ts new file mode 100644 index 0000000000..2cf90423b5 --- /dev/null +++ b/packages/client/src/connection/legacy-workspaces.ts @@ -0,0 +1,176 @@ +import { AgentSnapshotPayloadSchema } from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { + deriveAgentStateBucket, + getWorkspaceStateBucketPriority, +} from "@getpaseo/protocol/agent-state-bucket"; + +type AgentEntry = Extract< + SessionOutboundMessage, + { type: "fetch_agents_response" } +>["payload"]["entries"][number]; +type Workspace = Extract< + SessionOutboundMessage, + { type: "fetch_workspaces_response" } +>["payload"]["entries"][number]; +type WorkspaceUpdate = Extract<SessionOutboundMessage, { type: "workspace_update" }>; + +// Preserve the pre-registry app's identity format. This is an opaque ID, never +// a filesystem path: converting C:\ to C: changes a drive root into a relative path. +function legacyWorkspaceId(value: string): string { + return value.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/"; +} + +// COMPAT(legacyWorkspaceDaemon): restored in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.1.97. +// The pre-registry app grouped agents by checkout path. Keep that representation +// at the client edge, including live updates, so app workflows need no version branch. +export class LegacyWorkspaces { + private readonly agents = new Map<string, AgentEntry>(); + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + const stamp = (agent: AgentSnapshotPayload): AgentSnapshotPayload => ({ + ...agent, + workspaceId: agent.workspaceId ?? legacyWorkspaceId(agent.cwd), + }); + if (message.type === "fetch_agents_response") + return { + ...message, + payload: { + ...message.payload, + entries: message.payload.entries.map((entry) => ({ + ...entry, + agent: stamp(entry.agent), + })), + }, + }; + if (message.type === "agent_update" && message.payload.kind === "upsert") + return { ...message, payload: { ...message.payload, agent: stamp(message.payload.agent) } }; + if ( + (message.type === "fetch_agent_response" || + message.type === "fetch_agent_timeline_response" || + message.type === "cancel_agent_response") && + message.payload.agent + ) { + return { + ...message, + payload: { ...message.payload, agent: stamp(message.payload.agent) }, + } as SessionOutboundMessage; + } + if ( + message.type === "status" && + (message.payload.status === "agent_created" || message.payload.status === "agent_resumed") + ) { + return { + ...message, + payload: { + ...message.payload, + agent: stamp(AgentSnapshotPayloadSchema.parse(message.payload.agent)), + }, + }; + } + return message; + } + + read(entries: AgentEntry[], reset: boolean): Workspace[] { + if (reset) this.agents.clear(); + for (const entry of entries) this.agents.set(entry.agent.id, entry); + const pageIds = new Set(entries.map(workspaceId)); + return [...this.workspaces().values()].filter((workspace) => pageIds.has(workspace.id)); + } + + update(message: SessionOutboundMessage): WorkspaceUpdate[] { + if (message.type !== "agent_update") return []; + const before = this.workspaces(); + const update = message.payload; + if (update.kind === "remove") this.agents.delete(update.agentId); + else { + const project = update.project ?? this.agents.get(update.agent.id)?.project; + if (update.agent.archivedAt) this.agents.delete(update.agent.id); + else if (project) this.agents.set(update.agent.id, { agent: update.agent, project }); + } + const after = this.workspaces(); + const changes: WorkspaceUpdate[] = []; + for (const [id, workspace] of after) { + if (JSON.stringify(before.get(id)) !== JSON.stringify(workspace)) + changes.push({ type: "workspace_update", payload: { kind: "upsert", workspace } }); + } + for (const id of before.keys()) + if (!after.has(id)) + changes.push({ type: "workspace_update", payload: { kind: "remove", id } }); + return changes; + } + + private workspaces(): Map<string, Workspace> { + const workspaces = new Map<string, Workspace>(); + for (const entry of this.agents.values()) { + const { agent, project } = entry; + const { checkout } = project; + const id = workspaceId(entry); + const status = deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + }); + const existing = workspaces.get(id); + if ( + existing && + getWorkspaceStateBucketPriority(existing.status) <= getWorkspaceStateBucketPriority(status) + ) + continue; + workspaces.set(id, { + id, + projectId: project.projectKey, + projectDisplayName: project.projectName, + projectCustomName: null, + projectRootPath: checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd, + workspaceDirectory: checkout.cwd, + projectKind: checkout.isGit ? "git" : "non_git", + workspaceKind: workspaceKind(checkout), + name: workspaceName(entry, id), + title: null, + status, + statusEnteredAt: agent.attentionTimestamp ?? agent.updatedAt, + activityAt: agent.updatedAt, + archivingAt: null, + diffStat: null, + scripts: [], + gitRuntime: gitRuntime(checkout), + githubRuntime: null, + project, + }); + } + return workspaces; + } +} + +function workspaceId(entry: AgentEntry): string { + return entry.agent.workspaceId ?? legacyWorkspaceId(entry.project.checkout.cwd); +} + +function workspaceKind(checkout: AgentEntry["project"]["checkout"]): Workspace["workspaceKind"] { + if (!checkout.isGit) return "directory"; + if (checkout.isPaseoOwnedWorktree) return "worktree"; + return "checkout"; +} + +function workspaceName(entry: AgentEntry, id: string): string { + const name = entry.project.workspaceName?.trim(); + if (name) return name; + const branch = entry.project.checkout.currentBranch?.trim(); + if (branch && branch !== "HEAD") return branch; + return id.slice(id.lastIndexOf("/") + 1); +} + +function gitRuntime(checkout: AgentEntry["project"]["checkout"]): Workspace["gitRuntime"] { + if (!checkout.isGit) return null; + return { + currentBranch: checkout.currentBranch, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + }; +} diff --git a/packages/client/src/connection/legacy.ts b/packages/client/src/connection/legacy.ts new file mode 100644 index 0000000000..5dec96e428 --- /dev/null +++ b/packages/client/src/connection/legacy.ts @@ -0,0 +1,282 @@ +import { SessionInboundMessageSchema } from "@getpaseo/protocol/messages"; +import { LegacyWorkspaces } from "./legacy-workspaces.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; +import type { + ServerInfoStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +export type ObservationRequest = { type: SessionInboundMessage["type"] } & Record<string, unknown>; +interface Interest { + id: string; + query: ObservationRequest; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0; remove after 2027-03-11 once daemon floor >= v0.8.0. +// Legacy directories retain their connection-wide, last-query-wins semantics. +// IDs below identify local listeners, not independent server subscriptions. +export class LegacySubscriptions { + private readonly interests = new Map<string, Interest>(); + + private readonly workspaces: LegacyWorkspaces | null; + constructor( + private readonly info: ServerInfoStatusPayload, + private readonly send: (message: ObservationRequest) => Promise<void>, + private readonly browserHost: unknown, + ) { + this.workspaces = info.features?.workspaceMultiplicity === true ? null : new LegacyWorkspaces(); + } + + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + return this.workspaces?.normalize(message) ?? message; + } + + prepareRequest(message: SessionInboundMessage): { + message: SessionInboundMessage; + receive(message: SessionOutboundMessage): SessionOutboundMessage; + finish(): Promise<void>; + } { + const identity = { + message, + receive: (value: SessionOutboundMessage) => value, + finish: async () => {}, + }; + const workspaces = this.workspaces; + if (workspaces && message.type === "fetch_workspaces_request") { + return { + ...identity, + message: SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId: message.requestId, + scope: "active", + sort: [{ key: "updated_at", direction: "desc" }], + page: message.page, + subscribe: message.subscribe, + }), + receive: (value) => { + if (value.type !== "fetch_agents_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { + type: "fetch_workspaces_response", + payload: { + ...value.payload, + entries: workspaces.read(value.payload.entries, !message.page?.cursor), + emptyProjects: [], + }, + }; + }, + }; + } + if (message.type === "subscribe_terminals_request") { + return { + ...identity, + receive: (value) => { + if (value.type !== "terminals_changed" || value.payload.cwd !== message.cwd) return value; + return { ...value, payload: { ...value.payload, requestId: message.requestId } }; + }, + }; + } + if (message.type === "workspace.label.list.request" && !message.subscribe) { + return { + ...identity, + message: { ...message, subscribe: { subscriptionId: `legacy:${crypto.randomUUID()}` } }, + }; + } + if (message.type === "checkout.diff.get.request") { + const subscriptionId = `legacy:${crypto.randomUUID()}`; + return { + message: SessionInboundMessageSchema.parse({ + ...message, + type: "subscribe_checkout_diff_request", + subscriptionId, + }), + receive: (value) => { + if (value.type !== "subscribe_checkout_diff_response") return value; + if (value.payload.requestId !== message.requestId) return value; + return { ...value, type: "checkout.diff.get.response" }; + }, + finish: () => this.send({ type: "unsubscribe_checkout_diff_request", subscriptionId }), + }; + } + return identity; + } + + start(query: ObservationRequest): Interest { + const interest = { id: `legacy:${crypto.randomUUID()}`, query }; + this.interests.set(interest.id, interest); + return interest; + } + + request({ id, query }: Interest): ObservationRequest | null { + switch (query.type) { + case "fetch_agents_request": + case "fetch_workspaces_request": + case "workspace.label.list.request": + return { ...query, subscribe: { subscriptionId: id } }; + case "fs.file.subscribe.request": + case "subscribe_checkout_diff_request": + return { ...query, subscriptionId: id }; + case "agent.timeline.set_subscription.request": + if (!this.info.features?.selectiveAgentTimeline) return null; + return { ...query, agentIds: this.members(query.type, "agentIds") }; + case "session.events.set_subscription.request": + if (!this.info.features?.explicitEventSubscriptions) return null; + return { + ...query, + events: this.members(query.type, "events").filter(isLegacyEvent), + }; + case "browser.host.register.request": + if (!matchesBrowserRegistration(this.browserHost, query)) { + throw new Error( + "This daemon requires browser_host registration in the client's hello capabilities", + ); + } + return null; // Older hosts register in hello. + default: + return query; + } + } + + private members(type: ObservationRequest["type"], field: string): string[] { + const members = new Set<string>(); + for (const interest of this.interests.values()) { + if (interest.query.type !== type) continue; + for (const value of interest.query[field] as string[]) members.add(value); + } + return [...members].sort(); + } + + release(id: string): ObservationRequest | null { + const interest = this.interests.get(id); + if (!interest) return null; + this.interests.delete(id); + const { query } = interest; + switch (query.type) { + case "agent.timeline.set_subscription.request": + case "session.events.set_subscription.request": + return this.request(interest); + case "fs.file.subscribe.request": + return { type: "fs.file.unsubscribe.request", subscriptionId: id }; + case "subscribe_checkout_diff_request": + return { type: "unsubscribe_checkout_diff_request", subscriptionId: id }; + case "subscribe_terminal_request": + if ( + [...this.interests.values()].some( + (item) => item.query.type === query.type && item.query.terminalId === query.terminalId, + ) + ) + return null; + return { type: "unsubscribe_terminal_request", terminalId: query.terminalId }; + case "subscribe_terminals_request": + if ( + [...this.interests.values()].some( + (item) => + item.query.type === query.type && + item.query.cwd === query.cwd && + item.query.workspaceId === query.workspaceId, + ) + ) + return null; + return { + type: "unsubscribe_terminals_request", + cwd: query.cwd, + workspaceId: query.workspaceId, + }; + default: + // Directory slots have no release RPC. Detach the local listener. + return null; + } + } + + owns(message: SessionOutboundMessage): boolean { + return [...this.interests.values()].some((interest) => this.matches(interest, message)); + } + + receive( + message: SessionOutboundMessage, + deliver: (message: SessionOutboundMessage) => void, + ): void { + const updates: SessionOutboundMessage[] = [ + message, + ...(this.workspaces?.update(message) ?? []), + ]; + if (message.type === "agent_stream" && message.payload.event.type === "attention_required") { + const { agentId, event } = message.payload; + updates.push({ + type: "agent_attention_required", + payload: { + agentId, + reason: event.reason, + timestamp: event.timestamp, + shouldNotify: event.shouldNotify, + ...(event.notification ? { notification: event.notification } : {}), + }, + }); + } + for (const update of updates) + for (const interest of this.interests.values()) { + if (!this.matches(interest, update)) continue; + // Normalize at the connection edge; all consumers receive the same handle shape. + deliver( + ("payload" in update + ? { ...update, payload: { ...update.payload, subscriptionId: interest.id } } + : { ...update, subscriptionId: interest.id }) as SessionOutboundMessage, + ); + } + } + + private matches({ id, query }: Interest, message: SessionOutboundMessage): boolean { + switch (query.type) { + case "fetch_agents_request": + return message.type === "agent_update"; + case "fetch_workspaces_request": + return message.type === "workspace_update"; + case "workspace.label.list.request": + return message.type === "workspace.label.update"; + case "fs.file.subscribe.request": + return message.type === "fs.file.update" && message.payload.subscriptionId === id; + case "subscribe_checkout_diff_request": + return message.type === "checkout_diff_update" && message.payload.subscriptionId === id; + case "subscribe_terminals_request": + return message.type === "terminals_changed" && message.payload.cwd === query.cwd; + case "subscribe_terminal_request": + return ( + message.type === "terminal_stream_exit" && message.payload.terminalId === query.terminalId + ); + case "agent.timeline.set_subscription.request": + return ( + (message.type === "agent_stream" || message.type === "agent.timeline.replacement") && + (query.agentIds as string[]).includes(message.payload.agentId) + ); + case "session.events.set_subscription.request": + return (query.events as string[]).includes( + message.type === "status" ? `status.${message.payload.status}` : message.type, + ); + case "browser.host.register.request": + return message.type === "browser.automation.execute.request"; + default: + return false; + } + } +} + +function isLegacyEvent(event: string): boolean { + return [ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", + ].includes(event); +} + +function matchesBrowserRegistration(advertised: unknown, request: ObservationRequest): boolean { + const host = BrowserAutomationHostCapabilitySchema.safeParse(advertised); + if (!host.success) return false; + if (host.data.hostKind !== request.hostKind) return false; + for (const command of request.supportedCommands as string[]) { + if (!host.data.supportedCommands.some((supported) => supported === command)) return false; + } + return true; +} diff --git a/packages/client/src/connection/owned.test.ts b/packages/client/src/connection/owned.test.ts new file mode 100644 index 0000000000..eca52f1749 --- /dev/null +++ b/packages/client/src/connection/owned.test.ts @@ -0,0 +1,129 @@ +import { expect, test } from "vitest"; +import { OwnedSubscriptions } from "./owned.js"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +interface Snapshot { + subscriptionId: string; + requestId: string; +} + +function connection() { + const released: string[] = []; + const failures: unknown[] = []; + const requests: Array<(id: string) => void> = []; + const subscriptions = new OwnedSubscriptions({ + release: async (id) => { + released.push(id); + }, + failed: (error) => { + failures.push(error); + }, + }); + subscriptions.restore(); + return { + subscriptions, + released, + failures, + requests, + observe: (signal?: AbortSignal) => + subscriptions.observe<Snapshot>( + (accept) => + new Promise((resolve) => { + requests.push((subscriptionId) => { + const snapshot = { subscriptionId, requestId: `request-${requests.length}` }; + accept(snapshot); + resolve(snapshot); + }); + }), + signal, + ), + }; +} + +function update(subscriptionId: string, seq: number): SessionOutboundMessage { + return { + type: "agent_update", + payload: { kind: "remove", agentId: `agent-${seq}`, subscriptionId, seq }, + }; +} + +test("a caller consuming the snapshot later receives its buffered updates in order", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("a"); + c.requests[1]!("b"); + c.subscriptions.receive(update("a", 1)); + c.subscriptions.receive(update("b", 2)); + await a.ready; + const seen: string[] = []; + a.subscribe({ + snapshot: (s) => seen.push(s.subscriptionId), + update: (m) => seen.push(JSON.stringify(m)), + }); + expect(seen).toEqual(["a", JSON.stringify(update("a", 1))]); + await a.release(); + c.subscriptions.receive(update("a", 3)); + expect(seen).toHaveLength(2); + expect(c.released).toEqual(["a"]); + expect(b.subscriptionId).toBe("b"); + await b.release(); +}); + +test("cancellation before the ID waits for bootstrap and releases exactly that ID", async () => { + const c = connection(); + const abort = new AbortController(); + const handle = c.observe(abort.signal); + abort.abort(); + const released = handle.release(); + expect(c.released).toEqual([]); + c.requests[0]!("cancelled"); + await released; + await expect(handle.ready).rejects.toThrow("released"); + expect(c.released).toEqual(["cancelled"]); +}); + +test("surviving handles use new remote IDs after reconnect and released handles stay gone", async () => { + const c = connection(); + const a = c.observe(); + const b = c.observe(); + c.requests[0]!("old-a"); + c.requests[1]!("old-b"); + await Promise.all([a.ready, b.ready]); + await b.release(); + const seen: string[] = []; + a.subscribe({ snapshot: (s) => seen.push(s.subscriptionId), update: () => seen.push("update") }); + c.subscriptions.disconnected(); + c.subscriptions.restore(); + expect(c.requests).toHaveLength(3); + c.requests[2]!("new-a"); + c.subscriptions.receive(update("old-a", 1)); + c.subscriptions.receive(update("new-a", 2)); + expect(seen).toEqual(["old-a", "new-a", "update"]); + expect(a.subscriptionId).toBe("new-a"); + await a.release(); +}); + +test("a late listener after bounded replay overflow gets a fresh snapshot", async () => { + const c = connection(); + const handle = c.observe(); + c.requests[0]!("old"); + await handle.ready; + const first: string[] = []; + handle.subscribe({ snapshot: (s) => first.push(s.subscriptionId), update: () => {} }); + for (let seq = 0; seq < 129; seq++) c.subscriptions.receive(update("old", seq)); + const late: string[] = []; + handle.subscribe({ + snapshot: (s) => late.push(s.subscriptionId), + update: () => late.push("update"), + }); + await expect.poll(() => c.requests.length).toBe(2); + expect(c.released).toEqual(["old"]); + expect(late).toEqual([]); + c.requests[1]!("fresh"); + c.subscriptions.receive(update("old", 130)); + c.subscriptions.receive(update("fresh", 131)); + expect(first).toEqual(["old", "fresh"]); + expect(late).toEqual(["fresh", "update"]); + await handle.release(); +}); diff --git a/packages/client/src/connection/owned.ts b/packages/client/src/connection/owned.ts new file mode 100644 index 0000000000..56bf6b98f0 --- /dev/null +++ b/packages/client/src/connection/owned.ts @@ -0,0 +1,235 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; + +export type SubscriptionSnapshot<T> = T & { subscriptionId: string }; + +export interface SubscriptionObserver<T> { + snapshot(snapshot: T): void; + update(message: SessionOutboundMessage): void; + error?(error: unknown): void; +} + +/** A stable local lifetime. IDs change on reconnect; legacy IDs identify local listeners only. */ +export interface OwnedSubscription<T> { + readonly subscriptionId: string | null; + readonly ready: Promise<SubscriptionSnapshot<T>>; + subscribe(observer: SubscriptionObserver<SubscriptionSnapshot<T>>): () => void; + release(): Promise<void>; +} + +interface Registration { + start(): void; + disconnected(): void; + receive(message: SessionOutboundMessage): void; + release(): Promise<void>; +} + +export interface OwnedSubscriptionsHost { + release(subscriptionId: string): Promise<void>; + failed(error: unknown): void; +} + +const MAX_BOOTSTRAP_UPDATES = 128; + +/** Transport routing and lifetime only; query semantics remain with each domain. */ +export class OwnedSubscriptions { + private readonly registrations = new Set<Registration>(); + private readonly routes = new Map<string, Registration>(); + private generation = 0; + private connected = false; + + constructor(private readonly host: OwnedSubscriptionsHost) {} + + observe<T>( + request: (accept: (snapshot: T) => void) => Promise<T>, + signal?: AbortSignal, + requestFailed?: () => void, + ): OwnedSubscription<T> { + let id: string | null = null; + let snapshot: SubscriptionSnapshot<T> | undefined; + let pending: Promise<void> | null = null; + let teardown: Promise<void> | null = null; + let disposed = false; + let overflow = false; + const buffered: SessionOutboundMessage[] = []; + const observers = new Set<SubscriptionObserver<SubscriptionSnapshot<T>>>(); + let resolve!: (snapshot: SubscriptionSnapshot<T>) => void; + let reject!: (error: unknown) => void; + const ready = new Promise<SubscriptionSnapshot<T>>((accept, fail) => { + resolve = accept; + reject = fail; + }); + void ready.catch(() => {}); + + const deliver = ( + observer: SubscriptionObserver<SubscriptionSnapshot<T>>, + message?: SessionOutboundMessage, + ) => { + try { + if (message) observer.update(message); + else if (snapshot) observer.snapshot(snapshot); + } catch (error) { + this.host.failed(error); + } + }; + const releaseId = async () => { + const released = id; + id = null; + if (!released) return; + this.routes.delete(released); + await this.host.release(released); + }; + const registration: Registration = { + start: () => { + if (disposed || pending || id || !this.connected) return; + const generation = this.generation; + pending = request((value) => { + if (generation !== this.generation) return; + assertSubscriptionSnapshot(value); + id = value.subscriptionId; + snapshot = value; + buffered.length = 0; + overflow = false; + if (disposed) return; + this.routes.set(id, registration); + for (const observer of observers) deliver(observer); + }) + .then(() => { + if (generation === this.generation && !disposed) { + if (!snapshot) throw new Error("The host omitted its subscription snapshot"); + resolve(snapshot); + } + return undefined; + }) + .catch((error) => { + if (generation !== this.generation) return; + reject(error); + for (const observer of observers) { + try { + observer.error?.(error); + } catch (failure) { + this.host.failed(failure); + } + } + void handle.release().catch(this.host.failed); + this.host.failed(error); + requestFailed?.(); + return undefined; + }) + .finally(() => { + pending = null; + if (generation !== this.generation && !disposed) registration.start(); + }); + }, + disconnected: () => { + id = null; + snapshot = undefined; + buffered.length = 0; + overflow = false; + }, + receive: (message) => { + if (disposed || ("payload" in message && Object.is(message.payload, snapshot))) return; + if (buffered.length < MAX_BOOTSTRAP_UPDATES && !overflow) buffered.push(message); + else { + buffered.length = 0; + overflow = true; + } + for (const observer of observers) deliver(observer, message); + }, + release: () => { + if (teardown) return teardown; + disposed = true; + signal?.removeEventListener("abort", abort); + observers.clear(); + buffered.length = 0; + if (id) this.routes.delete(id); + reject(new Error("Subscription released")); + teardown = (async () => { + await pending; + await releaseId(); + this.registrations.delete(registration); + })(); + return teardown; + }, + }; + const abort = () => { + void handle.release().catch(this.host.failed); + }; + const handle: OwnedSubscription<T> = { + get subscriptionId() { + return id; + }, + ready, + release: registration.release, + subscribe: (observer) => { + if (disposed) throw new Error("Subscription released"); + observers.add(observer); + if (overflow) { + overflow = false; + snapshot = undefined; + void releaseId() + .then(() => { + registration.start(); + return undefined; + }) + .catch(this.host.failed); + } else { + deliver(observer); + for (const message of buffered) deliver(observer, message); + } + return () => { + observers.delete(observer); + }; + }, + }; + this.registrations.add(registration); + if (signal?.aborted) abort(); + else { + signal?.addEventListener("abort", abort, { once: true }); + registration.start(); + } + return handle; + } + + owns(message: SessionOutboundMessage): boolean { + const payload = "payload" in message ? message.payload : message; + return ( + "subscriptionId" in payload && + typeof payload.subscriptionId === "string" && + this.routes.has(payload.subscriptionId) + ); + } + + receive(message: SessionOutboundMessage): void { + const payload = "payload" in message ? message.payload : message; + if ("subscriptionId" in payload && typeof payload.subscriptionId === "string") + this.routes.get(payload.subscriptionId)?.receive(message); + } + + restore(): void { + this.connected = true; + for (const registration of this.registrations) registration.start(); + } + + disconnected(): void { + this.connected = false; + this.generation++; + this.routes.clear(); + for (const registration of this.registrations) registration.disconnected(); + } + + async close(): Promise<void> { + await Promise.all([...this.registrations].map((registration) => registration.release())); + } +} + +function assertSubscriptionSnapshot(value: unknown): asserts value is { subscriptionId: string } { + if ( + value && + typeof value === "object" && + "subscriptionId" in value && + typeof value.subscriptionId === "string" && + value.subscriptionId + ) + return; + throw new Error("The host omitted its subscription ID"); +} diff --git a/packages/client/src/creation/index.test.ts b/packages/client/src/creation/index.test.ts new file mode 100644 index 0000000000..1ce46107e4 --- /dev/null +++ b/packages/client/src/creation/index.test.ts @@ -0,0 +1,224 @@ +import { expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationClient, type CreationResult } from "./index.js"; + +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; + +function fixture(modern: boolean) { + const requests: Array<{ kind: string; input: Record<string, unknown> }> = []; + const legacy: Array<{ kind: string; input: unknown }> = []; + let resolve!: (result: CreationResult) => void; + const result = new Promise<CreationResult>((done) => { + resolve = done; + }); + const client = new CreationClient({ + supports: (feature) => (feature === "creationLifecycle" ? modern : true), + requestId: () => "generated-key", + request: async (kind, input) => { + requests.push({ kind, input }); + return result; + }, + observe: () => () => {}, + legacyWorkspace: async (input) => { + legacy.push({ kind: "workspace", input }); + return { requestId: "legacy-workspace", workspace, error: null, setupTerminalId: null }; + }, + legacyAgent: async (input) => { + legacy.push({ kind: "agent", input }); + return agent; + }, + }); + const input = { + idempotencyKey: "intent-one", + source: { kind: "directory" as const, path: "/project" }, + agent: { + config: { provider: "codex", cwd: "/project" }, + initialPrompt: "Start once", + clientMessageId: "message-one", + }, + }; + return { client, requests, legacy, resolve, input }; +} + +test("duplicate client submissions join one complete intent and cumulative updates never regress", async () => { + const f = fixture(true); + const phases: string[] = []; + const first = f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + const duplicate = f.client.createWorkspace(f.input); + expect(duplicate).toBe(first); + expect(f.requests).toEqual([{ kind: "workspace", input: { ...f.input, subscribe: true } }]); + await expect( + f.client.createWorkspace({ + ...f.input, + agent: { ...f.input.agent, initialPrompt: "Different intent" }, + }), + ).rejects.toThrow("workspace_request_key_conflict"); + const snapshot: CreationSnapshot = { + kind: "workspace", + idempotencyKey: "intent-one", + revision: 2, + phase: "workspace_ready", + workspaceId: workspace.id, + agentId: agent.id, + workspace, + error: null, + }; + f.client.receive(snapshot); + f.client.receive({ ...snapshot, revision: 1, phase: "accepted" }); + expect(phases).toEqual(["workspace_ready"]); + const late: string[] = []; + f.client.createWorkspace({ ...f.input, onEvent: (update) => late.push(update.phase) }); + expect(late).toEqual(["workspace_ready"]); + f.resolve({ + workspace, + agent, + error: null, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await first).toMatchObject({ + workspace, + agent, + setupSkippedReason: "Untrusted source", + requestId: "response-one", + }); + expect(await duplicate).toEqual(await first); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("legacy adaptation keeps workspace, agent and initial prompt sequencing inside the client", async () => { + const f = fixture(false); + const phases: string[] = []; + const result = await f.client.createWorkspace({ + ...f.input, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + expect(result).toMatchObject({ workspace, agent, error: null }); + expect(phases).toEqual(["workspace_ready"]); + expect(f.legacy).toEqual([ + { kind: "workspace", input: { idempotencyKey: "intent-one", source: f.input.source } }, + { + kind: "agent", + input: { + config: { provider: "codex", cwd: workspace.workspaceDirectory }, + workspaceId: workspace.id, + initialPrompt: "Start once", + clientMessageId: "message-one", + }, + }, + ]); + expect(f.requests).toEqual([]); + f.client.close(); +}); + +test("legacy daemons cannot silently replace caller-selected IDs", async () => { + const f = fixture(false); + await expect(f.client.createWorkspace({ ...f.input, workspaceId: workspace.id })).rejects.toThrow( + "caller-selected", + ); + await expect( + f.client.createAgent({ agentId: agent.id, config: f.input.agent.config }), + ).rejects.toThrow("caller-selected"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test("keyed creation with an empty prompt works on legacy daemons without sending an empty message", async () => { + const f = fixture(false); + await f.client.createAgent({ + idempotencyKey: "empty-agent", + config: f.input.agent.config, + initialPrompt: "", + }); + expect(f.legacy).toEqual([ + { + kind: "agent", + input: { idempotencyKey: "empty-agent", config: f.input.agent.config, initialPrompt: "" }, + }, + ]); + f.client.close(); +}); + +test.each([ + ["/repo/project", "/repo/project/../outside"], + ["C:\\repo\\project", "C:\\repo\\project\\..\\outside"], +])("legacy creation rejects escaping %s before creating a workspace", async (source, cwd) => { + const f = fixture(false); + await expect( + f.client.createWorkspace({ + ...f.input, + source: { kind: "directory", path: source }, + agent: { ...f.input.agent, config: { provider: "codex", cwd } }, + }), + ).rejects.toThrow("inside the workspace source"); + expect(f.legacy).toEqual([]); + f.client.close(); +}); + +test.each([ + { source: { kind: "directory" as const, path: "/project" }, cwd: "/project/src" }, + { source: { kind: "directory" as const, path: "C:\\project" }, cwd: "C:\\project\\src" }, + { source: { kind: "worktree" as const, projectId: "project" }, cwd: "/project/src" }, +])("legacy creation preserves the agent subdirectory for $source", async ({ source, cwd }) => { + const f = fixture(false); + await f.client.createWorkspace({ + ...f.input, + source, + agent: { config: { provider: "codex", cwd } }, + }); + expect(f.legacy[1]).toMatchObject({ + kind: "agent", + input: { workspaceId: workspace.id, config: { cwd: "/project/worktree/src" } }, + }); + f.client.close(); +}); diff --git a/packages/client/src/creation/index.ts b/packages/client/src/creation/index.ts new file mode 100644 index 0000000000..fd9db1d9df --- /dev/null +++ b/packages/client/src/creation/index.ts @@ -0,0 +1,259 @@ +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceCreateResponse, +} from "@getpaseo/protocol/messages"; +import type { CreateAgentRequestOptions, CreateWorkspaceRequestOptions } from "../daemon-client.js"; + +type Kind = CreationSnapshot["kind"]; +type Observer = (snapshot: CreationSnapshot) => void; +export interface CreationResult { + agent?: AgentSnapshotPayload | null; + workspace?: WorkspaceCreateResponse["payload"]["workspace"]; + creation?: CreationSnapshot; + error: string | null; + errorCode?: string; + setupTerminalId?: string | null; + setupSkippedReason?: string; + requestId?: string; +} +interface Dependencies { + supports: (feature: "creationLifecycle" | "agentRequestReceipts") => boolean; + requestId: () => string; + request: (kind: Kind, input: Record<string, unknown>) => Promise<CreationResult>; + observe: ( + kind: Kind, + key: string, + next: (snapshot: CreationSnapshot | null) => void, + error: (error: unknown) => void, + ) => () => void; + legacyAgent: (input: CreateAgentRequestOptions) => Promise<AgentSnapshotPayload>; + legacyWorkspace: ( + input: CreateWorkspaceRequestOptions, + ) => Promise<WorkspaceCreateResponse["payload"]>; +} +interface Operation { + kind: Kind; + key: string; + fingerprint: string; + input: Record<string, unknown>; + observers: Set<Observer>; + snapshot?: CreationSnapshot; + result: Promise<CreationResult>; + resolve: (result: CreationResult) => void; + reject: (error: Error) => void; + settled: boolean; + modern: boolean; + recovering: boolean; + stopObserving?: () => void; +} + +/** Client-owned observation and compatibility. UI callbacks never advance creation. */ +export class CreationClient { + private readonly operations = new Map<string, Operation>(); + constructor(private readonly deps: Dependencies) {} + + createAgent(input: CreateAgentRequestOptions): Promise<CreationResult> { + const { onEvent, ...request } = input; + return this.start("agent", request, onEvent, () => this.legacyAgent(request)); + } + createWorkspace(input: CreateWorkspaceRequestOptions): Promise<CreationResult> { + const { onEvent, ...request } = input; + return this.start("workspace", request, onEvent, async (operation) => { + if (request.workspaceId || request.agent?.agentId) + throw new Error("Update the host to use caller-selected creation IDs."); + const { agent, ...workspaceInput } = request; + const sourceCwd = + request.source.kind === "directory" ? request.source.path : request.source.cwd; + const relativeCwd = + agent && sourceCwd ? relativeDirectory(agent.config!.cwd, sourceCwd) : undefined; + const workspace = await this.deps.legacyWorkspace(workspaceInput); + if (workspace.error || !workspace.workspace) return workspace; + this.receive({ + kind: "workspace", + idempotencyKey: operation.key, + revision: 1, + phase: "workspace_ready", + workspaceId: workspace.workspace.id, + agentId: null, + workspace: workspace.workspace, + setupSkippedReason: workspace.setupSkippedReason, + error: null, + }); + if (!agent) return workspace; + const cwd = `${workspace.workspace.workspaceDirectory!.replace(/[\\/]+$/, "")}${ + relativeCwd ?? relativeDirectory(agent.config!.cwd, workspace.workspace.projectRootPath) + }`; + const created = await this.legacyAgent({ + ...agent, + config: { ...agent.config!, cwd }, + workspaceId: workspace.workspace.id, + idempotencyKey: `${operation.key}:agent`, + }); + return { ...workspace, agent: created.agent }; + }); + } + + private async legacyAgent(input: CreateAgentRequestOptions): Promise<CreationResult> { + if (input.agentId) throw new Error("Update the host to use caller-selected creation IDs."); + const { idempotencyKey: _key, ...unkeyed } = input; + const hasPrompt = Boolean( + input.initialPrompt || input.images?.length || input.attachments?.length, + ); + // COMPAT(creationLifecycle): added in v0.8.0, remove after 2027-03-11 once the daemon floor supports creationLifecycle. + // Preserve the original create-and-prompt RPC: old hosts use its prompt for + // titles, creation context and startup. Their keyed create cannot accept it. + const request = !hasPrompt && this.deps.supports("agentRequestReceipts") ? input : unkeyed; + return { agent: await this.deps.legacyAgent(request), error: null }; + } + + private start( + kind: Kind, + input: Record<string, unknown>, + observer: Observer | undefined, + legacy: (operation: Operation) => Promise<CreationResult>, + ): Promise<CreationResult> { + const key = + typeof input.idempotencyKey === "string" ? input.idempotencyKey : this.deps.requestId(); + const identity = JSON.stringify([kind, key]); + const { requestId: _requestId, ...intent } = input; + const fingerprint = JSON.stringify(intent, (_key, value: unknown) => + value && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); + const previous = this.operations.get(identity); + if (previous) { + if (previous.fingerprint !== fingerprint) + return Promise.reject(new Error(`${kind}_request_key_conflict`)); + if (observer) { + previous.observers.add(observer); + if (previous.snapshot) this.notify(observer, previous.snapshot); + } + return previous.result; + } + let resolve!: Operation["resolve"]; + let reject!: Operation["reject"]; + const result = new Promise<CreationResult>((done, fail) => { + resolve = done; + reject = fail; + }); + const operation: Operation = { + kind, + key, + input: { ...input, idempotencyKey: key }, + fingerprint, + observers: new Set(observer ? [observer] : []), + result, + resolve, + reject, + settled: false, + modern: this.deps.supports("creationLifecycle"), + recovering: false, + }; + this.operations.set(identity, operation); + if (operation.modern) void this.submit(operation); + else + void legacy(operation).then( + (value) => this.finish(operation, value), + (error) => this.fail(operation, error), + ); + return result; + } + + private async submit(operation: Operation): Promise<void> { + try { + const result = await this.deps.request(operation.kind, { + ...operation.input, + subscribe: true, + }); + if (result.creation) this.receive(result.creation); + this.finish(operation, result); + } catch (error) { + if ( + operation.settled || + (error instanceof Error && "code" in error && error.code === "DAEMON_CONNECTION_LOST") + ) + return; + this.fail(operation, error); + } + } + receive(snapshot: CreationSnapshot): void { + const operation = this.operations.get(JSON.stringify([snapshot.kind, snapshot.idempotencyKey])); + if (!operation || (operation.snapshot && operation.snapshot.revision > snapshot.revision)) + return; + if (operation.snapshot?.revision !== snapshot.revision) { + operation.snapshot = snapshot; + for (const observer of operation.observers) this.notify(observer, snapshot); + } + if (operation.recovering && (snapshot.phase === "completed" || snapshot.phase === "failed")) { + this.finish(operation, { + agent: snapshot.agent, + workspace: snapshot.workspace, + creation: snapshot, + setupSkippedReason: snapshot.setupSkippedReason, + error: snapshot.error, + errorCode: snapshot.errorCode, + }); + } + } + reconnect(): void { + for (const operation of this.operations.values()) { + if (operation.settled || !operation.modern || operation.recovering) continue; + operation.recovering = true; + operation.stopObserving = this.deps.observe( + operation.kind, + operation.key, + (snapshot) => { + if (!snapshot) void this.submit(operation); + else { + // Re-subscription is authoritative even if its revision matches the last live event. + this.receive(snapshot); + } + }, + (error) => this.fail(operation, error), + ); + } + } + close(): void { + for (const operation of this.operations.values()) + if (!operation.settled) this.fail(operation, new Error("Daemon client closed")); + this.operations.clear(); + } + private finish(operation: Operation, result: CreationResult): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.resolve(result); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private fail(operation: Operation, error: unknown): void { + if (operation.settled) return; + operation.settled = true; + operation.stopObserving?.(); + operation.reject(error instanceof Error ? error : new Error(String(error))); + operation.observers.clear(); + this.operations.delete(JSON.stringify([operation.kind, operation.key])); + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch { + /* Observation does not control execution. */ + } + } +} + +function relativeDirectory(cwd: string, source: string): string { + const normalize = (value: string) => value.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = normalize(cwd), + root = normalize(source); + // Parent traversal must never survive mapping into the newly created checkout. + if ([normalized, root].some((path) => path.split("/").includes(".."))) + throw new Error("Agent directory must be inside the workspace source"); + if (normalized === root) return ""; + if (!normalized.startsWith(`${root}/`)) + throw new Error("Agent directory must be inside the workspace source"); + return normalized.slice(root.length); +} diff --git a/packages/client/src/daemon-client-relay-e2ee-transport.ts b/packages/client/src/daemon-client-relay-e2ee-transport.ts index 88f359f3ca..1bc175bf1b 100644 --- a/packages/client/src/daemon-client-relay-e2ee-transport.ts +++ b/packages/client/src/daemon-client-relay-e2ee-transport.ts @@ -20,12 +20,19 @@ export function createRelayE2eeTransportFactory(args: { daemonPublicKeyB64: string; logger: TransportLogger; }): DaemonTransportFactory { - return ({ url, headers }) => { - const base = args.baseFactory({ url, headers }); + return ({ url }) => { + const base = args.baseFactory({ url }); return createEncryptedTransport(base, args.daemonPublicKeyB64, args.logger); }; } +export function createRelayTransportFactory( + baseFactory: DaemonTransportFactory, +): DaemonTransportFactory { + // The relay upgrade precedes E2EE. Only the encrypted hello may carry daemon credentials. + return ({ url }) => baseFactory({ url }); +} + export function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/client/src/daemon-client-transport.test.ts b/packages/client/src/daemon-client-transport.test.ts index 378275c673..6f80717dd1 100644 --- a/packages/client/src/daemon-client-transport.test.ts +++ b/packages/client/src/daemon-client-transport.test.ts @@ -21,7 +21,7 @@ describe("daemon-client transport helpers", () => { createClientChannelMock.mockReset(); createClientChannelMock.mockRejectedValueOnce(new Error("handshake failed")); - let openHandler: (() => void) | null = null; + const connection: { open: (() => void) | null } = { open: null }; const close = vi.fn(); createEncryptedTransport( @@ -29,10 +29,10 @@ describe("daemon-client transport helpers", () => { send: vi.fn(), close, onOpen: (handler) => { - openHandler = handler; + connection.open = handler; return () => { - if (openHandler === handler) { - openHandler = null; + if (connection.open === handler) { + connection.open = null; } }; }, @@ -44,8 +44,8 @@ describe("daemon-client transport helpers", () => { { warn: vi.fn() }, ); - expect(openHandler).not.toBeNull(); - openHandler?.(); + expect(connection.open).not.toBeNull(); + connection.open?.(); await vi.waitFor(() => { expect(close).toHaveBeenCalledWith(4001, "E2EE handshake failed"); diff --git a/packages/client/src/daemon-client-transport.ts b/packages/client/src/daemon-client-transport.ts index 05bd08bbc6..01933fd1ec 100644 --- a/packages/client/src/daemon-client-transport.ts +++ b/packages/client/src/daemon-client-transport.ts @@ -17,6 +17,7 @@ export { export { createEncryptedTransport, createRelayE2eeTransportFactory, + createRelayTransportFactory, } from "./daemon-client-relay-e2ee-transport.js"; export { bindWsHandler, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 149e6b0040..95921a96a0 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1,8 +1,11 @@ +import { readFileSync } from "node:fs"; import { afterEach, expect, expectTypeOf, test, vi } from "vitest"; import { z } from "zod"; import { DaemonClient, + supportsUsageReports, type DaemonClientTrace, + type CreateAgentRequestOptions, type DaemonTransport, type Logger, } from "./daemon-client"; @@ -14,9 +17,6 @@ import { FileTransferOpcode, } from "@getpaseo/protocol/binary-frames/index"; import { - asUint8Array, - decodeTerminalResizePayload, - decodeTerminalStreamFrame, encodeTerminalSnapshotPayload, encodeTerminalStreamFrame, TerminalStreamOpcode, @@ -79,7 +79,7 @@ function createMockTransport() { }, close: () => {}, onMessage: (handler) => { - onMessage = handler; + onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -115,7 +115,7 @@ function createMockTransport() { serverId: `srv_test_${serverInfoOrdinal++}`, hostname: null, version: null, - ...(options?.features ? { features: options.features } : {}), + features: options?.features ?? { ownedSubscriptions: true }, }, }, }), @@ -255,11 +255,11 @@ test("does not infer browser automation capabilities from Electron runtime", asy const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined(); - expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBeUndefined(); + expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBe(true); }); test("advertises consumer-provided browser automation capabilities", async () => { @@ -285,7 +285,7 @@ test("advertises consumer-provided browser automation capabilities", async () => const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({ @@ -294,6 +294,166 @@ test("advertises consumer-provided browser automation capabilities", async () => }); }); +test.each([ + { receipts: false, structured: false }, + { receipts: true, structured: false }, + { receipts: false, structured: true }, + { receipts: true, structured: true }, +])( + "legacy creation preserves the original payload with receipts=$receipts, structured=$structured", + async ({ receipts, structured }) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "receipt-gate", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { agentRequestReceipts: receipts } }); + await connecting; + const input: CreateAgentRequestOptions = { + config: { + provider: "codex", + cwd: "/project", + title: "Explicit title", + model: "gpt-5", + modeId: "full-access", + }, + workspaceId: "workspace", + callerAgentId: "parent", + env: { CREATION_CONTEXT: "preserved" }, + labels: { source: "test" }, + idempotencyKey: "creation", + clientMessageId: "first-message", + initialPrompt: "Start this agent", + images: [{ data: "aGVsbG8=", mimeType: "image/png" }], + attachments: [ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 123, + title: "Review this PR", + url: "https://github.com/getpaseo/paseo/pull/123", + }, + ], + ...(structured ? { outputSchema: { type: "object" } } : {}), + }; + const created = client.createAgent(input); + void created.catch(() => {}); + const duplicate = client.createAgent(input); + void duplicate.catch(() => {}); + expect(transport.sent).toHaveLength(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "create_agent_request", + config: input.config, + workspaceId: input.workspaceId, + callerAgentId: input.callerAgentId, + env: input.env, + labels: input.labels, + clientMessageId: input.clientMessageId, + initialPrompt: input.initialPrompt, + images: input.images, + attachments: input.attachments, + ...(structured ? { outputSchema: input.outputSchema } : {}), + }); + expect(request).not.toHaveProperty("idempotencyKey"); + transport.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "Provider failed", + }, + }), + ); + await expect(created).rejects.toThrow("Provider failed"); + await expect(duplicate).rejects.toThrow("Provider failed"); + }, +); + +test.each([false, true])( + "legacy workspace creation preserves availability with receipt support=%s", + async (receipts) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "legacy-workspace", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { workspaceRequestReceipts: receipts } }); + await connecting; + const input = { + source: { kind: "directory" as const, path: "/project" }, + idempotencyKey: "workspace", + }; + const created = client.createWorkspace(input); + void created.catch(() => {}); + const duplicate = client.createWorkspace(input); + void duplicate.catch(() => {}); + expect(transport.sent).toHaveLength(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ type: "workspace.create.request", source: input.source }); + expect(request.idempotencyKey).toBe(receipts ? input.idempotencyKey : undefined); + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.response", + payload: { + requestId: request.requestId, + workspace: null, + error: "Directory unavailable", + setupTerminalId: null, + }, + }), + ); + await expect(created).resolves.toMatchObject({ error: "Directory unavailable" }); + await expect(duplicate).resolves.toMatchObject({ error: "Directory unavailable" }); + }, +); + +test.each(["agent", "workspace"] as const)( + "a lost legacy %s response is not automatically replayed on reconnect", + async (kind) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "legacy-disconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: {} }); + await connecting; + const creation = + kind === "agent" + ? client.createAgent({ + provider: "codex", + cwd: "/project", + initialPrompt: "Start once", + idempotencyKey: "intent", + }) + : client.createWorkspace({ + source: { kind: "directory", path: "/project" }, + idempotencyKey: "intent", + }); + void creation.catch(() => {}); + expect(transport.sent).toHaveLength(1); + transport.triggerClose({ code: 1006, reason: "Connection lost after dispatch" }); + await expect(creation).rejects.toThrow(); + const reconnecting = client.connect(); + transport.triggerOpen({ features: {} }); + await reconnecting; + expect(transport.sent).toEqual([]); + }, +); + test("Hub management requires daemon support before dispatching requests", async () => { const mock = createMockTransport(); const client = new DaemonClient({ @@ -313,57 +473,35 @@ test("Hub management requires daemon support before dispatching requests", async expect(mock.sent).toEqual([]); }); -test("sets the complete viewed timeline subscription only when the daemon supports it", async () => { - const supportedTransport = createMockTransport(); - const supportedClient = new DaemonClient({ - url: "ws://test", - clientId: "timeline_supported", - transportFactory: () => supportedTransport.transport, - reconnect: { enabled: false }, - }); - const legacyTransport = createMockTransport(); - const legacyClient = new DaemonClient({ +test("timeline observation consumes broadcasts from a host without selective delivery", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ url: "ws://test", clientId: "timeline_legacy", - transportFactory: () => legacyTransport.transport, + transportFactory: () => mock.transport, reconnect: { enabled: false }, }); - clients.push(supportedClient, legacyClient); - - const supportedConnect = supportedClient.connect(); - supportedTransport.triggerOpen({ features: { selectiveAgentTimeline: true } }); - await supportedConnect; - const legacyConnect = legacyClient.connect(); - legacyTransport.triggerOpen(); - await legacyConnect; - - expect(supportedClient.getLastServerInfoMessage()?.features).toEqual({ - selectiveAgentTimeline: true, - }); - - const setPromise = supportedClient.setAgentTimelineSubscription(["agent-b", "agent-a"]); - await Promise.resolve(); - const request = parseSentFrame(supportedTransport.sent[0]); - supportedTransport.triggerMessage( + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features: {} }); + await connecting; + const observation = client.observeTimeline(["agent"]); + await expect(observation.ready).resolves.toMatchObject({ agentIds: ["agent"] }); + const updates: unknown[] = []; + observation.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) }); + mock.triggerMessage( wrapSessionMessage({ - type: "agent.timeline.set_subscription.response", + type: "agent_stream", payload: { - requestId: request.requestId, - agentIds: ["agent-a", "agent-b"], + agentId: "agent", + timestamp: "2026-09-11T00:00:00Z", + event: { type: "turn_completed", provider: "codex" }, }, }), ); - await setPromise; - await legacyClient.setAgentTimelineSubscription(["agent-a"]); - - expect({ request, legacyFrames: legacyTransport.sent }).toEqual({ - request: { - type: "agent.timeline.set_subscription.request", - requestId: expect.any(String), - agentIds: ["agent-a", "agent-b"], - }, - legacyFrames: [], - }); + expect(updates).toEqual([expect.objectContaining({ type: "agent_stream" })]); + await observation.release(); + expect(mock.sent).toEqual([]); }); test("normalizes legacy and dedicated agent attention notifications", async () => { @@ -426,8 +564,13 @@ class FakeDaemon { if (typeof data !== "string") { return; } - const frame = JSON.parse(data) as { type?: string }; - if (frame.type !== "ping") { + const frame = JSON.parse(data) as { + type?: string; + message?: { type?: string; requestId: string; clientSentAt: number }; + }; + const sessionPing = + frame.type === "session" && frame.message?.type === "ping" ? frame.message : null; + if (frame.type !== "ping" && !sessionPing) { return; } this.pingsSentAt.push(performance.now()); @@ -438,19 +581,25 @@ class FakeDaemon { if (this.pongMode.kind === "silent") { return; } + const pong = sessionPing + ? wrapSessionMessage({ + type: "pong", + payload: { ...sessionPing, serverReceivedAt: Date.now(), serverSentAt: Date.now() }, + }) + : JSON.stringify({ type: "pong" }); if (this.pongMode.delayMs === 0) { - this.onMessage(JSON.stringify({ type: "pong" })); + this.onMessage(pong); return; } setTimeout(() => { - this.onMessage(JSON.stringify({ type: "pong" })); + this.onMessage(pong); }, this.pongMode.delayMs); }, close: (code?: number, reason?: string) => { this.closeEvents.push({ code, reason }); }, onMessage: (handler) => { - this.onMessage = handler; + this.onMessage = (data) => handler(data, typeof data !== "string"); return () => {}; }, onOpen: (handler) => { @@ -707,6 +856,142 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async () }); }); +test("keeps relay upgrade credentials out of the socket request", async () => { + const mock = createMockTransport(); + const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> = + []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_relay_auth_test", + password: "shared-secret", + authHeader: "Bearer shared-secret", + e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" }, + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return mock.transport; + }, + }); + clients.push(client); + void client.connect(); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" }); +}); + +test("refuses relay password auth without an encrypted hello", async () => { + const requests: unknown[] = []; + const client = new DaemonClient({ + url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2", + clientId: "clsk_unencrypted_relay_test", + password: "shared-secret", + connectTimeoutMs: 50, + reconnect: { enabled: false }, + transportFactory: (request) => { + requests.push(request); + return createMockTransport().transport; + }, + }); + clients.push(client); + await expect(client.connect()).rejects.toThrow("Relay credentials require E2EE"); + expect(requests).toEqual([]); +}); + +test("stops reconnecting after a password rejection on an established connection", async () => { + const socket = createMockTransport(); + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_reconnect_auth_test", + password: "old-secret", + reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 }, + transportFactory: () => { + attempts += 1; + return socket.transport; + }, + }); + clients.push(client); + const connected = client.connect(); + socket.triggerOpen(); + await connected; + socket.triggerMessage(JSON.stringify({ type: "hello.rejected", reason: "incorrect_password" })); + socket.triggerClose({ code: 4003, reason: "Incorrect password" }); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(client.authFailureReason).toBe("incorrect_password"); + expect(attempts).toBe(1); +}); + +test("sends a password containing spaces in hello without an invalid WebSocket subprotocol", async () => { + const mock = createMockTransport(); + const transportFactory = vi.fn(() => mock.transport); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + password: "two words", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ preserveSent: true }); + await connected; + expect(transportFactory).toHaveBeenCalledWith({ + url: "ws://test", + headers: {}, + }); + expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({ + type: "hello", + auth: { kind: "password", password: "two words" }, + }); +}); + +test("uses a local credential over a saved password when the desktop bridge provides one", async () => { + const mock = createMockTransport(); + const transportFactory = vi.fn(() => mock.transport); + const client = new DaemonClient({ + url: "ws://test", + clientId: "desktop-managed", + password: "stale-password", + localCredential: async () => "current-local-token", + reconnect: { enabled: false }, + transportFactory, + }); + clients.push(client); + const connected = client.connect(); + await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled()); + mock.triggerOpen({ preserveSent: true }); + await connected; + expect(transportFactory).toHaveBeenCalledWith({ url: "ws://test", headers: {} }); + expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({ + type: "hello", + auth: { kind: "localCredential", token: "current-local-token" }, + }); +}); + +test("uses the saved host password when the desktop bridge has no credential for the target", async () => { + const mock = createMockTransport(); + const transportFactory = vi.fn(() => mock.transport); + const localCredential = vi.fn(async () => undefined); + const client = new DaemonClient({ + url: "ws://remote-host", + clientId: "remote-saved-host", + password: "saved-password", + localCredential, + reconnect: { enabled: false }, + transportFactory, + }); + clients.push(client); + const connected = client.connect(); + await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled()); + mock.triggerOpen({ preserveSent: true }); + await connected; + expect(localCredential).toHaveBeenCalledOnce(); + expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({ + type: "hello", + auth: { kind: "password", password: "saved-password" }, + }); +}); + test("advertises client capabilities in hello", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -737,12 +1022,23 @@ test("advertises client capabilities in hello", async () => { clientType: "cli", protocolVersion: 1, capabilities: { + all_providers: true, + selective_agent_timeline: true, + timeline_replacement_invalidation: true, + provider_snapshot_references: true, + explicit_event_subscriptions: true, + owned_subscriptions: true, compact_provider_snapshots: true, custom_mode_icons: true, project_updates: true, provider_subagents: true, + projected_subagent_timeline: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, + timeline_notifications: true, + plugin_timeline_items: true, + workspace_setup_blocked: true, + hello_rejection: true, browser_host: { supportedCommands: ["list_tabs"], hostKind: "desktop app", @@ -771,7 +1067,7 @@ test("allows callers to disable default client capabilities", async () => { const hello = z .object({ type: z.literal("hello"), - capabilities: z.record(z.unknown()), + capabilities: z.record(z.string(), z.unknown()), }) .parse(JSON.parse(assertStr(mock.sent[0]))); expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false); @@ -944,6 +1240,130 @@ test("does not reconnect after close when ensureConnected is called", async () = expect(client.getConnectionState().status).toBe("disposed"); }); +test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_immediate_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app resumed" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + +test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_explicit_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "daemon restarted" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + const reconnect = client.connect(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + await reconnect; + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + +test("disabling reconnect cancels a pending retry until explicitly resumed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const transports = [first, second]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_paused_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app backgrounded" }); + + client.setReconnectEnabled(false); + await vi.advanceTimersByTimeAsync(1_500); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(transportIndex).toBe(1); + + client.setReconnectEnabled(true); + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + test("keeps the transport connected when a session RPC ping times out", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1362,7 +1782,7 @@ test("honors explicit getDaemonStatus timeout below the session RPC default", as clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { daemonStatusRpc: true } }); await connectPromise; const responsePromise = client.getDaemonStatus({ @@ -1633,6 +2053,78 @@ test("keeps default connect timeout shorter than session RPC waiters", async () } }); +test("foreground verification replaces a silently broken socket without waiting for heartbeats", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "foreground-recovery", + logger: noopLogger, + transportFactory: () => (++attempts === 1 ? first : second).transport, + }); + clients.push(client); + const connection = client.connect(); + first.openConnection(); + await connection; + + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(3_000); + expect(attempts).toBe(2); + second.openConnection(); + expect(client.getConnectionState()).toEqual({ status: "connected" }); +}); + +test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonAnswersPingsAfter("0.1s"); + const client = new DaemonClient({ + url: "ws://test", + clientId: "healthy-resume", + logger: noopLogger, + transportFactory: () => daemon.transport, + }); + clients.push(client); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(3_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.pingTimestamps()).toEqual(["0s"]); + expect(daemon.closesFromClient()).toEqual([]); +}); + +test("an obsolete foreground probe cannot close a replacement connection", async () => { + useHeartbeatClock(); + const first = new FakeDaemon(); + const second = new FakeDaemon(); + first.daemonGoesSilent(); + let attempts = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "resume-race", + logger: noopLogger, + transportFactory: () => (++attempts === 1 ? first : second).transport, + }); + clients.push(client); + const connection = client.connect(); + first.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + first.daemonClosesWith("network changed"); + client.ensureConnected(); + second.openConnection(); + await vi.advanceTimersByTimeAsync(3_000); + expect(attempts).toBe(2); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(second.closesFromClient()).toEqual([]); +}); + test("stays online through ten minutes of pongs that arrive five seconds late", async () => { useHeartbeatClock(); const session = new DaemonClientSession(); @@ -1988,7 +2480,7 @@ test("file context action RPCs correlate success and error responses", async () }); }); -test("serializes plugin source suffixes through the legacy path field", async () => { +test("sends plugin source identifiers unchanged for daemon-host resolution", async () => { const mock = createMockTransport(); const client = new DaemonClient({ url: "ws://test", @@ -2000,7 +2492,7 @@ test("serializes plugin source suffixes through the legacy path field", async () clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { pluginSourceInstallation: true } }); await connectPromise; const installPromise = client.installPluginSource({ @@ -2010,8 +2502,7 @@ test("serializes plugin source suffixes through the legacy path field", async () expect(request).toEqual({ type: "plugin.source.install.request", requestId: expect.any(String), - source: "owner/repository", - pluginPath: "plugins/review", + source: "owner/repository:plugins/review", }); mock.triggerMessage( wrapSessionMessage({ @@ -2336,6 +2827,17 @@ test("uploadFile sends metadata request and file bytes as binary chunks", async chunkSize: 5, }); + // Other tasks must run before a multi-chunk upload has queued all its bytes. + await new Promise((resolve) => setTimeout(resolve, 0)); + const framesDuringUpload = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame); + expect(framesDuringUpload.some((frame) => frame.opcode === FileTransferOpcode.FileEnd)).toBe( + false, + ); + await vi.waitFor(() => { + const frames = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame); + expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd); + }); + expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ type: "session", message: { @@ -2504,10 +3006,11 @@ test("sends create_agent_request with workspace and caller identity", async () = clients.push(client); const connectPromise = client.connect(); - mock.triggerOpen(); + mock.triggerOpen({ features: { agentRequestReceipts: true } }); await connectPromise; const createPromise = client.createAgent({ + idempotencyKey: "one-creation", provider: "codex", cwd: "/tmp/project/.paseo/worktrees/feature-a", workspaceId: "ws-feature-a", @@ -2521,6 +3024,7 @@ test("sends create_agent_request with workspace and caller identity", async () = expect(request).toEqual( expect.objectContaining({ type: "create_agent_request", + idempotencyKey: "one-creation", workspaceId: "ws-feature-a", callerAgentId: "parent-agent", }), @@ -2596,6 +3100,162 @@ test("sends worktree target and autoArchive in create_agent_request", async () = await expect(createPromise).rejects.toThrow("worktree auto archive sentinel"); }); +test("sends internal in create_agent_request when the host advertises internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: { backgroundWorkspaces: true } }); + await connectPromise; + + const createPromise = client.createAgent({ + provider: "codex", + cwd: "/tmp/project", + background: true, + }); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual( + expect.objectContaining({ + type: "create_agent_request", + config: { provider: "codex", cwd: "/tmp/project" }, + background: true, + }), + ); + + mock.triggerMessage( + wrapSessionMessage({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: request.requestId, + error: "internal agent sentinel", + }, + }), + ); + + await expect(createPromise).rejects.toThrow("internal agent sentinel"); +}); + +test("rejects an internal create before sending when the host predates internal agents", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(mock.sent).toHaveLength(0); +}); + +test("sends internal workspace creation and internal listings only to hosts that support them", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + // workspaceMultiplicity keeps the fetch on the real workspace directory RPC. + mock.triggerOpen({ + features: { workspaceMultiplicity: true, backgroundWorkspaces: true }, + }); + await connectPromise; + + void client + .createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true }) + .catch(() => undefined); + void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined); + void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined); + + expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "workspace.create.request", background: true }), + expect.objectContaining({ + type: "fetch_workspaces_request", + filter: { includeBackground: true }, + }), + expect.objectContaining({ + type: "fetch_agents_request", + filter: { includeBackground: true }, + }), + ]), + ); +}); + +test("rejects internal workspace creation and internal listings before sending on older hosts", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ features: {} }); + await connectPromise; + + await expect( + client.createWorkspace({ + source: { kind: "directory", path: "/tmp/project" }, + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(mock.sent).toHaveLength(0); + + // Agent listings gate on the older internalAgents feature. + const olderMock = createMockTransport(); + const olderClient = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => olderMock.transport, + }); + clients.push(olderClient); + const olderConnect = olderClient.connect(); + olderMock.triggerOpen({ features: {} }); + await olderConnect; + await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow( + "Update the host to use background workspaces.", + ); + expect(olderMock.sent).toHaveLength(0); +}); + test("sends structured attachments with create_agent_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -2896,6 +3556,45 @@ test("sends project.add.request without creating a workspace", async () => { }); }); +test("marks a workspace unread through the dotted RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread"); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ); + + await expect(markPromise).resolves.toBeUndefined(); +}); + test("searches GitHub repositories through the dotted RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3494,16 +4193,14 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { mock.triggerOpen(); await connectPromise; - const promise = client.subscribeCheckoutDiff( - "/tmp/project", - { mode: "uncommitted" }, - { subscriptionId: "checkout-sub-1" }, - ); + const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" }); + const promise = subscription.ready; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); expect(mock.sent).toHaveLength(1); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); + expect(request.subscriptionId).toBeUndefined(); expect(request.cwd).toBe("/tmp/project"); expect(request.compare).toEqual({ mode: "uncommitted" }); @@ -3532,7 +4229,7 @@ test("subscribes to checkout diff updates via RPC handshake", async () => { }); }); -test("getCheckoutDiff uses one-shot subscription protocol", async () => { +test("getCheckoutDiff reads a snapshot without creating a subscription", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -3553,7 +4250,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { expect(mock.sent).toHaveLength(1); const subscribeRequest = parseSentFrame(mock.sent[0]); - expect(subscribeRequest.type).toBe("subscribe_checkout_diff_request"); + expect(subscribeRequest.type).toBe("checkout.diff.get.request"); expect(subscribeRequest.cwd).toBe("/tmp/project"); expect(subscribeRequest.compare).toEqual({ mode: "base", @@ -3564,9 +4261,8 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { JSON.stringify({ type: "session", message: { - type: "subscribe_checkout_diff_response", + type: "checkout.diff.get.response", payload: { - subscriptionId: subscribeRequest.subscriptionId, cwd: "/tmp/project", files: [], error: null, @@ -3583,10 +4279,7 @@ test("getCheckoutDiff uses one-shot subscription protocol", async () => { requestId: subscribeRequest.requestId, }); - expect(mock.sent).toHaveLength(2); - const unsubscribeRequest = parseSentFrame(mock.sent[1]); - expect(unsubscribeRequest.type).toBe("unsubscribe_checkout_diff_request"); - expect(unsubscribeRequest.subscriptionId).toBe(subscribeRequest.subscriptionId); + expect(mock.sent).toHaveLength(1); }); test("requests branch suggestions via RPC", async () => { @@ -4131,7 +4824,7 @@ test("renames a branch via RPC", async () => { }); expect(mock.sent).toHaveLength(1); - const request = JSON.parse(mock.sent[0]) as { + const request = JSON.parse(assertStr(mock.sent[0])) as { type: "session"; message: { type: "checkout.rename_branch.request"; @@ -4233,29 +4926,52 @@ test("resubscribes checkout diff streams after reconnect", async () => { }); clients.push(client); - const internal = client as unknown as { - checkoutDiffSubscriptions: Map< - string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } - >; - }; - internal.checkoutDiffSubscriptions.set("checkout-sub-1", { - cwd: "/tmp/project", - compare: { mode: "base", baseRef: "main" }, - }); - const connectPromise = client.connect(); mock.triggerOpen(); await connectPromise; - expect(mock.sent).toHaveLength(1); + const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const first = parseSentFrame(mock.sent[0]); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: first.requestId, + subscriptionId: "server-first", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await observation.ready; + mock.triggerClose(); + const reconnect = client.connect(); + mock.triggerOpen(); + await reconnect; + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); - expect(request.type).toBe("subscribe_checkout_diff_request"); - expect(request.subscriptionId).toBe("checkout-sub-1"); - expect(request.cwd).toBe("/tmp/project"); - expect(request.compare).toEqual({ mode: "base", baseRef: "main" }); - expect(typeof request.requestId).toBe("string"); - expect(z.string().parse(request.requestId).length).toBeGreaterThan(0); + expect(request).toMatchObject({ + type: "subscribe_checkout_diff_request", + cwd: "/tmp/project", + compare: { mode: "base", baseRef: "main" }, + }); + expect(request.subscriptionId).toBeUndefined(); + expect(request.requestId).not.toBe(first.requestId); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscribe_checkout_diff_response", + payload: { + requestId: request.requestId, + subscriptionId: "server-second", + cwd: "/tmp/project", + files: [], + error: null, + }, + }), + ); + await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second")); }); test("fetches agents via RPC with filters, sort, and pagination", async () => { @@ -4282,10 +4998,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ], page: { limit: 25, cursor: "cursor-1" }, - subscribe: { subscriptionId: "sub-1" }, + subscribe: {}, }); - expect(mock.sent).toHaveLength(1); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); const request = parseSentFrame(mock.sent[0]); expect(request.type).toBe("fetch_agents_request"); expect(request.sort).toEqual([ @@ -4293,7 +5009,7 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { { key: "created_at", direction: "desc" }, ]); expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" }); - expect(request.subscribe).toEqual({ subscriptionId: "sub-1" }); + expect(request.subscribe).toEqual({}); mock.triggerMessage( JSON.stringify({ @@ -4317,6 +5033,10 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { await expect(promise).resolves.toEqual({ requestId: request.requestId, subscriptionId: "sub-1", + subscription: expect.objectContaining({ + subscriptionId: "sub-1", + release: expect.any(Function), + }), entries: [], pageInfo: { nextCursor: null, @@ -5162,11 +5882,15 @@ test("emits output events for the active terminal stream", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-1"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 11, error: null, @@ -5214,11 +5938,15 @@ test("emits snapshot events for the subscribed terminal stream", async () => { snapshots.push(event.state); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-2"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 12, error: null, @@ -5246,7 +5974,7 @@ test("emits snapshot events for the subscribed terminal stream", async () => { expect(snapshots).toEqual([state]); }); -test("sends input and resize frames for the subscribed terminal slot", async () => { +test("sends explicit terminal input and resize commands", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -5263,11 +5991,15 @@ test("sends input and resize frames for the subscribed terminal slot", async () mock.triggerOpen(); await connectPromise; - const subscribePromise = client.subscribeTerminal("term-1", "sub-3"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 13, error: null, @@ -5289,19 +6021,18 @@ test("sends input and resize frames for the subscribed terminal slot", async () intent: "update", }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(13); - expect(new TextDecoder().decode(inputFrame?.payload ?? new Uint8Array())).toBe("echo hello\r"); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(13); - expect(decodeTerminalResizePayload(resizeFrame?.payload ?? new Uint8Array())).toEqual({ - rows: 24, - cols: 80, - intent: "update", - }); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "input", data: "echo hello\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 24, cols: 80, intent: "update" }, + }, + ]); }); test("routes concurrent terminal stream frames by slot", async () => { @@ -5329,11 +6060,15 @@ test("routes concurrent terminal stream frames by slot", async () => { seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`); }); - const subscribeFirstPromise = client.subscribeTerminal("term-1", "sub-multi-1"); + const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 21, error: null, @@ -5343,11 +6078,15 @@ test("routes concurrent terminal stream frames by slot", async () => { ); await subscribeFirstPromise; - const subscribeSecondPromise = client.subscribeTerminal("term-2", "sub-multi-2"); + const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-2", slot: 22, error: null, @@ -5383,14 +6122,19 @@ test("routes concurrent terminal stream frames by slot", async () => { cols: 20, }); - const inputFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[0])!); - const resizeFrame = decodeTerminalStreamFrame(asUint8Array(mock.sent[1])!); - expect(seen).toEqual(["term-2:beta", "term-1:alpha"]); - expect(inputFrame?.opcode).toBe(TerminalStreamOpcode.Input); - expect(inputFrame?.slot).toBe(22); - expect(resizeFrame?.opcode).toBe(TerminalStreamOpcode.Resize); - expect(resizeFrame?.slot).toBe(21); + expect(mock.sent.map(parseSentFrame)).toEqual([ + { + type: "terminal_input", + terminalId: "term-2", + message: { type: "input", data: "echo beta\r" }, + }, + { + type: "terminal_input", + terminalId: "term-1", + message: { type: "resize", rows: 10, cols: 20 }, + }, + ]); }); test("ignores terminal stream frames after terminal_stream_exit", async () => { @@ -5418,11 +6162,15 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { seen.push(new TextDecoder().decode(event.data)); }); - const subscribePromise = client.subscribeTerminal("term-1", "sub-4"); + const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" }); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"), + ); mock.triggerMessage( wrapSessionMessage({ type: "subscribe_terminal_response", payload: { + subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId), terminalId: "term-1", slot: 14, error: null, @@ -5445,6 +6193,7 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { wrapSessionMessage({ type: "terminal_stream_exit", payload: { + subscriptionId: "server-sub-4", terminalId: "term-1", }, }), @@ -5459,6 +6208,21 @@ test("ignores terminal stream frames after terminal_stream_exit", async () => { ); expect(seen).toEqual(["before-exit"]); + await vi.waitFor(() => + expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"), + ); + const release = parseSentFrame(mock.sent.at(-1)); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "server-sub-4", + released: true, + }, + }), + ); + await Promise.resolve(); unsubscribe(); }); @@ -5804,24 +6568,41 @@ test("sends subscribe/unsubscribe terminals messages", async () => { mock.triggerOpen(); await connectPromise; - client.subscribeTerminals({ cwd: "/tmp/project" }); - client.unsubscribeTerminals({ cwd: "/tmp/project" }); - - expect(mock.sent).toHaveLength(2); - expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({ - type: "session", - message: { - type: "subscribe_terminals_request", - cwd: "/tmp/project", - }, - }); - expect(JSON.parse(assertStr(mock.sent[1]))).toEqual({ - type: "session", - message: { - type: "unsubscribe_terminals_request", - cwd: "/tmp/project", - }, + const observation = client.observeTerminals({ cwd: "/tmp/project" }); + await vi.waitFor(() => expect(mock.sent).toHaveLength(1)); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" }); + expect(request.subscriptionId).toBeUndefined(); + mock.triggerMessage( + wrapSessionMessage({ + type: "terminals_changed", + payload: { + requestId: request.requestId, + subscriptionId: "server-terminals", + cwd: "/tmp/project", + terminals: [], + }, + }), + ); + await observation.ready; + const release = observation.release(); + await vi.waitFor(() => expect(mock.sent).toHaveLength(2)); + const requestRelease = parseSentFrame(mock.sent[1]); + expect(requestRelease).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "server-terminals", }); + mock.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: requestRelease.requestId, + subscriptionId: "server-terminals", + released: true, + }, + }), + ); + await release; }); test("dispatches terminals_changed events to typed listeners", async () => { @@ -5949,6 +6730,85 @@ test("sends provider.usage.list.request and resolves provider.usage.list.respons }); }); +test.each([ + { + status: "available", + report: { + status: "available", + windows: [], + balances: undefined, + details: undefined, + planLabel: undefined, + }, + }, + { status: "error", report: { status: "error", error: "" } }, + { + status: "unavailable", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "" } }, + }, +] as const)( + "maps released-host $status usage and filters report IDs", + async ({ status, report }) => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { providerUsageList: true } }); + await connected; + const result = client.listUsageReports({ + requestId: "legacy-usage", + reportIds: ["claude"], + forceRefresh: true, + }); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "provider.usage.list.request", + requestId: "legacy-usage", + }); + const provider = { + providerId: "claude", + displayName: "Claude", + status, + windows: [], + planLabel: null, + fetchedAt: null, + error: null, + }; + mock.triggerMessage( + wrapSessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "legacy-usage", + fetchedAt: "2026-09-30T00:00:00.000Z", + providers: [provider, { ...provider, providerId: "codex" }], + }, + }), + ); + expect(await result).toStrictEqual({ + requestId: "legacy-usage", + reports: [ + { + id: "claude", + sourceId: "claude", + sourceLabel: "Claude", + icon: readFileSync( + new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url), + "utf8", + ), + account: {}, + fetchedAt: "2026-09-30T00:00:00.000Z", + report, + }, + ], + }); + }, +); + test("sends close_items_request and resolves close_items_response", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -6067,3 +6927,603 @@ test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", vi.useRealTimers(); } }); + +test("wire snapshot callers own expansion and receive hash references unchanged", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "wire-catalog", + providerSnapshots: "wire", + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ preserveSent: true }); + await connected; + expect(JSON.parse(assertStr(transport.sent[0])).capabilities.provider_snapshot_references).toBe( + true, + ); + const received: unknown[] = []; + client.on("providers_snapshot_update", (message) => received.push(message.payload)); + const payload = { + entries: [], + snapshotHash: "content", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T12:00:00.000Z", + }; + transport.triggerMessage(wrapSessionMessage({ type: "providers_snapshot_update", payload })); + expect(received).toEqual([payload]); + const request = client.getProvidersSnapshot(); + const sent = parseSentFrame(transport.sent.at(-1)!); + const body = { + ...payload, + compactSnapshot: { + entries: [ + { + provider: "codex", + enabled: true, + status: "ready", + models: [{ id: "astra", label: "Astra" }], + }, + ], + thinkingSets: [], + }, + requestId: sent.requestId, + }; + transport.triggerMessage( + wrapSessionMessage({ type: "get_providers_snapshot_response", payload: body }), + ); + expect(await request).toEqual(body); +}); + +test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-contract", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } }); + await connected; + const creation = client.createAgent({ + idempotencyKey: "draft-one", + provider: "codex", + cwd: "/project", + workspaceId: "wks_0123456789abcdef", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "agent.create.request", + idempotencyKey: "draft-one", + initialPrompt: "Start once", + clientMessageId: "first-message", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); + expect(transport.sent).toHaveLength(1); +}); + +test("creation lifecycle acknowledgement reaches the observer before the final response", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-progress", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connected = client.connect(); + transport.triggerOpen({ features: { creationLifecycle: true } }); + await connected; + const phases: string[] = []; + const creation = client.createAgent({ + provider: "codex", + cwd: "/project", + idempotencyKey: "observe-one", + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const request = parseSentFrame(transport.sent[0]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.update", + payload: { + kind: "agent", + idempotencyKey: "observe-one", + revision: 0, + phase: "accepted", + workspaceId: null, + agentId: "00000000-0000-4000-8000-000000000001", + error: null, + }, + }), + ); + expect(phases).toEqual(["accepted"]); + transport.triggerMessage( + wrapSessionMessage({ + type: "agent.create.response", + payload: { requestId: request.requestId, agent: null, error: "provider unavailable" }, + }), + ); + await expect(creation).rejects.toThrow("provider unavailable"); +}); + +test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "creation-reconnect", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const features = { creationLifecycle: true, ownedSubscriptions: true }; + const connect = client.connect(); + transport.triggerOpen({ features }); + await connect; + const phases: string[] = []; + const creation = client.createWorkspace({ + idempotencyKey: "reconnect-one", + source: { kind: "directory", path: "/project" }, + onEvent: (snapshot) => phases.push(snapshot.phase), + }); + void creation.catch(() => undefined); + const snapshot = { + kind: "workspace", + idempotencyKey: "reconnect-one", + phase: "accepted", + revision: 0, + workspaceId: "wks_0123456789abcdef", + agentId: null, + error: null, + }; + for (const subscriptionId of ["first-connection", "second-connection"]) { + transport.triggerClose(); + const reconnected = client.connect(); + transport.triggerOpen({ features }); + await reconnected; + await expect.poll(() => transport.sent.length).toBe(1); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "creation.subscribe.request", + idempotencyKey: "reconnect-one", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId, + snapshot, + error: null, + }, + }), + ); + } + transport.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.update", + payload: { + ...snapshot, + subscriptionId: "second-connection", + revision: 1, + phase: "failed", + error: "Provisioning failed", + }, + }), + ); + expect(await creation).toMatchObject({ error: "Provisioning failed" }); + await expect.poll(() => transport.sent.length).toBe(2); + const release = parseSentFrame(transport.sent.at(-1)); + expect(release).toMatchObject({ + type: "subscription.release.request", + subscriptionId: "second-connection", + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "subscription.release.response", + payload: { + requestId: release.requestId, + subscriptionId: "second-connection", + }, + }), + ); + expect(phases).toEqual(["accepted", "failed"]); +}); + +test("uploadFile started while connecting waits for the connection and uploads", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "upload-while-connecting", + logger: createMockLogger(), + reconnect: { enabled: false }, + suppressSendErrors: true, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connection = client.connect(); + expect(client.getConnectionState().status).toBe("connecting"); + + const upload = client.uploadFile({ + fileName: "notes.txt", + mimeType: "text/plain", + bytes: new TextEncoder().encode("hello world"), + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId: "req-upload-connecting", + chunkSize: 5, + }); + void upload.catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve, 0)); + mock.triggerOpen(); + await connection; + + await vi.waitFor(() => { + const frames = mock.sent + .filter((frame) => typeof frame !== "string") + .map(assertUint8Array) + .map(decodeFileTransferFrame); + expect(frames.map((frame) => frame.opcode)).toEqual([ + FileTransferOpcode.FileBegin, + FileTransferOpcode.FileChunk, + FileTransferOpcode.FileChunk, + FileTransferOpcode.FileChunk, + FileTransferOpcode.FileEnd, + ]); + }); + const requestIndex = mock.sent.findIndex( + (frame) => + typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting", + ); + expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" }); + expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string")); + + const file = { + type: "uploaded_file" as const, + id: "upload_req-upload-connecting", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt", + }; + mock.triggerMessage( + wrapSessionMessage({ + type: "file.upload.response", + payload: { requestId: "req-upload-connecting", file, error: null }, + }), + ); + await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null }); +}); + +test("uploadFile stops sending chunks when the connection closes between sends", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "upload-interrupted", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connection = client.connect(); + mock.triggerOpen(); + await connection; + const upload = client.uploadFile({ + fileName: "test.bin", + mimeType: "application/octet-stream", + bytes: new Uint8Array(1024 * 1024), + }); + const rejection = expect(upload).rejects.toThrow(); + await new Promise((resolve) => setTimeout(resolve, 0)); + const beforeClose = mock.sent.length; + mock.triggerClose(); + await rejection; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mock.sent).toHaveLength(beforeClose); + expect( + mock.sent + .filter((frame) => typeof frame !== "string") + .map(assertUint8Array) + .map(decodeFileTransferFrame) + .some((frame) => frame.opcode === FileTransferOpcode.FileEnd), + ).toBe(false); +}); + +test("rejects source installation on an older host before sending a request", async () => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "source-gate", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { pluginGitManagement: true } }); + await connecting; + const sentBefore = transport.sent.length; + await expect(client.installPluginSource({ source: "npm:review" })).rejects.toThrow( + "Update the host", + ); + expect(transport.sent.length).toBe(sentBefore); +}); + +test("reviewed plugin updates gate before requests and preserve exact proposal data", async () => { + for (const supported of [false, true]) { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "reviewed-updates", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => transport.transport, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ + features: { + pluginGitManagement: true, + pluginSourceInstallation: true, + pluginSourceUpdates: supported, + }, + }); + await connecting; + const proposal = { + id: "review", + expected: { + identity: { kind: "npm" as const, packageName: "review", pluginPath: "." }, + installationRoot: "/plugins/review/version-root", + revision: "1.0.0", + }, + target: { + kind: "npm" as const, + version: "1.1.0", + resolved: "https://registry.npmjs.org/review/-/review-1.1.0.tgz", + integrity: "sha512-test", + }, + }; + if (!supported) { + const sent = transport.sent.length; + await expect(client.previewPluginUpdates()).rejects.toThrow("Update the host"); + await expect(client.applyPluginUpdates([proposal])).rejects.toThrow("Update the host"); + expect(transport.sent.length).toBe(sent); + continue; + } + const checking = client.previewPluginUpdates({ pluginId: "review" }); + const check = parseSentFrame(transport.sent.at(-1)); + expect(check).toMatchObject({ + type: "plugin.source.update.preview.request", + pluginId: "review", + }); + const preview = { id: "review", outcome: "update", links: [], proposal }; + transport.triggerMessage( + wrapSessionMessage({ + type: "plugin.source.update.preview.response", + payload: { requestId: check.requestId, plugins: [preview] }, + }), + ); + await expect(checking).resolves.toEqual([preview]); + const applying = client.applyPluginUpdates([proposal]); + const apply = parseSentFrame(transport.sent.at(-1)); + expect(apply).toEqual({ + type: "plugin.source.update.apply.request", + requestId: expect.any(String), + proposals: [proposal], + }); + transport.triggerMessage( + wrapSessionMessage({ + type: "plugin.source.update.apply.response", + payload: { + requestId: apply.requestId, + plugins: [{ id: "review", outcome: "error", error: "changed since review" }], + }, + }), + ); + await expect(applying).resolves.toEqual([ + { id: "review", outcome: "error", error: "changed since review" }, + ]); + } +}); + +test.each([ + [undefined, false], + [{}, false], + [{ usageSources: false, providerUsageList: false }, false], + [{ usageSources: true }, true], + [{ providerUsageList: true }, true], + [{ usageSources: true, providerUsageList: true }, true], +] as const)("usage support for features %j is %s", (features, expected) => { + expect(supportsUsageReports(features)).toBe(expected); +}); + +test("uses modern usage RPC when both capabilities are advertised", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } }); + await connected; + const updates: unknown[] = []; + const result = client.listUsageReports( + { + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }, + (report) => updates.push(report), + ); + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "usage.list_reports.request", + requestId: "modern-usage", + reportIds: ["claude:work"], + forceRefresh: true, + }); + const payload = { + requestId: "modern-usage", + reports: [ + { + id: "claude:work", + sourceId: "claude", + sourceLabel: "Claude", + account: { label: "Work" }, + fetchedAt: "2026-09-30T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + ], + }; + const report = payload.reports[0]; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: "foreign", report }, + }), + ); + expect(updates).toEqual([]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); + expect(updates).toEqual([report]); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.response", + payload: { requestId: payload.requestId, error: null }, + }), + ); + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { requestId: payload.requestId, report }, + }), + ); + expect(await result).toStrictEqual(payload); + expect(updates).toEqual([report]); +}); + +test("rejects usage requests when the host has neither capability", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: {} }); + await connected; + await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage."); + expect(mock.sent).toEqual([]); +}); + +test("usage request timeout detaches its update listener", async () => { + vi.useFakeTimers(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connected = client.connect(); + mock.triggerOpen({ features: { usageSources: true } }); + await connected; + const updates: unknown[] = []; + const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) => + updates.push(report), + ); + const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)"); + await vi.advanceTimersByTimeAsync(60_001); + await rejected; + mock.triggerMessage( + wrapSessionMessage({ + type: "usage.list_reports.update", + payload: { + requestId: "timeout-usage", + report: { + id: "source:late", + account: {}, + sourceId: "source", + sourceLabel: "Source", + fetchedAt: "2026-01-01T00:00:00.000Z", + report: { status: "available", windows: [] }, + }, + }, + }), + ); + expect(updates).toEqual([]); + } finally { + vi.useRealTimers(); + } +}); + +test.each([false, true])( + "agent provenance requires host support=%s while human sends remain compatible", + async (supported) => { + const transport = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "provenance", + transportFactory: () => transport.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + transport.triggerOpen({ features: { agentMessageProvenance: supported } }); + await connecting; + if (!supported) { + await expect( + client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }), + ).rejects.toThrow("Update the Paseo host"); + expect(transport.sent).toHaveLength(0); + } + const send = client.sendAgentMessage("recipient", "review", { + messageId: "delivery", + ...(supported ? { sourceAgentId: "server::sender" } : {}), + }); + const request = parseSentFrame(transport.sent[0]); + expect(request).toMatchObject({ + type: "send_agent_message_request", + text: "review", + messageId: "delivery", + }); + expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined); + transport.triggerMessage( + wrapSessionMessage({ + type: "send_agent_message_response", + payload: { + requestId: request.requestId, + agentId: "recipient", + accepted: true, + error: null, + }, + }), + ); + await send; + }, +); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 62bf674b5f..f900fe41e5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1,7 +1,18 @@ +import { legacyUsageIcon } from "./legacy-usage-icons.js"; +import { subscribeTimeline, type TimelineMessage } from "./timeline-subscription/index.js"; +import { ProviderSnapshotUpdates } from "./provider-snapshots/index.js"; +import { + ConnectionSubscriptions, + type OwnedSubscription, + DEFAULT_CLIENT_CAPABILITIES, + type TimelineSubscription, +} from "./connection/index.js"; +import { CreationClient } from "./creation/index.js"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; -import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; +import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; -import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -84,6 +95,7 @@ import type { RefreshProvidersSnapshotResponseMessage, ProviderDiagnosticResponseMessage, ProviderUsageListResponseMessage, + UsageReportEntry, DaemonGetStatusResponse, DaemonGetPairingOfferResponse, DaemonConfigReloadResponse, @@ -108,6 +120,10 @@ import type { PluginLogEntry, PluginSourceStatusItem, PluginSourceUpdateItem, + PluginUpdateSelection, + PluginUpdateProposal, + PluginUpdatePreview, + PluginUpdateResult, AgentSkillSelection, AgentSkillsStatus, AgentSkillsSaveResult, @@ -126,7 +142,6 @@ import type { MutableDaemonConfigPatch, } from "@getpaseo/protocol/messages"; import { isRelayClientWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; -import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscription-key"; import { asUint8Array, decodeFileTransferFrame, @@ -138,6 +153,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { createRelayE2eeTransportFactory, + createRelayTransportFactory, createWebSocketTransportFactory, decodeMessageData, defaultWebSocketFactory, @@ -203,6 +219,68 @@ function normalizePassword(value: string | undefined): string | null { return value.length > 0 ? value : null; } +function compatibleBearerPassword(password: string | null): string | null { + return password && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(password) ? password : null; +} + +type HelloAuth = + | { kind: "password"; password: string } + | { kind: "localCredential"; token: string } + | undefined; + +export type DaemonAuthFailureReason = "password_required" | "incorrect_password"; + +export class DaemonAuthenticationError extends Error { + readonly reason: DaemonAuthFailureReason; + + constructor(reason: DaemonAuthFailureReason) { + super(reason === "password_required" ? "Password required" : "Incorrect password"); + this.name = "DaemonAuthenticationError"; + this.reason = reason; + } +} + +export function getDaemonAuthFailureReason(error: unknown): DaemonAuthFailureReason | null { + return error instanceof DaemonAuthenticationError ? error.reason : null; +} + +function authFailureFromLegacyClose(event: unknown): DaemonAuthFailureReason | null { + if (!event || typeof event !== "object" || !("reason" in event)) return null; + if (event.reason === "Password required") return "password_required"; + if (event.reason === "Incorrect password") return "incorrect_password"; + return null; +} + +function chooseConnectionAuth( + config: DaemonClientConfig, + localCredential: string | undefined, +): { helloAuth: HelloAuth; headers: Record<string, string>; protocols?: string[] } { + const password = normalizePassword(config.password); + let helloAuth: HelloAuth; + if (localCredential) helloAuth = { kind: "localCredential", token: localCredential }; + else if (password) helloAuth = { kind: "password", password }; + const headers: Record<string, string> = {}; + const compatibleBearer = localCredential ? null : compatibleBearerPassword(password); + // COMPAT(headerAuth): added in v0.9.1, remove after 2027-03-24. + if (compatibleBearer) headers.Authorization = `Bearer ${compatibleBearer}`; + else if (!localCredential && config.authHeader) headers.Authorization = config.authHeader; + return { + helloAuth, + headers, + ...(compatibleBearer ? { protocols: [`paseo.bearer.${compatibleBearer}`] } : {}), + }; +} + +function resolveConnectionAuth( + config: DaemonClientConfig, +): ReturnType<typeof chooseConnectionAuth> | Promise<ReturnType<typeof chooseConnectionAuth>> { + const resolution = config.localCredential?.(); + if (resolution instanceof Promise) { + return resolution.then((credential) => chooseConnectionAuth(config, credential)); + } + return chooseConnectionAuth(config, resolution); +} + function extractCorrelatedResponseIdentity(input: unknown): CorrelatedResponseIdentity | null { if (!input || typeof input !== "object") { return null; @@ -308,12 +386,16 @@ export type BrowserAutomationExecuteRequestMessage = BrowserAutomationExecuteReq export type BrowserAutomationExecuteResponseMessage = BrowserAutomationExecuteResponse; export interface DaemonClientConfig { + /** Deliver compact bodies/hash references to a caller-owned snapshot cache. + * The default keeps public SDK snapshot entries expanded. */ + providerSnapshots?: "wire"; url: string; clientId: string; clientType?: "mobile" | "browser" | "cli" | "mcp" | "hub"; appVersion?: string; runtimeGeneration?: number | null; password?: string; + localCredential?: () => string | undefined | Promise<string | undefined>; authHeader?: string; suppressSendErrors?: boolean; transportFactory?: DaemonTransportFactory; @@ -342,7 +424,9 @@ export interface DaemonClientTrace { } export interface SendMessageOptions { + sourceAgentId?: string; messageId?: string; + /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ activeTurnBehavior?: ActiveTurnBehavior; images?: Array<{ data: string; mimeType: string }>; attachments?: SendAgentMessageRequest["attachments"]; @@ -359,6 +443,8 @@ export interface AgentAttentionRequiredNotification { type AgentConfigOverrides = Partial<Omit<AgentSessionConfig, "provider" | "cwd">>; export interface CreateAgentRequestOptions extends AgentConfigOverrides { + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config?: AgentSessionConfig; provider?: AgentProvider; cwd?: string; @@ -366,6 +452,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { workspaceId?: string; callerAgentId?: string; initialPrompt?: string; + idempotencyKey?: string; clientMessageId?: string; outputSchema?: Record<string, unknown>; images?: CreateAgentRequestMessage["images"]; @@ -373,6 +460,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { git?: GitSetupOptions; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + background?: CreateAgentRequestMessage["background"]; // COMPAT(createAgentWorktree): low-level old callers may still send the // create-agent worktree field. Added in v0.2.0; remove after 2027-01-17. worktreeName?: string; @@ -380,6 +468,23 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides { labels?: Record<string, string>; } +export interface CreateWorkspaceRequestOptions { + source: WorkspaceCreateRequest["source"]; + title?: string; + /** Workspace discovery visibility; contents retain normal durability. */ + background?: boolean; + callerAgentId?: string; + idempotencyKey?: string; + workspaceId?: string; + agent?: Omit< + CreateAgentRequestOptions, + "workspaceId" | "onEvent" | "worktree" | "git" | "worktreeName" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; + firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; + requestId?: string; +} + export interface CreatePaseoWorktreeInput extends Pick< CreatePaseoWorktreeRequest, | "cwd" @@ -462,6 +567,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload" type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"]; +interface UsageListReportsPayload { + requestId: string; + reports: UsageReportEntry[]; +} type DaemonStatusPayload = DaemonGetStatusResponse["payload"]; type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"]; type DiagnosticsPayload = DiagnosticsResponse["payload"]; @@ -565,6 +674,17 @@ export interface FetchAgentTimelineOptions { timeout?: number; } +export interface AgentTimelineSearchOptions { + agentId: string; + query: string; + cursor?: number; +} + +export type AgentTimelineSearchPayload = Extract< + SessionOutboundMessage, + { type: "agent.timeline.search.response" } +>["payload"]; + export type AgentTimelinePromptIndexPayload = Extract< SessionOutboundMessage, { type: "agent.timeline.list_prompts.response" } @@ -641,6 +761,7 @@ type FetchAgentsPayload = Extract< >["payload"]; type FetchAgentsRequest = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>; export type FetchAgentsOptions = Omit<FetchAgentsRequest, "type" | "requestId"> & { + signal?: AbortSignal; requestId?: string; timeout?: number; }; @@ -680,6 +801,7 @@ type FetchWorkspacesPayload = Extract< >["payload"]; type FetchWorkspacesRequest = Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>; export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, "type" | "requestId"> & { + signal?: AbortSignal; requestId?: string; }; export type FetchWorkspacesEntry = FetchWorkspacesPayload["entries"][number]; @@ -860,13 +982,25 @@ type SetDaemonConfigResponse = Extract< >; type CorrelatedResponseMessage = | Extract<SessionOutboundMessage, { payload: { requestId: string } }> + | Extract<SessionOutboundMessage, { type: "terminals_changed" }> | GetDaemonConfigResponse | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; -type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = Extract< - CorrelatedResponseMessage, - { type: TType } ->["payload"]; +type CorrelatedResponsePayloads = { + [Message in CorrelatedResponseMessage as Message["type"]]: Message["payload"]; +}; +type CorrelatedResponsePayload<TType extends CorrelatedResponseType> = + CorrelatedResponsePayloads[TType]; + +export class DaemonConnectionError extends Error { + constructor( + message: string, + readonly code: "DAEMON_CONNECTION_LOST" | "DAEMON_REQUEST_TIMEOUT" = "DAEMON_CONNECTION_LOST", + ) { + super(message); + this.name = "DaemonConnectionError"; + } +} class DaemonRpcError extends Error { readonly requestId: string; @@ -1043,7 +1177,7 @@ function toReasonCode(reason: string | null | undefined): string | null { } interface PendingSend { - message: SessionInboundMessage; + send: () => void; resolve: () => void; reject: (error: Error) => void; timeoutHandle: ReturnType<typeof setTimeout>; @@ -1061,8 +1195,59 @@ interface PingProbe { drivesLivenessFailure: boolean; } +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +export function supportsUsageReports(features: ServerInfoStatusPayload["features"]): boolean { + return features?.usageSources === true || features?.providerUsageList === true; +} + +function buildFetchAgentsRequest( + options: FetchAgentsOptions | undefined, + requestId: string, +): SessionInboundMessage { + return SessionInboundMessageSchema.parse({ + type: "fetch_agents_request", + requestId, + ...(options?.scope ? { scope: options.scope } : {}), + ...(options?.filter ? { filter: options.filter } : {}), + ...(options?.sort ? { sort: options.sort } : {}), + ...(options?.page ? { page: options.page } : {}), + ...(options?.subscribe ? { subscribe: options.subscribe } : {}), + ...(options?.sync ? { sync: options.sync } : {}), + ...(options?.includeBackground !== undefined + ? { includeBackground: options.includeBackground } + : {}), + }); +} + export class DaemonClient { + private readonly providerSnapshotUpdates = new ProviderSnapshotUpdates({ + active: (message) => this.owned.owns(message), + fetch: (cwd) => this.requestProvidersSnapshot({ cwd }), + emit: (message) => this.deliverSessionMessage(message), + failed: (error) => this.logger.error({ err: error }, "Failed to resolve provider snapshot"), + }); + private readonly owned = new ConnectionSubscriptions({ + send: (message) => + this.sendSessionMessageOrThrow( + SessionInboundMessageSchema.parse({ ...message, requestId: this.createRequestId() }), + ), + release: async (subscriptionId) => { + if (!this.isConnected) return; + try { + await this.sendCorrelatedSessionRequest({ + message: { type: "subscription.release.request", subscriptionId }, + responseType: "subscription.release.response", + }); + } catch (error) { + this.disposeTransport(1001, "Subscription release failed"); + this.scheduleReconnect({ reason: "Subscription release failed" }); + throw error; + } + }, + failed: (error) => this.logger.error({ err: error }, "Subscription failed"), + }); private transport: DaemonTransport | null = null; + private helloAuth: HelloAuth; private transportCleanup: Array<() => void> = []; private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set(); private messageHandlers: Map< @@ -1082,19 +1267,8 @@ export class DaemonClient { private connectResolve: (() => void) | null = null; private connectReject: ((error: Error) => void) | null = null; private lastErrorValue: string | null = null; + private authFailureReasonValue: DaemonAuthFailureReason | null = null; private connectionState: ConnectionState = { status: "idle" }; - private checkoutDiffSubscriptions = new Map< - string, - { - cwd: string; - compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; - } - >(); - private terminalDirectorySubscriptions = new Map<string, { cwd: string; workspaceId?: string }>(); - private fileSubscriptions = new Map< - string, - { cwd: string; path: string; onUpdate: (version: FileVersion) => void } - >(); private readonly terminalStreams = new TerminalStreamRouter(); private pendingBinaryFileReads = new Map<string, PendingBinaryFileRead>(); private activeBinaryFileTransfers = new Map<string, BinaryFileTransferState>(); @@ -1109,6 +1283,7 @@ export class DaemonClient { private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null; private runtimeMetrics: DaemonClientRuntimeMetrics | null = null; private pingProbe: PingProbe | null = null; + private connectionVerification: DaemonTransport | null = null; private livenessHeartbeatTimer: ReturnType<typeof setTimeout> | null = null; private lastLivenessRttMs: number | null = null; private consecutiveLivenessFailures = 0; @@ -1184,7 +1359,7 @@ export class DaemonClient { return this.connectPromise; } - private attemptConnect(): void { + private async attemptConnect(): Promise<void> { if (this.connectionState.status === "disposed") { this.rejectConnect(new Error("Daemon client is disposed")); return; @@ -1197,34 +1372,38 @@ export class DaemonClient { if (this.connectionState.status === "connecting") { return; } - - const headers: Record<string, string> = {}; - const password = normalizePassword(this.config.password); - if (password) { - headers.Authorization = `Bearer ${password}`; - } else if (this.config.authHeader) { - headers.Authorization = this.config.authHeader; + // This attempt supersedes any retry the last disconnect scheduled. Left + // armed, that retry would tear down the connection this attempt opens. + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; } - const protocols = password ? [`paseo.bearer.${password}`] : undefined; try { + const resolution = resolveConnectionAuth(this.config); + const selected = resolution instanceof Promise ? await resolution : resolution; + if (!this.shouldReconnect) return; + this.helloAuth = selected.helloAuth; // Reconnect can overlap with browser close/error delivery ordering. // Always dispose previous transport before constructing the next one. this.disposeTransport(); const baseTransportFactory = this.config.transportFactory ?? createWebSocketTransportFactory(this.config.webSocketFactory ?? defaultWebSocketFactory); - const shouldUseRelayE2ee = - this.config.e2ee?.enabled === true && isRelayClientWebSocketUrl(this.config.url); + const isRelayTransport = isRelayClientWebSocketUrl(this.config.url); + const shouldUseRelayE2ee = this.config.e2ee?.enabled === true && isRelayTransport; + this.assertEncryptedRelayAuth(selected.helloAuth, isRelayTransport); - let transportFactory = baseTransportFactory; + let transportFactory = isRelayTransport + ? createRelayTransportFactory(baseTransportFactory) + : baseTransportFactory; if (shouldUseRelayE2ee) { const daemonPublicKeyB64 = this.config.e2ee?.daemonPublicKeyB64; if (!daemonPublicKeyB64) { throw new Error("daemonPublicKeyB64 is required for relay E2EE"); } transportFactory = createRelayE2eeTransportFactory({ - baseFactory: baseTransportFactory, + baseFactory: transportFactory, daemonPublicKeyB64, logger: this.logger, }); @@ -1232,11 +1411,12 @@ export class DaemonClient { const transportUrl = this.resolveTransportUrlForAttempt(); const transport = transportFactory({ url: transportUrl, - headers, - ...(protocols ? { protocols } : {}), + headers: selected.headers, + ...(selected.protocols ? { protocols: selected.protocols } : {}), }); this.transport = transport; this.lastServerInfoMessage = null; + this.authFailureReasonValue = null; this.updateConnectionState( { @@ -1267,7 +1447,7 @@ export class DaemonClient { this.pendingGenericTransportErrorTimeout = null; } this.lastErrorValue = null; - this.sendHelloMessage(); + void this.sendHelloMessage(); }), transport.onClose((event) => { this.resetConnectTimeout(); @@ -1275,7 +1455,10 @@ export class DaemonClient { clearTimeout(this.pendingGenericTransportErrorTimeout); this.pendingGenericTransportErrorTimeout = null; } - const reason = describeTransportClose(event); + this.authFailureReasonValue ??= authFailureFromLegacyClose(event); + const reason = this.authFailureReasonValue + ? new DaemonAuthenticationError(this.authFailureReasonValue).message + : describeTransportClose(event); if (reason) { this.lastErrorValue = reason; } @@ -1324,7 +1507,9 @@ export class DaemonClient { reasonCode: "transport_error", }); }), - transport.onMessage((data) => this.handleTransportMessage(data)), + transport.onMessage((data) => { + if (this.transport === transport) this.handleTransportMessage(data); + }), ]; } catch (error) { this.resetConnectTimeout(); @@ -1339,6 +1524,12 @@ export class DaemonClient { } } + private assertEncryptedRelayAuth(helloAuth: HelloAuth, isRelayTransport: boolean): void { + if (!isRelayTransport || !helloAuth || this.config.e2ee?.enabled === true) return; + this.setReconnectEnabled(false); + throw new Error("Relay credentials require E2EE"); + } + private resolveConnect(): void { if (this.connectResolve) { this.connectResolve(); @@ -1371,11 +1562,13 @@ export class DaemonClient { } this.resetConnectTimeout(); this.disposeTransport(1000, "Client closed"); + this.providerSnapshotUpdates.clear(); this.clearWaiters(new Error("Daemon client closed")); + await this.owned.close(); + this.creations.close(); this.rejectPendingSendQueue(new Error("Daemon client closed")); this.rejectPingProbe(new Error("Daemon client closed")); this.terminalStreams.clearSlots(); - this.fileSubscriptions.clear(); this.lastServerInfoMessage = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); @@ -1389,22 +1582,47 @@ export class DaemonClient { ); } - ensureConnected(): void { + ensureConnected(options?: { verify?: boolean }): void { if (this.connectionState.status === "disposed") { return; } if (!this.shouldReconnect) { this.shouldReconnect = true; } - if ( - this.connectionState.status === "connected" || - this.connectionState.status === "connecting" - ) { + if (this.connectionState.status === "connected") { + if (options?.verify) this.verifyConnection(); + return; + } + if (this.connectionState.status === "connecting") return; + if (this.connectPromise) { + this.attemptConnect(); return; } void this.connect(); } + private verifyConnection(): void { + const transport = this.transport; + if (!transport || this.connectionVerification === transport) return; + this.connectionVerification = transport; + // A session probe has its own deadline, independent of a heartbeat that the OS + // may have suspended. A successful response also proves the session can serve RPCs. + void this.ping({ timeoutMs: 3_000 }) + .catch((error: unknown) => { + if (this.transport !== transport || this.connectionState.status !== "connected") return; + this.disposeTransport(1001, "Connection verification failed"); + this.scheduleReconnect({ + reason: error instanceof Error ? error.message : String(error), + event: "CONNECTION_VERIFICATION_FAILED", + reasonCode: "liveness_timeout", + }); + this.ensureConnected(); + }) + .finally(() => { + if (this.connectionVerification === transport) this.connectionVerification = null; + }); + } + getConnectionState(): ConnectionState { return this.connectionState; } @@ -1429,6 +1647,10 @@ export class DaemonClient { return this.lastErrorValue; } + get authFailureReason(): DaemonAuthFailureReason | null { + return this.authFailureReasonValue; + } + getLastLivenessRttMs(): number | null { return this.lastLivenessRttMs; } @@ -1439,7 +1661,9 @@ export class DaemonClient { subscribe(handler: DaemonEventHandler): () => void { this.eventListeners.add(handler); - return () => this.eventListeners.delete(handler); + return () => { + this.eventListeners.delete(handler); + }; } subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void { @@ -1541,7 +1765,7 @@ export class DaemonClient { private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void { if (!this.transport) { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", @@ -1605,12 +1829,23 @@ export class DaemonClient { * This prevents waiters from hanging forever when called during connection. */ private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> { + return this.sendWhenConnected(() => { + const payload = SessionInboundMessageSchema.parse(message); + this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + }); + } + + /** Resolves once connected, waiting out a connection that is still being established. */ + private whenConnected(): Promise<void> { + return this.sendWhenConnected(() => undefined); + } + + private sendWhenConnected(send: () => void): Promise<void> { const status = this.connectionState.status; // If connected, send immediately if (this.transport && status === "connected") { - const payload = SessionInboundMessageSchema.parse(message); - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + send(); return Promise.resolve(); } @@ -1623,15 +1858,20 @@ export class DaemonClient { if (idx !== -1) { this.pendingSendQueue.splice(idx, 1); } - reject(new Error(`Timed out waiting for connection to send message`)); + reject( + new DaemonConnectionError( + "Timed out waiting for connection to send message", + "DAEMON_REQUEST_TIMEOUT", + ), + ); }, DEFAULT_SEND_QUEUE_TIMEOUT_MS); - this.pendingSendQueue.push({ message, resolve, reject, timeoutHandle }); + this.pendingSendQueue.push({ send, resolve, reject, timeoutHandle }); }); } // Not connected and not connecting - fail immediately - return Promise.reject(new Error(`Transport not connected (status: ${status})`)); + return Promise.reject(new DaemonConnectionError(`Transport not connected (status: ${status})`)); } /** @@ -1645,11 +1885,10 @@ export class DaemonClient { clearTimeout(pending.timeoutHandle); try { if (this.transport && this.connectionState.status === "connected") { - const payload = SessionInboundMessageSchema.parse(pending.message); - this.sendJsonMessage("session", payload.type, { type: "session", message: payload }); + pending.send(); pending.resolve(); } else { - pending.reject(new Error("Connection lost before message could be sent")); + pending.reject(new DaemonConnectionError("Connection lost before message could be sent")); } } catch (error) { pending.reject(error instanceof Error ? error : new Error(String(error))); @@ -1677,6 +1916,7 @@ export class DaemonClient { select: (msg: SessionOutboundMessage) => T | null; options?: { skipQueue?: boolean }; }): Promise<T> { + const wire = this.owned.prepareRequest(params.message); const timeout = params.timeout ?? DEFAULT_SESSION_RPC_TIMEOUT_MS; const { promise, cancel } = this.waitForWithCancel<RpcWaitResult<T>>( (msg) => { @@ -1691,7 +1931,7 @@ export class DaemonClient { }), }; } - const value = params.select(msg); + const value = params.select(wire.receive(msg)); if (value === null) { return null; } @@ -1702,7 +1942,7 @@ export class DaemonClient { ); try { - await this.sendSessionMessageOrThrow(params.message); + await this.sendSessionMessageOrThrow(wire.message); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); cancel(err); @@ -1710,11 +1950,13 @@ export class DaemonClient { throw err; } - const result = await promise; - if (result.kind === "error") { - throw result.error; + try { + const result = await promise; + if (result.kind === "error") throw result.error; + return result.value; + } finally { + await wire.finish(); } - return result.value; } private async sendCorrelatedRequest< @@ -1796,7 +2038,7 @@ export class DaemonClient { private sendSessionMessageStrict(message: SessionInboundMessage): void { if (!this.transport || this.connectionState.status !== "connected") { - throw new Error("Transport not connected"); + throw new DaemonConnectionError("Transport not connected"); } const payload = SessionInboundMessageSchema.parse(message); try { @@ -1855,6 +2097,20 @@ export class DaemonClient { } } + async markWorkspaceUnread(workspaceId: string, requestId?: string): Promise<void> { + const response = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.mark_unread.response">({ + requestId, + message: { + type: "workspace.mark_unread.request", + workspaceId, + }, + }); + if (!response.success) { + throw new Error(response.error ?? "Failed to mark workspace unread"); + } + } + sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; @@ -2030,18 +2286,125 @@ export class DaemonClient { // Agent RPCs (requestId-correlated) // ============================================================================ - async fetchAgents(options?: FetchAgentsOptions): Promise<FetchAgentsPayload> { + private observe<T extends CorrelatedResponseType>( + responseType: T, + message: { type: SessionInboundMessage["type"] } & Record<string, unknown>, + options?: { requestId?: string; timeout?: number; signal?: AbortSignal }, + ): OwnedSubscription<CorrelatedResponsePayload<T>> { + let resetSource = false; + return this.owned.observeRequest<CorrelatedResponsePayload<T>>( + message, + async (query, accept) => { + resetSource = false; + const requestId = this.createRequestId(options?.requestId); + const request = SessionInboundMessageSchema.parse({ ...query, requestId }); + try { + return await this.sendCorrelatedRequest<T, CorrelatedResponsePayload<T>>({ + message: request, + responseType, + requestId, + timeout: options?.timeout, + options: { skipQueue: true }, + selectPayload: (payload): CorrelatedResponsePayload<T> | null => { + if ("error" in payload && typeof payload.error === "string") + throw new DaemonRpcError({ + requestId: payload.requestId, + requestType: message.type, + error: payload.error, + }); + accept(payload); + return payload; + }, + }); + } catch (error) { + resetSource = !(error instanceof DaemonRpcError) && this.isConnected; + throw error; + } + }, + options?.signal, + () => { + // Reject and release this handle before closing a source with an unknown bootstrap outcome. + // Closing first would classify the failure as a reconnect and replay the failed request. + if (resetSource) { + this.disposeTransport(1001, "Subscription request failed"); + this.scheduleReconnect({ reason: "Subscription request failed" }); + } + }, + ); + } + + registerBrowserHost( + registration: Omit< + Extract<SessionInboundMessage, { type: "browser.host.register.request" }>, + "type" | "requestId" + >, + options?: { signal?: AbortSignal }, + ): OwnedSubscription<CorrelatedResponsePayload<"browser.host.register.response">> { + return this.observe( + "browser.host.register.response", + { type: "browser.host.register.request", ...registration }, + options, + ); + } + + observeEvents( + events: SessionEventSubscription[], + options?: { signal?: AbortSignal; notifications?: boolean }, + ): OwnedSubscription<CorrelatedResponsePayload<"session.events.set_subscription.response">> { + return this.observe( + "session.events.set_subscription.response", + { + type: "session.events.set_subscription.request", + events, + notifications: options?.notifications, + }, + options, + ); + } + + observeAgents( + options: Omit<FetchAgentsOptions, "subscribe"> = {}, + ): OwnedSubscription<FetchAgentsPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); + const { signal, requestId, timeout, ...query } = options; + return this.observe( + "fetch_agents_response", + { ...query, type: "fetch_agents_request", subscribe: {} }, + { signal, requestId, timeout }, + ); + } + + observeWorkspaces( + options: Omit<FetchWorkspacesOptions, "subscribe"> = {}, + ): OwnedSubscription<FetchWorkspacesPayload> { + if (options.includeBackground || options.filter?.includeBackground) + this.requireBackgroundWorkspaces(); + const { signal, requestId, ...query } = options; + return this.observe( + "fetch_workspaces_response", + { ...query, type: "fetch_workspaces_request", subscribe: {} }, + { signal, requestId }, + ); + } + + fetchAgents( + options: FetchAgentsOptions & { subscribe: {} }, + ): Promise<FetchAgentsPayload & { subscription: OwnedSubscription<FetchAgentsPayload> }>; + fetchAgents(options?: FetchAgentsOptions): Promise<FetchAgentsPayload>; + async fetchAgents( + options?: FetchAgentsOptions, + ): Promise<FetchAgentsPayload & { subscription?: OwnedSubscription<FetchAgentsPayload> }> { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeAgents(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "fetch_agents_request", - requestId: resolvedRequestId, - ...(options?.scope ? { scope: options.scope } : {}), - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), - }); + const message = buildFetchAgentsRequest(options, resolvedRequestId); return this.sendRequest({ requestId: resolvedRequestId, message, @@ -2060,6 +2423,7 @@ export class DaemonClient { } async fetchAgentHistory(options?: FetchAgentHistoryOptions): Promise<FetchAgentHistoryPayload> { + if (options?.filter?.includeBackground) this.requireBackgroundWorkspaces(); const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_agent_history_request", @@ -2114,16 +2478,28 @@ export class DaemonClient { }); } - async fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload> { + fetchWorkspaces( + options: FetchWorkspacesOptions & { subscribe: {} }, + ): Promise<FetchWorkspacesPayload & { subscription: OwnedSubscription<FetchWorkspacesPayload> }>; + fetchWorkspaces(options?: FetchWorkspacesOptions): Promise<FetchWorkspacesPayload>; + async fetchWorkspaces( + options?: FetchWorkspacesOptions, + ): Promise< + FetchWorkspacesPayload & { subscription?: OwnedSubscription<FetchWorkspacesPayload> } + > { + if (options?.includeBackground || options?.filter?.includeBackground) + this.requireBackgroundWorkspaces(); + if (options?.subscribe) { + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = this.observeWorkspaces(options); + return { ...(await subscription.ready), subscription }; + } const resolvedRequestId = this.createRequestId(options?.requestId); const message = SessionInboundMessageSchema.parse({ type: "fetch_workspaces_request", + ...options, requestId: resolvedRequestId, - ...(options?.filter ? { filter: options.filter } : {}), - ...(options?.sort ? { sort: options.sort } : {}), - ...(options?.page ? { page: options.page } : {}), - ...(options?.subscribe ? { subscribe: options.subscribe } : {}), - ...(options?.sync ? { sync: options.sync } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, @@ -2141,21 +2517,31 @@ export class DaemonClient { }); } - listWorkspaceLabels(options: { - subscriptionId: string; - sync?: { generation: string; afterSeq: number }; - requestId?: string; - }): Promise<WorkspaceLabelListPayload> { + listWorkspaceLabels( + options: { + sync?: { generation: string; afterSeq: number }; + requestId?: string; + } = {}, + ): Promise<WorkspaceLabelListPayload> { return this.sendNamespacedCorrelatedSessionRequest({ requestId: options.requestId, message: { type: "workspace.label.list.request", - subscribe: { subscriptionId: options.subscriptionId }, ...(options.sync ? { sync: options.sync } : {}), }, }); } + observeWorkspaceLabels(options?: { + signal?: AbortSignal; + }): OwnedSubscription<WorkspaceLabelListPayload> { + return this.observe( + "workspace.label.list.response", + { type: "workspace.label.list.request", subscribe: {} }, + options, + ); + } + setWorkspaceLabel(options: { workspaceId: string; label: Extract< @@ -2443,58 +2829,73 @@ export class DaemonClient { return { agent: payload.agent, project: payload.project ?? null }; } - private resubscribeCheckoutDiffSubscriptions(): void { - if (this.checkoutDiffSubscriptions.size === 0) { - return; - } - for (const [subscriptionId, subscription] of this.checkoutDiffSubscriptions) { - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd: subscription.cwd, - compare: subscription.compare, - requestId: this.createRequestId(), - }); - this.sendSessionMessage(message); - } - } - - private resubscribeTerminalDirectorySubscriptions(): void { - if (this.terminalDirectorySubscriptions.size === 0) { - return; - } - for (const subscription of this.terminalDirectorySubscriptions.values()) { - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: subscription.cwd, - ...(subscription.workspaceId !== undefined - ? { workspaceId: subscription.workspaceId } - : {}), - }); - } - } - - private resubscribeFileSubscriptions(): void { - for (const [subscriptionId, subscription] of this.fileSubscriptions) { - void this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: subscription.cwd, - path: subscription.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }) - .then((payload) => subscription.onUpdate(payload.initial)) - .catch(() => undefined); - } - } - // ============================================================================ // Agent Lifecycle // ============================================================================ + private readonly creations = new CreationClient({ + supports: (feature) => this.lastServerInfoMessage?.features?.[feature] === true, + requestId: () => this.createRequestId(), + request: (kind, input) => + kind === "workspace" + ? this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "workspace.create.request" }, + responseType: "workspace.create.response", + timeout: 0, + }) + : this.sendCorrelatedSessionRequest({ + requestId: input.requestId as string | undefined, + message: { ...input, type: "agent.create.request" }, + responseType: "agent.create.response", + timeout: 0, + }), + observe: (kind, idempotencyKey, next, error) => { + const observation = this.observe("creation.subscribe.response", { + type: "creation.subscribe.request", + kind, + idempotencyKey, + }); + observation.subscribe({ + snapshot: (result) => next(result.snapshot), + update: (message) => { + if (message.type === "workspace.create.update" || message.type === "agent.create.update") + next(message.payload); + }, + error, + }); + return () => { + void observation.release().catch(error); + }; + }, + legacyAgent: (input) => this.createLegacyAgent(input), + legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId), + }); + + // Background workspaces are gated once at the transport boundary. + private requireBackgroundWorkspaces(): void { + if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) { + throw new Error("Update the host to use background workspaces."); + } + } + + supportsBackgroundWorkspaces(): boolean { + return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true; + } + async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> { + if (options.background !== undefined) this.requireBackgroundWorkspaces(); + const result = await this.creations.createAgent({ + ...options, + config: resolveAgentConfig(options), + }); + if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); + return result.agent; + } + + private async createLegacyAgent( + options: CreateAgentRequestOptions, + ): Promise<AgentSnapshotPayload> { const requestId = this.createRequestId(options.requestId); const config = resolveAgentConfig(options); @@ -2506,6 +2907,7 @@ export class DaemonClient { ...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}), ...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}), ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), + idempotencyKey: options.idempotencyKey, ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(options.images && options.images.length > 0 ? { images: options.images } : {}), @@ -2515,6 +2917,7 @@ export class DaemonClient { ...(options.git ? { git: options.git } : {}), ...(options.worktree ? { worktree: options.worktree } : {}), ...(options.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), + ...(options.background !== undefined ? { background: options.background } : {}), ...(options.worktreeName ? { worktreeName: options.worktreeName } : {}), ...(options.labels && Object.keys(options.labels).length > 0 ? { labels: options.labels } @@ -2907,6 +3310,21 @@ export class DaemonClient { return { seq: payload.seq, epoch: payload.epoch }; } + async searchAgentTimeline({ + agentId, + query, + cursor, + }: AgentTimelineSearchOptions): Promise<AgentTimelineSearchPayload> { + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "agent.timeline.search.request", requestId, agentId, query, cursor }, + responseType: "agent.timeline.search.response", + }); + if (payload.error) throw new Error(payload.error); + return payload; + } + async listAgentTimelinePrompts( agentId: string, options: { requestId?: string; timeout?: number } = {}, @@ -2966,6 +3384,10 @@ export class DaemonClient { subagentId: string, options: FetchProviderSubagentTimelineOptions = {}, ): Promise<ProviderSubagentTimelinePayload> { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + if (this.lastServerInfoMessage?.features?.projectedSubagentTimeline !== true) { + throw new Error("Update the host to view subagent conversations."); + } const requestId = this.createRequestId(options.requestId); const message = SessionInboundMessageSchema.parse({ type: "agent.provider_subagents.timeline.get.request", @@ -2993,33 +3415,24 @@ export class DaemonClient { return payload; } - async setAgentTimelineSubscription(agentIds: string[]): Promise<void> { - // COMPAT(selectiveAgentTimeline): added in v0.1.106. Old daemons keep their - // legacy global stream and do not understand this RPC. Remove after - // 2027-01-12 once the supported daemon floor is >= v0.1.106. - if (!this.lastServerInfoMessage?.features?.selectiveAgentTimeline) { - return; - } + observeTimeline( + agentIds: string[], + options?: { signal?: AbortSignal }, + ): OwnedSubscription<CorrelatedResponsePayload<"agent.timeline.set_subscription.response">> { + return this.observe( + "agent.timeline.set_subscription.response", + { type: "agent.timeline.set_subscription.request", agentIds }, + options, + ); + } - const requestId = this.createRequestId(); - const normalizedAgentIds = [...new Set(agentIds)].sort(); - const message = SessionInboundMessageSchema.parse({ - type: "agent.timeline.set_subscription.request", - agentIds: normalizedAgentIds, - requestId, - }); - - await this.sendRequest({ - requestId, - message, - options: { skipQueue: true }, - select: (response) => { - if (response.type !== "agent.timeline.set_subscription.response") { - return null; - } - return response.payload.requestId === requestId ? response.payload : null; - }, - }); + subscribeAgentTimeline( + agentId: string, + handler: (message: TimelineMessage) => void, + ): TimelineSubscription { + return subscribeTimeline(agentId, this.observeTimeline([agentId]), handler, (error) => + this.logger.error({ err: error }, "Timeline observation failed"), + ); } async buildAgentForkContext( @@ -3067,6 +3480,12 @@ export class DaemonClient { text: string, options?: SendMessageOptions, ): Promise<void> { + if ( + options?.sourceAgentId && + this.lastServerInfoMessage?.features?.agentMessageProvenance !== true + ) { + throw new Error("Update the Paseo host to send messages with agent provenance."); + } const requestId = this.createRequestId(); const messageId = options?.messageId ?? crypto.randomUUID(); const message = SessionInboundMessageSchema.parse({ @@ -3074,6 +3493,7 @@ export class DaemonClient { requestId, agentId, text, + sourceAgentId: options?.sourceAgentId, ...(messageId ? { messageId } : {}), ...(options?.activeTurnBehavior ? { activeTurnBehavior: options.activeTurnBehavior } : {}), ...(options?.images ? { images: options.images } : {}), @@ -3313,7 +3733,11 @@ export class DaemonClient { return payload.notice ?? null; } - async restartServer(reason?: string, requestId?: string): Promise<RestartRequestedStatusPayload> { + async restartServer( + reason?: string, + requestId?: string, + options?: { timeout?: number }, + ): Promise<RestartRequestedStatusPayload> { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "restart_server_request", @@ -3323,6 +3747,7 @@ export class DaemonClient { return this.sendRequest({ requestId: resolvedRequestId, message, + timeout: options?.timeout, options: { skipQueue: true }, select: (msg) => { if (msg.type !== "status") { @@ -3726,79 +4151,31 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise<CheckoutDiffPayload> { - const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; - try { - const payload = await this.subscribeCheckoutDiff(cwd, compare, { - subscriptionId: oneShotSubscriptionId, - requestId, - }); - return { - cwd: payload.cwd, - files: payload.files, - error: payload.error, - diffTooLarge: payload.diffTooLarge, - requestId: payload.requestId, - }; - } finally { - try { - this.unsubscribeCheckoutDiff(oneShotSubscriptionId); - } catch { - // Ignore disconnect races during one-shot cleanup. - } - } + return this.sendCorrelatedSessionRequest({ + message: { + type: "checkout.diff.get.request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + responseType: "checkout.diff.get.response", + requestId, + }); } - async subscribeCheckoutDiff( + observeCheckoutDiff( cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, - options?: { subscriptionId?: string; requestId?: string }, - ): Promise<SubscribeCheckoutDiffPayload> { - const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); - const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); - const previousSubscription = this.checkoutDiffSubscriptions.get(subscriptionId) ?? null; - this.checkoutDiffSubscriptions.set(subscriptionId, { - cwd, - compare: normalizedCompare, - }); - - const resolvedRequestId = this.createRequestId(options?.requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_checkout_diff_request", - subscriptionId, - cwd, - compare: normalizedCompare, - requestId: resolvedRequestId, - }); - - try { - return await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_checkout_diff_response", - options: { skipQueue: true }, - selectPayload: (payload) => { - if (payload.subscriptionId !== subscriptionId) { - return null; - } - return payload; - }, - }); - } catch (error) { - if (previousSubscription) { - this.checkoutDiffSubscriptions.set(subscriptionId, previousSubscription); - } else { - this.checkoutDiffSubscriptions.delete(subscriptionId); - } - throw error; - } - } - - unsubscribeCheckoutDiff(subscriptionId: string): void { - this.checkoutDiffSubscriptions.delete(subscriptionId); - this.sendSessionMessage({ - type: "unsubscribe_checkout_diff_request", - subscriptionId, - }); + options?: { requestId?: string; signal?: AbortSignal }, + ): OwnedSubscription<SubscribeCheckoutDiffPayload> { + return this.observe( + "subscribe_checkout_diff_response", + { + type: "subscribe_checkout_diff_request", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + }, + options, + ); } async checkoutCommit( @@ -4211,11 +4588,31 @@ export class DaemonClient { } async createWorkspace( - input: { - source: WorkspaceCreateRequest["source"]; - title?: string; - firstAgentContext?: WorkspaceCreateRequest["firstAgentContext"]; - }, + input: CreateWorkspaceRequestOptions, + requestId?: string, + ): Promise<WorkspaceCreatePayload> { + const resolvedRequestId = this.createRequestId(requestId ?? input.requestId); + if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); + if (input.agent?.background !== undefined) + throw new Error("Configure background on the workspace, not its initial agent."); + const result = await this.creations.createWorkspace({ + ...input, + requestId: resolvedRequestId, + ...(input.agent + ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } + : {}), + }); + return { + ...result, + workspace: result.workspace ?? null, + agent: result.agent ?? undefined, + setupTerminalId: result.setupTerminalId ?? null, + requestId: result.requestId ?? resolvedRequestId, + }; + } + + private async createLegacyWorkspace( + input: CreateWorkspaceRequestOptions, requestId?: string, ): Promise<WorkspaceCreatePayload> { return this.sendCorrelatedSessionRequest({ @@ -4223,7 +4620,14 @@ export class DaemonClient { message: { type: "workspace.create.request", source: input.source, + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove after 2027-03-15 once the daemon floor supports workspace receipts. + ...(this.lastServerInfoMessage?.features?.workspaceRequestReceipts && + input.idempotencyKey !== undefined + ? { idempotencyKey: input.idempotencyKey } + : {}), ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.background !== undefined ? { background: input.background } : {}), + ...(input.callerAgentId ? { callerAgentId: input.callerAgentId } : {}), ...(input.firstAgentContext !== undefined ? { firstAgentContext: input.firstAgentContext } : {}), @@ -4402,36 +4806,40 @@ export class DaemonClient { } } + observeFile(input: { + cwd: string; + path: string; + signal?: AbortSignal; + }): OwnedSubscription<CorrelatedResponsePayload<"fs.file.subscribe.response">> { + return this.observe( + "fs.file.subscribe.response", + { type: "fs.file.subscribe.request", cwd: input.cwd, path: input.path }, + { signal: input.signal }, + ); + } + async subscribeFile( - input: { cwd: string; path: string }, + input: { cwd: string; path: string; signal?: AbortSignal }, onUpdate: (version: FileVersion) => void, - ): Promise<{ initial: FileVersion; unsubscribe: () => void }> { - const subscriptionId = this.createRequestId(); - this.fileSubscriptions.set(subscriptionId, { ...input, onUpdate }); - try { - const payload = await this.sendCorrelatedSessionRequest({ - message: { - type: "fs.file.subscribe.request", - cwd: input.cwd, - path: input.path, - subscriptionId, - }, - responseType: "fs.file.subscribe.response", - }); - return { - initial: payload.initial, - unsubscribe: () => { - if (!this.fileSubscriptions.delete(subscriptionId)) return; - void this.sendCorrelatedSessionRequest({ - message: { type: "fs.file.unsubscribe.request", subscriptionId }, - responseType: "fs.file.unsubscribe.response", - }).catch(() => undefined); - }, - }; - } catch (error) { - this.fileSubscriptions.delete(subscriptionId); - throw error; - } + ): Promise<{ + initial: FileVersion; + subscription: OwnedSubscription<CorrelatedResponsePayload<"fs.file.subscribe.response">>; + unsubscribe: () => Promise<void>; + }> { + const subscription = this.observeFile(input); + let initial = true; + subscription.subscribe({ + snapshot: (snapshot) => { + // The initial version is returned below. Subsequent snapshots repair reconnects. + if (!initial) onUpdate(snapshot.initial); + initial = false; + }, + update: (message) => { + if (message.type === "fs.file.update") onUpdate(message.payload.version); + }, + }); + const snapshot = await subscription.ready; + return { initial: snapshot.initial, subscription, unsubscribe: subscription.release }; } async writeFile(input: { @@ -4501,6 +4909,9 @@ export class DaemonClient { if (!bytes) { throw new Error("File bytes are required."); } + // The file frames bypass the send queue, so start only on an open connection. + await this.whenConnected(); + const uploadTransport = this.transport; const resolvedRequestId = this.createRequestId(input.requestId); const modifiedAt = input.modifiedAt ?? new Date().toISOString(); const responsePromise = this.sendCorrelatedRequest({ @@ -4517,37 +4928,63 @@ export class DaemonClient { options: { skipQueue: true }, }); - this.sendBinaryFrame( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileBegin, - requestId: resolvedRequestId, - metadata: { - mime: input.mimeType, - size: bytes.byteLength, - encoding: "binary", - modifiedAt, - fileName: input.fileName, - }, - }), + let settled = false; + void responsePromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, ); - - const chunkSize = input.chunkSize ?? 1024 * 1024; - for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + try { this.sendBinaryFrame( encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileChunk, + opcode: FileTransferOpcode.FileBegin, requestId: resolvedRequestId, - payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)), + metadata: { + mime: input.mimeType, + size: bytes.byteLength, + encoding: "binary", + modifiedAt, + fileName: input.fileName, + }, }), ); - } - this.sendBinaryFrame( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId: resolvedRequestId, - }), - ); + const chunkSize = input.chunkSize ?? 128 * 1024; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + // Native WebSocket.send encodes binary synchronously. Let rendering and + // incoming messages run between bounded pieces on every platform. + await new Promise<void>((resolve) => setTimeout(resolve, 0)); + if (settled) return await responsePromise; + if (this.transport !== uploadTransport || this.connectionState.status !== "connected") { + throw new DaemonConnectionError("Connection changed during file upload"); + } + this.sendBinaryFrame( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: resolvedRequestId, + payload: bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)), + }), + ); + } + + this.sendBinaryFrame( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: resolvedRequestId, + }), + ); + } catch (error) { + this.rejectWaitersForRequestId( + resolvedRequestId, + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } return responsePromise; } @@ -4662,7 +5099,16 @@ export class DaemonClient { ifNoneMatch?: string; requestId?: string; }): Promise<GetProvidersSnapshotPayload> { - const payload = await this.sendCorrelatedSessionRequest({ + const payload = await this.requestProvidersSnapshot(options); + return normalizeProvidersSnapshotPayload(payload, this.config.providerSnapshots !== "wire"); + } + + private requestProvidersSnapshot(options?: { + cwd?: string; + ifNoneMatch?: string; + requestId?: string; + }): Promise<GetProvidersSnapshotPayload> { + return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { type: "get_providers_snapshot_request", @@ -4671,7 +5117,6 @@ export class DaemonClient { }, responseType: "get_providers_snapshot_response", }); - return normalizeProvidersSnapshotPayload(payload); } async getDaemonConfig( @@ -4687,6 +5132,10 @@ export class DaemonClient { } async getDaemonStatus(options?: DaemonStatusOptions): Promise<DaemonStatusPayload> { + if (!this.lastServerInfoMessage) throw new DaemonConnectionError("Transport not connected"); + if (this.lastServerInfoMessage?.features?.daemonStatusRpc !== true) { + throw new Error("Update the host to read daemon status."); + } return this.sendCorrelatedSessionRequest({ requestId: options?.requestId, message: { @@ -4858,6 +5307,107 @@ export class DaemonClient { }); } + async listUsageReports( + options?: { + agentId?: string; + requestId?: string; + forceRefresh?: boolean; + reportIds?: string[]; + }, + onReport?: (report: UsageReportEntry) => void, + ): Promise<UsageListReportsPayload> { + const features = this.getLastServerInfoMessage()?.features; + if (!supportsUsageReports(features)) { + throw new Error("Update the host to see usage."); + } + if (options?.agentId !== undefined && options.reportIds !== undefined) + throw new Error("agentId and reportIds cannot be combined"); + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + if (features?.usageSources !== true) { + if (options?.agentId !== undefined) + return { requestId: this.createRequestId(options.requestId), reports: [] }; + // Released hosts serve a five-minute cache and have no forceRefresh option. + const payload = await this.listProviderUsage({ requestId: options?.requestId }); + return { + requestId: payload.requestId, + reports: payload.providers + .filter( + (provider) => !options?.reportIds || options.reportIds.includes(provider.providerId), + ) + .map((provider) => { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + // 0.10 reports have no typed problems; preserve their unavailable badge and error text. + let report: UsageListReportsPayload["reports"][number]["report"]; + if (provider.status === "available") { + report = { + status: "available", + windows: provider.windows, + balances: provider.balances ?? undefined, + details: provider.details ?? undefined, + planLabel: provider.planLabel ?? undefined, + }; + } else if (provider.status === "error") { + report = { status: "error", error: provider.error ?? "" }; + } else { + report = { + status: "unavailable", + problem: { kind: "no_quota", detail: provider.error ?? "" }, + }; + } + return { + id: provider.providerId, + sourceId: provider.providerId, + sourceLabel: provider.displayName, + icon: legacyUsageIcon(provider.providerId), + account: {}, + fetchedAt: provider.fetchedAt ?? payload.fetchedAt, + report, + }; + }), + }; + } + const requestId = this.createRequestId(options?.requestId); + const reports: UsageReportEntry[] = []; + let active = true; + const unsubscribe = this.subscribeRawMessages((message) => { + if ( + !active || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== requestId + ) + return; + if (message.type === "usage.list_reports.response" || message.type === "rpc_error") { + active = false; + return; + } + if (message.type !== "usage.list_reports.update") return; + reports.push(message.payload.report); + onReport?.(message.payload.report); + }); + try { + const response = await this.sendRequest({ + requestId, + message: { + type: "usage.list_reports.request", + requestId, + forceRefresh: options?.forceRefresh, + reportIds: options?.reportIds, + agentId: options?.agentId, + }, + select: (message) => + message.type === "usage.list_reports.response" && message.payload.requestId === requestId + ? message.payload + : null, + }); + if (response.error !== null) throw new Error(response.error); + return { requestId, reports }; + } finally { + active = false; + unsubscribe(); + } + } + async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>; async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>; async listCommands( @@ -4897,7 +5447,7 @@ export class DaemonClient { }); } - async getPluginCatalog(): Promise<Array<{ id: string; clientBundle: string }>> { + async getPluginCatalog() { const requestId = this.createRequestId(); const payload = await this.sendCorrelatedSessionRequest({ requestId, @@ -4998,18 +5548,21 @@ export class DaemonClient { ref?: string; }): Promise<PluginListItem> { const requestId = this.createRequestId(); - const reference = parsePluginSourceReference(input.source); + // COMPAT(pluginSourceInstallation): added in v0.8.0; remove after 2027-03-16 once daemon floor supports source identifiers. + if (this.getLastServerInfoMessage()?.features?.pluginSourceInstallation !== true) { + throw new Error("Update the host to install plugin sources."); + } const payload = await this.sendCorrelatedSessionRequest({ requestId, message: { type: "plugin.source.install.request", requestId, - source: reference.source, - ...(reference.pluginPath ? { pluginPath: reference.pluginPath } : {}), + source: input.source, ...(input.id ? { id: input.id } : {}), ...(input.ref ? { ref: input.ref } : {}), }, responseType: "plugin.source.install.response", + timeout: 5 * 60 * 1000, }); return payload.plugin; } @@ -5028,6 +5581,38 @@ export class DaemonClient { return payload.plugins; } + private requirePluginUpdates(): void { + // COMPAT(pluginSourceUpdates): added in v0.8.0; remove after 2027-03-16 once daemon floor supports reviewed updates. + if (this.getLastServerInfoMessage()?.features?.pluginSourceUpdates !== true) + throw new Error("Update the host to review plugin updates."); + } + + async previewPluginUpdates( + input: { pluginId?: string; target?: PluginUpdateSelection } = {}, + ): Promise<PluginUpdatePreview[]> { + this.requirePluginUpdates(); + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.source.update.preview.request", requestId, ...input }, + responseType: "plugin.source.update.preview.response", + timeout: 300_000, + }); + return payload.plugins; + } + + async applyPluginUpdates(proposals: PluginUpdateProposal[]): Promise<PluginUpdateResult[]> { + this.requirePluginUpdates(); + const requestId = this.createRequestId(); + const payload = await this.sendCorrelatedSessionRequest({ + requestId, + message: { type: "plugin.source.update.apply.request", requestId, proposals }, + responseType: "plugin.source.update.apply.response", + timeout: 300_000, + }); + return payload.plugins; + } + async updatePluginSources(pluginId?: string): Promise<PluginSourceUpdateItem[]> { const requestId = this.createRequestId(); const payload = await this.sendCorrelatedSessionRequest({ @@ -5270,33 +5855,17 @@ export class DaemonClient { // Terminals // ============================================================================ - subscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.set(terminalSubscriptionKey(input.cwd, input.workspaceId), { - cwd: input.cwd, - workspaceId: input.workspaceId, - }); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "subscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); - } - - unsubscribeTerminals(input: { cwd: string; workspaceId?: string }): void { - this.terminalDirectorySubscriptions.delete( - terminalSubscriptionKey(input.cwd, input.workspaceId), + observeTerminals(input: { + cwd: string; + workspaceId?: string; + signal?: AbortSignal; + }): OwnedSubscription<CorrelatedResponsePayload<"terminals_changed">> { + const { signal, ...query } = input; + return this.observe( + "terminals_changed", + { type: "subscribe_terminals_request", ...query }, + { signal }, ); - if (!this.transport || this.connectionState.status !== "connected") { - return; - } - this.sendSessionMessage({ - type: "unsubscribe_terminals_request", - cwd: input.cwd, - ...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}), - }); } async listTerminals( @@ -5363,53 +5932,92 @@ export class DaemonClient { }); } - async subscribeTerminal( + /** + * Snapshot/restore and output arrive in receive. Subscribe to the returned handle + * for terminal_stream_exit: payload.error means observation failure, not PTY exit. + * Either outcome detaches this slot and releases the handle automatically. + */ + observeTerminal( terminalId: string, - optionsOrRequestId?: - | { restore?: SubscribeTerminalRequest["restore"]; requestId?: string } - | string, - ): Promise<SubscribeTerminalPayload> { - const restore = typeof optionsOrRequestId === "object" ? optionsOrRequestId.restore : undefined; - const requestId = - typeof optionsOrRequestId === "object" ? optionsOrRequestId.requestId : optionsOrRequestId; - const resolvedRequestId = this.createRequestId(requestId); - const message = SessionInboundMessageSchema.parse({ - type: "subscribe_terminal_request", - terminalId, - requestId: resolvedRequestId, - ...(restore ? { restore } : {}), + receive: (event: TerminalStreamEvent) => void, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): OwnedSubscription<SubscribeTerminalPayload> { + options?.signal?.throwIfAborted(); + const observation = this.observe( + "subscribe_terminal_response", + { + type: "subscribe_terminal_request", + terminalId, + ...(options?.restore ? { restore: options.restore } : {}), + }, + options, + ); + let detach = () => {}; + let released = false; + const release = () => { + released = true; + detach(); + options?.signal?.removeEventListener("abort", abort); + return observation.release(); + }; + const abort = () => { + void release().catch((error) => this.logger.error({ err: error }, "Terminal release failed")); + }; + options?.signal?.addEventListener("abort", abort, { once: true }); + observation.subscribe({ + snapshot: (payload) => { + detach(); + if (released || options?.signal?.aborted || payload.error !== null) return; + const subscriptionId = payload.subscriptionId; + detach = this.terminalStreams.attach(subscriptionId, terminalId, payload.slot, (event) => { + if (released || observation.subscriptionId !== subscriptionId) return; + try { + receive(event); + } catch (error) { + this.logger.error({ err: error }, "Terminal observer failed"); + } + }); + }, + update: (message) => { + if (message.type === "terminal_stream_exit") { + detach(); + queueMicrotask(abort); + } + }, + error: () => { + detach(); + }, }); - const payload = await this.sendCorrelatedRequest({ - requestId: resolvedRequestId, - message, - responseType: "subscribe_terminal_response", - options: { skipQueue: true }, - }); - if (payload.error === null) { - this.terminalStreams.setSlot(terminalId, payload.slot); - } - return payload; + return { + get subscriptionId() { + return observation.subscriptionId; + }, + ready: observation.ready, + subscribe: observation.subscribe, + release, + }; } - unsubscribeTerminal(terminalId: string): void { - this.terminalStreams.removeTerminal(terminalId); - this.sendSessionMessage({ - type: "unsubscribe_terminal_request", - terminalId, - }); + async subscribeTerminal( + terminalId: string, + options?: { + restore?: SubscribeTerminalRequest["restore"]; + signal?: AbortSignal; + requestId?: string; + }, + ): Promise< + SubscribeTerminalPayload & { subscription: OwnedSubscription<SubscribeTerminalPayload> } + > { + const subscription = this.observeTerminal(terminalId, () => {}, options); + return { ...(await subscription.ready), subscription }; } sendTerminalInput(terminalId: string, message: TerminalInput["message"]): void { - const frame = this.terminalStreams.encodeInput(terminalId, message); - if (frame) { - this.sendBinaryFrame(frame); - return; - } - this.sendSessionMessage({ - type: "terminal_input", - terminalId, - message, - }); + this.sendSessionMessage({ type: "terminal_input", terminalId, message }); } async killTerminal(terminalId: string, requestId?: string): Promise<KillTerminalPayload> { @@ -5633,8 +6241,9 @@ export class DaemonClient { return this.config.url; } - private sendHelloMessage(): void { - if (!this.transport) { + private async sendHelloMessage(): Promise<void> { + const transport = this.transport; + if (!transport) { this.scheduleReconnect({ reason: "Transport unavailable before hello", event: "HELLO_TRANSPORT_MISSING", @@ -5644,18 +6253,16 @@ export class DaemonClient { } try { + if (this.transport !== transport) return; + const auth = this.helloAuth; this.sendJsonMessage("hello", "hello", { type: "hello", clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, + ...(auth ? { auth } : {}), capabilities: { - [CLIENT_CAPS.customModeIcons]: true, - [CLIENT_CAPS.reasoningMergeEnum]: true, - [CLIENT_CAPS.terminalReflowableSnapshot]: true, - [CLIENT_CAPS.providerSubagents]: true, - [CLIENT_CAPS.projectUpdates]: true, - [CLIENT_CAPS.compactProviderSnapshots]: true, + ...DEFAULT_CLIENT_CAPABILITIES, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), @@ -5672,6 +6279,8 @@ export class DaemonClient { } private disposeTransport(code = 1001, reason = "Reconnecting"): void { + this.owned.disconnected(); + this.providerSnapshotUpdates.clear(); this.stopLivenessHeartbeat(); this.cleanupTransport(); if (this.transport) { @@ -5717,10 +6326,11 @@ export class DaemonClient { rawData instanceof Blob && typeof rawData.arrayBuffer === "function" ) { + const transport = this.transport; void rawData .arrayBuffer() .then((buffer) => { - this.handleTransportMessage(buffer); + if (this.transport === transport) this.handleTransportMessage(buffer); return; }) .catch(() => { @@ -5796,6 +6406,18 @@ export class DaemonClient { return; } + if (parsed.data.type === "hello.rejected") { + const reasonMessage = { + password_required: "Password required", + incorrect_password: "Incorrect password", + incompatible_protocol: "Incompatible protocol version", + }; + this.lastErrorValue = reasonMessage[parsed.data.reason]; + this.authFailureReasonValue = + parsed.data.reason === "incompatible_protocol" ? null : parsed.data.reason; + return; + } + this.traceInstant("paseo.ws.message.inbound", { envelopeType: "session", messageType: parsed.data.message.type, @@ -5961,6 +6583,10 @@ export class DaemonClient { setReconnectEnabled(enabled: boolean): void { this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } }; + if (!enabled && this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } } private scheduleReconnect(input?: { @@ -5979,21 +6605,30 @@ export class DaemonClient { this.lastErrorValue = reason.trim(); } + this.owned.disconnected(); + this.providerSnapshotUpdates.clear(); + // Clear all pending waiters and queued sends since the connection was lost // and responses from the previous connection will never arrive. - this.clearWaiters(new Error(reason ?? "Connection lost")); - this.rejectPendingSendQueue(new Error(reason ?? "Connection lost")); - this.rejectPingProbe(new Error(reason ?? "Connection lost")); + this.clearWaiters(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPendingSendQueue(new DaemonConnectionError(reason ?? "Connection lost")); + this.rejectPingProbe(new DaemonConnectionError(reason ?? "Connection lost")); this.terminalStreams.clearSlots(); this.lastServerInfoMessage = null; + if (this.authFailureReasonValue) this.setReconnectEnabled(false); + if (wasDisposed) { this.rejectConnect(new Error(reason ?? "Daemon client is disposed")); return; } this.emitDisconnectedStateForReconnect(reason, input); if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { - this.rejectConnect(new Error(reason ?? "Transport disconnected before connect")); + this.rejectConnect( + this.authFailureReasonValue + ? new DaemonAuthenticationError(this.authFailureReasonValue) + : new Error(reason ?? "Transport disconnected before connect"), + ); return; } @@ -6077,7 +6712,24 @@ export class DaemonClient { } private handleSessionMessage(msg: SessionOutboundMessage): void { - const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg); + msg = this.owned.normalize(msg); + if ( + msg.type === "providers_snapshot_update" && + this.config.providerSnapshots !== "wire" && + msg.payload.snapshotHash && + !msg.payload.compactSnapshot + ) { + if (this.owned.owns(msg)) this.providerSnapshotUpdates.receive(msg); + return; + } + this.deliverSessionMessage(msg); + } + + private deliverSessionMessage(msg: SessionOutboundMessage): void { + const consumerMessage = normalizeProviderSnapshotUpdateMessage( + msg, + this.config.providerSnapshots !== "wire", + ); if (consumerMessage.type === "status") { const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload); @@ -6088,25 +6740,17 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.startLivenessHeartbeat(); - this.resubscribeCheckoutDiffSubscriptions(); - this.resubscribeTerminalDirectorySubscriptions(); - this.resubscribeFileSubscriptions(); + this.owned.restore(serverInfo, { + ...DEFAULT_CLIENT_CAPABILITIES, + ...this.config.capabilities, + }); + this.creations.reconnect(); this.flushPendingSendQueue(); this.resolveConnect(); } } } - if (consumerMessage.type === "terminal_stream_exit") { - this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId); - } - - if (consumerMessage.type === "fs.file.update") { - this.fileSubscriptions - .get(consumerMessage.payload.subscriptionId) - ?.onUpdate(consumerMessage.payload.version); - } - if (this.rawMessageListeners.size > 0) { for (const handler of this.rawMessageListeners) { try { @@ -6135,18 +6779,28 @@ export class DaemonClient { } } + if ( + consumerMessage.type === "workspace.create.update" || + consumerMessage.type === "agent.create.update" + ) { + if (!consumerMessage.payload.subscriptionId) this.creations.receive(consumerMessage.payload); + } this.resolveWaiters(consumerMessage); + this.owned.receive(consumerMessage); } private resolveWaiters(msg: SessionOutboundMessage): void { for (const waiter of Array.from(this.waiters)) { - const result = waiter.predicate(msg); - if (result !== null) { + try { + const result = waiter.predicate(msg); + if (result === null) continue; this.waiters.delete(waiter); - if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); - } + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); waiter.resolve(result); + } catch (error) { + this.waiters.delete(waiter); + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + waiter.reject(error instanceof Error ? error : new Error(String(error))); } } } @@ -6238,7 +6892,10 @@ export class DaemonClient { options?: WaitOptions, ): WaitHandle<T> { // Capture stack trace at call site, not inside setTimeout - const timeoutError = new Error(`Timeout waiting for message (${timeout}ms)`); + const timeoutError = new DaemonConnectionError( + `Timeout waiting for message (${timeout}ms)`, + "DAEMON_REQUEST_TIMEOUT", + ); let waiter: Waiter<T> | null = null; let settled = false; @@ -6311,6 +6968,16 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon config, provider, cwd, + agentId: _agentId, + onEvent: _onEvent, + idempotencyKey: _idempotencyKey, + clientMessageId: _clientMessageId, + callerAgentId: _callerAgentId, + outputSchema: _outputSchema, + attachments: _attachments, + worktree: _worktree, + autoArchive: _autoArchive, + background: _background, env: _env, workspaceId: _workspaceId, initialPrompt: _initialPrompt, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 0dde83cb4d..ce6d4a5156 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1,3 +1,5 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { afterEach, expect, test, vi } from "vitest"; import { createPaseoApi, createPaseoClient } from "./index.js"; import { DaemonClient } from "./daemon-client.js"; @@ -24,6 +26,17 @@ class FakeWebSocket { send(data: string | ArrayBuffer | Uint8Array): void { this.sent.push(data); + const frame = JSON.parse(String(data)); + if (frame.message?.type === "subscription.release.request") + this.message( + sessionMessage({ + type: "subscription.release.response", + payload: { + requestId: frame.message.requestId, + subscriptionId: frame.message.subscriptionId, + }, + }), + ); } close(): void { @@ -83,11 +96,15 @@ function parseSentFrame( } async function connectClient( - features: Record<string, boolean> = { providersSnapshotCwd: true }, + features: Record<string, boolean> = { + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + }, ): Promise<{ client: PaseoClient; ws: FakeWebSocket }> { - vi.stubGlobal("WebSocket", FakeWebSocket); const client = createPaseoClient({ url: "ws://daemon.test", + webSocketFactory: (url) => new FakeWebSocket(url), reconnect: { enabled: false }, }); @@ -118,6 +135,27 @@ async function connectClient( return { client, ws }; } +function acknowledgeObservation(ws: FakeWebSocket, subscriptionId: string): void { + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: request.type.replace(/([._])request$/, "$1response"), + payload: { + requestId: request.requestId, + subscriptionId, + entries: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }, + }), + ); +} + +async function observeAgents(client: PaseoClient, ws: FakeWebSocket): Promise<string> { + const ready = client.agents.list({ subscribe: {} }); + acknowledgeObservation(ws, "agents-sdk"); + return (await ready).subscriptionId!; +} + function createWorkspace(input: Partial<PaseoWorkspace> = {}): PaseoWorkspace { return { id: "workspace_sdk", @@ -228,8 +266,11 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect(Object.keys(paseo).sort()).toEqual([ "agents", "config", + "dispose", + "observeEvents", "projects", "providers", + "terminals", "workspaces", ]); expect("connect" in paseo).toBe(false); @@ -237,6 +278,37 @@ test("createPaseoApi borrows daemon capabilities without exposing connection own expect("skills" in paseo.agents).toBe(false); }); +test("agent handles send permission responses for their agent", async () => { + const { client, ws } = await connectClient(); + + await client.agents.ref("agent_sdk").respondToPermission({ + requestId: "permission-request", + response: { + behavior: "deny", + selectedActionId: "deny-once", + message: "Not approved", + interrupt: true, + }, + }); + + expect(parseSentFrame(ws.sent.at(-1))).toEqual({ + type: "session", + message: { + type: "agent_permission_response", + agentId: "agent_sdk", + requestId: "permission-request", + response: { + behavior: "deny", + selectedActionId: "deny-once", + message: "Not approved", + interrupt: true, + }, + }, + }); + + await client.close(); +}); + test("project actions list registered projects through the existing RPC", async () => { const { client, ws } = await connectClient(); @@ -303,6 +375,71 @@ test("project actions list registered projects through the existing RPC", async await client.close(); }); +test("project actions subscribe to existing project updates", async () => { + const { client, ws } = await connectClient(); + const updates: string[] = []; + const unsubscribe = client.projects.subscribe((update) => { + updates.push(update.kind === "upsert" ? update.project.projectDisplayName : update.projectId); + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["project.update"], + }); + acknowledgeObservation(ws, "projects-sdk"); + + ws.message( + sessionMessage({ + type: "project.update", + payload: { + subscriptionId: "projects-sdk", + kind: "upsert", + project: { + projectId: "project_sdk", + projectKey: "sdk", + projectDisplayName: "Renamed SDK", + projectCustomName: "Renamed SDK", + projectCustomIconRevision: null, + projectIconRevision: "icon-revision", + projectRootPath: "/repo/sdk", + projectKind: "git", + syncSeq: 9, + }, + generation: "daemon-generation", + seq: 9, + }, + }), + ); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + subscriptionId: "projects-sdk", + kind: "remove", + projectId: "project_removed", + generation: "daemon-generation", + seq: 10, + }, + }), + ); + + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + unsubscribe(); + ws.message( + sessionMessage({ + type: "project.update", + payload: { + subscriptionId: "projects-sdk", + kind: "remove", + projectId: "project_after_unsubscribe", + }, + }), + ); + expect(updates).toEqual(["Renamed SDK", "project_removed"]); + + await client.close(); +}); + test("agent actions list the daemon directory without exposing the low-level client", async () => { const { client, ws } = await connectClient(); const listedAgent = createAgent({ title: "Planner" }); @@ -430,6 +567,9 @@ test("workspace handles keep identity and refresh snapshots through existing dri await expect(refetchPromise).resolves.toEqual(refreshedWorkspace); expect(workspace.current()).toEqual(refreshedWorkspace); + const directoryReady = client.workspaces.list({ subscribe: {} }); + acknowledgeObservation(ws, "workspaces-sdk"); + await directoryReady; const updates: string[] = []; const unsubscribe = workspace.subscribe((update) => { if (update.kind === "upsert") { @@ -440,10 +580,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri ws.message( sessionMessage({ type: "workspace_update", - payload: { - kind: "upsert", - workspace: pushedWorkspace, - }, + payload: { subscriptionId: "workspaces-sdk", kind: "upsert", workspace: pushedWorkspace }, }), ); expect(updates).toEqual(["sdk pushed"]); @@ -475,6 +612,7 @@ test("workspace handles keep identity and refresh snapshots through existing dri sessionMessage({ type: "workspace_update", payload: { + subscriptionId: "workspaces-sdk", kind: "upsert", workspace: createWorkspace({ name: "sdk after unsubscribe" }), }, @@ -553,6 +691,61 @@ test("plugin-shaped PR workspace create and agent create use the existing daemon await client.close(); }); +test("agents.create forwards internal on the request, outside the agent config", async () => { + const { client, ws } = await connectClient({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + backgroundWorkspaces: true, + }); + + const createPromise = client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + title: "Summary helper", + background: true, + autoArchive: true, + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "create_agent_request", + config: { provider: "codex", model: "gpt-5.4", cwd: "/repo/sdk", title: "Summary helper" }, + background: true, + autoArchive: true, + }); + expect(request.config).not.toHaveProperty("internal"); + ws.message( + sessionMessage({ + type: "status", + payload: { + status: "agent_created", + requestId: request.requestId, + agentId: "agent_helper", + agent: createAgent({ id: "agent_helper" }), + }, + }), + ); + + const agent = await createPromise; + expect(agent.id).toBe("agent_helper"); + await client.close(); +}); + +test("agents.create with internal fails before sending on a host without internal agents", async () => { + const { client, ws } = await connectClient(); + const sentBefore = ws.sent.length; + + await expect( + client.agents.create({ + config: { provider: "codex/gpt-5.4" }, + cwd: "/repo/sdk", + background: true, + }), + ).rejects.toThrow("Update the host to use background workspaces."); + expect(ws.sent).toHaveLength(sentBefore); + await client.close(); +}); + test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => { const { client, ws } = await connectClient(); const createdAgent = createAgent(); @@ -589,6 +782,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local expect(agent.id).toBe("agent_sdk"); expect(agent.current()).toEqual(createdAgent); + await observeAgents(client, ws); const updatedAgents: string[] = []; const unsubscribe = agent.subscribe((update) => { if (update.kind === "upsert") { @@ -599,23 +793,23 @@ test("agent handles delegate create, send, timeline refetch, archive, and local ws.message( sessionMessage({ type: "agent_update", - payload: { - kind: "upsert", - agent: updatedAgent, - project: null, - }, + payload: { subscriptionId: "agents-sdk", kind: "upsert", agent: updatedAgent, project: null }, }), ); expect(updatedAgents).toEqual(["Updated"]); expect(agent.current()).toEqual(updatedAgent); - const sendPromise = agent.send("hello", { messageId: "message-sdk" }); + const sendPromise = agent.send("hello", { + messageId: "message-sdk", + activeTurnBehavior: "steer", + }); const sendRequest = parseSentSessionMessage(ws.sent.at(-1)); expect(sendRequest).toMatchObject({ type: "send_agent_message_request", agentId: "agent_sdk", text: "hello", messageId: "message-sdk", + activeTurnBehavior: "steer", }); ws.message( @@ -633,6 +827,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local const runPromise = agent.run("finish the task", { messageId: "run-message-sdk", + activeTurnBehavior: "interrupt", timeoutMs: 30_000, }); const runSendRequest = parseSentSessionMessage(ws.sent.at(-1)); @@ -641,6 +836,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local agentId: "agent_sdk", text: "finish the task", messageId: "run-message-sdk", + activeTurnBehavior: "interrupt", }); ws.message( sessionMessage({ @@ -867,11 +1063,13 @@ test("agent handles expose the observed snapshot through readonly properties", a runtimeInfo: { provider: "codex", sessionId: "session-sdk", model: "gpt-5.4" }, archivedAt: "2026-05-16T02:00:00.000Z", }); + await observeAgents(client, ws); const unsubscribe = agent.subscribe(() => {}); ws.message( sessionMessage({ type: "agent_update", payload: { + subscriptionId: "agents-sdk", kind: "upsert", agent: observedAgent, project: null, @@ -1046,6 +1244,8 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd }); const readyPromise = client.providers.waitForReady({ cwd: "/repo/sdk", timeoutMs: 5_000 }); + acknowledgeObservation(ws, "provider-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const readyRequest = parseSentSessionMessage(ws.sent.at(-1)); expect(readyRequest).toMatchObject({ type: "get_providers_snapshot_request", @@ -1066,6 +1266,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/other", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:30.000Z", @@ -1076,6 +1277,7 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:01.000Z", @@ -1092,11 +1294,14 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd cwd: "/repo/./sdk", timeoutMs: 5_000, }); + acknowledgeObservation(ws, "provider-canonical-wait"); + await new Promise((resolve) => setTimeout(resolve, 0)); const canonicalReadyRequest = parseSentSessionMessage(ws.sent.at(-1)); ws.message( sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-canonical-wait", cwd: "/repo/sdk", entries: [{ provider: "codex", status: "ready", enabled: true }], generatedAt: "2026-05-16T00:00:02.000Z", @@ -1168,6 +1373,62 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd provider: "codex", diagnostic: "Codex is ready.", }); + const usagePromise = client.providers.listUsage({ + requestId: "provider-usage-request", + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "provider.usage.list.request", + requestId: "provider-usage-request", + }); + ws.message( + sessionMessage({ + type: "provider.usage.list.response", + payload: { + requestId: "provider-usage-request", + fetchedAt: "2026-05-16T00:10:00.000Z", + providers: [ + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "pro", + windows: [ + { + id: "weekly", + label: "Weekly limit", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-05-20T00:00:00.000Z", + tone: "ok", + }, + ], + }, + ], + }, + }), + ); + await expect(usagePromise).resolves.toEqual({ + requestId: "provider-usage-request", + fetchedAt: "2026-05-16T00:10:00.000Z", + providers: [ + { + providerId: "codex", + displayName: "Codex", + status: "available", + planLabel: "pro", + windows: [ + { + id: "weekly", + label: "Weekly limit", + usedPct: 25, + remainingPct: 75, + resetsAt: "2026-05-20T00:00:00.000Z", + tone: "ok", + }, + ], + }, + ], + }); const snapshotUpdates: string[] = []; const snapshotModelDefaults: Array<string | undefined> = []; @@ -1175,10 +1436,16 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd snapshotUpdates.push(update.generatedAt); snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId); }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["providers_snapshot_update"], + }); + acknowledgeObservation(ws, "provider-updates"); ws.message( sessionMessage({ type: "providers_snapshot_update", payload: { + subscriptionId: "provider-updates", cwd: "/repo/sdk", entries: [ { @@ -1206,14 +1473,36 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd await client.close(); }); -test("waitForReady requires canonical provider snapshot identity from the host", async () => { +test("waitForReady reads an older host on the existing connection", async () => { const { client, ws } = await connectClient({}); - const sentBeforeWait = ws.sent.length; - - await expect(client.providers.waitForReady({ cwd: "/repo/./sdk" })).rejects.toThrow( - "Update the host to wait for provider discovery.", + const waiting = client.providers.waitForReady({ cwd: "/repo/./sdk" }); + await expect + .poll(() => parseSentSessionMessage(ws.sent.at(-1)).type) + .toBe("get_providers_snapshot_request"); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "get_providers_snapshot_response", + payload: { + requestId: request.requestId, + entries: [], + generatedAt: "2026-09-11T00:00:00.000Z", + }, + }), ); - expect(ws.sent).toHaveLength(sentBeforeWait); + await expect(waiting).resolves.toMatchObject({ entries: [] }); + expect(FakeWebSocket.instances).toHaveLength(1); + await client.close(); +}); + +test("provider usage requires the advertised host capability", async () => { + const { client, ws } = await connectClient({}); + const sentBeforeUsage = ws.sent.length; + + await expect(client.providers.listUsage()).rejects.toThrow( + "Update the host to list provider usage.", + ); + expect(ws.sent).toHaveLength(sentBeforeUsage); await client.close(); }); @@ -1386,3 +1675,62 @@ test("agent config requires provider/model syntax", async () => { await client.close(); }); + +test("canceled timeline handles and captured state are collectible while their API stays alive", async () => { + const source = ` + import assert from "node:assert/strict"; + import { createPaseoApi } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)}; + import { DaemonClient } from ${JSON.stringify(new URL("./daemon-client.ts", import.meta.url).href)}; + const drivers = [], apis = []; + let errors = 0; + async function batch(mode) { + const driver = new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: mode, reconnect: { enabled: false } }); + const scope = new AbortController(); + const api = createPaseoApi(driver, { signal: scope.signal }); + drivers.push(driver); apis.push(api); + const refs = [], ready = []; + for (let i = 0; i < 100; i++) { + const state = { bytes: new Uint8Array(64 * 1024) }; + const handle = api.agents.ref("unconnected-agent").timeline.subscribe(update => { + if (update.event.type === "error") errors++; + return state.bytes[0]; + }); + refs.push({ handle: new WeakRef(handle), state: new WeakRef(state) }); + ready.push(handle.ready.catch(() => {})); + if (mode === "callable") handle(); + if (mode === "release") await handle.release(); + } + if (mode === "scope-abort") scope.abort(); + if (mode === "client-close") await driver.close(); + if (mode !== "active") await Promise.all(ready); + return refs; + } + const groups = {}; + for (const mode of ["callable", "release", "scope-abort", "client-close", "active"]) groups[mode] = await batch(mode); + async function collect() { + for (let i = 0; i < 12; i++) { await new Promise(r => setImmediate(r)); global.gc(); } + } + const count = refs => ({ handles: refs.filter(r => r.handle.deref()).length, states: refs.filter(r => r.state.deref()).length }); + await collect(); + const live = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API alive", live, errors })); + for (const api of apis) await api.dispose(); + await collect(); + const disposed = Object.fromEntries(Object.entries(groups).map(([mode, refs]) => [mode, count(refs)])); + console.log(JSON.stringify({ phase: "API disposed", disposed })); + for (const driver of drivers) await driver.close(); + for (const mode of ["callable", "release", "scope-abort", "client-close"]) assert.deepEqual(live[mode], { handles: 0, states: 0 }, mode); + assert.deepEqual(live.active, { handles: 100, states: 100 }, "active ownership is retained"); + assert.equal(errors, 100, "client shutdown reports failure; explicit cancellation does not"); + for (const result of Object.values(disposed)) assert.deepEqual(result, { handles: 0, states: 0 }); + `; + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !/^(PASEO_|EXPO_|E2E_|AGENT_BROWSER_)/.test(key)), + ); + const result = await promisify(execFile)( + process.execPath, + ["--expose-gc", "--import", "tsx", "--input-type=module", "-e", source], + { env, timeout: 15000 }, + ); + expect(result.stdout).toContain('"phase":"API alive"'); +}, 20000); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0cd94e5b62..5bccc94009 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,5 +1,10 @@ +import type { OwnedSubscription } from "./connection/index.js"; +export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js"; +import type { DaemonClientConfig } from "./daemon-client.js"; +import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import type { AgentSnapshotPayload, + CreationSnapshot, CreateAgentRequestMessage, FetchWorkspacesRequestMessage, FetchWorkspacesResponseMessage, @@ -15,15 +20,31 @@ import type { MutableDaemonConfig, MutableDaemonConfigPatch, ProviderDiagnosticResponseMessage, + ProviderUsageListResponseMessage, ProjectPlacementPayload, WorkspaceProjectDescriptorPayload, RefreshProvidersSnapshotResponseMessage, - SendAgentMessageRequest, SessionOutboundMessage, WorkspaceDescriptorPayload, WorkspaceCreateRequest, } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./daemon-client.js"; +import { DaemonClient, type CreateAgentRequestOptions } from "./daemon-client.js"; +import { + createTerminalActions, + type PaseoTerminalActions, + type PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; +export type { + PaseoTerminal, + PaseoTerminalActions, + PaseoTerminalHandle, + PaseoTerminalCreateOptions, + PaseoTerminalListOptions, + PaseoTerminalListResult, + PaseoTerminalCaptureOptions, + PaseoTerminalCaptureResult, + PaseoWorkspaceTerminalActions, +} from "./terminals/index.js"; import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types"; import type { FetchAgentsEntry, @@ -33,6 +54,7 @@ import type { FetchAgentTimelineDirection, FetchAgentTimelinePayload, FetchAgentTimelineProjection, + SendMessageOptions, WaitForFinishResult, } from "./daemon-client.js"; @@ -57,6 +79,7 @@ export interface PaseoLogger { } export interface PaseoClientConfig { + capabilities?: DaemonClientConfig["capabilities"]; url: string; clientId?: string; appVersion?: string; @@ -87,8 +110,14 @@ export type PaseoProjectListOptions = Omit<ProjectListRequestMessage, "type" | " requestId?: string; }; export type PaseoProjectListResult = ProjectListResponseMessage["payload"]; +export type PaseoProjectUpdate = Extract< + SessionOutboundMessage, + { type: "project.update" } +>["payload"]; +export type PaseoProjectUpdateHandler = (update: PaseoProjectUpdate) => void; export interface PaseoAgentListResult { + subscription?: OwnedSubscription<PaseoAgentListResult>; requestId: string; subscriptionId?: string | null; entries: FetchAgentsEntry[]; @@ -102,6 +131,7 @@ export type PaseoWorkspaceListOptions = Omit< }; export interface PaseoWorkspaceListResult { + subscription?: OwnedSubscription<PaseoWorkspaceListResult>; requestId: string; subscriptionId?: string | null; entries: PaseoWorkspace[]; @@ -113,8 +143,16 @@ export interface PaseoWorkspaceOpenOptions { requestId?: string; } -export type PaseoWorkspaceCreateOptions = Omit<WorkspaceCreateRequest, "type" | "requestId"> & { +export type PaseoWorkspaceCreateOptions = Omit< + WorkspaceCreateRequest, + "type" | "requestId" | "agent" | "subscribe" +> & { requestId?: string; + agent?: Omit< + PaseoAgentCreateOptions, + "worktree" | "git" | "onEvent" | "idempotencyKey" | "requestId" + >; + onEvent?: (snapshot: CreationSnapshot) => void; }; export interface PaseoWorkspaceArchiveResult { @@ -140,6 +178,7 @@ export interface PaseoWorkspaceHandle { readonly agents: { create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>; }; + readonly terminals: PaseoWorkspaceTerminalActions; current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; @@ -155,9 +194,16 @@ export interface PaseoWorkspaceHandle { export interface PaseoProjectActions { list(options?: PaseoProjectListOptions): Promise<PaseoProjectListResult>; + subscribe(handler: PaseoProjectUpdateHandler): () => void; } export interface PaseoWorkspaceActions { + list(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription<PaseoWorkspaceListResult>; + } + >; list(options?: PaseoWorkspaceListOptions): Promise<PaseoWorkspaceListResult>; ref(workspace: string | PaseoWorkspace): PaseoWorkspaceHandle; open( @@ -195,6 +241,9 @@ export interface PaseoAgentConfig { } export interface PaseoAgentCreateOptions { + idempotencyKey?: string; + agentId?: string; + onEvent?: (snapshot: CreationSnapshot) => void; config: PaseoAgentConfig; cwd: string; parent?: string | PaseoAgentHandle; @@ -208,6 +257,8 @@ export interface PaseoAgentCreateOptions { git?: CreateAgentRequestMessage["git"]; worktree?: CreateAgentRequestMessage["worktree"]; autoArchive?: CreateAgentRequestMessage["autoArchive"]; + /** Visibility intent for a NEW workspace. Existing targets reject this option. */ + background?: CreateAgentRequestMessage["background"]; requestId?: string; labels?: Record<string, string>; } @@ -227,17 +278,19 @@ export interface PaseoAgentTimelineRefetchOptions { requestId?: string; } -export interface PaseoAgentSendOptions { - messageId?: string; - images?: Array<{ data: string; mimeType: string }>; - attachments?: SendAgentMessageRequest["attachments"]; -} +export type PaseoAgentSendOptions = SendMessageOptions; export interface PaseoAgentRunOptions extends PaseoAgentSendOptions { timeoutMs?: number; } export type PaseoAgentRunResult = WaitForFinishResult; +export type PaseoAgentPermissionResponse = AgentPermissionResponse; + +export interface PaseoAgentRespondToPermissionOptions { + requestId: string; + response: PaseoAgentPermissionResponse; +} export interface PaseoAgentCommandsOptions { requestId?: string; @@ -251,6 +304,21 @@ export type PaseoAgentStream = Extract<SessionOutboundMessage, { type: "agent_st export type PaseoAgentUpdateHandler = (update: PaseoAgentUpdate) => void; +export type PaseoAgentTimelineEvent = + | PaseoAgentStream + | { + agentId: string; + event: { type: "replacement"; epoch: string }; + } + | { + agentId: string; + subscriptionId: string; + event: { type: "subscription_restored" }; + } + | { agentId: string; event: { type: "error"; error: string } }; + +export type PaseoAgentTimelineSubscription = ReturnType<DaemonClient["subscribeAgentTimeline"]>; + export interface PaseoAgentTimelineHandle { append(item: Omit<PluginTimelineItem, "pluginId">): Promise<{ seq: number; epoch: string }>; /** @@ -260,10 +328,14 @@ export interface PaseoAgentTimelineHandle { */ refetch(options?: PaseoAgentTimelineRefetchOptions): Promise<FetchAgentTimelinePayload>; /** - * Local listener for agent_stream events matching this handle id. It does not - * retain timeline entries or own application cache state. + * Delivers live events only. After reconnect, subscription_restored precedes + * subsequent updates. History may have been missed; use refetch() to request + * the range you need. No history is fetched automatically. A replacement event + * invalidates the previous epoch. Subscription errors release this observation. + * Await the returned unsubscribe function's `ready` promise before starting + * work that must be observed. It rejects if establishment fails. */ - subscribe(handler: (event: PaseoAgentStream) => void): () => void; + subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription; } export interface PaseoAgentHandle { @@ -291,6 +363,7 @@ export interface PaseoAgentHandle { current(): PaseoAgent | null; refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>; send(text: string, options?: PaseoAgentSendOptions): Promise<void>; + respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>; /** Waits for the current turn, including one started with `prompt`. */ @@ -309,6 +382,12 @@ export interface PaseoAgentHandle { } export interface PaseoAgentActions { + list(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription<PaseoAgentListResult>; + } + >; list(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>; ref(agent: string | PaseoAgent): PaseoAgentHandle; create(options: PaseoAgentCreateOptions): Promise<PaseoAgentHandle>; @@ -338,6 +417,10 @@ export type PaseoProviderSnapshotUpdate = Extract< >["payload"]; export type PaseoProviderRefreshResult = RefreshProvidersSnapshotResponseMessage["payload"]; export type PaseoProviderDiagnosticResult = ProviderDiagnosticResponseMessage["payload"]; +export type PaseoProviderUsageResult = ProviderUsageListResponseMessage["payload"]; +export interface PaseoProviderUsageOptions { + requestId?: string; +} export interface PaseoProviderListOptions { cwd?: string; @@ -376,6 +459,7 @@ export interface PaseoProviderActions { provider: PaseoAgentProvider, options?: { requestId?: string }, ): Promise<PaseoProviderDiagnosticResult>; + listUsage(options?: PaseoProviderUsageOptions): Promise<PaseoProviderUsageResult>; subscribe(handler: (update: PaseoProviderSnapshotUpdate) => void): () => void; } @@ -400,6 +484,9 @@ export interface PaseoConfigActions { } export interface PaseoApi { + dispose(): Promise<void>; + observeEvents: DaemonClient["observeEvents"]; + readonly terminals: PaseoTerminalActions; readonly workspaces: PaseoWorkspaceActions; readonly projects: PaseoProjectActions; readonly agents: PaseoAgentActions; @@ -420,54 +507,214 @@ export function createPaseoClient(config: PaseoClientConfig): PaseoClient { clientId: config.clientId ?? createGeneratedClientId(), clientType: "cli", }); + const api = createPaseoApi(daemonClient); return { - ...createPaseoApi(daemonClient), + ...api, connect: () => daemonClient.connect(), - close: () => daemonClient.close(), + close: async () => { + try { + await api.dispose(); + } finally { + await daemonClient.close(); + } + }, ensureConnected: () => daemonClient.ensureConnected(), getConnectionState: () => daemonClient.getConnectionState(), }; } -export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { - const createAgentHandle = createAgentHandleFactory(daemonClient); +function toDaemonAgentCreateOptions( + options: PaseoAgentCreateOptions, + placement?: { workspaceId: string; cwd: string }, +): CreateAgentRequestOptions { + const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; + const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { provider, model } = parseProviderModel(providerModel); + return { + ...requestOptions, + config: { + ...runtimeConfig, + provider, + model, + cwd: placement?.cwd ?? cwd, + ...(title !== undefined ? { title } : {}), + ...(providerOptions !== undefined ? { providerOptions } : {}), + }, + ...(placement ? { workspaceId: placement.workspaceId } : {}), + ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), + ...(prompt !== undefined ? { initialPrompt: prompt } : {}), + }; +} + +export function createPaseoApi( + daemonClient: DaemonClient, + scopeOptions?: { signal?: AbortSignal }, +): PaseoApi { + const handles = new Set<{ release(): Promise<void> }>(); + const agentListeners = new Set<PaseoAgentUpdateHandler>(); + const workspaceListeners = new Set<PaseoWorkspaceUpdateHandler>(); + const lifetime = new AbortController(); + const own = <T extends { release(): Promise<void> }>(create: () => T): T => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + const handle = create(); + handles.add(handle); + const release = handle.release.bind(handle); + handle.release = async () => { + await release(); + handles.delete(handle); + }; + return handle; + }; + const listenAgents = (handler: PaseoAgentUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + agentListeners.add(handler); + return () => { + agentListeners.delete(handler); + }; + }; + const listenWorkspaces = (handler: PaseoWorkspaceUpdateHandler) => { + if (lifetime.signal.aborted) throw new Error("Paseo API is disposed"); + workspaceListeners.add(handler); + return () => { + workspaceListeners.delete(handler); + }; + }; + const createAgentHandle = createAgentHandleFactory( + daemonClient, + listenAgents, + (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), + ); const createAgent = async ( options: PaseoAgentCreateOptions, placement?: { workspaceId: string; cwd: string }, ) => { - const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; - const { provider, model } = parseProviderModel(providerModel); - const effectiveCwd = placement?.cwd ?? cwd; - const agent = await daemonClient.createAgent({ - ...requestOptions, - config: { - ...runtimeConfig, - provider, - model, - cwd: effectiveCwd, - ...(title !== undefined ? { title } : {}), - ...(providerOptions !== undefined ? { providerOptions } : {}), - }, - ...(placement ? { workspaceId: placement.workspaceId } : {}), - ...(parent ? { callerAgentId: resolveAgentId(parent) } : {}), - ...(prompt !== undefined ? { initialPrompt: prompt } : {}), - }); + const agent = await daemonClient.createAgent(toDaemonAgentCreateOptions(options, placement)); return createAgentHandle(agent); }; - const createWorkspaceHandle = createWorkspaceHandleFactory(daemonClient, createAgent); + const terminals = createTerminalActions(daemonClient, async (workspaceId) => { + const workspace = await createWorkspaceHandle(workspaceId).refresh(); + if (!workspace?.workspaceDirectory) { + throw new Error(`Workspace ${workspaceId} is not active or has no available directory`); + } + return workspace.workspaceDirectory; + }); + const createWorkspaceHandle = createWorkspaceHandleFactory( + daemonClient, + createAgent, + terminals, + listenWorkspaces, + ); + + let disposal: Promise<void> | null = null; + const dispose = (): Promise<void> => { + if (disposal) return disposal; + lifetime.abort(); + scopeOptions?.signal?.removeEventListener("abort", abort); + agentListeners.clear(); + workspaceListeners.clear(); + disposal = Promise.allSettled([...handles].map((handle) => handle.release())).then( + (results) => { + handles.clear(); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length) + throw new AggregateError(failures, "Failed to release API subscriptions"); + return undefined; + }, + ); + return disposal; + }; + const abort = () => { + void dispose().catch((error) => console.error("API subscription cleanup failed", error)); + }; + if (scopeOptions?.signal?.aborted) abort(); + else scopeOptions?.signal?.addEventListener("abort", abort, { once: true }); + + const observeEvents: DaemonClient["observeEvents"] = (events, options) => + own(() => daemonClient.observeEvents(events, options)); + + const subscribeEvent = ( + event: "project.update" | "providers_snapshot_update", + update: (message: SessionOutboundMessage) => void, + ): (() => void) => { + const observation = observeEvents([event]); + observation.subscribe({ snapshot: () => {}, update }); + return () => { + void observation + .release() + .catch((error) => console.error("Event subscription cleanup failed", error)); + }; + }; + + function listWorkspaces(options: PaseoWorkspaceListOptions & { subscribe: {} }): Promise< + PaseoWorkspaceListResult & { + subscriptionId: string; + subscription: OwnedSubscription<PaseoWorkspaceListResult>; + } + >; + function listWorkspaces(options?: PaseoWorkspaceListOptions): Promise<PaseoWorkspaceListResult>; + async function listWorkspaces( + options?: PaseoWorkspaceListOptions, + ): Promise<PaseoWorkspaceListResult> { + if (!options?.subscribe) return daemonClient.fetchWorkspaces(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeWorkspaces(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "workspace_update") + for (const listener of workspaceListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } + + function listAgents(options: PaseoAgentListOptions & { subscribe: {} }): Promise< + PaseoAgentListResult & { + subscriptionId: string; + subscription: OwnedSubscription<PaseoAgentListResult>; + } + >; + function listAgents(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult>; + async function listAgents(options?: PaseoAgentListOptions): Promise<PaseoAgentListResult> { + if (!options?.subscribe) return daemonClient.fetchAgents(options); + if (options.subscribe.subscriptionId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + const subscription = own(() => daemonClient.observeAgents(options)); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update") + for (const listener of agentListeners) listener(message.payload); + }, + }); + return { ...(await subscription.ready), subscription }; + } return { + dispose, + observeEvents, + terminals, projects: { list: (options) => daemonClient.listProjects(options), + subscribe: (handler) => { + return subscribeEvent("project.update", (message) => { + if (message.type === "project.update") handler(message.payload); + }); + }, }, workspaces: { - list: (options) => daemonClient.fetchWorkspaces(options), + list: listWorkspaces, ref: (workspace) => createWorkspaceHandle(workspace), open: (input, requestId) => openWorkspace(daemonClient, createWorkspaceHandle, input, requestId), - create: async ({ requestId, ...options }) => { - const result = await daemonClient.createWorkspace(options, requestId); + create: async ({ requestId, agent, ...options }) => { + const result = await daemonClient.createWorkspace( + { ...options, ...(agent ? { agent: toDaemonAgentCreateOptions(agent) } : {}) }, + requestId, + ); if (result.error || !result.workspace) { throw new Error(result.error ?? "The daemon did not create a workspace"); } @@ -475,19 +722,13 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, archive: (workspace, requestId) => daemonClient.archiveWorkspace(resolveWorkspaceId(workspace), requestId), - subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - handler(message.payload); - }), + subscribe: listenWorkspaces, }, agents: { - list: (options) => daemonClient.fetchAgents(options), + list: listAgents, ref: (agent) => createAgentHandle(agent), create: (options) => createAgent(options), - subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - handler(message.payload); - }), + subscribe: listenAgents, }, providers: { listModels: (provider, options) => daemonClient.listProviderModels(provider, options), @@ -498,13 +739,21 @@ export function createPaseoApi(daemonClient: DaemonClient): PaseoApi { }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), - waitForReady: (options) => waitForProvidersReady(daemonClient, options), + waitForReady: (options) => + waitForProvidersReady( + daemonClient, + observeEvents(["providers_snapshot_update"]), + lifetime.signal, + options, + ), refresh: (options) => daemonClient.refreshProvidersSnapshot(options), diagnostic: (provider, options) => daemonClient.getProviderDiagnostic(provider, options), - subscribe: (handler) => - daemonClient.on("providers_snapshot_update", (message) => { - handler(message.payload); - }), + listUsage: (options) => listProviderUsage(daemonClient, options), + subscribe: (handler) => { + return subscribeEvent("providers_snapshot_update", (message) => { + if (message.type === "providers_snapshot_update") handler(message.payload); + }); + }, }, config: { get: (requestId) => daemonClient.getDaemonConfig(requestId), @@ -523,6 +772,8 @@ type CreateAgent = ( function createWorkspaceHandleFactory( daemonClient: DaemonClient, createAgent: CreateAgent, + terminals: PaseoTerminalActions, + listen: (handler: PaseoWorkspaceUpdateHandler) => () => void, ): WorkspaceHandleFactory { return (workspace) => { const id = typeof workspace === "string" ? workspace : workspace.id; @@ -531,9 +782,12 @@ function createWorkspaceHandleFactory( const refresh = async (options?: { requestId?: string }) => { let cursor: string | undefined; let requestId = options?.requestId; + // A ref addresses one workspace by id, so a background one resolves too. + const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}; do { const result = await daemonClient.fetchWorkspaces({ requestId, + filter, page: { limit: 200, ...(cursor ? { cursor } : {}) }, }); const match = result.entries.find((entry) => entry.id === id); @@ -574,6 +828,10 @@ function createWorkspaceHandleFactory( ); }, }, + terminals: { + create: (options) => terminals.create({ ...options, workspaceId: id }), + list: (options) => terminals.list({ ...options, workspaceId: id }), + }, current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), @@ -585,14 +843,12 @@ function createWorkspaceHandleFactory( return result; }, subscribe: (handler) => - daemonClient.on("workspace_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.workspace.id === id) { current = update.workspace; handler(update); } if (update.kind === "remove" && update.id === id) { - current = null; handler(update); } }), @@ -600,7 +856,11 @@ function createWorkspaceHandleFactory( }; } -function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactory { +function createAgentHandleFactory( + daemonClient: DaemonClient, + listen: (handler: PaseoAgentUpdateHandler) => () => void, + subscribeTimeline: DaemonClient["subscribeAgentTimeline"], +): AgentHandleFactory { return (agent) => { const id = typeof agent === "string" ? agent : agent.id; let current = typeof agent === "string" ? null : agent; @@ -617,9 +877,26 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor return result; }, subscribe: (handler) => - daemonClient.on("agent_stream", (message) => { - if (message.payload.agentId === id) { - handler(message.payload); + subscribeTimeline(id, (message) => { + switch (message.type) { + case "agent_stream": + return handler(message.payload); + case "agent.timeline.subscription_restored": + return handler({ + agentId: id, + subscriptionId: message.payload.subscriptionId, + event: { type: "subscription_restored" }, + }); + case "agent.timeline.error": + return handler({ + agentId: id, + event: { type: "error", error: message.payload.error }, + }); + case "agent.timeline.replacement": + return handler({ + agentId: id, + event: { type: "replacement", epoch: message.payload.epoch }, + }); } }), }, @@ -668,6 +945,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, + respondToPermission: async ({ requestId, response }) => { + await daemonClient.respondToPermission(id, requestId, response); + }, run: async (text, options) => { const { timeoutMs, ...sendOptions } = options ?? {}; await daemonClient.sendAgentMessage(id, text, sendOptions); @@ -702,14 +982,12 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor await daemonClient.detachAgent(id); }, subscribe: (handler) => - daemonClient.on("agent_update", (message) => { - const update = message.payload; + listen((update) => { if (update.kind === "upsert" && update.agent.id === id) { current = update.agent; handler(update); } if (update.kind === "remove" && update.agentId === id) { - current = null; handler(update); } }), @@ -752,86 +1030,107 @@ function parseProviderModel(selection: string): { provider: string; model: strin }; } -function waitForProvidersReady( +function listProviderUsage( daemonClient: DaemonClient, + options?: PaseoProviderUsageOptions, +): Promise<PaseoProviderUsageResult> { + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-02-28 once daemon floor >= v0.1.98. + if (daemonClient.getLastServerInfoMessage()?.features?.providerUsageList !== true) { + return Promise.reject(new Error("Update the host to list provider usage.")); + } + return daemonClient.listProviderUsage(options); +} + +async function waitForProvidersReady( + daemonClient: DaemonClient, + observation: ReturnType<DaemonClient["observeEvents"]>, + signal: AbortSignal, options: PaseoProviderWaitOptions = {}, ): Promise<PaseoProviderSnapshotResult> { - // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. - if (daemonClient.getLastServerInfoMessage()?.features?.providersSnapshotCwd !== true) { - return Promise.reject(new Error("Update the host to wait for provider discovery.")); - } - const { timeoutMs = 60_000, ...snapshotOptions } = options; - return new Promise((resolve, reject) => { - let settled = false; - let requestId: string | null = null; - let snapshotCwd: string | undefined; - const pendingUpdates = new Map<string | undefined, PaseoProviderSnapshotUpdate>(); - let latestEntries: PaseoProviderSnapshotResult["entries"] = []; + try { + await observation.ready; + signal.throwIfAborted(); + return await new Promise<PaseoProviderSnapshotResult>((resolve, reject) => { + let settled = false; + let requestId: string | null = null; + let snapshotCwd: string | undefined; + const pendingUpdates = new Map<string | undefined, PaseoProviderSnapshotUpdate>(); + let latestEntries: PaseoProviderSnapshotResult["entries"] = []; - const cleanup = () => { - clearTimeout(timeout); - unsubscribe(); - }; - const finish = (snapshot: PaseoProviderSnapshotResult) => { - if (settled) return; - settled = true; - cleanup(); - resolve(snapshot); - }; - const fail = (error: unknown) => { - if (settled) return; - settled = true; - cleanup(); - reject(error instanceof Error ? error : new Error(String(error))); - }; - const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; + const cleanup = () => { + clearTimeout(timeout); + unsubscribe(); + signal.removeEventListener("abort", abort); + }; + const finish = (snapshot: PaseoProviderSnapshotResult) => { + if (settled) return; + settled = true; + cleanup(); + resolve(snapshot); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + const updateMatches = (update: PaseoProviderSnapshotUpdate) => update.cwd === snapshotCwd; - const unsubscribe = daemonClient.on("providers_snapshot_update", (message) => { - const update = message.payload; - if (!requestId) { - pendingUpdates.set(update.cwd, update); - return; - } - if (!updateMatches(update)) return; - latestEntries = update.entries; - if (update.entries.some((entry) => entry.status === "loading")) return; - finish({ ...update, requestId }); + const unsubscribe = observation.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "providers_snapshot_update") return; + const update = message.payload; + if (!requestId) { + pendingUpdates.set(update.cwd, update); + return; + } + if (!updateMatches(update)) return; + latestEntries = update.entries; + if (update.entries.some((entry) => entry.status === "loading")) return; + finish({ ...update, requestId }); + }, + }); + const abort = () => fail(new Error("Paseo API is disposed")); + signal.addEventListener("abort", abort, { once: true }); + + const timeout = setTimeout(() => { + const loading = latestEntries + .filter((entry) => entry.status === "loading") + .map((entry) => entry.provider) + .join(", "); + fail( + new Error( + loading + ? `Timed out waiting for providers: ${loading}` + : "Timed out waiting for provider discovery", + ), + ); + }, timeoutMs); + + void daemonClient + .getProvidersSnapshot(snapshotOptions) + .then((snapshot) => { + requestId = snapshot.requestId; + snapshotCwd = snapshot.cwd; + latestEntries = snapshot.entries; + if (!snapshot.entries.some((entry) => entry.status === "loading")) { + finish(snapshot); + return; + } + const pendingUpdate = pendingUpdates.get(snapshotCwd); + if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { + finish({ ...pendingUpdate, requestId }); + } + return undefined; + }) + .catch(fail); }); - - const timeout = setTimeout(() => { - const loading = latestEntries - .filter((entry) => entry.status === "loading") - .map((entry) => entry.provider) - .join(", "); - fail( - new Error( - loading - ? `Timed out waiting for providers: ${loading}` - : "Timed out waiting for provider discovery", - ), - ); - }, timeoutMs); - - void daemonClient - .getProvidersSnapshot(snapshotOptions) - .then((snapshot) => { - requestId = snapshot.requestId; - snapshotCwd = snapshot.cwd; - latestEntries = snapshot.entries; - if (!snapshot.entries.some((entry) => entry.status === "loading")) { - finish(snapshot); - return; - } - const pendingUpdate = pendingUpdates.get(snapshotCwd); - if (pendingUpdate && !pendingUpdate.entries.some((entry) => entry.status === "loading")) { - finish({ ...pendingUpdate, requestId }); - } - return undefined; - }) - .catch(fail); - }); + } finally { + await observation.release(); + } } function createGeneratedClientId(): string { diff --git a/packages/client/src/legacy-usage-icons.ts b/packages/client/src/legacy-usage-icons.ts new file mode 100644 index 0000000000..227bde8134 --- /dev/null +++ b/packages/client/src/legacy-usage-icons.ts @@ -0,0 +1,21 @@ +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +// Released hosts omit source icons. Delete these copies with the list conversion. +const ICONS: Readonly<Record<string, string>> = { + claude: + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>\n', + codex: + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"/></svg>\n', + copilot: + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 416" fill="currentColor"><path d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"\n fill-rule="nonzero"/><path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z"/></svg>\n', + cursor: + '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 466.73 532.09">\n <path fill="currentColor" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>\n</svg>\n', + zai: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai\n', + grok: '\n', + kimi: '\n \n\n', + minimax: + '\n', +}; + +export function legacyUsageIcon(sourceId: string): string | undefined { + return Object.hasOwn(ICONS, sourceId) ? ICONS[sourceId] : undefined; +} diff --git a/packages/client/src/provider-snapshots/index.ts b/packages/client/src/provider-snapshots/index.ts new file mode 100644 index 0000000000..b73a88e944 --- /dev/null +++ b/packages/client/src/provider-snapshots/index.ts @@ -0,0 +1,70 @@ +import type { + GetProvidersSnapshotResponseMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +type Update = Extract; +type Snapshot = GetProvidersSnapshotResponseMessage["payload"]; + +interface PendingSnapshot { + latest: Update; + running: boolean; +} + +/** Resolve announcements only while observed, coalescing changes during a fetch. */ +export class ProviderSnapshotUpdates { + private pending = new Map(); + + constructor( + private readonly host: { + active(update: Update): boolean; + fetch(cwd: string | undefined): Promise; + emit(update: Update): void; + failed(error: unknown): void; + }, + ) {} + + clear(): void { + this.pending.clear(); + } + + receive(update: Update): void { + if (!this.host.active(update)) return; + const key = JSON.stringify([update.payload.subscriptionId, update.payload.cwd]); + const pending = this.pending.get(key) ?? { latest: update, running: false }; + pending.latest = update; + this.pending.set(key, pending); + if (pending.running) return; + pending.running = true; + void this.resolve(key, pending); + } + + private async resolve(key: string, pending: PendingSnapshot): Promise { + try { + while (this.pending.get(key) === pending) { + const announced = pending.latest; + if (!this.host.active(announced)) return; + const snapshot = await this.host.fetch(announced.payload.cwd); + if (!this.host.active(announced)) return; + if (this.pending.get(key) !== pending) return; + // A change arriving while the body was in flight needs the current body. + // If that response already covers it, no second fetch is necessary. + if ( + pending.latest !== announced && + pending.latest.payload.snapshotHash !== snapshot.snapshotHash + ) + continue; + this.pending.delete(key); + this.host.emit({ + type: "providers_snapshot_update", + payload: { ...snapshot, subscriptionId: announced.payload.subscriptionId }, + }); + return; + } + } catch (error) { + if (this.pending.get(key) === pending) this.host.failed(error); + } finally { + if (this.pending.get(key) === pending) this.pending.delete(key); + } + } +} diff --git a/packages/client/src/terminal-stream-router.test.ts b/packages/client/src/terminal-stream-router.test.ts index f4a6c294c7..7b9e598410 100644 --- a/packages/client/src/terminal-stream-router.test.ts +++ b/packages/client/src/terminal-stream-router.test.ts @@ -9,8 +9,7 @@ describe("terminal-stream-router", () => { const events: TerminalStreamEvent[] = []; const payload = new TextEncoder().encode("restored screen"); - router.setSlot("term-1", 7); - router.onEvent((event) => events.push(event)); + router.attach("subscription-1", "term-1", 7, (event) => events.push(event)); router.handleFrame({ opcode: TerminalStreamOpcode.Restore, slot: 7, @@ -20,9 +19,45 @@ describe("terminal-stream-router", () => { expect(events).toEqual([ { terminalId: "term-1", + subscriptionId: "subscription-1", type: "restore", data: payload, }, ]); }); }); + +test("two registrations for one terminal retain independent slots and release", () => { + const router = new TerminalStreamRouter(); + const a: TerminalStreamEvent[] = []; + const b: TerminalStreamEvent[] = []; + const releaseA = router.attach("subscription-a", "same-terminal", 1, (event) => a.push(event)); + router.attach("subscription-b", "same-terminal", 2, (event) => b.push(event)); + const payload = new TextEncoder().encode("output"); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toEqual([expect.objectContaining({ subscriptionId: "subscription-a" })]); + expect(b).toEqual([expect.objectContaining({ subscriptionId: "subscription-b" })]); + releaseA(); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 1, payload }); + router.handleFrame({ opcode: TerminalStreamOpcode.Output, slot: 2, payload }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(2); +}); + +test("legacy listeners share a terminal slot without releasing each other", () => { + const router = new TerminalStreamRouter(); + const received: string[] = []; + const release = router.attach("first", "terminal", 1, () => received.push("first")); + router.attach("second", "terminal", 1, () => received.push("second")); + const frame = { + opcode: TerminalStreamOpcode.Output, + slot: 1, + payload: new TextEncoder().encode("bytes"), + }; + router.handleFrame(frame); + expect(received).toEqual(["first", "second"]); + release(); + router.handleFrame(frame); + expect(received).toEqual(["first", "second", "second"]); +}); diff --git a/packages/client/src/terminal-stream-router.ts b/packages/client/src/terminal-stream-router.ts index 5760af28eb..a2c9575450 100644 --- a/packages/client/src/terminal-stream-router.ts +++ b/packages/client/src/terminal-stream-router.ts @@ -1,22 +1,28 @@ import { decodeTerminalSnapshotPayload, - encodeTerminalResizePayload, - encodeTerminalStreamFrame, TerminalStreamOpcode, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { TerminalInput, TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalState } from "@getpaseo/protocol/messages"; -export type TerminalStreamEvent = - | { terminalId: string; type: "output"; data: Uint8Array } - | { terminalId: string; type: "snapshot"; state: TerminalState } - | { terminalId: string; type: "restore"; data: Uint8Array }; +export type TerminalStreamEvent = { terminalId: string; subscriptionId: string } & ( + | { type: "output"; data: Uint8Array } + | { type: "snapshot"; state: TerminalState } + | { type: "restore"; data: Uint8Array } +); export class TerminalStreamRouter { - private readonly terminalSlots = new Map(); - private readonly slotTerminals = new Map(); + private readonly slots = new Map< + number, + Set<{ + subscriptionId: string; + terminalId: string; + receive: (event: TerminalStreamEvent) => void; + }> + >(); private readonly listeners = new Set<(event: TerminalStreamEvent) => void>(); + /** Passive wire diagnostics. App state consumes the registration's callback. */ onEvent(handler: (event: TerminalStreamEvent) => void): () => void { this.listeners.add(handler); return () => { @@ -24,109 +30,51 @@ export class TerminalStreamRouter { }; } - setSlot(terminalId: string, slot: number): void { - const existingTerminalId = this.slotTerminals.get(slot); - if (existingTerminalId && existingTerminalId !== terminalId) { - this.terminalSlots.delete(existingTerminalId); - } - - const existingSlot = this.terminalSlots.get(terminalId); - if (typeof existingSlot === "number" && existingSlot !== slot) { - this.slotTerminals.delete(existingSlot); - } - - this.terminalSlots.set(terminalId, slot); - this.slotTerminals.set(slot, terminalId); - } - - removeTerminal(terminalId: string): void { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return; - } - this.terminalSlots.delete(terminalId); - if (this.slotTerminals.get(slot) === terminalId) { - this.slotTerminals.delete(slot); - } + attach( + subscriptionId: string, + terminalId: string, + slot: number, + receive: (event: TerminalStreamEvent) => void, + ): () => void { + const registration = { subscriptionId, terminalId, receive }; + const registrations = this.slots.get(slot) ?? new Set(); + registrations.add(registration); + this.slots.set(slot, registrations); + return () => { + registrations.delete(registration); + if (registrations.size === 0) this.slots.delete(slot); + }; } clearSlots(): void { - this.terminalSlots.clear(); - this.slotTerminals.clear(); - } - - encodeInput(terminalId: string, message: TerminalInput["message"]): Uint8Array | null { - const slot = this.terminalSlots.get(terminalId); - if (typeof slot !== "number") { - return null; - } - - if (message.type === "input") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Input, - slot, - payload: message.data, - }); - } - - if (message.type === "resize") { - return encodeTerminalStreamFrame({ - opcode: TerminalStreamOpcode.Resize, - slot, - payload: encodeTerminalResizePayload({ - rows: message.rows, - cols: message.cols, - ...(message.intent ? { intent: message.intent } : {}), - }), - }); - } - - return null; + this.slots.clear(); } handleFrame(frame: TerminalStreamFrame): void { - const terminalId = this.slotTerminals.get(frame.slot); - if (!terminalId) { - return; - } - - if (frame.opcode === TerminalStreamOpcode.Output) { - this.emit({ - terminalId, - type: "output", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Restore) { - this.emit({ - terminalId, - type: "restore", - data: frame.payload, - }); - return; - } - - if (frame.opcode === TerminalStreamOpcode.Snapshot) { - const state = decodeTerminalSnapshotPayload(frame.payload); - if (!state) { - return; - } - this.emit({ - terminalId, - type: "snapshot", - state, - }); - } - } - - private emit(event: TerminalStreamEvent): void { - for (const listener of this.listeners) { - try { - listener(event); - } catch { - // no-op + const registrations = this.slots.get(frame.slot); + if (!registrations) return; + for (const registration of registrations) { + const identity = { + subscriptionId: registration.subscriptionId, + terminalId: registration.terminalId, + }; + let event: TerminalStreamEvent; + if (frame.opcode === TerminalStreamOpcode.Output) + event = { ...identity, type: "output", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Restore) + event = { ...identity, type: "restore", data: frame.payload }; + else if (frame.opcode === TerminalStreamOpcode.Snapshot) { + const state = decodeTerminalSnapshotPayload(frame.payload); + if (!state) return; + event = { ...identity, type: "snapshot", state }; + } else return; + registration.receive(event); + for (const listener of this.listeners) { + try { + listener(event); + } catch { + /* Passive diagnostics cannot interrupt delivery. */ + } } } } diff --git a/packages/client/src/terminals/index.test.ts b/packages/client/src/terminals/index.test.ts new file mode 100644 index 0000000000..2e9a51a93f --- /dev/null +++ b/packages/client/src/terminals/index.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { createTerminalActions } from "./index.js"; + +test("an older host rejects terminal operations before sending requests", async () => { + const unexpectedRequest = () => { + throw new Error("Unexpected daemon request"); + }; + const terminals = createTerminalActions( + { + ensureConnected: () => {}, + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "older-host", + hostname: null, + version: null, + features: {}, + }), + createTerminal: unexpectedRequest, + listTerminals: unexpectedRequest, + sendTerminalInput: unexpectedRequest, + captureTerminal: unexpectedRequest, + killTerminal: unexpectedRequest, + }, + unexpectedRequest, + ); + + await expect(terminals.create({ workspaceId: "wks_existing" })).rejects.toThrow( + "Update the host", + ); + await expect(terminals.list({ workspaceId: "wks_existing" })).rejects.toThrow("Update the host"); + const terminal = terminals.ref("terminal-existing"); + expect(terminal.current()).toBeNull(); + expect(() => terminal.write("text")).toThrow("Update the host"); + expect(() => terminal.sendKeys(["Enter"])).toThrow("Update the host"); + expect(() => terminal.capture()).toThrow("Update the host"); + await expect(terminal.kill()).rejects.toThrow("Update the host"); +}); diff --git a/packages/client/src/terminals/index.ts b/packages/client/src/terminals/index.ts new file mode 100644 index 0000000000..e1fd874a63 --- /dev/null +++ b/packages/client/src/terminals/index.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import type { DaemonClient } from "../daemon-client.js"; + +const TerminalSchema = z.object({ + id: z.string(), + workspaceId: z.string(), + cwd: z.string(), + name: z.string(), +}); + +export type PaseoTerminal = z.infer; + +export interface PaseoTerminalCreateOptions { + workspaceId: string; + /** Process working directory; defaults to the workspace directory. */ + cwd?: string; + name?: string; + command?: string; + args?: string[]; + size?: { rows: number; cols: number }; + requestId?: string; +} + +export interface PaseoTerminalListOptions { + /** Ownership filter. When supplied, cwd does not restrict the results. */ + workspaceId?: string; + /** Workspace root directory filter for unscoped listings. */ + cwd?: string; + requestId?: string; +} + +export interface PaseoTerminalListResult { + entries: PaseoTerminal[]; + requestId: string; +} + +export interface PaseoTerminalCaptureOptions { + start?: number; + end?: number; + stripAnsi?: boolean; + requestId?: string; +} + +export type PaseoTerminalCaptureResult = Awaited>; + +export interface PaseoTerminalHandle { + readonly id: string; + current(): PaseoTerminal | null; + refresh(options?: { requestId?: string }): Promise; + /** Sends literal input and returns its UTF-16 length. Does not await command execution. */ + write(data: string): number; + /** Expands CLI key tokens; other strings are literal. Returns the input's UTF-16 length. */ + sendKeys(keys: readonly string[]): number; + capture(options?: PaseoTerminalCaptureOptions): Promise; + kill(requestId?: string): Promise; +} + +export interface PaseoTerminalActions { + create(options: PaseoTerminalCreateOptions): Promise; + list(options?: PaseoTerminalListOptions): Promise; + ref(terminal: string | PaseoTerminal): PaseoTerminalHandle; +} + +export interface PaseoWorkspaceTerminalActions { + create(options?: Omit): Promise; + list(options?: { requestId?: string }): Promise; +} + +type TerminalClient = Pick< + DaemonClient, + | "ensureConnected" + | "getLastServerInfoMessage" + | "createTerminal" + | "listTerminals" + | "sendTerminalInput" + | "captureTerminal" + | "killTerminal" +>; + +/** @package */ +export function createTerminalActions( + daemonClient: TerminalClient, + resolveWorkspaceDirectory: (workspaceId: string) => Promise, +): PaseoTerminalActions { + function client(): TerminalClient { + daemonClient.ensureConnected(); + // COMPAT(workspaceTerminals): added in v0.7.3, remove gate after 2027-09-05. + if (daemonClient.getLastServerInfoMessage()?.features?.workspaceTerminals !== true) { + throw new Error("Update the host to use workspace terminals through the SDK."); + } + return daemonClient; + } + + const list = async (options: PaseoTerminalListOptions = {}): Promise => { + const result = await client().listTerminals(options.cwd, options.requestId, { + workspaceId: options.workspaceId, + }); + return { + entries: result.terminals.map((terminal) => TerminalSchema.parse(terminal)), + requestId: result.requestId, + }; + }; + + const ref = (terminal: string | PaseoTerminal): PaseoTerminalHandle => { + const id = typeof terminal === "string" ? terminal : terminal.id; + let current = typeof terminal === "string" ? null : terminal; + const write = (data: string): number => { + client().sendTerminalInput(id, { type: "input", data }); + return data.length; + }; + return { + id, + current: () => current, + refresh: async (options) => { + const result = await list(options); + current = result.entries.find((entry) => entry.id === id) ?? null; + return current; + }, + write, + sendKeys: (keys) => write(keys.map(resolveKeyToken).join("")), + capture: (options = {}) => { + const { requestId, ...captureOptions } = options; + return client().captureTerminal(id, captureOptions, requestId); + }, + kill: async (requestId) => { + const result = await client().killTerminal(id, requestId); + if (!result.success) throw new Error(`Failed to kill terminal ${id}`); + current = null; + }, + }; + }; + + return { + create: async ({ workspaceId, cwd, name, requestId, ...options }) => { + const driver = client(); + if (!workspaceId) throw new Error("workspaceId is required"); + const directory = cwd ?? (await resolveWorkspaceDirectory(workspaceId)); + const result = await driver.createTerminal(directory, name, requestId, { + ...options, + workspaceId, + }); + if (result.error || !result.terminal) { + throw new Error(result.error ?? "The daemon did not create a terminal"); + } + return ref(TerminalSchema.parse(result.terminal)); + }, + list, + ref, + }; +} + +function resolveKeyToken(key: string): string { + switch (key) { + case "Enter": + return "\r"; + case "Tab": + return "\t"; + case "Escape": + return "\u001b"; + case "Space": + return " "; + case "BSpace": + return "\u007f"; + case "C-c": + return "\u0003"; + case "C-d": + return "\u0004"; + case "C-z": + return "\u001a"; + case "C-l": + return "\u000c"; + case "C-a": + return "\u0001"; + case "C-e": + return "\u0005"; + default: + return key; + } +} diff --git a/packages/client/src/timeline-subscription/index.ts b/packages/client/src/timeline-subscription/index.ts new file mode 100644 index 0000000000..9293a0a739 --- /dev/null +++ b/packages/client/src/timeline-subscription/index.ts @@ -0,0 +1,75 @@ +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { OwnedSubscription, TimelineSubscription } from "../connection/index.js"; + +type TimelineUpdate = Extract< + SessionOutboundMessage, + { type: "agent_stream" | "agent.timeline.replacement" } +>; + +/** Local SDK lifecycle messages; these are not additional wire RPCs. */ +export type TimelineMessage = + | TimelineUpdate + | { + type: "agent.timeline.subscription_restored"; + payload: { agentId: string; subscriptionId: string }; + } + | { type: "agent.timeline.error"; payload: { agentId: string; error: string } }; + +/** Restores live delivery; the consumer owns history reads and gap recovery. */ +export function subscribeTimeline( + agentId: string, + observation: OwnedSubscription<{ agentIds: string[] }>, + handler: (message: TimelineMessage) => void, + reportError: (error: unknown) => void, +): TimelineSubscription { + let established = false; + let released = false; + const notify = (message: TimelineMessage) => { + if (released) return; + try { + handler(message); + } catch (error) { + reportError(error); + } + }; + const release = () => { + released = true; + return observation.release(); + }; + const stop = () => { + // Use the public release entry point so the owning API also forgets this handle. + void subscription.release().catch(reportError); + }; + const fail = (error: unknown) => { + notify({ + type: "agent.timeline.error", + payload: { agentId, error: error instanceof Error ? error.message : String(error) }, + }); + stop(); + }; + observation.subscribe({ + snapshot: ({ subscriptionId }) => { + if (!established) { + established = true; + return; + } + notify({ + type: "agent.timeline.subscription_restored", + payload: { agentId, subscriptionId }, + }); + }, + update: (message) => { + if (message.type === "agent_stream" || message.type === "agent.timeline.replacement") + notify(message); + }, + error: fail, + }); + const subscription: TimelineSubscription = Object.assign(stop, { + ready: observation.ready.then(() => undefined), + release, + subscriptionId: null, + }); + Object.defineProperty(subscription, "subscriptionId", { get: () => observation.subscriptionId }); + void subscription.ready.catch(fail); + return subscription; +} diff --git a/packages/desktop/bin/paseo b/packages/desktop/bin/paseo index 05797ae6d1..957280d684 100755 --- a/packages/desktop/bin/paseo +++ b/packages/desktop/bin/paseo @@ -44,6 +44,4 @@ fi RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js" CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js" -# PASEO_DESKTOP_MANAGED marks daemons started through this bundled CLI as -# desktop-managed, so the desktop app restarts them when it upgrades. -exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_DESKTOP_MANAGED=1 PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" +exec env ELECTRON_RUN_AS_NODE=1 PASEO_NODE_ENV=production PASEO_CLI="${SCRIPT_PATH}" "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@" diff --git a/packages/desktop/capture-harness/main.js b/packages/desktop/capture-harness/main.js index b51735275b..5501ff8ba4 100644 --- a/packages/desktop/capture-harness/main.js +++ b/packages/desktop/capture-harness/main.js @@ -4,6 +4,7 @@ const http = require("node:http"); const path = require("node:path"); const { isDeepStrictEqual } = require("node:util"); const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, session } = require("electron"); +const { adaptWebContents } = require("../dist/features/browser-automation/ipc.js"); const ROOT = __dirname; const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out"); @@ -48,7 +49,7 @@ const PERMANENT_STATE_FILTER = new Set( .filter(Boolean), ); const PERMANENT_VARIANT_FILTER = new Set( - (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "attach-off") + (process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "default") .split(",") .map((variant) => variant.trim()) .filter(Boolean), @@ -56,9 +57,9 @@ const PERMANENT_VARIANT_FILTER = new Set( const PERMANENT_CAPTURE_MODES = ["viewport", "full-page"]; const PERMANENT_THROTTLING_VARIANTS = [ { - id: "capture-only-throttling", - code: "capture-only", - label: "backgroundThrottling disabled only during each capture", + id: "default-throttling", + code: "default", + label: "Chromium background throttling enabled", disableGuestBackgroundThrottlingAtAttach: false, }, { @@ -442,8 +443,10 @@ async function readGuestMetrics(contents) { } async function capturePageSequence(contents) { - contents.invalidate(); - return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage"); + return adaptWebContents(contents).withFrameProduction(async () => { + contents.invalidate(); + return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage"); + }); } async function captureFullPage(contents) { @@ -485,8 +488,10 @@ async function captureFullPage(contents) { } async function captureFullPageSequence(contents) { - contents.invalidate(); - return await captureFullPage(contents); + return adaptWebContents(contents).withFrameProduction(async () => { + contents.invalidate(); + return await captureFullPage(contents); + }); } function installHarnessWebviewGuards(win, options = {}) { @@ -1569,9 +1574,8 @@ async function automationDrag(guest, sourceRef, targetRef) { } async function automationType(guest, refEntry, text) { - const send = await attachAutomationDebugger(guest); await automationClick(guest, refEntry); - await send("Input.insertText", { text }); + await guest.insertText(text); } function sendContainedEnter(guest) { @@ -2115,7 +2119,7 @@ async function runAutomationGroup() { installHarnessWebviewGuards(win, { preloadPath: PRODUCTION_BROWSER_GUEST_PRELOAD_PATH, }); - const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true }); + const tracker = trackAttachedGuests(win); try { await withTimeout( win.loadFile(path.join(ROOT, "index.html"), { @@ -2636,13 +2640,25 @@ async function runBrowserProfileGroup() { async function main() { ensureDirSync(OUT_DIR); if ( - !["all", "existing", "permanent-parking", "automation", "browser-profile"].includes( - HARNESS_GROUP, - ) + ![ + "all", + "existing", + "permanent-parking", + "automation", + "browser-profile", + "titlebar-drag", + ].includes(HARNESS_GROUP) ) { fail(`unknown harness group ${HARNESS_GROUP}`); } + if (HARNESS_GROUP === "titlebar-drag") { + const { runTitlebarDragGroup } = require("./titlebar-drag.js"); + await runTitlebarDragGroup(OUT_DIR); + pass(`capture harness titlebar-drag complete output=${OUT_DIR}`); + return; + } + if (HARNESS_GROUP === "browser-profile") { const browserProfileResults = await runBrowserProfileGroup(); await fsp.writeFile( diff --git a/packages/desktop/capture-harness/titlebar-drag-mouse.m b/packages/desktop/capture-harness/titlebar-drag-mouse.m new file mode 100644 index 0000000000..ac8894bc52 --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag-mouse.m @@ -0,0 +1,52 @@ +// titlebar-drag-mouse.m — native mouse helper for the titlebar-drag harness. +// Posts real CGEvents (kCGHIDEventTap) so a drag moves the actual window; pure +// CDP input would never exercise Electron's draggable-region pipeline. +// Usage: titlebar-drag-mouse drag x1 y1 x2 y2 | click x y +// Every event carries flags 0 so a user-held Cmd/Opt cannot leak into the drag. +// Exit 2 when this process lacks Accessibility trust: the harness fails loudly +// instead of posting nothing and reading as a pass. +#import +#import +#import +#import +#import + +static void post(CGEventType type, CGPoint p, useconds_t delay) { + CGEventRef event = CGEventCreateMouseEvent(NULL, type, p, kCGMouseButtonLeft); + if (!event) { + fprintf(stderr, "mouse: CGEventCreateMouseEvent failed\n"); + exit(3); + } + CGEventSetFlags(event, (CGEventFlags)0); + CGEventPost(kCGHIDEventTap, event); + CFRelease(event); + usleep(delay); +} + +int main(int argc, char **argv) { + if (!AXIsProcessTrusted()) { + fprintf(stderr, "mouse: not accessibility-trusted\n"); + return 2; + } + if (argc == 6 && strcmp(argv[1], "drag") == 0) { + CGPoint a = {atof(argv[2]), atof(argv[3])}; + CGPoint b = {atof(argv[4]), atof(argv[5])}; + post(kCGEventMouseMoved, a, 150000); + post(kCGEventLeftMouseDown, a, 150000); + for (int i = 1; i <= 20; i++) { + CGPoint step = {a.x + (b.x - a.x) * i / 20, a.y + (b.y - a.y) * i / 20}; + post(kCGEventLeftMouseDragged, step, 25000); + } + post(kCGEventLeftMouseUp, b, 25000); + return 0; + } + if (argc == 4 && strcmp(argv[1], "click") == 0) { + CGPoint p = {atof(argv[2]), atof(argv[3])}; + post(kCGEventMouseMoved, p, 150000); + post(kCGEventLeftMouseDown, p, 150000); + post(kCGEventLeftMouseUp, p, 25000); + return 0; + } + fprintf(stderr, "usage: %s drag x1 y1 x2 y2 | click x y\n", argv[0]); + return 1; +} diff --git a/packages/desktop/capture-harness/titlebar-drag.js b/packages/desktop/capture-harness/titlebar-drag.js new file mode 100644 index 0000000000..fe4a35d06c --- /dev/null +++ b/packages/desktop/capture-harness/titlebar-drag.js @@ -0,0 +1,272 @@ +// Native mouse input is required: CDP mouse events do not move Electron windows. +// Load production CSS so restoring the global no-drag rule reproduces the failure. +const fs = require("node:fs"); +const fsp = require("node:fs/promises"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); +const { BrowserWindow } = require("electron"); + +const execFileAsync = promisify(execFile); +const PACKAGE_ROOT = path.join(__dirname, "..", ".."); +const INDEX_HTML = path.join(PACKAGE_ROOT, "app", "public", "index.html"); +const MOUSE_SOURCE = path.join(__dirname, "titlebar-drag-mouse.m"); +const BASE = { x: 100, y: 150 }; +const DRAG_DELTA = { x: 80, y: 50 }; +const DRAG_POINTS = [ + { check: "header-overlay-blank", x: 625, y: 18 }, + { check: "tab-row-overlay-blank", x: 625, y: 50 }, + { check: "sidebar-overlay-blank", x: 125, y: 18 }, + { check: "direct-rail-blank", x: 125, y: 50 }, +]; +const CLICK_POINTS = [ + { check: "header-button-click", id: "header-btn", x: 955, y: 18 }, + { check: "window-owned-toggle-click", id: "window-toggle", x: 40, y: 18 }, + { check: "window-owned-focusable-click", id: "focusable", x: 320, y: 18 }, + { check: "window-owned-negative-tabindex-button-click", id: "negative-button", x: 400, y: 18 }, + { check: "sidebar-tab-click", id: "sidebar-btn", x: 205, y: 18 }, + { check: "rail-tab-click", id: "rail-btn", x: 205, y: 50 }, + { check: "portal-button-click", id: "portal-btn", x: 490, y: 18 }, + { check: "floating-panel-button-click", id: "floating-btn", x: 785, y: 18 }, +]; + +/** Static overlays and a sibling window control mirror the app shell ownership. */ +function buildFixtureHtml(appStyles) { + const dragOverlay = + 'style="position:absolute;top:0;left:0;display:block;width:100%;height:100%;-webkit-app-region:drag"'; + const button = (id, style) => + ``; + return ` + + + +
+
+
+ +${button("window-toggle", "position:absolute;left:10px;top:6px;width:60px;height:24px;z-index:2")} +
Focusable
+
Button
+
+
+
+ Chat link + ${button("chat-code", "position:absolute;left:250px;top:320px;width:180px;height:24px")} +
file.ts
+
+
+
+
+ ${button("portal-btn", "position:fixed;left:460px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+
+ ${button("floating-btn", "position:fixed;left:755px;top:6px;width:60px;height:24px;pointer-events:auto")} +
+`; +} + +function readRealAppStyles() { + const html = fs.readFileSync(INDEX_HTML, "utf8"); + const styles = [...html.matchAll(/]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n"); + if (!styles.trim()) throw new Error(`no + + @@ -233,20 +242,6 @@ async function callBrowserTool(client, name, args = {}) { return mcpPayload(await client.callTool({ name, args }), name); } -async function callBrowserToolUntilReady(client, name, args = {}) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const result = await client.callTool({ name, args }); - const payload = result.structuredContent; - if (payload?.ok === true) return payload.result; - if (payload?.ok !== false || payload.error?.retryable !== true) { - return mcpPayload(result, name); - } - await delay(100); - } - throw new Error(`${name} remained unavailable for ${timeoutMs}ms`); -} - async function waitForGuestSelector(client, browserId) { const deadline = Date.now() + 5_000; while (Date.now() < deadline) { @@ -262,7 +257,22 @@ async function waitForGuestSelector(client, browserId) { return false; } -async function createCallerAgent(daemonPort) { +async function waitForGuestActiveElement(client, browserId, elementId) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const evaluated = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.activeElement?.id ?? null", + }); + if (JSON.parse(evaluated.resultJson) === elementId) { + return true; + } + await delay(50); + } + return false; +} + +async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) { const transport = new StreamableHTTPClientTransport( new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`), ); @@ -272,12 +282,11 @@ async function createCallerAgent(daemonPort) { name: "create_agent", args: { relationship: { kind: "detached" }, - workspace: { kind: "existing", workspaceId: workspaceIds[0] }, + workspace: { kind: "existing", workspaceId }, title: "Browser desktop browser E2E caller", provider: "mock/ten-second-stream", settings: { modeId: "load-test" }, initialPrompt: "Remain available while the browser bridge regression runs.", - background: true, }, }); const result = response.structuredContent; @@ -287,7 +296,7 @@ async function createCallerAgent(daemonPort) { ); assert(typeof result.agentId === "string", "create_agent returned no caller agent id"); assert( - result.workspaceId === workspaceIds[0], + result.workspaceId === workspaceId, `MCP caller attached to unexpected workspace ${result.workspaceId}`, ); return result.agentId; @@ -351,7 +360,8 @@ async function readPresentation(page, browserId) { async function readViewport(client, browserId) { const evaluated = await callBrowserTool(client, "browser_evaluate", { browserId, - function: "() => ({ width: window.innerWidth, height: window.innerHeight })", + function: + "() => ({ width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio })", }); return JSON.parse(evaluated.resultJson); } @@ -368,17 +378,12 @@ async function clickGuestElement(page, client, browserId, selector) { }); const elementRect = JSON.parse(evaluated.resultJson); assert(elementRect, `Guest element ${selector} was unavailable`); - const webviewRect = await page.evaluate((id) => { - const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - if (!(webview instanceof HTMLElement)) return null; - const rect = webview.getBoundingClientRect(); - return { x: rect.x, y: rect.y }; - }, browserId); - assert(webviewRect, `Browser webview ${browserId} was unavailable`); - await page.mouse.click( - webviewRect.x + elementRect.x + elementRect.width / 2, - webviewRect.y + elementRect.y + elementRect.height / 2, - ); + await page.locator(`[data-paseo-browser-id="${browserId}"]`).click({ + position: { + x: elementRect.x + elementRect.width / 2, + y: elementRect.y + elementRect.height / 2, + }, + }); } async function selectDeviceSize(page, label) { @@ -449,7 +454,176 @@ function recordViewportMismatch(failures, label, actual, expected) { ); } -async function runRegression({ page, client, serverId, targetUrl, callerAgentId, artifactDir }) { +async function setWindowHidden(inspectorPort, hidden) { + const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json(); + const socket = new WebSocket(target.webSocketDebuggerUrl); + try { + await new Promise((resolve, reject) => { + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("message", ({ data }) => { + const response = JSON.parse(data); + if (response.id !== 1) return; + if (response.error || response.result?.exceptionDetails) reject(new Error(data)); + else resolve(); + }); + socket.addEventListener( + "open", + () => + socket.send( + JSON.stringify({ + id: 1, + method: "Runtime.evaluate", + params: { + expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`, + }, + }), + ), + { once: true }, + ); + }); + } finally { + socket.close(); + } +} + +async function verifyHiddenBrowserScreenshots({ + page, + client, + browserId, + artifactDir, + inspectorPort, +}) { + const readFrames = async () => { + const result = await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => window.frameCount", + }); + return JSON.parse(result.resultJson); + }; + const expectAnimation = async () => { + const start = await readFrames(); + assert(typeof start === "number", "Animation fixture was not loaded"); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if ((await readFrames()) > start + 2) return; + await delay(50); + } + throw new Error("Browser did not animate in the restored window"); + }; + const measurements = []; + const expectIdle = async (label) => { + await delay(500); // Let Chromium consume the visibility change before sampling. + const start = await readFrames(); + await delay(1_000); // A fixed sampling interval is the behavior under test. + const frames = (await readFrames()) - start; + measurements.push({ label, frames }); + writeJson(path.join(artifactDir, "hidden-browser-frames.json"), measurements); + assert(frames === 0, `${label}: hidden browser produced ${frames} animation frames`); + }; + try { + await expectAnimation(); + await setWindowHidden(inspectorPort, true); + await expectIdle("before-capture"); + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => { document.body.style.background = 'rgb(0,255,0)'; }", + }); + const response = await client.callTool({ name: "browser_screenshot", args: { browserId } }); + mcpPayload(response, "browser_screenshot"); + const screenshot = response.content.find((item) => item.type === "image"); + assert(screenshot, "browser_screenshot returned no image"); + fs.writeFileSync( + path.join(artifactDir, "hidden-browser-viewport.png"), + Buffer.from(screenshot.data, "base64"), + ); + const pixel = await page.evaluate(async (base64) => { + const image = new Image(); + image.src = `data:image/png;base64,${base64}`; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.width; + canvas.height = image.height; + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + return [ + ...context.getImageData(Math.floor(image.width / 2), Math.floor(image.height / 2), 1, 1) + .data, + ]; + }, screenshot.data); + assert( + JSON.stringify(pixel) === "[0,255,0,255]", + `Hidden screenshot returned stale pixels: ${pixel}`, + ); + await expectIdle("after-capture"); + } finally { + await setWindowHidden(inspectorPort, false); + } + await expectAnimation(); +} + +async function verifyBackgroundTypingStaysInGuest({ page, client, serverId, browserId, agent }) { + await page + .getByTestId(`workspace-deck-entry-${serverId}:${agent.workspaceId}`) + .getByTestId(`workspace-tab-agent_${agent.agentId}`) + .click({ timeout: timeoutMs }); + const composer = page + .getByRole("textbox", { name: "Message agent..." }) + .filter({ visible: true }) + .first(); + const draft = "draft the user is typing"; + await composer.click(); + await composer.pressSequentially(draft); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not keep focus while the user typed", + ); + + const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId }); + const ref = snapshot.snapshot.match(/textbox "Typing target" \[ref=(@e\d+)\]/)?.[1]; + assert(ref, `browser_snapshot did not expose the typing target: ${snapshot.snapshot}`); + await callBrowserTool(client, "browser_type", { browserId, ref, text: "agent text" }); + await callBrowserTool(client, "browser_keypress", { browserId, ref, key: "!" }); + + // The user keeps typing in the composer while the agent types into the tab's focused field. + await composer.click(); + await composer.press("End"); + assert( + await composer.evaluate((element) => element === document.activeElement), + "Composer did not take focus back from the browser tab", + ); + await callBrowserTool(client, "browser_type", { browserId, text: " more" }); + await callBrowserTool(client, "browser_keypress", { browserId, key: "?" }); + + const guestValue = JSON.parse( + ( + await callBrowserTool(client, "browser_evaluate", { + browserId, + function: "() => document.getElementById('typing-target').value", + }) + ).resultJson, + ); + const composerValue = await composer.inputValue(); + assert( + composerValue === draft, + `browser_type and browser_keypress in a background tab changed the focused composer: ${JSON.stringify(composerValue)}`, + ); + assert( + guestValue === "agent text! more?", + `browser_type and browser_keypress did not reach the browser tab: ${JSON.stringify(guestValue)}`, + ); + await composer.fill(""); +} + +async function runRegression({ + page, + client, + serverId, + targetUrl, + callerAgentId, + typingAgent, + artifactDir, + inspectorPort, +}) { const failures = []; const originalWorkspaceId = workspaceIds[0]; const originalWorkspaceRow = page.getByTestId( @@ -477,7 +651,10 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, await page.waitForFunction( (id) => { const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`); - return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews"; + const surface = webview?.parentElement; + return ( + surface?.getAttribute("aria-hidden") === "false" && surface.style.pointerEvents === "auto" + ); }, browserId, { timeout: timeoutMs }, @@ -492,12 +669,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, ); await clickGuestElement(page, client, browserId, "#typing-target"); - const activeGuestElement = await callBrowserTool(client, "browser_evaluate", { - browserId, - function: "() => document.activeElement?.id ?? null", - }); assert( - JSON.parse(activeGuestElement.resultJson) === "typing-target", + await waitForGuestActiveElement(client, browserId, "typing-target"), "Physical browser click did not focus the guest input", ); const focusedGuest = await page.evaluate( @@ -508,10 +681,11 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, const deviceSizeMenuPainted = await selectDeviceSize(page, "iPhone SE · 375×667"); assert(deviceSizeMenuPainted, "Device size menu did not paint above the browser surface"); + const deviceViewport = await readViewport(client, browserId); recordViewportMismatch( failures, "device size menu paints and receives input above the browser surface", - await readViewport(client, browserId), + deviceViewport, { width: 375, height: 667 }, ); @@ -520,6 +694,12 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, text: "Bridge target", timeoutMs: 5_000, }); + const resizedScreenshot = await callBrowserTool(client, "browser_screenshot", { browserId }); + assert( + resizedScreenshot.width === Math.round(375 * deviceViewport.scale) && + resizedScreenshot.height === Math.round(667 * deviceViewport.scale), + `Screenshot after resize returned ${resizedScreenshot.width}×${resizedScreenshot.height}`, + ); const requestedViewport = { width: 640, height: 480 }; await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport }); recordViewportMismatch( @@ -595,7 +775,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, { timeout: timeoutMs }, ); try { - await callBrowserToolUntilReady(client, "browser_screenshot", { browserId }); + await callBrowserTool(client, "browser_screenshot", { browserId }); } catch (error) { failures.push(`inactive browser remains captureable: ${String(error)}`); } @@ -708,6 +888,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, const parkedGuest = await readGuest(page, browserId); assert(parkedGuest, "Browser guest was not parked after workspace eviction"); + await verifyHiddenBrowserScreenshots({ page, client, browserId, artifactDir, inspectorPort }); + const listed = await callBrowserTool(client, "browser_list_tabs"); assert( listed.tabs.some((tab) => tab.browserId === browserId), @@ -728,6 +910,13 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, text: "Clicked", timeoutMs: 5_000, }); + await verifyBackgroundTypingStaysInGuest({ + page, + client, + serverId, + browserId, + agent: typingAgent, + }); await originalWorkspaceRow.click(); await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click(); @@ -875,6 +1064,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId, guestFocus: "passed", overlayPlane: "passed", inactiveCapture: "passed", + hiddenWindowCapture: "passed", list: "passed", snapshot: "passed", click: "passed", @@ -893,7 +1083,9 @@ async function main() { const workspaceRoot = path.join(runtimeDir, "workspaces"); fs.mkdirSync(paseoHome, { recursive: true }); - const [daemonPort, expoPort, cdpPort] = await Promise.all([ + const [daemonPort, expoPort, cdpPort, inspectorPort, remotePort] = await Promise.all([ + reservePort(), + reservePort(), reservePort(), reservePort(), reservePort(), @@ -901,13 +1093,28 @@ async function main() { const listen = `127.0.0.1:${daemonPort}`; seedPaseoHome(paseoHome, listen, workspaceRoot); const target = await startTargetPage(); + seedPluginLinks(paseoHome, workspaceIds[0], target.url, workspaceIds[1]); + const remoteHome = path.join(runtimeDir, "remote-home"); + seedPaseoHome(remoteHome, `127.0.0.1:${remotePort}`, path.join(runtimeDir, "remote-workspaces")); const children = []; let browser = null; let client = null; try { + // Observe the real Electron shell handoff without launching a user's browser. + const openerDirectory = path.join(runtimeDir, "url-handler"); + const externalOpenLog = path.join(artifactDir, "external-opens.txt"); + fs.mkdirSync(openerDirectory, { recursive: true }); + fs.writeFileSync(externalOpenLog, ""); + fs.writeFileSync( + path.join(openerDirectory, "xdg-open"), + '#!/bin/sh\nprintf "%s\\n" "$1" >> "$PASEO_TEST_EXTERNAL_OPEN_LOG"\n', + { mode: 0o755 }, + ); const commonEnv = { ...process.env, + PATH: `${openerDirectory}${path.delimiter}${process.env.PATH}`, + PASEO_TEST_EXTERNAL_OPEN_LOG: externalOpenLog, PASEO_HOME: paseoHome, PASEO_LISTEN: listen, PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`, @@ -928,9 +1135,29 @@ async function main() { children.push(daemon.child); await waitForPort(daemonPort, "daemon", daemon); + const remoteDaemon = spawnLogged( + "remote-daemon", + process.execPath, + ["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")], + { + cwd: rootDir, + env: { + ...commonEnv, + PASEO_HOME: remoteHome, + PASEO_LISTEN: `127.0.0.1:${remotePort}`, + PASEO_SERVER_ID: "plugin-links-remote", + PASEO_NODE_ENV: "development", + }, + }, + artifactDir, + ); + children.push(remoteDaemon.child); + await waitForPort(remotePort, "remote daemon", remoteDaemon); + const desktopArgs = [ process.execPath, devRunner, + `--inspect=127.0.0.1:${inspectorPort}`, ...(process.platform === "linux" ? ["--no-sandbox"] : []), ]; const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift(); @@ -962,6 +1189,22 @@ async function main() { const page = await waitForAppPage(browser, expoPort); const status = await waitForDesktopStatus(page); + const checkPluginLinks = () => + runPluginLinksRegression({ + page, + remotePort, + workspaceId: workspaceIds[0], + remoteWorkspaceId: workspaceIds[1], + url: target.url, + artifactDir, + externalOpenLog: process.platform === "linux" ? externalOpenLog : null, + }); + if (process.env.PASEO_DESKTOP_PLUGIN_LINKS_ONLY === "1") { + const pluginLinks = await checkPluginLinks(); + writeJson(path.join(artifactDir, "result.json"), { pluginLinks }); + console.log("Plugin external links and workspace browser passed."); + return; + } const settingsMemory = await runSettingsMemoryRegression(page); if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") { writeJson(path.join(artifactDir, "result.json"), { settingsMemory }); @@ -974,6 +1217,10 @@ async function main() { await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); + const typingAgent = { + workspaceId: workspaceIds.at(-1), + agentId: await createCallerAgent(daemonPort, workspaceIds.at(-1)), + }; const transport = new StreamableHTTPClientTransport( new URL( `http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`, @@ -985,10 +1232,13 @@ async function main() { client, serverId: status.serverId, targetUrl: target.url, + inspectorPort, callerAgentId, + typingAgent, artifactDir, }); - writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory }); + const pluginLinks = await checkPluginLinks(); + writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks }); console.log( `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); diff --git a/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs new file mode 100644 index 0000000000..d32b7d65b8 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle-renderer.electron.mjs @@ -0,0 +1,170 @@ +import { openSync, closeSync } from "node:fs"; +import { warmMetro } from "../../app/e2e/support/metro-warmup.mjs"; +import { spawn, execFileSync } from "node:child_process"; +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import net from "node:net"; +import { _electron as electron, expect } from "playwright/test"; +import { readDaemonInstance } from "@getpaseo/server/daemon-control"; + +// Run under the test's private Xvfb display; native dialog input never targets +// a user's desktop. All daemon operations still cross the real preload/IPC. +export async function verifyAttachedDaemonControls({ repo, root, env, home, port, instance }) { + if (!process.env.DISPLAY) + throw new Error("Renderer lifecycle QA requires a private Xvfb display"); + const listener = net.createServer(); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const metroPort = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + const metroLog = openSync(path.join(root, "metro.log"), "w"); + const metro = spawn( + process.execPath, + [ + path.join(repo, "node_modules/expo/bin/cli"), + "start", + "--web", + "--port", + String(metroPort), + "--offline", + ], + { + cwd: path.join(repo, "packages/app"), + detached: true, + stdio: ["ignore", metroLog, metroLog], + env: { + ...env, + EXPO_NO_DOTENV: "1", + CI: "1", + PASEO_WEB_PLATFORM: "electron", + EXPO_PUBLIC_LOCAL_DAEMON: `127.0.0.1:${port}`, + }, + }, + ); + closeSync(metroLog); + let desktop; + let page; + try { + await expect + .poll( + async () => { + try { + return (await fetch(`http://127.0.0.1:${metroPort}/status`)).ok; + } catch { + return false; + } + }, + { timeout: 60_000 }, + ) + .toBe(true); + // /status means Metro is listening; the app bundle may still need a cold compile. + await warmMetro(metroPort); + desktop = await electron.launch({ + args: [path.join(repo, "packages/desktop/dist/main.js"), "--no-sandbox"], + env: { + ...env, + EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`, + PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1", + PASEO_ELECTRON_USER_DATA_DIR: path.join(root, "renderer-user-data"), + }, + }); + page = await desktop.firstWindow(); + page.on("pageerror", (error) => console.log("Renderer error:", error.message)); + await page.route(/:(6767|6768)\b/, (route) => route.abort()); + await page.getByRole("button", { name: "Settings", exact: true }).click({ timeout: 90_000 }); + await page.getByRole("button", { name: "Enable built-in daemon", exact: true }).click(); + await expect(page.getByText("Attached to an existing daemon", { exact: true })).toBeVisible(); + await expect( + page.getByText("Removes localhost from this device and stops the built-in daemon", { + exact: true, + }), + ).toBeHidden(); + await page + .getByText("Attached to an existing daemon", { exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(root, "attached-daemon.png") }); + + async function respondToNativeDialog(title, artifact, key) { + let windowId; + await expect + .poll( + () => { + try { + windowId = execFileSync("xdotool", ["search", "--onlyvisible", "--name", title], { + encoding: "utf8", + }) + .trim() + .split("\n") + .at(-1); + return Boolean(windowId); + } catch { + return false; + } + }, + { timeout: 10_000 }, + ) + .toBe(true); + const screenshot = await desktop.evaluate(async ({ desktopCapturer }) => { + const sources = await desktopCapturer.getSources({ + types: ["screen"], + thumbnailSize: { width: 1280, height: 800 }, + }); + return sources[0].thumbnail.toPNG().toString("base64"); + }); + await writeFile(path.join(root, artifact), Buffer.from(screenshot, "base64")); + // Escape destroys the dialog on key-down. Send through the private display + // focus so key-up does not target an already-destroyed X11 window. + execFileSync("xdotool", ["windowfocus", "--sync", windowId, "key", key]); + } + + const management = page.getByRole("switch", { name: "Manage built-in daemon" }); + await management.click(); + await respondToNativeDialog( + "Pause built-in daemon", + "pause-attached-confirmation.png", + "Return", + ); + await expect(management).not.toBeChecked(); + expect((await readDaemonInstance(home)).pid).toBe(instance.pid); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-cancelled.png", "Escape"); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeEnabled(); + expect(await readDaemonInstance(home)).toMatchObject({ + pid: instance.pid, + startedAt: instance.startedAt, + }); + + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await respondToNativeDialog("Stop local daemon", "stop-attached-confirmation.png", "Return"); + await expect.poll(() => readDaemonInstance(home), { timeout: 20_000 }).toBeNull(); + await expect(page.getByRole("button", { name: "Stop daemon", exact: true })).toBeHidden(); + await page.screenshot({ path: path.join(root, "attached-daemon-stopped.png") }); + console.log( + "PASS: real Desktop renderer pauses management without stopping an attached daemon, then confirms and stops that instance.", + ); + } catch (error) { + if (page) { + console.log( + "Renderer state:", + await page + .locator("body") + .innerText() + .catch(() => "closed"), + ); + await page.screenshot({ path: path.join(root, "renderer-failure.png") }).catch(() => {}); + } + throw error; + } finally { + if (desktop) { + const exited = once(desktop.process(), "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + } + if (metro.exitCode === null) { + const exited = once(metro, "exit"); + process.kill(-metro.pid, "SIGTERM"); + await exited; + } + } +} diff --git a/packages/desktop/e2e/daemon-lifecycle.e2e.mjs b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs new file mode 100644 index 0000000000..870e0f7db5 --- /dev/null +++ b/packages/desktop/e2e/daemon-lifecycle.e2e.mjs @@ -0,0 +1,213 @@ +import { verifyAttachedDaemonControls } from "./daemon-lifecycle-renderer.electron.mjs"; +import { once } from "node:events"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, rm, readdir, copyFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; +import { savePersistedConfig } from "@getpaseo/server/configuration"; +import { + startDaemonInstance, + stopDaemonInstance, + readDaemonInstance, +} from "@getpaseo/server/daemon-control"; + +const repo = fileURLToPath(new URL("../../..", import.meta.url)); +const root = await mkdtemp(path.join(tmpdir(), "paseo desktop lifecycle ")); +const home = path.join(root, "daemon"); +const env = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith("PASEO_") && key !== "ELECTRON_RUN_AS_NODE", + ), +); +env.HOME = root; +env.USERPROFILE = root; +env.PASEO_HOME = home; +const server = net.createServer(); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const port = server.address().port; +await new Promise((resolve) => server.close(resolve)); +assert.ok(port !== 6767 && port !== 6768); +await mkdir(path.join(root, "web")); +await writeFile(path.join(root, "web/index.html"), "Lifecycle web UI"); +savePersistedConfig(home, { + daemon: { listen: `127.0.0.1:${port}`, relay: { enabled: false } }, + features: { + dictation: { enabled: false }, + voiceMode: { enabled: false }, + webUi: { enabled: true, distDir: path.join(root, "web") }, + }, +}); +const main = path.join(root, "main.cjs"); +const managerPath = path.join(repo, "packages/desktop/dist/daemon/daemon-manager.js"); +await writeFile( + main, + `const { app } = require("electron"); app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); app.whenReady().then(() => { global.lifecycle = require(${JSON.stringify(managerPath)}); });`, +); +let desktop; +let captured; +async function openDesktop() { + desktop = await electron.launch({ + args: ["--no-sandbox", "--ozone-platform=headless", main], + env: { + ...env, + PASEO_LISTEN: "127.0.0.1:1", + PASEO_WEB_UI_ENABLED: "false", + PASEO_HOST: "unused:1", + }, + }); + await expect + .poll(() => desktop.evaluate(() => Boolean(global.lifecycle)), { timeout: 10_000 }) + .toBe(true); +} +async function closeDesktop() { + if (!desktop) return; + const child = desktop.process(); + const exited = once(child, "exit"); + await desktop.evaluate(({ app }) => app.exit(0)).catch(() => {}); + await exited; + desktop = null; +} +async function command(name, args) { + console.log(`Desktop command: ${name}${args?.reason ? ` (${args.reason})` : ""}`); + return desktop.evaluate( + async (_, input) => global.lifecycle.createDaemonCommandHandlers()[input.name](input.args), + { name, args }, + ); +} +try { + // Simulate a legacy independent launch that carries the old Desktop flag. + const launch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = launch.instance; + await openDesktop(); + const attached = await command("start_desktop_daemon"); + assert.equal(attached.pid, captured.pid); + assert.equal(attached.desktopManaged, true); + assert.equal(attached.ownedByDesktop, false); + for (const reason of ["quit", "settings", "app_update", "host_remove", "version_mismatch"]) { + const status = await command("stop_desktop_daemon", { reason }); + assert.equal(status.pid, captured.pid, `${reason} stopped an attached daemon`); + process.kill(captured.pid, 0); + } + const restarted = await command("restart_desktop_daemon"); + assert.equal(restarted.pid, captured.pid); + await assert.rejects( + command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: "outdated-confirmation", + }), + /changed since confirmation/, + ); + const stopped = await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + assert.equal(stopped.status, "stopped"); + captured = null; + + const owned = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(owned.pid, captured.pid); + assert.equal(owned.ownedByDesktop, true); + assert.equal(owned.listen, `127.0.0.1:${port}`); + assert.equal( + JSON.parse(await readFile(path.join(home, "config.json"), "utf8")).features.webUi.enabled, + true, + ); + const liveWebUi = await fetch(`http://127.0.0.1:${port}/`); + assert.equal(liveWebUi.status, 200); + assert.match(await liveWebUi.text(), /Lifecycle web UI/); + await command("patch_desktop_settings", { daemon: { keepRunningAfterQuit: true } }); + await closeDesktop(); + desktop = null; + process.kill(captured.pid, 0); + await openDesktop(); + assert.equal((await command("start_desktop_daemon")).ownedByDesktop, false); + await command("stop_desktop_daemon", { reason: "quit" }); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { + reason: "manual_ipc", + pid: captured.pid, + startedAt: captured.startedAt, + }); + captured = null; + const next = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(next.ownedByDesktop, true); + await command("stop_desktop_daemon", { reason: "quit" }); + assert.equal(await readDaemonInstance(home), null); + captured = null; + await closeDesktop(); + if (process.platform === "linux") { + const rendererLaunch = await startDaemonInstance({ + home, + command: process.execPath, + args: [path.join(repo, "packages/server/dist/scripts/supervisor-entrypoint.js")], + env, + mode: "deployment", + desktopManaged: true, + timeoutMs: 30_000, + }); + captured = rendererLaunch.instance; + await verifyAttachedDaemonControls({ repo, root, env, home, port, instance: captured }); + captured = null; + } + if (process.platform !== "win32") { + const preload = path.join(root, "slow-worker.mjs"); + await writeFile( + preload, + 'if (process.argv[1]?.endsWith("daemon-worker.js")) await new Promise(resolve => setTimeout(resolve, 45000));', + ); + await openDesktop(); + // Playwright removes NODE_OPTIONS from electron.launch's environment. + // Restore this runtime input inside the isolated fixture before it launches the daemon. + await desktop.evaluate( + (_, value) => { + process.env.NODE_OPTIONS = value; + }, + `--import=${pathToFileURL(preload).href}`, + ); + const began = Date.now(); + const starting = await command("start_desktop_daemon"); + captured = await readDaemonInstance(home); + assert.equal(starting.status, "starting"); + assert.equal(starting.ownedByDesktop, true); + assert.equal(starting.pid, captured.pid); + assert.ok(Date.now() - began < 40_000, "Desktop did not return its bounded starting state"); + process.kill(captured.pid, 0); + await command("stop_desktop_daemon", { reason: "quit" }); + captured = null; + await closeDesktop(); + console.log( + "PASS: Desktop readiness deadline returns starting and preserves its owned slow launch.", + ); + } + console.log( + "PASS: attached legacy daemon survives automatic actions; explicit captured stop, owned launch, managed settings, worker restart, and next-session attachment.", + ); +} finally { + await closeDesktop(); + if (captured) + await stopDaemonInstance(home, { instance: captured, force: true, timeoutMs: 2_000 }); + const artifacts = + process.env.PASEO_DESKTOP_LIFECYCLE_ARTIFACT_DIR ?? + (await mkdtemp(path.join(tmpdir(), "paseo-desktop-lifecycle-artifacts-"))); + await mkdir(artifacts, { recursive: true }); + for (const name of await readdir(root)) + if (name.endsWith(".png") || name === "metro.log") + await copyFile(path.join(root, name), path.join(artifacts, name)); + await rm(root, { recursive: true, force: true }); + console.log(`Lifecycle artifacts: ${artifacts}`); +} diff --git a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts index 7685140229..1d0b829f67 100644 --- a/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts +++ b/packages/desktop/e2e/keyboard-shortcut-unassign.spec.ts @@ -14,10 +14,13 @@ const SHORTCUTS_ROW = "show-shortcuts"; * daemon is left unmanaged so the app talks to the E2E daemon instead of trying * to start one of its own. */ -async function installDesktopBridge(page: Page): Promise { - await page.addInitScript(() => { +async function installDesktopBridge( + page: Page, + platform: "darwin" | "win32" = "darwin", +): Promise { + await page.addInitScript((desktopPlatform) => { window.paseoDesktop = { - platform: "darwin", + platform: desktopPlatform, events: { on: () => () => {} }, invoke: async (command: string) => { if (command === "get_desktop_settings") { @@ -29,11 +32,11 @@ async function installDesktopBridge(page: Page): Promise { return null; }, }; - }); + }, platform); } -async function openShortcutsSettings(page: Page) { - await installDesktopBridge(page); +async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") { + await installDesktopBridge(page, platform); await gotoAppShell(page); await openSettings(page); await openSettingsSection(page, "shortcuts"); @@ -67,6 +70,38 @@ async function closeRowMenu(page: Page) { await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0); } +async function clearShortcut(page: Page, label: string, action: string) { + await page.getByRole("button", { name: `Actions for ${label}` }).click(); + await page.getByTestId(`shortcut-clear-${action}`).click(); +} + +async function captureShortcut(page: Page, label: string, action: string, keys: string) { + await page.getByRole("button", { name: `Actions for ${label}` }).click(); + await page.getByTestId(`shortcut-bind-${action}`).click(); + await page.keyboard.press(keys); +} + +async function expectInterruptShortcut(page: Page, chord: string) { + const row = page.getByText("Interrupt agent", { exact: true }).locator(".."); + await expect(row.getByText(chord, { exact: true })).toBeVisible(); +} + +async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") { + const row = page.getByText("Interrupt agent", { exact: true }).locator(".."); + await row.getByRole("button", { name: action }).click(); +} + +async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) { + await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K"); + await page.keyboard.press("Alt+J"); + await expectInterruptShortcut(page, firstCombo); + await expectInterruptShortcut(page, secondCombo); + await page.keyboard.press("Backspace"); + await expectInterruptShortcut(page, firstCombo); + const row = page.getByText("Interrupt agent", { exact: true }).locator(".."); + await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0); +} + test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => { await openShortcutsSettings(page); @@ -110,6 +145,29 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page } await page.keyboard.press("Shift+?"); await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + await test.step("show the cleared shortcut in help, then bind new keys", async () => { + const help = await openCheatSheet(page); + const row = help.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); + await expect(row.getByText("Not set", { exact: true })).toBeVisible(); + await expect(help.getByText("?", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); + + await openRowMenu(page); + await bind.click(); + await page.keyboard.press("Alt+Shift+K"); + await page.getByText("Done", { exact: true }).click(); + await expect(page.getByText("⌥⇧K", { exact: true })).toBeVisible(); + const reboundHelp = await openCheatSheet(page); + const reboundRow = reboundHelp.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); + await expect(reboundRow.getByText("⌥⇧K", { exact: true })).toBeVisible(); + await expect(reboundRow.getByText("?", { exact: true })).toHaveCount(0); + await page.keyboard.press("Escape"); + await openSettings(page); + await openSettingsSection(page, "shortcuts"); + }); + await openRowMenu(page); await page.getByTestId(`shortcut-reset-${SHORTCUTS_ROW}`).click(); await expect(notSet).toHaveCount(0); @@ -124,45 +182,34 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page } await expect(dialog).not.toBeVisible({ timeout: 10_000 }); }); -test("an unassigned shortcut lists no keys in the shortcuts cheat sheet", async ({ page }) => { - await openShortcutsSettings(page); +for (const { platform, keys, label, firstCombo, secondCombo } of [ + { + platform: "darwin", + keys: "Meta+Shift+Backspace", + label: "⇧⌘⌫", + firstCombo: "⌥K", + secondCombo: "⌥J", + }, + { + platform: "win32", + keys: "Control+Shift+Backspace", + label: "Ctrl+Shift+⌫", + firstCombo: "Alt+K", + secondCombo: "Alt+J", + }, +] as const) { + test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({ + page, + }) => { + await openShortcutsSettings(page, platform); + await clearShortcut(page, "Archive workspace", "archive-workspace"); + await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys); + await expectInterruptShortcut(page, label); + await finishInterruptCapture(page, "Done"); + await expectInterruptShortcut(page, label); - await openRowMenu(page); - await page.getByTestId(`shortcut-clear-${SHORTCUTS_ROW}`).click(); - await expect(page.getByText("Not set", { exact: true })).toBeVisible(); - - const dialog = await openCheatSheet(page); - - // Addressed by testID rather than by filtering on its own text: an anchored - // `hasText` matches only the innermost node holding exactly that label, so a - // row-scoped assertion about anything *else* in the row finds nothing. - const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); - await expect(row).toBeVisible(); - // No badge pill, blank or otherwise, for a shortcut with no keys. - await expect(dialog.getByText("?", { exact: true })).toHaveCount(0); - // It says so, rather than leaving a silent gap where the keys were. Same words - // the settings row uses for the same state. - await expect(row.getByText("Not set", { exact: true })).toBeVisible(); -}); - -test("a rebound shortcut lists its new keys in the shortcuts cheat sheet", async ({ page }) => { - await openShortcutsSettings(page); - - const defaultKeys = page.getByText("?", { exact: true }); - await expect(defaultKeys).toBeVisible(); - - await page.getByTestId(`shortcut-actions-${SHORTCUTS_ROW}`).click(); - await page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`).click(); - await page.keyboard.press("Alt+Shift+K"); - await page.getByText("Done", { exact: true }).click(); - - const reboundKeys = page.getByText("⌥⇧K", { exact: true }); - await expect(reboundKeys).toBeVisible(); - await expect(defaultKeys).toHaveCount(0); - - const dialog = await openCheatSheet(page); - const row = dialog.getByTestId(`shortcut-help-row-${SHORTCUTS_ROW}`); - await expect(row.getByText("⌥⇧K", { exact: true })).toBeVisible(); - // The whole point: the cheat sheet stops advertising the shipped default. - await expect(row.getByText("?", { exact: true })).toHaveCount(0); -}); + await eraseLastCapturedCombo(page, firstCombo, secondCombo); + await finishInterruptCapture(page, "Cancel"); + await expectInterruptShortcut(page, label); + }); +} diff --git a/packages/desktop/e2e/linux-artifact-smoke.js b/packages/desktop/e2e/linux-artifact-smoke.js new file mode 100644 index 0000000000..5a81bf07de --- /dev/null +++ b/packages/desktop/e2e/linux-artifact-smoke.js @@ -0,0 +1,61 @@ +// Observe published Linux artifacts without repairing their sandbox permissions. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { smokePackagedDesktopApp } = require("./packaged-app-smoke.js"); + +async function main() { + if (process.getuid() === 0) throw new Error("Launch the smoke as an unprivileged user"); + const release = path.resolve(process.argv[2]); + const portableSandbox = process.argv[3] === "enabled"; + const artifactRoot = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR; + const installedOnly = process.argv.includes("--installed-only"); + const extracted = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-linux-artifacts-")); + const findArtifact = (suffix) => { + const matches = fs.readdirSync(release).filter((file) => file.endsWith(suffix)); + if (matches.length !== 1) throw new Error(`Expected one ${suffix} in ${release}: ${matches}`); + return path.join(release, matches[0]); + }; + try { + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "installed"); + const helper = fs.statSync("/opt/Paseo/chrome-sandbox"); + if (helper.uid !== 0 || (helper.mode & 0o7777) !== 0o4755) { + throw new Error("Installed native package did not provide a root-owned 4755 helper"); + } + await smokePackagedDesktopApp({ appPath: "/opt/Paseo", expectedSandbox: true }); + if (installedOnly) return; + + const appImage = findArtifact(".AppImage"); + fs.chmodSync(appImage, 0o755); + execFileSync(appImage, ["--appimage-extract"], { cwd: extracted, stdio: "ignore" }); + const appDir = path.join(extracted, "squashfs-root"); + const desktopEntry = fs.readFileSync(path.join(appDir, "Paseo.desktop"), "utf8"); + if (/^Exec=.*--no-sandbox/m.test(desktopEntry)) { + throw new Error("AppImage desktop entry bypasses runtime sandbox policy"); + } + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "appimage"); + await smokePackagedDesktopApp({ + appPath: appDir, + executablePath: appImage, + launchArgs: ["--appimage-extract-and-run"], + expectedSandbox: portableSandbox, + }); + + const tarDir = path.join(extracted, "tar"); + fs.mkdirSync(tarDir); + execFileSync("tar", ["-xzf", findArtifact(".tar.gz"), "-C", tarDir]); + const appPath = fs.existsSync(path.join(tarDir, "Paseo")) + ? tarDir + : path.join(tarDir, fs.readdirSync(tarDir)[0]); + process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR = path.join(artifactRoot, "tar"); + await smokePackagedDesktopApp({ appPath, expectedSandbox: portableSandbox }); + } finally { + fs.rmSync(extracted, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/desktop/e2e/packaged-app-smoke.js b/packages/desktop/e2e/packaged-app-smoke.js index 0c5e2b798b..f1310e287f 100644 --- a/packages/desktop/e2e/packaged-app-smoke.js +++ b/packages/desktop/e2e/packaged-app-smoke.js @@ -5,6 +5,9 @@ const os = require("node:os"); const path = require("node:path"); const { setTimeout: delay } = require("node:timers/promises"); const { chromium } = require("playwright"); +const { extractFile } = require("@electron/asar"); +const { WebSocket } = require("ws"); +const assert = require("node:assert/strict"); const EXECUTABLE_NAME = "Paseo"; const SMOKE_TIMEOUT_MS = 60_000; @@ -30,6 +33,29 @@ function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } +async function assertBuiltinPluginsStarted(listen) { + const { DaemonClient } = await import("@getpaseo/client/internal/daemon-client"); + const { builtinPlugins } = + await import("../../server/dist/server/server/plugins/builtin/index.js"); + const client = new DaemonClient({ + url: `ws://${listen}/ws`, + clientId: "packaged-builtin-smoke", + webSocketFactory: (url, options) => + new WebSocket(url, options?.protocols, { headers: options?.headers }), + }); + try { + await client.connect(); + const catalog = await client.getPluginCatalog(); + assert.deepEqual( + catalog.map(({ id }) => id).sort(), + [...builtinPlugins].sort(), + "Every built-in plugin must start in the packaged desktop daemon", + ); + } finally { + await client.close(); + } +} + function assertExecutable(filePath, label) { if (!fs.existsSync(filePath)) { throw new Error(`${label} does not exist: ${filePath}`); @@ -67,52 +93,17 @@ function getMacMainExecutablePath(appPath) { return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME); } -function ensureLinuxSandboxPermissions(appPath) { - if (process.platform !== "linux") { - return; - } - - const sandboxPath = path.join(appPath, "chrome-sandbox"); - if (!fs.existsSync(sandboxPath)) { - throw new Error(`Chromium sandbox helper does not exist: ${sandboxPath}`); - } - - const hasRequiredPermissions = () => { - const stat = fs.statSync(sandboxPath); - return stat.uid === 0 && (stat.mode & 0o7777) === 0o4755; - }; - if (hasRequiredPermissions()) { - return; - } - - const chown = spawnSync("sudo", ["-n", "chown", "root:root", sandboxPath], { - encoding: "utf8", - }); - const chmod = - chown.status === 0 - ? spawnSync("sudo", ["-n", "chmod", "4755", sandboxPath], { encoding: "utf8" }) - : null; - if (chown.error || chown.status !== 0 || chmod?.error || chmod?.status !== 0) { - throw new Error( - `Failed to configure Chromium sandbox helper ${sandboxPath}. Run: sudo chown root:root ${sandboxPath} && sudo chmod 4755 ${sandboxPath}.\n${chown.stderr?.trim() || chmod?.stderr?.trim() || chown.error || chmod?.error || "Permissions remained incorrect."}`, - ); - } - if (!hasRequiredPermissions()) { - throw new Error(`Chromium sandbox helper permissions remained incorrect: ${sandboxPath}`); - } -} - -function getLaunchCommand(executablePath) { +function getLaunchCommand(executablePath, args) { if (process.platform !== "linux") { return { command: executablePath, - args: [], + args, }; } return { command: "xvfb-run", - args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath], + args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath, ...args], }; } @@ -157,19 +148,15 @@ function getShellCommand(script) { } function createDefaultDaemonEnv(extraEnv) { - const env = { - ...process.env, + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_"))), ...extraEnv, }; - - delete env.PASEO_HOME; - delete env.PASEO_LISTEN; - return env; } function createIsolatedDesktopEnv({ home, listen, userData, cdpPort }) { return { - ...process.env, + ...createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }), PASEO_HOME: home, PASEO_LISTEN: listen, PASEO_ELECTRON_USER_DATA_DIR: userData, @@ -222,7 +209,10 @@ async function waitForFile(filePath, label) { const deadline = Date.now() + SMOKE_TIMEOUT_MS; while (Date.now() < deadline) { if (fs.existsSync(filePath)) { - return fs.readFileSync(filePath, "utf8"); + const contents = fs.readFileSync(filePath, "utf8"); + if (contents.trim().length > 0) { + return contents; + } } await delay(250); } @@ -312,7 +302,7 @@ function formatLogs({ stdout, stderr, userData, daemonHome }) { ].join("\n\n"); } -async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { +async function writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }) { const artifactDir = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR?.trim(); if (!artifactDir) { return; @@ -320,13 +310,15 @@ async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHom fs.mkdirSync(artifactDir, { recursive: true }); fs.writeFileSync( - path.join(artifactDir, "failure.txt"), - `${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\n${formatLogs({ - stdout, - stderr, - userData, - daemonHome, - })}\n`, + path.join(artifactDir, error ? "failure.txt" : "success.txt"), + `${error instanceof Error ? (error.stack ?? error.message) : String(error ?? "Packaged desktop smoke passed")}\n\n${formatLogs( + { + stdout, + stderr, + userData, + daemonHome, + }, + )}\n`, ); const desktopLog = readIfExists(path.join(userData, "logs", "main.log")); @@ -653,24 +645,15 @@ async function smokeColdCliDaemonStart({ appPath }) { const pidPath = path.join(home, "paseo.pid"); const port = await reserveLocalTcpPort(); const listen = `127.0.0.1:${port}`; - const env = createDefaultDaemonEnv(); + const env = createDefaultDaemonEnv({ HOME: home, USERPROFILE: home }); + configureIsolatedDaemonHome(home, listen); try { console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim"); await runCliShimCommand({ appPath, env, - args: [ - "daemon", - "start", - "--home", - home, - "--listen", - listen, - "--no-relay", - "--no-mcp", - "--no-inject-mcp", - ], + args: ["daemon", "start", "--home", home], label: "Bundled CLI shim cold daemon start", }); @@ -802,10 +785,93 @@ async function stopCliDaemon({ appPath, env }) { }); } -async function smokePackagedDesktopApp({ appPath }) { - const executablePath = getExecutablePath(appPath); +async function openSmokeWorkspace({ appPath, env, page, daemonHome }) { + const projectPath = path.join(daemonHome, "sandbox-smoke-project"); + fs.mkdirSync(projectPath); + fs.writeFileSync(path.join(projectPath, "README.md"), "Packaged Linux sandbox smoke\n"); + const workspace = await runCliShimJsonCommand({ + appPath, + env, + args: [ + "workspace", + "create", + "--isolation", + "local", + "--path", + projectPath, + "--title", + "Sandbox smoke workspace", + ], + label: "Create packaged smoke workspace", + }); + await page.getByRole("button", { name: "Sandbox smoke workspace", exact: true }).click(); + await page.waitForURL((url) => url.pathname.endsWith(`/workspace/${workspace.workspaceId}`)); + await page.getByRole("button", { name: "Agent", exact: true }).click(); + await page.getByRole("textbox", { name: "Message agent..." }).click(); + console.log(`Packaged desktop smoke: renderer opened workspace ${workspace.workspaceId}`); +} + +async function assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }) { + const diagnostics = await page.evaluate(() => + window.paseoDesktop.invoke("desktop_sandbox_diagnostics"), + ); + if (diagnostics.enabled !== expectedSandbox) { + throw new Error( + `Unexpected sandbox decision: ${JSON.stringify(diagnostics)}; expected enabled=${expectedSandbox}`, + ); + } + if (!stdout.join("").includes("[desktop] app startup")) { + throw new Error("Missing production app startup log"); + } + const session = await browser.newBrowserCDPSession(); + const { processInfo } = await session.send("SystemInfo.getProcessInfo"); + await session.detach(); + const renderers = processInfo.filter((info) => info.type === "renderer"); + if (renderers.length === 0) throw new Error("No renderer process found"); + for (const renderer of renderers) { + const status = fs.readFileSync(`/proc/${renderer.id}/status`, "utf8"); + const security = status + .split("\n") + .filter((line) => /^(NoNewPrivs|Seccomp|Uid|Gid):/.test(line)) + .join("; "); + const report = `Sandbox evidence: enabled=${expectedSandbox}; ${diagnostics.reason}; renderer ${renderer.id}: ${security}`; + console.log(report); + stdout.push(`${report}\n`); + if ( + expectedSandbox && + (!/^NoNewPrivs:\s+1$/m.test(status) || !/^Seccomp:\s+2$/m.test(status)) + ) { + throw new Error(`Renderer lacks OS sandbox: ${report}\n${stderr.join("")}`); + } + } +} + +function assertLinuxDesktopIdentity(appPath) { + if (process.platform === "linux") { + assert.deepEqual( + fs.readFileSync(path.join(appPath, "resources", "icon.png")), + fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")), + "Packaged Linux windows must have the Paseo icon at their runtime lookup path", + ); + const metadata = JSON.parse( + extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(), + ); + if (metadata.desktopName !== `${EXECUTABLE_NAME}.desktop`) { + throw new Error( + `Packaged Linux desktop identity ${JSON.stringify(metadata.desktopName)} does not match ${EXECUTABLE_NAME}.desktop`, + ); + } + } +} + +async function smokePackagedDesktopApp({ + appPath, + executablePath = getExecutablePath(appPath), + launchArgs = [], + expectedSandbox, +}) { assertExecutable(executablePath, "Packaged app executable"); - ensureLinuxSandboxPermissions(appPath); + assertLinuxDesktopIdentity(appPath); await smokeColdCliDaemonStart({ appPath }); const userData = createTempDir("paseo-smoke-user-data-"); @@ -829,7 +895,7 @@ async function smokePackagedDesktopApp({ appPath }) { const stdout = []; const stderr = []; - const launch = getLaunchCommand(executablePath); + const launch = getLaunchCommand(executablePath, launchArgs); console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`); const child = spawn(launch.command, launch.args, { detached: process.platform !== "win32", @@ -879,14 +945,21 @@ async function smokePackagedDesktopApp({ appPath }) { deadline, }); console.log("Packaged desktop smoke: renderer-started desktop daemon reported running"); + await assertBuiltinPluginsStarted(listen); + console.log("Packaged desktop smoke: every built-in plugin started"); await smokeCliShim({ appPath, env }); await smokeCliTerminal({ appPath, env }); + if (expectedSandbox !== undefined) { + await assertSandboxState({ browser, page, expectedSandbox, stdout, stderr }); + await openSmokeWorkspace({ appPath, env, page, daemonHome }); + } + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome }); await stopDaemonForCleanup(); console.log( `Packaged desktop smoke passed: real renderer and preload loaded; renderer-started desktop daemon pid ${status.pid}, listen ${status.listen}; CLI shim daemon status and terminal smoke succeeded`, ); } catch (error) { - await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( + await writeSmokeArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch( (artifactError) => { console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`); }, diff --git a/packages/desktop/e2e/plugin-links.electron.mjs b/packages/desktop/e2e/plugin-links.electron.mjs new file mode 100644 index 0000000000..4e1654c8b1 --- /dev/null +++ b/packages/desktop/e2e/plugin-links.electron.mjs @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect } from "@playwright/test"; + +export function seedPluginLinks(paseoHome, workspaceId, url, remoteWorkspaceId) { + const directory = path.join(paseoHome, "link-plugin"); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "link-check", requirements: { paseo: ">=0.8.0" } }), + ); + fs.writeFileSync( + path.join(directory, "index.client.tsx"), + ` +import { useState } from "react"; +import { View, Text, Pressable } from "react-native"; +import { openExternalUrl } from "@getpaseo/plugin/client"; +import { ExternalLink } from "@getpaseo/plugin/client/ui"; +function Links({ navigation }) { + const [result, setResult] = useState("Ready"); + return + {result} + { window.open(${JSON.stringify(url)}, "_blank", "noopener,noreferrer"); }}>Old documentation workaround + { try { await openExternalUrl(${JSON.stringify(url)}); setResult("Opened externally"); } catch (error) { setResult(String(error)); } }}>Open externally + {ExternalLink ? Documentation link : null} + navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(remoteWorkspaceId)}, serverId: "plugin-links-remote" })}>Open remote workspace browser + {navigation.openBrowser ? "Browser available" : "Browser unavailable"} + navigation.openBrowser({ url: ${JSON.stringify(url)}, workspaceId: ${JSON.stringify(workspaceId)} })}>Open workspace browser + ; +} +export default function(client) { client.addSurface("main", Links); client.addSidebarItem({ id: "links", title: "Plugin links QA", icon: "Link", surface: "main" }); return () => {}; } +`, + ); + const configPath = path.join(paseoHome, "config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + config.pluginsEnabled = true; + config.plugins = { "link-check": { source: "directory", path: directory, enabled: true } }; + fs.writeFileSync(configPath, JSON.stringify(config)); +} + +export async function runPluginLinksRegression({ + page, + remotePort, + workspaceId, + remoteWorkspaceId, + url, + artifactDir, + externalOpenLog, +}) { + const pluginEntry = page.getByRole("button", { name: "Plugin links QA", exact: true }); + await expect(pluginEntry).toBeVisible({ timeout: 90_000 }); + await page.evaluate((port) => { + const key = "@paseo:daemon-registry"; + const registry = JSON.parse(localStorage.getItem(key)); + const endpoint = `127.0.0.1:${port}`; + const connection = { id: `direct:${endpoint}`, type: "directTcp", endpoint }; + const now = new Date().toISOString(); + registry.push({ + serverId: "plugin-links-remote", + label: "Remote browser host", + connections: [connection], + preferredConnectionId: connection.id, + createdAt: now, + updatedAt: now, + }); + localStorage.setItem(key, JSON.stringify(registry)); + }, remotePort); + await page.reload(); + await pluginEntry.click(); + await expect( + page.getByRole("button", { name: "Old documentation workaround", exact: true }), + ).toBeVisible({ timeout: 60_000 }); + const oldPopup = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Old documentation workaround", exact: true }).click(); + const popup = await oldPopup; + await popup.waitForLoadState(); + await popup.screenshot({ path: path.join(artifactDir, "plugin-old-popup.png") }); + await popup.close(); + + const popups = []; + page.on("popup", (openedPopup) => popups.push(openedPopup)); + await page.getByRole("button", { name: "Open externally", exact: true }).click(); + await expect(page.getByText("Opened externally", { exact: true })).toBeVisible(); + await page.getByRole("link", { name: "Documentation link", exact: true }).click(); + if (externalOpenLog) { + await expect + .poll(() => fs.readFileSync(externalOpenLog, "utf8").trim().split("\n")) + .toEqual([url, url]); + } + await expect(page.getByText("Browser available", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Open workspace browser", exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`)); + await expectPresentedBrowser(page, url); + expect(popups).toHaveLength(0); + await page.screenshot({ path: path.join(artifactDir, "plugin-workspace-browser.png") }); + await pluginEntry.click(); + await page.getByRole("button", { name: "Open remote workspace browser", exact: true }).click(); + await expect(page).toHaveURL(new RegExp(`/h/plugin-links-remote/workspace/${remoteWorkspaceId}`)); + await expectPresentedBrowser(page, url); + await page.screenshot({ path: path.join(artifactDir, "plugin-remote-workspace-browser.png") }); + return { + remoteWorkspaceId, + oldWorkaroundOpenedPopup: true, + newExternalPopups: popups.length, + workspaceId, + url, + }; +} + +async function expectPresentedBrowser(page, url) { + await expect + .poll(() => + page + .locator("webview") + .evaluateAll((views) => + views + .filter((view) => view.parentElement?.getAttribute("aria-hidden") === "false") + .map((view) => view.getURL()), + ), + ) + .toEqual([url]); +} diff --git a/packages/desktop/e2e/settings-memory.electron.mjs b/packages/desktop/e2e/settings-memory.electron.mjs index 688f4ff8d9..95ab39b6ce 100644 --- a/packages/desktop/e2e/settings-memory.electron.mjs +++ b/packages/desktop/e2e/settings-memory.electron.mjs @@ -9,7 +9,10 @@ const MAX_TRANSIENT_DETACHED_PANES = 2; const SETTINGS_DESTINATIONS = [ "General", "Appearance", - "Layout", + "Sidebar", + "Chat", + "Terminal", + "Browser", "Editor", "Shortcuts", "Integrations", diff --git a/packages/desktop/e2e/support/runtime.ts b/packages/desktop/e2e/support/runtime.ts index 14b770b2fd..9032e5c56e 100644 --- a/packages/desktop/e2e/support/runtime.ts +++ b/packages/desktop/e2e/support/runtime.ts @@ -62,6 +62,9 @@ export interface DesktopRuntimeConfig { slowInstall?: boolean; /** Initial PID reported by desktop_daemon_status. Defaults to null. */ daemonPid?: number | null; + daemonHome?: string; + /** Current-session ownership, independent of the legacy management flag. */ + ownedByDesktop?: boolean; daemonVersion?: string | null; daemonLogPath?: string; /** Initial manageBuiltInDaemon setting. Defaults to false. */ @@ -139,7 +142,7 @@ export async function installDesktopRuntime( let manageDaemon = cfg.manageBuiltInDaemon ?? false; let daemonRunning = true; let currentPid: number | null = cfg.daemonPid ?? null; - let startCount = 0; + let ownedByDesktop = cfg.ownedByDesktop ?? false; let manualUpdateAdmitted = false; window.__desktopDaemonStartRequested = false; @@ -150,7 +153,9 @@ export async function installDesktopRuntime( listen: cfg.daemonListen ?? "127.0.0.1:6767", hostname: null, pid: currentPid, - home: "", + home: cfg.daemonHome ?? "", + startedAt: currentPid === null ? null : "2026-09-01T00:00:00.000Z", + ownedByDesktop, version: cfg.daemonVersion ?? null, desktopManaged: manageDaemon, error: null, @@ -162,11 +167,11 @@ export async function installDesktopRuntime( if (cfg.hangDaemonStart) { return new Promise(() => undefined); } - startCount += 1; + if (!daemonRunning) { + currentPid = (cfg.daemonPid ?? 10000) + 1000; + ownedByDesktop = true; + } daemonRunning = true; - // First start (bootstrap) returns the configured PID; subsequent starts - // (after a stop) get a fresh PID so tests can observe the change. - currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000; return buildDaemonStatus(); } @@ -268,6 +273,7 @@ export async function installDesktopRuntime( } if (command === "stop_desktop_daemon") { + ownedByDesktop = false; daemonRunning = false; currentPid = null; return buildDaemonStatus(); @@ -396,6 +402,12 @@ export async function interceptDaemonManagementConfirmDialog( return page.evaluate(() => window.__capturedDialogCall!); } +export async function interceptDaemonStopConfirmDialog(page: Page): Promise { + await page.getByRole("button", { name: "Stop daemon", exact: true }).click(); + await page.waitForFunction(() => !!window.__capturedDialogCall); + return page.evaluate(() => window.__capturedDialogCall!); +} + export async function toggleDaemonManagement( page: Page, _action: "enable" | "disable", @@ -405,7 +417,9 @@ export async function toggleDaemonManagement( export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void { expect(args.title).toBe("Pause built-in daemon"); - expect(args.message).toContain("stop the built-in daemon immediately"); + expect(args.message).toBe( + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + ); } export async function expectDaemonManagementEnabled(page: Page): Promise { diff --git a/packages/desktop/e2e/updates.spec.ts b/packages/desktop/e2e/updates.spec.ts index 9a2153163e..9919acddb4 100644 --- a/packages/desktop/e2e/updates.spec.ts +++ b/packages/desktop/e2e/updates.spec.ts @@ -18,6 +18,7 @@ import { clickInstallUpdate, expectInstallInProgress, interceptDaemonManagementConfirmDialog, + interceptDaemonStopConfirmDialog, toggleDaemonManagement, expectDaemonManagementConfirmDialog, expectDaemonManagementEnabled, @@ -27,8 +28,8 @@ import { expectDaemonStatusVersion, } from "./support/runtime"; -// No Playwright Electron runner exists; we simulate the desktop bridge via -// addInitScript so Electron-gated UI activates without a real Electron process. +// These renderer cases use the Desktop bridge fixture. Actual Electron ownership +// and native confirmation journeys live in daemon-lifecycle.e2e.mjs. test.describe("Desktop updates", () => { test("a desktop-managed daemon explains why its update action is disabled", async ({ page, @@ -48,17 +49,6 @@ test.describe("Desktop updates", () => { await expect(page.getByTestId("host-page-update-button")).toBeDisabled(); }); - test("update banner appears in the sidebar when an app update is available", async ({ page }) => { - await installDesktopRuntime(page, { - serverId: getServerId(), - updateAvailable: true, - latestVersion: "1.2.3", - }); - await gotoAppShell(page); - - await expectUpdateBanner(page, "1.2.3"); - }); - test("clicking install shows the installing state on the callout", async ({ page }) => { await installDesktopRuntime(page, { serverId: getServerId(), @@ -104,13 +94,12 @@ test.describe("Desktop updates", () => { }); test.describe("Desktop daemon management", () => { - test("disabling built-in daemon management shows confirm dialog with correct copy", async ({ - page, - }) => { + test("cancelling the management confirmation preserves the enabled daemon", async ({ page }) => { const serverId = getServerId(); await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: false, }); await gotoAppShell(page); @@ -122,26 +111,12 @@ test.describe("Desktop daemon management", () => { await expectDaemonManagementEnabled(page); }); - test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => { - const serverId = getServerId(); - await installDesktopRuntime(page, { - serverId, - manageBuiltInDaemon: true, - confirmShouldAccept: false, - }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - await expectDaemonManagementEnabled(page); - await toggleDaemonManagement(page, "disable"); - await expectDaemonManagementEnabled(page); - }); - test("confirming the dialog disables built-in daemon management", async ({ page }) => { const serverId = getServerId(); await installDesktopRuntime(page, { serverId, manageBuiltInDaemon: true, + ownedByDesktop: true, confirmShouldAccept: true, }); await gotoAppShell(page); @@ -172,29 +147,34 @@ test.describe("Desktop daemon management", () => { await expectDaemonStatusLogPath(page, realState.logPath); }); - test("stopping and restarting the daemon updates the PID", async ({ page }) => { - const serverId = getServerId(); - const realState = await loadRealDaemonState(); - await installDesktopRuntime(page, { - serverId, - manageBuiltInDaemon: true, - daemonPid: realState.pid, - daemonVersion: realState.version, - daemonLogPath: realState.logPath, - confirmShouldAccept: true, + for (const confirmShouldAccept of [false, true]) { + test(`${confirmShouldAccept ? "confirming" : "cancelling"} Stop identifies the owned daemon`, async ({ + page, + }) => { + const serverId = getServerId(); + const realState = await loadRealDaemonState(); + const daemonHome = process.env.E2E_PASEO_HOME!; + await installDesktopRuntime(page, { + serverId, + daemonPid: realState.pid, + daemonHome, + ownedByDesktop: true, + confirmShouldAccept, + }); + await gotoAppShell(page); + await openDesktopSettings(page, serverId); + + const dialog = await interceptDaemonStopConfirmDialog(page); + expect(dialog).toEqual({ + title: "Stop local daemon?", + message: [ + "This daemon was launched by this Desktop session.", + `Home: ${daemonHome}`, + `Supervisor PID: ${realState.pid}`, + "Running agent work will be interrupted.", + ].join("\n"), + }); + await expectDaemonStatusPid(page, confirmShouldAccept ? null : realState.pid); }); - await gotoAppShell(page); - await openDesktopSettings(page, serverId); - - await expectDaemonStatusPid(page, realState.pid); - - await toggleDaemonManagement(page, "disable"); - await expectDaemonManagementDisabled(page); - await expectDaemonStatusPid(page, null); - - await toggleDaemonManagement(page, "enable"); - await expectDaemonManagementEnabled(page); - const newPid = realState.pid !== null ? realState.pid + 1000 : 11000; - await expectDaemonStatusPid(page, newPid); - }); + } }); diff --git a/packages/desktop/e2e/window-startup.e2e.mjs b/packages/desktop/e2e/window-startup.e2e.mjs new file mode 100644 index 0000000000..e6654c09c2 --- /dev/null +++ b/packages/desktop/e2e/window-startup.e2e.mjs @@ -0,0 +1,48 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { _electron as electron, expect } from "playwright/test"; + +const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-")); +const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url)); + +async function launchWindowWithoutHiddenFrames() { + const main = path.join(root, "main.cjs"); + await writeFile( + main, + `const { app, BrowserWindow } = require("electron"); + const { showWindowWhenReady } = require(${JSON.stringify(modulePath)}); + app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))}); + app.whenReady().then(async () => { + const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false }); + showWindowWhenReady(win, { showOnLoad: true }); + await win.loadURL("data:text/html,

Desktop ready

"); + global.visibleAfterLoad = win.isVisible(); + });`, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"), + ); + const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : []; + return electron.launch({ + args: ["--no-sandbox", ...displayArgs, main], + env, + }); +} + +async function expectLoadedDesktopIsShown(desktop) { + await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true); + const page = await desktop.firstWindow(); + await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible(); +} + +let desktop; +try { + desktop = await launchWindowWithoutHiddenFrames(); + await expectLoadedDesktopIsShown(desktop); + console.log("Loaded desktop is visible without hidden compositor frames"); +} finally { + await desktop?.close(); + await rm(root, { recursive: true, force: true }); +} diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts index 36259bee4f..4ee1655242 100644 --- a/packages/desktop/e2e/workspace-restart.spec.ts +++ b/packages/desktop/e2e/workspace-restart.spec.ts @@ -6,6 +6,8 @@ import { getE2EDaemonPort } from "../../app/e2e/support/helpers/daemon-port"; import { installDaemonWebSocketGate } from "../../app/e2e/support/helpers/daemon-websocket-gate"; import { expectAppRoute } from "../../app/e2e/support/helpers/route-assertions"; import { seedWorkspace } from "../../app/e2e/support/helpers/seed-client"; +import { openAgentRoute, seedMockAgentWorkspace } from "../../app/e2e/support/helpers/mock-agent"; +import { scrollAgentChatToBottom } from "../../app/e2e/support/helpers/agent-bottom-anchor"; import { getServerId } from "../../app/e2e/support/helpers/server-id"; import { waitForWorkspaceTabsVisible } from "../../app/e2e/support/helpers/workspace-tabs"; import { @@ -118,3 +120,131 @@ test("refresh keeps one continuous splash before restoring the desktop workspace await workspace.cleanup(); } }); + +function inspectChatDragExclusions(scroll: Element) { + const header = document.querySelector('[data-testid="composer-dock-header"]'); + if (!header) throw new Error("Expected the workspace header"); + const headerRect = header.getBoundingClientRect(); + const headerY = headerRect.top + headerRect.height / 2; + const content = scroll.firstElementChild; + const focusScope = scroll.closest("[tabindex]"); + if (!content || !focusScope) throw new Error("Expected chat content and its focus scope"); + const contentRect = content.getBoundingClientRect(); + const chatX = (contentRect.left + contentRect.right) / 2; + const tabRow = [...document.querySelectorAll('[data-testid="workspace-tabs-row"]')].find( + (row) => { + const rect = row.getBoundingClientRect(); + return rect.left < chatX && rect.right > chatX && rect.height > 0; + }, + ); + if (!tabRow) throw new Error("Expected the chat pane's tab row"); + const tabRect = tabRow.getBoundingClientRect(); + const tabY = tabRect.top + tabRect.height / 2; + const exclusions = [...scroll.querySelectorAll("*")].filter((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && + style.getPropertyValue("-webkit-app-region") === "no-drag" && + ((rect.top < headerY && rect.bottom > headerY) || (rect.top < tabY && rect.bottom > tabY)) && + rect.right > headerRect.left && + rect.left < headerRect.right + ); + }); + return { + contentCrossesHeader: contentRect.top < headerY && contentRect.bottom > headerY, + contentCrossesTabRow: contentRect.top < tabY && contentRect.bottom > tabY, + focusScopeRegion: getComputedStyle(focusScope).getPropertyValue("-webkit-app-region"), + exclusions: exclusions.length, + }; +} + +test("scrolled chat does not exclude the workspace titlebar from dragging", async ({ + page, +}, testInfo) => { + const response = Array.from( + { length: 80 }, + (_, index) => + `Paragraph ${index}: a long conversation keeps the chat scrolled below its first message.\n\n[Drag reference](https://example.com) and [file.ts](./file.ts)\n\n\`\`\`ts\nconst item = ${index};\n\`\`\``, + ).join("\n\n"); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "desktop-scrolled-chat-", + title: "Scrollable conversation", + initialPrompt: "Show a long response", + featureValues: { mockAssistantResponse: response }, + }); + + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await installDesktopRuntime(page, { + serverId: getServerId(), + daemonListen: `127.0.0.1:${getE2EDaemonPort()}`, + }); + await openAgentRoute(page, agent); + const chat = page.getByTestId("agent-chat-scroll"); + await expect(chat).toBeVisible(); + + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await scrollAgentChatToBottom(page); + // Electron can subtract no-drag rectangles outside a scroll viewport's clip. + // Check the offending geometry, rather than DOM hit testing or a specific row style. + await expect + .poll(() => chat.evaluate(inspectChatDragExclusions)) + .toEqual({ + contentCrossesHeader: true, + contentCrossesTabRow: true, + focusScopeRegion: "no-drag", + exclusions: 0, + }); + } + + // Window-owned controls are siblings of workspace chrome, not descendants of it. + const closeMenu = page.getByRole("button", { name: "Close menu", exact: true }); + await expect(closeMenu).toHaveCSS("-webkit-app-region", "no-drag"); + await closeMenu.click(); + const openMenu = page.getByRole("button", { name: "Open menu", exact: true }); + await expect(openMenu).toHaveCSS("-webkit-app-region", "no-drag"); + await openMenu.click(); + await expect(closeMenu).toBeVisible(); + + const menuTrigger = page.getByTestId("workspace-header-menu-trigger"); + await expect(menuTrigger).toHaveCSS("-webkit-app-region", "no-drag"); + await menuTrigger.click(); + const menu = page.getByTestId("workspace-header-menu"); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem").first()).toHaveCSS("-webkit-app-region", "no-drag"); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await page.getByRole("textbox", { name: "Message agent...", exact: true }).click(); + // installDesktopRuntime supplies a macOS bridge, including shortcut identity. + await page.keyboard.press("Meta+f"); + const find = page.getByRole("textbox", { name: "Find in pane", exact: true }); + await expect(find).toBeFocused(); + await expect(find).toHaveCSS("-webkit-app-region", "no-drag"); + await find.fill("Paragraph 40:"); + await expect(page.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1"); + for (const name of ["Next match", "Previous match", "Close Find"]) { + await expect(page.getByRole("button", { name, exact: true })).toHaveCSS( + "-webkit-app-region", + "no-drag", + ); + } + await page.getByRole("button", { name: "Close Find", exact: true }).click(); + await expect(find).toBeHidden(); + await expect + .poll(() => + chat.evaluate((scroll) => ({ + focusReturned: document.activeElement?.contains(scroll), + tabindex: document.activeElement?.getAttribute("tabindex"), + appRegion: getComputedStyle(document.activeElement!).getPropertyValue( + "-webkit-app-region", + ), + })), + ) + .toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" }); + await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") }); + } finally { + await agent.cleanup(); + } +}); diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7e1cf10b5a..8634d1f058 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -17,6 +17,7 @@ files: - "!node_modules/@getpaseo/**/*.test.*" - "!node_modules/@getpaseo/**/*.spec.*" - "!node_modules/@getpaseo/server/dist/server/web-ui/**" + - "!node_modules/@getpaseo/server/dist/server/builtin-plugins/**" # Workspace packages are symlinked, so anything a dev generates inside them # lands in the asar. CI never has these; a local .debug run adds gigabytes. - "!node_modules/@getpaseo/*/.debug/**" @@ -27,6 +28,9 @@ asarUnpack: - dist/daemon/node-entrypoint-runner.js - node_modules/@getpaseo/server/dist/server/terminal/shell-integration/**/* extraResources: + # Preserve physical compiler inputs, including .d.ts files omitted from packaged node_modules. + - from: ../server/dist/server/builtin-plugins + to: builtin-plugins - from: ../app/dist to: app-dist - from: assets/editor-targets @@ -37,6 +41,7 @@ publish: repo: paseo mac: artifactName: "Paseo-${version}-${arch}.${ext}" + minimumSystemVersion: "13.0.0" category: public.app-category.developer-tools icon: assets/icon.icns hardenedRuntime: true @@ -60,12 +65,20 @@ linux: extraResources: - from: bin/paseo to: bin/paseo + - from: assets/icon.png + to: icon.png target: - AppImage - deb - rpm - tar.gz +deb: + afterInstall: scripts/linux-sandbox/after-install.tpl +rpm: + afterInstall: scripts/linux-sandbox/after-install.tpl appImage: + # A nonempty argument list replaces builder's hardcoded --no-sandbox default. + executableArgs: ["--class=Paseo"] # Keep the AppImage filename stable across versions (no ${version}) so # electron-updater overwrites the file in place instead of renaming it to a new # versioned path. A rename orphans the old binary, breaks desktop shortcuts, and diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a81cb001e..e6e2dbd567 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.7.2", + "version": "0.11.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", @@ -17,14 +17,16 @@ "scripts": { "build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", - "capture-harness": "./capture-harness/run.sh", + "capture-harness": "npm run build:main && ./capture-harness/run.sh", "dev": "./scripts/dev.sh", "dev:win": "powershell ./scripts/dev.ps1", "test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop", "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", - "test": "vitest run --exclude \"e2e/**\"", - "typecheck": "tsgo --noEmit -p tsconfig.json" + "test": "install-electron && vitest run --exclude \"e2e/**\"", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs", + "test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs" }, "dependencies": { "@getpaseo/cli": "*", @@ -35,13 +37,15 @@ "zod": "^4.4.3" }, "devDependencies": { + "@electron/asar": "3.4.1", "@types/node": "24.6.0", "@types/ws": "^8.5.14", - "electron": "41.2.0", + "electron": "44.2.0", "electron-builder": "26.8.1", "typescript": "5.9.3", "unzip-crx-3": "^0.2.0", "vitest": "^4.1.6", "wait-on": "8.0.5" - } + }, + "desktopName": "Paseo.desktop" } diff --git a/packages/desktop/scripts/after-pack.js b/packages/desktop/scripts/after-pack.js index e5222d68fe..9826a6a1e9 100644 --- a/packages/desktop/scripts/after-pack.js +++ b/packages/desktop/scripts/after-pack.js @@ -3,6 +3,8 @@ const path = require("path"); const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js"); +const { installLinuxLauncher } = require("./linux-sandbox"); + const EXECUTABLE_NAME = "Paseo"; // electron-builder arch enum → Node.js arch string @@ -115,6 +117,10 @@ exports.default = async function afterPack(context) { pruneNativeModules(context.appOutDir, platform, arch); + if (platform === "linux") { + installLinuxLauncher(context.appOutDir); + } + if (platform === "linux" || platform === "win32") { if (arch !== process.arch) { console.log( diff --git a/packages/desktop/scripts/dev-runner-readiness.mjs b/packages/desktop/scripts/dev-runner-readiness.mjs new file mode 100644 index 0000000000..b3fc07eb6e --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.mjs @@ -0,0 +1,17 @@ +export async function waitForMetro(url, timeoutMs = 60_000) { + const statusUrl = new URL("/status", url); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(statusUrl, { signal: AbortSignal.timeout(1_000) }); + const body = (await response.text()).trim(); + if (response.status === 200 && body === "packager-status:running") { + return; + } + } catch { + // Expo temporarily binds the port while checking availability, before Metro starts. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for Metro at ${url}`); +} diff --git a/packages/desktop/scripts/dev-runner-readiness.test.mjs b/packages/desktop/scripts/dev-runner-readiness.test.mjs new file mode 100644 index 0000000000..c3e2cc9fcb --- /dev/null +++ b/packages/desktop/scripts/dev-runner-readiness.test.mjs @@ -0,0 +1,41 @@ +import http from "node:http"; +import { expect, test } from "vitest"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; + +async function startMetroStatusServer(responses) { + let lastResponse = null; + let nextResponse = 0; + const server = http.createServer((request, response) => { + const reply = responses[Math.min(nextResponse++, responses.length - 1)]; + lastResponse = { path: request.url, ...reply }; + response.writeHead(reply.status); + response.end(reply.body); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return { + url: `http://127.0.0.1:${server.address().port}`, + lastResponse: () => lastResponse, + async close() { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, + }; +} + +test("Metro readiness rejects an unrelated listener before accepting the packager status", async () => { + const metro = await startMetroStatusServer([ + { status: 503, body: "packager-status:running" }, + { status: 200, body: "unrelated HTTP server" }, + { status: 200, body: "packager-status:running" }, + ]); + try { + await waitForMetro(metro.url, 2_000); + expect(metro.lastResponse()).toEqual({ + path: "/status", + status: 200, + body: "packager-status:running", + }); + } finally { + await metro.close(); + } +}); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 852bf7d85c..008b77d15f 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -1,5 +1,4 @@ #!/usr/bin/env node -import net from "node:net"; import path from "node:path"; import { execFileSync, spawn } from "node:child_process"; import { createRequire } from "node:module"; @@ -10,6 +9,7 @@ import { resolveChildKillTarget, } from "./dev-runner-config.mjs"; +import { waitForMetro } from "./dev-runner-readiness.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); @@ -134,35 +134,6 @@ function stopAll(signal) { }, 50); } -async function waitForPort(port, host = "127.0.0.1", timeoutMs = 60_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await canConnect(port, host)) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - throw new Error(`Timed out waiting for ${host}:${port}`); -} - -function canConnect(port, host) { - return new Promise((resolve) => { - const socket = net.createConnection({ port, host }); - socket.setTimeout(1000); - socket.once("connect", () => { - socket.destroy(); - resolve(true); - }); - socket.once("timeout", () => { - socket.destroy(); - resolve(false); - }); - socket.once("error", () => resolve(false)); - }); -} - registerDevRunnerShutdownSignals({ signalSource: process, stop: stopAll }); spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { @@ -179,7 +150,7 @@ spawnChild("metro", "npx", ["expo", "start", "--port", String(expoPort)], { }); try { - await waitForPort(expoPort); + await waitForMetro(expoDevUrl); } catch (error) { console.error(`[dev] ${error.message}`); exitCode = 1; diff --git a/packages/desktop/scripts/linux-sandbox/after-install.tpl b/packages/desktop/scripts/linux-sandbox/after-install.tpl new file mode 100644 index 0000000000..5723ea065b --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/after-install.tpl @@ -0,0 +1,54 @@ +#!/bin/bash + +if type update-alternatives >/dev/null 2>&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/${executable}' -a -e '/usr/bin/${executable}' -a "`readlink '/usr/bin/${executable}'`" != '/etc/alternatives/${executable}' ]; then + rm -f '/usr/bin/${executable}' + fi + update-alternatives --install '/usr/bin/${executable}' '${executable}' '/opt/${sanitizedProductName}/${executable}' 100 || ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +else + ln -sf '/opt/${sanitizedProductName}/${executable}' '/usr/bin/${executable}' +fi + +# Package installation runs as root, whose namespace access says nothing about +# the desktop user under AppArmor. Always provide Chromium's SUID alternative. +chown root:root '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 +chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox' || exit 1 + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then + update-desktop-database /usr/share/applications || true +fi + +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# In that case, we just skip installing the profile since the app runs fine without it on 22.04. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +# +# Unfortunately, at the moment AppArmor doesn't have a good story for backwards compatibility. +# https://askubuntu.com/questions/1517272/writing-a-backwards-compatible-apparmor-profile +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/${sanitizedProductName}/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/${executable}' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a chroot'ed environment. + # Use cases are for example environments where images for clients are maintained. + # There, AppArmor might correctly be installed, but live updating makes no sense. + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: + # > By using '-W -T' we ensure that any abstraction updates are also pulled in. + # https://wiki.debian.org/AppArmor/Contribute/FirstTimeProfileImport + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi +fi diff --git a/packages/desktop/scripts/linux-sandbox/index.js b/packages/desktop/scripts/linux-sandbox/index.js new file mode 100644 index 0000000000..f641e721c2 --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/index.js @@ -0,0 +1,12 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +// Keep one pre-Chromium entrypoint for AppRun, desktop entries, updates, and tarballs. +exports.installLinuxLauncher = function installLinuxLauncher(appOutDir) { + const launcher = path.join(appOutDir, "Paseo"); + if (!fs.existsSync(`${launcher}.bin`)) { + fs.renameSync(launcher, `${launcher}.bin`); + } + fs.copyFileSync(path.join(__dirname, "launcher.sh"), launcher); + fs.chmodSync(launcher, 0o755); +}; diff --git a/packages/desktop/scripts/linux-sandbox/launcher.sh b/packages/desktop/scripts/linux-sandbox/launcher.sh new file mode 100644 index 0000000000..8f0b6a609f --- /dev/null +++ b/packages/desktop/scripts/linux-sandbox/launcher.sh @@ -0,0 +1,55 @@ +#!/bin/sh +set -eu + +# Resolve /usr/bin alternatives and user-created symlinks before finding Electron. +launcher=$(readlink -f -- "$0") +executable="${launcher}.bin" + +# Node entrypoints are not Chromium processes. Never inject browser flags here. +if [ "${ELECTRON_RUN_AS_NODE:-}" = 1 ]; then + exec "$executable" "$@" +fi + +for arg in "$@"; do + if [ "$arg" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by --no-sandbox' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + fi +done + +# Match main's whitespace-separated debugging flags without evaluating shell code. +set -f +for flag in ${PASEO_ELECTRON_FLAGS:-}; do + if [ "$flag" = '--no-sandbox' ]; then + export PASEO_DESKTOP_SANDBOX_REASON='requested by PASEO_ELECTRON_FLAGS' + printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" --no-sandbox "$@" + fi +done + +# Map the real UID and create a network namespace as Chromium does. A bare +# unshare --user can succeed under AppArmor while namespace capabilities fail. +if namespace_error=$(unshare --user --map-root-user --net true 2>&1); then + export PASEO_DESKTOP_SANDBOX_REASON='user namespaces available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" +fi + +helper="$(dirname -- "$launcher")/chrome-sandbox" +if [ -x "$helper" ] && [ "$(stat -Lc '%u:%a' -- "$helper")" = '0:4755' ] && + grep -q '^NoNewPrivs:[[:space:]]*0$' /proc/self/status; then + mount_options=$(findmnt -n -o VFS-OPTIONS --target "$helper" 2>/dev/null || true) + case ",$mount_options," in + *,nosuid,*|,,) ;; + *) + export PASEO_DESKTOP_SANDBOX_REASON='root-owned SUID helper available' + printf '[linux-sandbox] enabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true + exec "$executable" "$@" + ;; + esac +fi + +export PASEO_DESKTOP_SANDBOX_REASON="user namespaces unavailable${namespace_error:+ ($namespace_error)}; no usable SUID helper" +printf '[linux-sandbox] disabled: %s\n' "$PASEO_DESKTOP_SANDBOX_REASON" >&2 || true +exec "$executable" --no-sandbox "$@" diff --git a/packages/desktop/src/daemon/cli/external.test.ts b/packages/desktop/src/daemon/cli/external.test.ts index 3a17c62916..5649c9f5c5 100644 --- a/packages/desktop/src/daemon/cli/external.test.ts +++ b/packages/desktop/src/daemon/cli/external.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), })); -vi.mock("@getpaseo/server", () => ({ +vi.mock("@getpaseo/server/process", () => ({ spawnProcess: mocks.spawnProcess, })); diff --git a/packages/desktop/src/daemon/cli/external.ts b/packages/desktop/src/daemon/cli/external.ts index 424ef1d163..6c9deeccb1 100644 --- a/packages/desktop/src/daemon/cli/external.ts +++ b/packages/desktop/src/daemon/cli/external.ts @@ -1,4 +1,4 @@ -import { spawnProcess } from "@getpaseo/server"; +import { spawnProcess } from "@getpaseo/server/process"; import log from "electron-log/main"; import type { NodeEntrypointInvocation } from "../node-entrypoint-launcher.js"; import { createNodeEntrypointInvocation } from "../runtime-paths.js"; diff --git a/packages/desktop/src/daemon/daemon-manager.test.ts b/packages/desktop/src/daemon/daemon-manager.test.ts index 70a6bc251c..d18a8cd48c 100644 --- a/packages/desktop/src/daemon/daemon-manager.test.ts +++ b/packages/desktop/src/daemon/daemon-manager.test.ts @@ -1,13 +1,13 @@ -import { EventEmitter } from "node:events"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { hostname, tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS } from "../settings/desktop-settings"; -import { getBundledCliShimPath } from "../integrations/cli-install"; import { createDaemonCommandHandlers } from "./daemon-manager"; const mocks = vi.hoisted(() => ({ - paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home", + paseoHome: "", settings: { releaseChannel: "stable", daemon: { @@ -25,13 +25,13 @@ const mocks = vi.hoisted(() => ({ spawnProcess: vi.fn(), logInfo: vi.fn(), logError: vi.fn(), - appLogPath: "/tmp/paseo-desktop-daemon-manager-test-main.log", + appLogPath: "", getElectronLogFile: vi.fn(), })); vi.mock("electron", () => ({ app: { - getPath: vi.fn(() => "/tmp/paseo-user-data"), + getPath: vi.fn(() => mocks.paseoHome), getVersion: vi.fn(() => "1.2.3"), isPackaged: true, }, @@ -51,8 +51,9 @@ vi.mock("electron-log/main", () => ({ }, })); -vi.mock("@getpaseo/server", () => ({ - resolvePaseoHome: vi.fn(() => mocks.paseoHome), +vi.mock("@getpaseo/server/daemon-control", async (importOriginal) => ({ + ...(await importOriginal>()), + resolvePaseoHome: () => mocks.paseoHome, spawnProcess: mocks.spawnProcess, })); @@ -67,7 +68,7 @@ vi.mock("../settings/desktop-settings-electron.js", () => ({ vi.mock("./runtime-paths.js", () => ({ createNodeEntrypointInvocation: mocks.createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint: vi.fn(() => ({ - entryPath: "/tmp/daemon.js", + entryPath: path.join(mocks.paseoHome, "daemon.js"), execArgv: [], })), })); @@ -77,40 +78,13 @@ vi.mock("./cli/external.js", () => ({ runExternalCliTextCommand: mocks.runExternalCliTextCommand, })); -function desktopSettingsWithManagement(enabled: boolean) { - return { - ...DEFAULT_DESKTOP_SETTINGS, - daemon: { - ...DEFAULT_DESKTOP_SETTINGS.daemon, - manageBuiltInDaemon: enabled, - }, - }; -} - -type MockChildProcess = EventEmitter & { - pid: number; - spawnfile: string; - spawnargs: string[]; - unref: ReturnType; -}; - -function createMockChildProcess(): MockChildProcess { - const child = new EventEmitter() as MockChildProcess; - child.pid = 1234; - child.spawnfile = "node"; - child.spawnargs = ["node", "daemon.js"]; - child.unref = vi.fn(); - return child; -} - -function scheduleFailedStartup(child: MockChildProcess): void { - setImmediate(() => { - child.emit("exit", 1, null); - }); -} - describe("daemon-manager commands", () => { + let fixtureRoot: string; + beforeEach(() => { + fixtureRoot = mkdtempSync(path.join(tmpdir(), "paseo daemon manager ")); + mocks.paseoHome = path.join(fixtureRoot, "home"); + mocks.appLogPath = path.join(fixtureRoot, "main.log"); mocks.settings = DEFAULT_DESKTOP_SETTINGS; mocks.runExternalCliJsonCommand.mockReset(); mocks.runExternalCliTextCommand.mockReset(); @@ -121,384 +95,10 @@ describe("daemon-manager commands", () => { mocks.logError.mockReset(); mocks.getElectronLogFile.mockReset(); mocks.getElectronLogFile.mockReturnValue({ path: mocks.appLogPath }); - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); }); afterEach(() => { - rmSync(mocks.paseoHome, { recursive: true, force: true }); - rmSync(mocks.appLogPath, { force: true }); - }); - - it("refuses start and restart while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - await expect(handlers.restart_desktop_daemon()).rejects.toThrow( - "Built-in daemon management is disabled.", - ); - - expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("keeps stop callable while built-in daemon management is disabled", async () => { - mocks.settings = desktopSettingsWithManagement(false); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]); - }); - - it("routes running desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(1, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "status", - "--json", - ]); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ - reason: "manual_ipc", - statusBefore: expect.objectContaining({ - status: "running", - pid: 4242, - serverId: "server-1", - desktopManaged: true, - }), - }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "manual_ipc", - cliResult: { action: "stopped" }, - statusAfter: expect.objectContaining({ - status: "stopped", - serverId: null, - }), - }), - ); - }); - - it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.stop_desktop_daemon()).resolves.toEqual({ - serverId: "", - status: "stopped", - listen: null, - hostname: null, - pid: null, - home: mocks.paseoHome, - version: null, - desktopManaged: false, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(2, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - }); - - it("records the renderer stop reason when stopping the desktop daemon", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "running", - serverId: "server-1", - pid: 4242, - listen: "127.0.0.1:6767", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - serverId: "", - }); - const handlers = createDaemonCommandHandlers(); - - await handlers.stop_desktop_daemon({ reason: "host_remove" }); - - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop requested", - expect.objectContaining({ reason: "host_remove" }), - ); - expect(mocks.logInfo).toHaveBeenCalledWith( - "[desktop daemon]", - "desktop daemon stop completed", - expect.objectContaining({ - reason: "host_remove", - cliResult: { action: "stopped", reason: "lifecycle_shutdown_rpc" }, - }), - ); - }); - - it("uses a stale reachable desktop daemon when the version matches", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-1", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: null, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.spawnProcess).not.toHaveBeenCalled(); - }); - - it("restarts a stale reachable desktop daemon when the version differs", async () => { - mocks.runExternalCliJsonCommand - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ - localDaemon: "stale_pid", - connectedDaemon: "reachable", - serverId: "server-1", - pid: 7675, - listen: "127.0.0.1:6767", - daemonVersion: "1.2.2", - desktopManaged: true, - }) - .mockResolvedValueOnce({ action: "stopped" }) - .mockResolvedValueOnce({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }) - .mockResolvedValueOnce({ - localDaemon: "running", - connectedDaemon: "reachable", - serverId: "server-2", - pid: 8888, - listen: "127.0.0.1:6767", - hostname: "dev-host", - daemonVersion: "1.2.3", - desktopManaged: true, - }); - mocks.spawnProcess.mockReturnValue(createMockChildProcess()); - const handlers = createDaemonCommandHandlers(); - - await expect(handlers.start_desktop_daemon()).resolves.toEqual({ - serverId: "server-2", - status: "running", - listen: "127.0.0.1:6767", - hostname: "dev-host", - pid: 8888, - home: mocks.paseoHome, - version: "1.2.3", - desktopManaged: true, - error: null, - }); - - expect(mocks.runExternalCliJsonCommand).toHaveBeenNthCalledWith(3, [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", - ]); - expect(mocks.spawnProcess).toHaveBeenCalled(); - }); - - it("starts the managed daemon detached from desktop stdio and reports daemon log failures", async () => { - mkdirSync(mocks.paseoHome, { recursive: true }); - writeFileSync( - `${mocks.paseoHome}/daemon.log`, - ["old log line", "recent daemon failure"].join("\n"), - ); - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "stopped", - connectedDaemon: "unreachable", - serverId: "", - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - const handlers = createDaemonCommandHandlers(); - - let thrown: Error | null = null; - try { - await handlers.start_desktop_daemon(); - } catch (error) { - thrown = error instanceof Error ? error : new Error(String(error)); - } - - expect(thrown).toBeInstanceOf(Error); - const message = thrown?.message ?? ""; - const recentLogsLabel = message.match(/Recent logs \(([^)]*)\):/)?.[1]; - expect(message).toContain("Daemon failed to start: exit code 1"); - expect(recentLogsLabel?.split(/[\\/]/).at(-1)).toBe("daemon.log"); - expect(message).toContain("recent daemon failure"); - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); - expect(mocks.spawnProcess).toHaveBeenCalledWith( - "node", - [], - expect.objectContaining({ - detached: true, - stdio: ["ignore", "ignore", "ignore"], - envOverlay: expect.objectContaining({ - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }), - }), - ); - }); - - it("passes stale lock reclaim only after a live desktop daemon is confirmed unresponsive", async () => { - mocks.runExternalCliJsonCommand.mockResolvedValue({ - localDaemon: "unresponsive", - connectedDaemon: "unreachable", - serverId: "", - pid: 7675, - listen: "127.0.0.1:6767", - desktopManaged: true, - }); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: ["--reclaim-stale-pid-lock"] }), - ); - }); - - it("does not pass stale lock reclaim when the status command fails", async () => { - mocks.runExternalCliJsonCommand.mockRejectedValue(new Error("status command failed")); - mocks.spawnProcess.mockImplementation(() => { - const child = createMockChildProcess(); - scheduleFailedStartup(child); - return child; - }); - - await expect(createDaemonCommandHandlers().start_desktop_daemon()).rejects.toThrow( - "Daemon failed to start: exit code 1", - ); - - expect(mocks.createNodeEntrypointInvocation).toHaveBeenCalledWith( - expect.objectContaining({ args: [] }), - ); + rmSync(fixtureRoot, { recursive: true, force: true }); }); it("returns the Electron main-process log tail from electron-log", () => { @@ -515,4 +115,64 @@ describe("daemon-manager commands", () => { ), }); }); + + it("exposes updater diagnostics through the desktop command boundary", () => { + const diagnostics = createDaemonCommandHandlers().desktop_update_diagnostics(); + + expect(diagnostics).toMatchObject({ + platform: process.platform, + currentVersion: "1.2.3", + }); + }); + + it("reports a stopped daemon without launching the CLI when no local daemon runs", async () => { + mkdirSync(mocks.paseoHome); + writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n"); + mocks.runExternalCliJsonCommand.mockResolvedValue({ + home: mocks.paseoHome, + pid: null, + startedAt: null, + listen: null, + hostname: null, + localDaemon: "stopped", + desktopManaged: false, + connectedDaemon: "not_probed", + }); + + const status = await createDaemonCommandHandlers().desktop_daemon_status(); + + expect(status).toMatchObject({ serverId: "", status: "stopped", pid: null }); + expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled(); + }); + + it("reports an errored daemon when the local daemon state cannot be read", async () => { + mkdirSync(mocks.paseoHome); + writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage"); + + const status = await createDaemonCommandHandlers().desktop_daemon_status(); + + expect(status).toMatchObject({ serverId: "", status: "errored", pid: null }); + expect(status.error).toBeTruthy(); + }); + + it("returns a local credential only for its live managed daemon listen", async () => { + mkdirSync(mocks.paseoHome); + const token = "a".repeat(43); + writeFileSync(path.join(mocks.paseoHome, "local-credential"), `${token}\n`, { mode: 0o600 }); + const lock = { + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: hostname(), + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6799", + desktopManaged: true, + }; + const lockPath = path.join(mocks.paseoHome, "paseo.pid"); + writeFileSync(lockPath, JSON.stringify(lock)); + const handler = createDaemonCommandHandlers().desktop_local_credential; + expect(await handler({ listen: "localhost:6799" })).toBe(token); + expect(await handler({ listen: "remote:6799" })).toBeNull(); + writeFileSync(lockPath, JSON.stringify({ ...lock, desktopManaged: false })); + expect(await handler({ listen: "localhost:6799" })).toBeNull(); + }); }); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index c565b226f3..5f3f5394c6 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -1,9 +1,17 @@ -import { type ChildProcess } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import { app, ipcMain, powerMonitor } from "electron"; import log from "electron-log/main"; -import { resolvePaseoHome, spawnProcess } from "@getpaseo/server"; +import { + resolvePaseoHome, + startDaemonInstance, + DaemonInstanceError, + stopDaemonInstance, + readDaemonInstance, + isSameDaemonInstance, + readLocalCredentialForTarget, + type DaemonInstance, +} from "@getpaseo/server/daemon-control"; import { copyAttachmentFileToManagedStorage, deleteManagedAttachmentFile, @@ -37,7 +45,9 @@ import { import type { DesktopSettings } from "../settings/desktop-settings.js"; import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js"; import { isRunningUnderARM64Translation } from "../system/arm64-translation.js"; +import { describeSandbox } from "../diagnostics/sandbox.js"; import { getDesktopAppLogs } from "../diagnostics/app-logs.js"; +import { getDesktopUpdaterDiagnostics } from "../diagnostics/updater.js"; import { deleteLegacySkillSelection, readLegacySkillSelection, @@ -45,9 +55,7 @@ import { import { tailFile } from "../diagnostics/tail-file.js"; const DAEMON_LOG_FILENAME = "daemon.log"; -const STARTUP_POLL_INTERVAL_MS = 200; -const STARTUP_POLL_MAX_ATTEMPTS = 150; -const DETACHED_STARTUP_GRACE_MS = 1200; +let ownedLaunch: { home: string; instance: DaemonInstance } | null = null; type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; const DESKTOP_DAEMON_STOP_REASON_VALUES = [ @@ -73,6 +81,8 @@ export interface DesktopDaemonStatus { home: string; version: string | null; desktopManaged: boolean; + ownedByDesktop: boolean; + startedAt: string | null; error: string | null; } @@ -122,73 +132,19 @@ function logFilePath(): string { } export function isDesktopManagedDaemonRunningSync(): boolean { + if (!ownedLaunch) return false; try { - const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8"); - const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown }; - if (lock.desktopManaged !== true) return false; - if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false; - return isProcessRunning(lock.pid); + const lock = JSON.parse(readFileSync(path.join(ownedLaunch.home, "paseo.pid"), "utf8")); + return isSameDaemonInstance(lock, ownedLaunch.instance) && isProcessRunning(lock.pid); } catch { return false; } } -function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record { - return { - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - version: status.version, - desktopManaged: status.desktopManaged, - error: status.error, - }; -} - -const DESKTOP_DAEMON_STOP_CLI_ARGS = [ - "daemon", - "stop", - "--json", - "--timeout", - "5", - "--force", - "--kill-timeout", - "5", -]; - -async function runDesktopDaemonStopViaCli({ - reason, - statusBefore, - resolveStatusAfter = false, -}: { - reason: DesktopDaemonStopReason; - statusBefore?: DesktopDaemonStatus | null; - resolveStatusAfter?: boolean; -}): Promise<{ - cliResult: unknown; - statusAfter: DesktopDaemonStatus | null; -}> { - logDesktopDaemonLifecycle("desktop daemon stop requested", { - reason, - statusBefore: statusBefore ? summarizeDesktopDaemonStatus(statusBefore) : null, - }); - - const cliResult = await runExternalCliJsonCommand(DESKTOP_DAEMON_STOP_CLI_ARGS); - const statusAfter = resolveStatusAfter ? await resolveDesktopDaemonStatus() : null; - - logDesktopDaemonLifecycle("desktop daemon stop completed", { - reason, - cliResult, - statusAfter: statusAfter ? summarizeDesktopDaemonStatus(statusAfter) : null, - }); - - return { cliResult, statusAfter }; -} - export async function stopDesktopDaemonViaCli( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, ): Promise { - await runDesktopDaemonStopViaCli({ reason }); + await stopDesktopDaemon(reason); } function isProcessRunning(pid: number): boolean { @@ -203,12 +159,6 @@ function isProcessRunning(pid: number): boolean { } } -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function logDesktopDaemonLifecycle(message: string, details?: Record): void { log.info("[desktop daemon]", message, { pid: process.pid, @@ -221,25 +171,27 @@ function statusFromDaemonProbe( home: string, ): DesktopDaemonStatus { const local = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped"; - const reachable = payload.connectedDaemon === "reachable"; - const processAlive = local === "running"; - const stalledProcess = local === "unresponsive"; + const processAlive = local === "running" || local === "not_ready"; let status: DesktopDaemonState = "stopped"; - if (reachable || processAlive) { - status = "running"; - } else if (stalledProcess) { - status = "errored"; - } + if (local === "not_ready") status = "starting"; + if (local === "running") status = "running"; return { serverId: typeof payload.serverId === "string" ? payload.serverId : "", status, listen: typeof payload.listen === "string" ? payload.listen : null, hostname: status === "running" && typeof payload.hostname === "string" ? payload.hostname : null, - pid: (processAlive || stalledProcess) && typeof payload.pid === "number" ? payload.pid : null, + pid: processAlive && typeof payload.pid === "number" ? payload.pid : null, home, version: typeof payload.daemonVersion === "string" ? payload.daemonVersion : null, desktopManaged: payload.desktopManaged === true, + startedAt: typeof payload.startedAt === "string" ? payload.startedAt : null, + ownedByDesktop: Boolean( + ownedLaunch && + ownedLaunch.home === home && + payload.pid === ownedLaunch.instance.pid && + payload.startedAt === ownedLaunch.instance.startedAt, + ), error: null, }; } @@ -272,23 +224,34 @@ export async function resolveDesktopDaemonStatus(): Promise const home = getPaseoHome(); try { - const payload = (await runExternalCliJsonCommand(["daemon", "status", "--json"])) as Record< - string, - unknown - >; + // The app polls this while no local daemon runs. Answer that case in-process, + // since launching the CLI once per poll keeps spawning processes while idle. + if (!(await readDaemonInstance(home))) { + return statusFromDaemonProbe({ localDaemon: "stopped" }, home); + } + + const payload = (await runExternalCliJsonCommand([ + "daemon", + "status", + "--home", + home, + "--json", + ])) as Record; return statusFromDaemonProbe(payload, home); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage }); + logDesktopDaemonLifecycle("resolveStatus failed", { error: errorMessage }); return { serverId: "", - status: "stopped", + status: "errored", listen: null, hostname: null, pid: null, home, version: null, desktopManaged: false, + ownedByDesktop: false, + startedAt: null, error: errorMessage, }; } @@ -301,7 +264,7 @@ function normalizeVersion(version: string | null): string | null { } function shouldRestartForVersion(current: DesktopDaemonStatus): boolean { - if (!current.desktopManaged) return false; + if (!current.ownedByDesktop) return false; const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); return Boolean(appVersion && daemonVersion && appVersion !== daemonVersion); @@ -313,40 +276,6 @@ function assertBuiltInDaemonManagementEnabled(settings: DesktopSettings): void { } } -function buildStartupFailureError(result: { - code: number | null; - signal: string | null; - error?: Error; -}): Error { - const reason = result.error - ? result.error.message - : `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`; - const parts = [`Daemon failed to start: ${reason}`]; - const logs = tailFile(logFilePath(), 15); - if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`); - return new Error(parts.join("\n\n")); -} - -async function pollForRunningDaemon(): Promise { - async function poll(attempt: number): Promise { - if (attempt >= STARTUP_POLL_MAX_ATTEMPTS) return resolveDesktopDaemonStatus(); - const status = await resolveDesktopDaemonStatus(); - if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) { - logDesktopDaemonLifecycle("polling daemon status after detached start", { - attempt: attempt + 1, - status: status.status, - pid: status.pid, - listen: status.listen, - serverId: status.serverId || null, - }); - } - if (status.status === "running" && status.serverId && status.listen) return status; - await sleep(STARTUP_POLL_INTERVAL_MS); - return poll(attempt + 1); - } - return poll(0); -} - async function startDaemon(): Promise { assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); @@ -359,7 +288,7 @@ async function startDaemon(): Promise { error: current.error, desktopManaged: current.desktopManaged, }); - if (current.status === "running") { + if (current.status === "running" || current.status === "starting") { if (shouldRestartForVersion(current)) { logDesktopDaemonLifecycle("daemon version mismatch, restarting", { appVersion: normalizeVersion(resolveDesktopAppVersion()), @@ -371,118 +300,69 @@ async function startDaemon(): Promise { } } - const daemonRunner = resolveDaemonRunnerEntrypoint(); - const reclaimStalePidLock = - current.status === "errored" && current.desktopManaged && current.error === null; + const home = getPaseoHome(); const invocation = createNodeEntrypointInvocation({ - entrypoint: daemonRunner, + entrypoint: resolveDaemonRunnerEntrypoint(), argvMode: "node-script", - args: reclaimStalePidLock ? ["--reclaim-stale-pid-lock"] : [], + args: [], baseEnv: process.env, }); - - logDesktopDaemonLifecycle("starting detached daemon", { - appIsPackaged: app.isPackaged, - daemonRunnerEntry: daemonRunner.entryPath, - daemonRunnerExecArgv: daemonRunner.execArgv, - command: invocation.command, - args: invocation.args, - electronRunAsNode: invocation.env.ELECTRON_RUN_AS_NODE ?? null, - parentExecPath: process.execPath, - parentElectronRunAsNode: process.env.ELECTRON_RUN_AS_NODE ?? null, - electronVersion: process.versions.electron ?? null, - nodeVersion: process.versions.node, - platform: process.platform, - arch: process.arch, - }); - - const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { - detached: true, - envMode: "internal", - env: invocation.env, - envOverlay: { - PASEO_DESKTOP_MANAGED: "1", - PASEO_CLI: getBundledCliShimPath(), - PASEO_WEB_UI_ENABLED: "false", - }, - stdio: ["ignore", "ignore", "ignore"], - }); - - logDesktopDaemonLifecycle("detached spawn returned", { - childPid: child.pid ?? null, - spawnfile: child.spawnfile, - spawnargs: child.spawnargs, - }); - - child.unref(); - - type GraceResult = - | { exitedEarly: false } - | { exitedEarly: true; code: number | null; signal: string | null; error?: Error }; - - const result = await new Promise((resolve) => { - let settled = false; - const finish = (value: GraceResult) => { - if (settled) return; - settled = true; - resolve(value); - }; - - const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS); - - child.once("error", (error) => { - clearTimeout(timer); - finish({ exitedEarly: true, code: null, signal: null, error }); + try { + await startDaemonInstance({ + home, + timeoutMs: 30_000, + ...invocation, + env: { ...invocation.env, PASEO_CLI: getBundledCliShimPath() }, + mode: "managed", + desktopManaged: true, + onAcquired: (instance) => { + ownedLaunch = { home, instance }; + }, }); - child.once("exit", (code, signal) => { - clearTimeout(timer); - finish({ exitedEarly: true, code, signal }); - }); - }); - - logDesktopDaemonLifecycle("detached startup grace period completed", { - childPid: child.pid ?? null, - exitedEarly: result.exitedEarly, - ...(result.exitedEarly - ? { - exitCode: result.code, - signal: result.signal, - error: result.error?.message ?? null, - } - : {}), - }); - - if (result.exitedEarly) { - throw buildStartupFailureError(result); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) throw error; } - - return pollForRunningDaemon(); + return resolveDesktopDaemonStatus(); } export async function stopDesktopDaemon( reason: DesktopDaemonStopReason = DEFAULT_DESKTOP_DAEMON_STOP_REASON, + confirmedInstance?: { pid: number; startedAt: string }, ): Promise { - const status = await resolveDesktopDaemonStatus(); - if (status.status !== "running") { - logDesktopDaemonLifecycle("desktop daemon stop skipped", { - reason, - statusBefore: summarizeDesktopDaemonStatus(status), - }); - return status; - } - - const { statusAfter } = await runDesktopDaemonStopViaCli({ - reason, - statusBefore: status, - resolveStatusAfter: true, + const home = getPaseoHome(); + const instance = await readDaemonInstance(home); + const owned = Boolean( + instance && + ownedLaunch && + ownedLaunch.home === home && + isSameDaemonInstance(instance, ownedLaunch.instance), + ); + const explicit = + reason === "manual_ipc" && + confirmedInstance && + instance && + instance.pid === confirmedInstance.pid && + instance.startedAt === confirmedInstance.startedAt; + if (confirmedInstance && !explicit) + throw new Error( + "Daemon changed since confirmation; inspect its current home and PID before stopping it.", + ); + if (!instance || (!owned && !explicit)) return resolveDesktopDaemonStatus(); + logDesktopDaemonLifecycle("stopping captured supervisor", { reason, pid: instance.pid, owned }); + await stopDaemonInstance(home, { + instance, + timeoutMs: 15_000, + requestShutdown: async (ready) => { + await runExternalCliJsonCommand(["daemon", "stop", "--host", ready.listen, "--json"]); + }, }); - return statusAfter ?? (await resolveDesktopDaemonStatus()); + if (owned) ownedLaunch = null; + return resolveDesktopDaemonStatus(); } async function restartDaemon(): Promise { - assertBuiltInDaemonManagementEnabled(await getDesktopSettingsStore().get()); - await stopDesktopDaemon("restart"); - return startDaemon(); + await runExternalCliJsonCommand(["daemon", "restart", "--home", getPaseoHome(), "--json"]); + return resolveDesktopDaemonStatus(); } function getDaemonLogs(): DesktopDaemonLogs { @@ -494,7 +374,7 @@ function getDaemonLogs(): DesktopDaemonLogs { } async function getCliDaemonStatus(): Promise { - return await runExternalCliTextCommand(["daemon", "status"]); + return await runExternalCliTextCommand(["daemon", "status", "--home", getPaseoHome()]); } async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { @@ -526,11 +406,28 @@ export function createDaemonCommandHandlers(): Record resolveDesktopDaemonStatus(), + desktop_local_credential: async (args) => { + const instance = await readDaemonInstance(getPaseoHome()); + if (!instance?.desktopManaged || typeof args?.listen !== "string") return null; + return readLocalCredentialForTarget(getPaseoHome(), args.listen); + }, start_desktop_daemon: () => startDaemon(), - stop_desktop_daemon: (args) => stopDesktopDaemon(parseDesktopDaemonStopReason(args)), + stop_desktop_daemon: (args) => + stopDesktopDaemon( + parseDesktopDaemonStopReason(args), + typeof args?.pid === "number" && typeof args.startedAt === "string" + ? { pid: args.pid, startedAt: args.startedAt } + : undefined, + ), restart_desktop_daemon: () => restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), + desktop_sandbox_diagnostics: () => + describeSandbox({ + disabled: app.commandLine.hasSwitch("no-sandbox"), + launcherReason: process.env.PASEO_DESKTOP_SANDBOX_REASON, + }), desktop_app_logs: () => getDesktopAppLogs(), + desktop_update_diagnostics: () => getDesktopUpdaterDiagnostics(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index cbea764dcb..f38e679d3e 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -63,6 +63,22 @@ function createFakeMacBundle(options: { includeHelper: boolean }): { } describe("desktop packaging", () => { + it("uses an Electron runtime whose Squirrel handoff explicitly wakes ShipIt", () => { + const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + devDependencies?: Record; + }; + const electronVersion = pkg.devDependencies?.electron ?? "0.0.0"; + const electronMajor = Number(electronVersion.split(".")[0]); + + expect(electronMajor).toBeGreaterThanOrEqual(44); + }); + + it("requires macOS 13 or newer in the packaged application", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + + expect(config).toContain('minimumSystemVersion: "13.0.0"'); + }); + it("unpacks server zsh shell integration files for external shells", () => { const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); @@ -161,3 +177,16 @@ describe("desktop packaging", () => { } }); }); + +it("installs the Linux helper as root-owned 4755 regardless of root's namespace access", () => { + const config = readFileSync(join(packageRoot, "electron-builder.yml"), "utf8"); + expect(config).toContain("afterInstall: scripts/linux-sandbox/after-install.tpl"); + const installer = readFileSync( + join(packageRoot, "scripts/linux-sandbox/after-install.tpl"), + "utf8", + ); + expect(installer).not.toContain("unshare"); + expect(installer).toContain("chown root:root '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).toContain("chmod 4755 '/opt/${sanitizedProductName}/chrome-sandbox'"); + expect(installer).not.toContain("chmod 0755"); +}); diff --git a/packages/desktop/src/daemon/linux-launcher.posix.test.ts b/packages/desktop/src/daemon/linux-launcher.posix.test.ts new file mode 100644 index 0000000000..ba7a8ec02b --- /dev/null +++ b/packages/desktop/src/daemon/linux-launcher.posix.test.ts @@ -0,0 +1,123 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +// The launcher reads Linux /proc state as well as POSIX command interfaces. +const it = test.runIf(process.platform === "linux"); + +const require = createRequire(import.meta.url); +const afterPack = require("../../scripts/after-pack.js").default; + +async function launch( + options: { + namespaces?: boolean; + helper?: string; + mount?: string; + env?: NodeJS.ProcessEnv; + args?: string[]; + symlink?: boolean; + rerun?: boolean; + } = {}, +) { + const root = mkdtempSync(join(tmpdir(), "paseo-launcher-")); + try { + const app = join(root, "app with spaces"); + const commands = join(root, "commands"); + mkdirSync(app); + mkdirSync(commands); + writeFileSync( + join(app, "Paseo"), + `#!${process.execPath}\nconsole.log(JSON.stringify(process.argv.slice(2)));\n`, + ); + chmodSync(join(app, "Paseo"), 0o755); + // The command interface represents the host's userns policy, independent of CI's host. + writeFileSync(join(commands, "unshare"), `#!/bin/sh\nexit ${options.namespaces ? 0 : 1}\n`); + chmodSync(join(commands, "unshare"), 0o755); + for (const [name, output] of Object.entries({ + stat: options.helper ?? "1000:755", + findmnt: options.mount ?? "rw", + })) { + writeFileSync(join(commands, name), `#!/bin/sh\nprintf '%s\\n' '${output}'\n`); + chmodSync(join(commands, name), 0o755); + } + writeFileSync(join(app, "chrome-sandbox"), "helper"); + chmodSync(join(app, "chrome-sandbox"), 0o755); + await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + if (options.rerun) await afterPack({ appOutDir: app, electronPlatformName: "linux", arch: 1 }); + const executablePath = options.symlink ? join(root, "paseo") : join(app, "Paseo"); + if (options.symlink) symlinkSync(join(app, "Paseo"), executablePath); + const args = options.args ?? ["path with spaces", "$(touch never)", "semi;colon", "*.txt"]; + const result = spawnSync(executablePath, args, { + encoding: "utf8", + env: { + ...process.env, + FORCE_COLOR: undefined, + PATH: `${commands}:${process.env.PATH}`, + APPIMAGE: "/tmp/Paseo.AppImage", + PASEO_DESKTOP_SMOKE: "0", + ...options.env, + }, + }); + expect(result.status, result.stderr).toBe(0); + return { args: JSON.parse(result.stdout), stderr: result.stderr, input: args }; + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +it("launches a portable app with the fallback on argv when user namespaces are denied", async () => { + const result = await launch(); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("[linux-sandbox] disabled"); +}); + +it("keeps sandboxing for AppImage when the real user can create namespaces", async () => { + const result = await launch({ namespaces: true }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: user namespaces available"); +}); + +it("keeps sandboxing via the installed root-owned helper when userns is denied", async () => { + const result = await launch({ helper: "0:4755", env: { APPIMAGE: "" } }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] enabled: root-owned SUID helper available"); +}); + +it("does not trust 4755 on a nosuid mount", async () => { + const result = await launch({ helper: "0:4755", mount: "rw,nosuid,nodev" }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("preserves sandbox flags and all Node entrypoint arguments without probing", async () => { + const result = await launch({ env: { ELECTRON_RUN_AS_NODE: "1" }, args: ["--version"] }); + expect(result.args).toEqual(["--version"]); + expect(result.stderr).toBe(""); +}); + +it("reports an explicit user override without injecting a duplicate", async () => { + const result = await launch({ namespaces: true, args: ["--no-sandbox", "--version"] }); + expect(result.args).toEqual(result.input); + expect(result.stderr).toContain("[linux-sandbox] disabled: requested by --no-sandbox"); +}); + +it("resolves symlink launches and keeps the real executable intact on repeated packaging", async () => { + const result = await launch({ symlink: true, rerun: true }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("does not depend on APPIMAGE being present for an extracted portable app", async () => { + const result = await launch({ env: { APPIMAGE: "" } }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); +}); + +it("applies a debugging environment sandbox override before Chromium starts", async () => { + const result = await launch({ + namespaces: true, + env: { PASEO_ELECTRON_FLAGS: "--disable-gpu\t--no-sandbox" }, + }); + expect(result.args).toEqual(["--no-sandbox", ...result.input]); + expect(result.stderr).toContain("requested by PASEO_ELECTRON_FLAGS"); +}); diff --git a/packages/desktop/src/daemon/runtime-paths.test.ts b/packages/desktop/src/daemon/runtime-paths.test.ts index dbb456df87..7ed6e901ee 100644 --- a/packages/desktop/src/daemon/runtime-paths.test.ts +++ b/packages/desktop/src/daemon/runtime-paths.test.ts @@ -21,10 +21,6 @@ vi.mock("electron-log/main", () => ({ default: { warn: vi.fn() }, })); -vi.mock("@getpaseo/server", () => ({ - spawnProcess: vi.fn(), -})); - const originalPlatform = process.platform; const originalExecPath = process.execPath; const originalResourcesPath = process.resourcesPath; diff --git a/packages/desktop/src/diagnostics/sandbox.test.ts b/packages/desktop/src/diagnostics/sandbox.test.ts new file mode 100644 index 0000000000..e9e80040ca --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { describeSandbox } from "./sandbox"; + +it("reports a portable fallback independently of the renderer's Node isolation setting", () => { + expect( + describeSandbox({ + disabled: true, + launcherReason: "user namespaces unavailable; no usable SUID helper", + }), + ).toEqual({ + enabled: false, + reason: "user namespaces unavailable; no usable SUID helper", + }); +}); + +it("reports enabled defaults and explicit overrides without a packaged launcher", () => { + expect(describeSandbox({ disabled: false })).toEqual({ + enabled: true, + reason: "Chromium default", + }); + expect(describeSandbox({ disabled: true })).toEqual({ + enabled: false, + reason: "requested by --no-sandbox", + }); +}); diff --git a/packages/desktop/src/diagnostics/sandbox.ts b/packages/desktop/src/diagnostics/sandbox.ts new file mode 100644 index 0000000000..af10c25397 --- /dev/null +++ b/packages/desktop/src/diagnostics/sandbox.ts @@ -0,0 +1,10 @@ +export function describeSandbox(input: { disabled: boolean; launcherReason?: string }): { + enabled: boolean; + reason: string; +} { + return { + enabled: !input.disabled, + reason: + input.launcherReason ?? (input.disabled ? "requested by --no-sandbox" : "Chromium default"), + }; +} diff --git a/packages/desktop/src/diagnostics/updater.test.ts b/packages/desktop/src/diagnostics/updater.test.ts new file mode 100644 index 0000000000..bfe43aceab --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { collectDesktopUpdaterDiagnostics } from "./updater"; + +let testDirectory = ""; + +afterEach(() => { + if (testDirectory) { + rmSync(testDirectory, { recursive: true, force: true }); + testDirectory = ""; + } +}); + +describe("desktop updater diagnostics", () => { + it("collects the staged version and existing ShipIt evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + const updateBundlePath = path.join(shipItDirectory, "update.test", "Paseo.app"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ + launchAfterInstallation: true, + updateBundleURL: pathToFileURL(updateBundlePath).href, + }), + ); + writeFileSync(path.join(shipItDirectory, "ShipIt_stdout.log"), "stdout evidence\n"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "stderr evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: (bundlePath) => (bundlePath === updateBundlePath ? "0.7.2" : null), + }); + + expect(diagnostics.currentVersion).toBe("0.7.0"); + expect(diagnostics.targetVersion).toBe("0.7.2"); + expect(diagnostics.targetVersionError).toBeNull(); + expect(diagnostics.shipItDirectory).toBe(shipItDirectory); + expect(diagnostics.state).toMatchObject({ exists: true }); + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "stdout evidence", + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "stderr evidence", + }); + expect(diagnostics.state?.modifiedAt).not.toBeNull(); + }); + + it("reports malformed ShipIt state without hiding other evidence", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipItState.plist"), "not JSON"); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => "unused", + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toMatch(/JSON|Unexpected token/); + expect(diagnostics.stderr?.contents).toBe("installer evidence"); + }); + + it("reports bundle version lookup failures", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(shipItDirectory, { recursive: true }); + writeFileSync( + path.join(shipItDirectory, "ShipItState.plist"), + JSON.stringify({ updateBundleURL: pathToFileURL(path.join(shipItDirectory, "Paseo.app")) }), + ); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => { + throw new Error("plutil failed"); + }, + }); + + expect(diagnostics.targetVersion).toBeNull(); + expect(diagnostics.targetVersionError).toBe("plutil failed"); + }); + + it("keeps readable ShipIt evidence when another file cannot be read", () => { + testDirectory = mkdtempSync(path.join(tmpdir(), "paseo-updater-diagnostics-")); + const shipItDirectory = path.join(testDirectory, "sh.paseo.desktop.ShipIt"); + mkdirSync(path.join(shipItDirectory, "ShipIt_stdout.log"), { recursive: true }); + writeFileSync(path.join(shipItDirectory, "ShipIt_stderr.log"), "installer evidence\n"); + + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "darwin", + currentVersion: "0.7.0", + cachePath: testDirectory, + readBundleVersion: () => null, + }); + + expect(diagnostics.stdout).toMatchObject({ + exists: true, + contents: "", + error: expect.any(String), + }); + expect(diagnostics.stderr).toMatchObject({ + exists: true, + contents: "installer evidence", + error: null, + }); + }); + + it("does not look for ShipIt files outside macOS", () => { + const diagnostics = collectDesktopUpdaterDiagnostics({ + platform: "linux", + currentVersion: "0.7.2", + cachePath: "/unused", + readBundleVersion: () => null, + }); + + expect(diagnostics).toEqual({ + platform: "linux", + currentVersion: "0.7.2", + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }); + }); +}); diff --git a/packages/desktop/src/diagnostics/updater.ts b/packages/desktop/src/diagnostics/updater.ts new file mode 100644 index 0000000000..465c5d0390 --- /dev/null +++ b/packages/desktop/src/diagnostics/updater.ts @@ -0,0 +1,148 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { app } from "electron"; +import { tailFile } from "./tail-file.js"; + +const SHIPIT_DIRECTORY_NAME = "sh.paseo.desktop.ShipIt"; +const SHIPIT_LOG_TAIL_LINES = 100; + +export interface DesktopUpdaterDiagnosticFile { + path: string; + exists: boolean; + modifiedAt: string | null; + contents: string; + error: string | null; +} + +export interface DesktopUpdaterDiagnostics { + platform: NodeJS.Platform; + currentVersion: string; + targetVersion: string | null; + targetVersionError: string | null; + shipItDirectory: string | null; + state: DesktopUpdaterDiagnosticFile | null; + stdout: DesktopUpdaterDiagnosticFile | null; + stderr: DesktopUpdaterDiagnosticFile | null; +} + +interface DesktopUpdaterDiagnosticEnvironment { + platform: NodeJS.Platform; + currentVersion: string; + cachePath: string; + readBundleVersion(bundlePath: string): string | null; +} + +export function collectDesktopUpdaterDiagnostics( + environment: DesktopUpdaterDiagnosticEnvironment, +): DesktopUpdaterDiagnostics { + const { platform, currentVersion, cachePath, readBundleVersion } = environment; + if (platform !== "darwin") { + return { + platform, + currentVersion, + targetVersion: null, + targetVersionError: null, + shipItDirectory: null, + state: null, + stdout: null, + stderr: null, + }; + } + + const shipItDirectory = path.join(cachePath, SHIPIT_DIRECTORY_NAME); + const state = readDiagnosticFile(path.join(shipItDirectory, "ShipItState.plist")); + const targetVersion = diagnoseTargetVersion(state.contents, readBundleVersion); + return { + platform, + currentVersion, + ...targetVersion, + shipItDirectory, + state, + stdout: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stdout.log"), true), + stderr: readDiagnosticFile(path.join(shipItDirectory, "ShipIt_stderr.log"), true), + }; +} + +export function getDesktopUpdaterDiagnostics(): DesktopUpdaterDiagnostics { + return collectDesktopUpdaterDiagnostics({ + platform: process.platform, + currentVersion: app.getVersion(), + cachePath: path.join(app.getPath("home"), "Library", "Caches"), + readBundleVersion: readMacBundleVersion, + }); +} + +function readDiagnosticFile(filePath: string, tail = false): DesktopUpdaterDiagnosticFile { + let exists = false; + let modifiedAt: string | null = null; + try { + modifiedAt = statSync(filePath).mtime.toISOString(); + exists = true; + const contents = tail + ? tailFile(filePath, SHIPIT_LOG_TAIL_LINES, { throwOnReadError: true }) + : readFileSync(filePath, "utf8"); + return { path: filePath, exists, modifiedAt, contents, error: null }; + } catch (error) { + if (isMissingFileError(error)) { + return { path: filePath, exists: false, modifiedAt: null, contents: "", error: null }; + } + return { + path: filePath, + exists, + modifiedAt, + contents: "", + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function diagnoseTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): Pick { + try { + return { + targetVersion: readTargetVersion(stateContents, readBundleVersion), + targetVersionError: null, + }; + } catch (error) { + return { + targetVersion: null, + targetVersionError: error instanceof Error ? error.message : String(error), + }; + } +} + +function readTargetVersion( + stateContents: string, + readBundleVersion: (bundlePath: string) => string | null, +): string | null { + if (!stateContents) return null; + + const state = JSON.parse(stateContents) as unknown; + if (!isRecord(state) || typeof state.updateBundleURL !== "string") return null; + return readBundleVersion(fileURLToPath(state.updateBundleURL)); +} + +function readMacBundleVersion(bundlePath: string): string | null { + return execFileSync( + "/usr/bin/plutil", + [ + "-extract", + "CFBundleShortVersionString", + "raw", + path.join(bundlePath, "Contents", "Info.plist"), + ], + { encoding: "utf8" }, + ).trim(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingFileError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/desktop/src/features/app-update-service.test.ts b/packages/desktop/src/features/app-update-service.test.ts index e3be85bca0..5f55ff6099 100644 --- a/packages/desktop/src/features/app-update-service.test.ts +++ b/packages/desktop/src/features/app-update-service.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createAppUpdateService, + type AppUpdateInstallRequest, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, type RuntimeUpdateInfo, @@ -27,9 +28,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } | null = null; checkCount = 0; downloadCallCount = 0; + requestedDownloadVersions: string[] = []; downloadedVersions: string[] = []; installedVersions: string[] = []; - installModes: Array<{ isSilent: boolean; isForceRunAfter: boolean }> = []; + installModes: Array<{ targetVersion: string; isSilent: boolean; isForceRunAfter: boolean }> = []; configure(input: AppUpdateRuntimeConfiguration): void { this.configuration = input; @@ -130,8 +132,9 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { return { ...result, isUpdateAvailable }; } - async downloadUpdate(): Promise { + async downloadUpdate(targetVersion: string): Promise { this.downloadCallCount += 1; + this.requestedDownloadVersions.push(targetVersion); if (this.activeDownload) { return this.activeDownload.promise; } @@ -140,10 +143,10 @@ class FakeAppUpdateRuntime implements AppUpdateRuntime { } } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { if (this.downloadedUpdate) { this.installedVersions.push(this.downloadedUpdate.version); - this.installModes.push({ isSilent, isForceRunAfter }); + this.installModes.push({ targetVersion, isSilent, isForceRunAfter }); } } } @@ -387,7 +390,9 @@ describe("app update service", () => { expect(installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: true, isForceRunAfter: false }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: true, isForceRunAfter: false }, + ]); }); it("does not install an older download while its replacement is still rolling out", async () => { @@ -491,7 +496,9 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.installedVersions).toEqual(["1.2.5"]); - expect(runtime.installModes).toEqual([{ isSilent: false, isForceRunAfter: true }]); + expect(runtime.installModes).toEqual([ + { targetVersion: "1.2.5", isSilent: false, isForceRunAfter: true }, + ]); }); it("waits for a stale active download before downloading and installing the rechecked version", async () => { @@ -518,6 +525,7 @@ describe("app update service", () => { expect(result.installed).toBe(true); expect(runtime.downloadedVersions).toEqual(["1.2.4", "1.2.5"]); + expect(runtime.requestedDownloadVersions).toEqual(["1.2.5"]); expect(runtime.installedVersions).toEqual(["1.2.5"]); }); diff --git a/packages/desktop/src/features/app-update-service.ts b/packages/desktop/src/features/app-update-service.ts index 876a5a5553..21789950bf 100644 --- a/packages/desktop/src/features/app-update-service.ts +++ b/packages/desktop/src/features/app-update-service.ts @@ -41,11 +41,17 @@ export interface AppUpdateRuntimeConfiguration { onError(error: unknown): void; } +export interface AppUpdateInstallRequest { + targetVersion: string; + isSilent: boolean; + isForceRunAfter: boolean; +} + export interface AppUpdateRuntime { configure(input: AppUpdateRuntimeConfiguration): void; checkForUpdates(): Promise; - downloadUpdate(): Promise; - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void; + downloadUpdate(targetVersion: string): Promise; + quitAndInstall(input: AppUpdateInstallRequest): void; } export interface AppUpdateService { @@ -101,15 +107,21 @@ function buildCheckResult(input: { async function performQuitAndInstall( runtime: AppUpdateRuntime, { + targetVersion, onBeforeQuit, restart, }: { + targetVersion: string; onBeforeQuit?: () => Promise; restart: boolean; }, ): Promise { if (onBeforeQuit) await onBeforeQuit(); - runtime.quitAndInstall(/* isSilent */ !restart, /* isForceRunAfter */ restart); + runtime.quitAndInstall({ + targetVersion, + isSilent: !restart, + isForceRunAfter: restart, + }); } function getErrorMessage(error: unknown): string { @@ -356,7 +368,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const attemptedVersion: string = preparingUpdateVersion ?? readyVersion; preparingUpdateVersion ??= readyVersion; try { - await deps.runtime.downloadUpdate(); + await deps.runtime.downloadUpdate(attemptedVersion); } catch (error) { if ( attemptedVersion !== readyVersion && @@ -406,7 +418,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer } if (isReadyToInstallVersion(readyVersion)) { - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, version: readyVersion, @@ -426,7 +442,11 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer message: "A newer update was found and will be installed later.", }; } - await performQuitAndInstall(deps.runtime, { onBeforeQuit, restart }); + await performQuitAndInstall(deps.runtime, { + targetVersion: readyVersion, + onBeforeQuit, + restart, + }); return { installed: true, diff --git a/packages/desktop/src/features/attachments.ts b/packages/desktop/src/features/attachments.ts index 96e9aea6c9..82554a5d5a 100644 --- a/packages/desktop/src/features/attachments.ts +++ b/packages/desktop/src/features/attachments.ts @@ -1,6 +1,6 @@ import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { resolvePaseoHome } from "@getpaseo/server"; +import { resolvePaseoHome } from "@getpaseo/server/daemon-control"; const ATTACHMENTS_DIRNAME = "desktop-attachments"; const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/; diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index d4c42c0322..6cd6da2c7e 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -39,6 +39,7 @@ vi.mock("electron-updater", () => ({ import { bucketFromStagingUserId, checkForAppUpdate, + createAppUpdateLifecycleLogger, resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, @@ -95,6 +96,50 @@ describe("checkForAppUpdate", () => { expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); + + it("logs the update handoff with current and selected target versions", () => { + const info = vi.fn(); + const lifecycleLog = createAppUpdateLifecycleLogger({ info }); + + lifecycleLog.checkStarted({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + lifecycleLog.checkCompleted({ + currentVersion: "1.2.3", + targetVersion: "1.2.5", + releaseChannel: "stable", + intent: "manual", + hasUpdate: true, + readyToInstall: true, + errorMessage: null, + }); + lifecycleLog.updateDownloaded("1.2.4"); + lifecycleLog.downloadRequested("1.2.5"); + lifecycleLog.quitAndInstallRequested({ + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + + expect(info).toHaveBeenCalledWith("[auto-updater] check started", { + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] update downloaded", { + targetVersion: "1.2.4", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] download requested", { + targetVersion: "1.2.5", + }); + expect(info).toHaveBeenCalledWith("[auto-updater] quitAndInstall requested", { + targetVersion: "1.2.5", + isSilent: false, + isForceRunAfter: true, + }); + }); }); describe("shouldInstallAppUpdateOnQuit", () => { diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index 46656a3917..ef71dc25b4 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -3,10 +3,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { app } from "electron"; import { UUID } from "builder-util-runtime"; +import log from "electron-log/main"; import { autoUpdater } from "electron-updater"; import { createAppUpdateService, type AppUpdateCheckResult, + type AppUpdateInstallRequest, type AppUpdateInstallResult, type AppUpdateRuntime, type AppUpdateRuntimeConfiguration, @@ -35,6 +37,48 @@ let cachedStagingUserIdPromise: Promise | null = null; const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND"; +interface AppUpdateLogSink { + info(message: string, details: object): void; +} + +interface AppUpdateCheckLogDetails { + currentVersion: string; + releaseChannel: AppReleaseChannel; + intent: AppUpdateCheckIntent; +} + +interface AppUpdateCheckCompletedLogDetails extends AppUpdateCheckLogDetails { + targetVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + errorMessage: string | null; +} + +export function createAppUpdateLifecycleLogger(logger: AppUpdateLogSink) { + return { + checkStarted(details: AppUpdateCheckLogDetails): void { + logger.info("[auto-updater] check started", details); + }, + checkCompleted(details: AppUpdateCheckCompletedLogDetails): void { + logger.info("[auto-updater] check completed", details); + }, + updateAvailable(targetVersion: string): void { + logger.info("[auto-updater] update available", { targetVersion }); + }, + updateDownloaded(targetVersion: string): void { + logger.info("[auto-updater] update downloaded", { targetVersion }); + }, + downloadRequested(targetVersion: string): void { + logger.info("[auto-updater] download requested", { targetVersion }); + }, + quitAndInstallRequested(details: AppUpdateInstallRequest): void { + logger.info("[auto-updater] quitAndInstall requested", details); + }, + }; +} + +const updateLifecycleLog = createAppUpdateLifecycleLogger(log); + function isUpdateChannelNotPublished(error: unknown): boolean { return ( typeof error === "object" && @@ -133,10 +177,14 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { }; autoUpdater.on("update-available", (info) => { - input.onUpdateAvailable(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateAvailable(updateInfo.version); + input.onUpdateAvailable(updateInfo); }); autoUpdater.on("update-downloaded", (info) => { - input.onUpdateDownloaded(info as RuntimeUpdateInfo); + const updateInfo = info as RuntimeUpdateInfo; + updateLifecycleLog.updateDownloaded(updateInfo.version); + input.onUpdateDownloaded(updateInfo); }); autoUpdater.on("error", (error) => { if (isUpdateChannelNotPublished(error)) return; @@ -158,12 +206,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { } } - downloadUpdate(): Promise { + downloadUpdate(targetVersion: string): Promise { + updateLifecycleLog.downloadRequested(targetVersion); return autoUpdater.downloadUpdate(); } - quitAndInstall(isSilent: boolean, isForceRunAfter: boolean): void { + quitAndInstall({ targetVersion, isSilent, isForceRunAfter }: AppUpdateInstallRequest): void { autoUpdater.autoRunAppAfterInstall = isForceRunAfter; + updateLifecycleLog.quitAndInstallRequested({ + targetVersion, + isSilent, + isForceRunAfter, + }); autoUpdater.quitAndInstall(isSilent, isForceRunAfter); } } @@ -197,7 +251,22 @@ export async function checkForAppUpdate({ releaseChannel: AppReleaseChannel; intent: AppUpdateCheckIntent; }): Promise { - return appUpdateService.checkForAppUpdate({ currentVersion, releaseChannel, intent }); + updateLifecycleLog.checkStarted({ currentVersion, releaseChannel, intent }); + const result = await appUpdateService.checkForAppUpdate({ + currentVersion, + releaseChannel, + intent, + }); + updateLifecycleLog.checkCompleted({ + currentVersion, + targetVersion: result.latestVersion, + releaseChannel, + intent, + hasUpdate: result.hasUpdate, + readyToInstall: result.readyToInstall, + errorMessage: result.errorMessage, + }); + return result; } export async function downloadAndInstallUpdate( diff --git a/packages/desktop/src/features/browser-automation/ipc.test.ts b/packages/desktop/src/features/browser-automation/ipc.test.ts index dba7581964..427e86e1b1 100644 --- a/packages/desktop/src/features/browser-automation/ipc.test.ts +++ b/packages/desktop/src/features/browser-automation/ipc.test.ts @@ -1,6 +1,5 @@ -import type { Rectangle } from "electron"; import { describe, expect, test, vi } from "vitest"; -import type { TabImage } from "./service.js"; +import { executeAutomationCommand, type BrowserRegistry, type TabImage } from "./service.js"; import { adaptWebContents, HostSnapshotEngineRegistry } from "./ipc.js"; import type { IsolatedKeyboardInputEvent } from "./trusted-input.js"; @@ -108,16 +107,22 @@ type ConsoleMessageListener = ( ) => void; class FakeWebContents { + public backgroundThrottling = true; + public getBackgroundThrottling(): boolean { + return this.backgroundThrottling; + } + public setBackgroundThrottling(allowed: boolean): void { + if (this.destroyed) throw new Error("Object has been destroyed"); + this.backgroundThrottling = allowed; + } public readonly debugger = new FakeDebugger(); public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; public readonly loadedUrls: string[] = []; - public readonly captures: Array<{ - rect: Rectangle | undefined; - options: { stayHidden?: boolean } | undefined; - }> = []; + public frameListener: ((image: TabImage) => void) | null = null; + public endedFrameSubscriptions = 0; public readonly invalidations: string[] = []; private consoleMessageListener: ConsoleMessageListener | null = null; - private destroyedListener: (() => void) | null = null; + public readonly destroyedListeners = new Set<() => void>(); public destroyed = false; public constructor(private readonly webContentsId: number) {} @@ -167,12 +172,14 @@ class FakeWebContents { public reload(): void {} - public async capturePage( - rect?: Rectangle, - options?: { stayHidden?: boolean }, - ): Promise { - this.captures.push({ rect, options }); - return new FakeImage(); + public beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void { + expect(onlyDirty).toBe(false); + this.frameListener = callback; + } + + public endFrameSubscription(): void { + this.frameListener = null; + this.endedFrameSubscriptions += 1; } public invalidate(): void { @@ -183,6 +190,8 @@ class FakeWebContents { this.inputEvents.push(event); } + public async insertText(_text: string): Promise {} + public on(event: "console-message", listener: ConsoleMessageListener): void { expect(event).toBe("console-message"); this.consoleMessageListener = listener; @@ -190,7 +199,12 @@ class FakeWebContents { public once(event: "destroyed", listener: () => void): void { expect(event).toBe("destroyed"); - this.destroyedListener = listener; + this.destroyedListeners.add(listener); + } + + public removeListener(event: "destroyed", listener: () => void): void { + expect(event).toBe("destroyed"); + this.destroyedListeners.delete(listener); } public emitConsoleMessage(input: { @@ -208,7 +222,11 @@ class FakeWebContents { public destroy(): void { this.destroyed = true; this.debugger.emitDetach(); - this.destroyedListener?.(); + this.frameListener = null; + for (const listener of this.destroyedListeners) { + this.destroyedListeners.delete(listener); + listener(); + } } } @@ -237,16 +255,86 @@ describe("browser automation IPC adapter", () => { ]); }); - test("delegates viewport capture to the guest without a renderer prep bridge", async () => { + test("captures one rendered frame and releases the subscription", async () => { const contents = new FakeWebContents(20); const tab = adaptWebContents(contents); + const controller = new AbortController(); + const capture = tab.captureFrame(controller.signal); + contents.frameListener?.(new FakeImage()); - const image = await tab.capturePage({ stayHidden: false }); - tab.invalidate(); + expect((await capture).getSize()).toEqual({ width: 640, height: 480 }); + expect(contents.frameListener).toBeNull(); + controller.abort(); + expect(contents.endedFrameSubscriptions).toBe(1); + }); - expect(image.getSize()).toEqual({ width: 640, height: 480 }); - expect(contents.captures).toEqual([{ rect: undefined, options: { stayHidden: false } }]); - expect(contents.invalidations).toEqual(["invalidate"]); + test("cancels a pending frame subscription when the capture budget expires", async () => { + const contents = new FakeWebContents(2001); + const tab = adaptWebContents(contents); + const controller = new AbortController(); + const capture = tab.captureFrame(controller.signal); + const failure = expect(capture).rejects.toThrow("capture deadline"); + controller.abort(new Error("capture deadline")); + + await failure; + expect(contents.frameListener).toBeNull(); + expect(contents.endedFrameSubscriptions).toBe(1); + }); + + test("closing a guest settles its screenshot and releases the next queued capture", async () => { + vi.useFakeTimers(); + try { + const closing = new FakeWebContents(2002); + const next = new FakeWebContents(2003); + const tabs = new Map([ + ["closing", adaptWebContents(closing)], + ["next", adaptWebContents(next)], + ]); + const registry: BrowserRegistry = { + listRegisteredBrowserIds: () => [...tabs.keys()], + listRegisteredBrowserIdsForWorkspace: () => [...tabs.keys()], + getTabContents: (id) => tabs.get(id) ?? null, + getBrowserWorkspaceId: () => null, + getWorkspaceActiveBrowserId: () => null, + }; + const capture = (browserId: string) => + executeAutomationCommand( + { + type: "browser.automation.execute.request", + requestId: browserId, + command: { command: "screenshot", args: { browserId, fullPage: false } }, + }, + registry, + ); + let closedResult: unknown; + const closedCapture = Promise.resolve(capture("closing")).then((result) => { + closedResult = result; + return result; + }); + const nextCapture = capture("next"); + await vi.advanceTimersByTimeAsync(0); + expect(closing.frameListener).not.toBeNull(); + closing.destroy(); + await vi.advanceTimersByTimeAsync(0); + expect(closedResult).toEqual({ + requestId: "closing", + ok: false, + error: { + code: "browser_tab_closed", + message: "Browser tab closing has been closed", + retryable: false, + }, + }); + expect(closing.destroyedListeners.size).toBe(0); + expect(next.frameListener).not.toBeNull(); + next.frameListener?.(new FakeImage()); + await expect(nextCapture).resolves.toMatchObject({ ok: true }); + await closedCapture; + expect(next.destroyedListeners.size).toBe(1); + } finally { + await vi.runAllTimersAsync(); + vi.useRealTimers(); + } }); test("collects console messages until the guest is destroyed", () => { @@ -672,3 +760,38 @@ function deferred() { }); return { promise, resolve }; } + +describe("pixel capture frame production", () => { + test.each([true, false])( + "restores the prior throttling policy (%s) after capture or failure", + async (previous) => { + const contents = new FakeWebContents(8001); + contents.backgroundThrottling = previous; + const tab = adaptWebContents(contents); + expect( + await tab.withFrameProduction(async () => { + expect(contents.backgroundThrottling).toBe(false); + return "pixels"; + }), + ).toBe("pixels"); + expect(contents.backgroundThrottling).toBe(previous); + await expect( + tab.withFrameProduction(async () => { + throw new Error("capture failed"); + }), + ).rejects.toThrow("capture failed"); + expect(contents.backgroundThrottling).toBe(previous); + }, + ); + + test("does not touch a guest destroyed during capture", async () => { + const contents = new FakeWebContents(8002); + const tab = adaptWebContents(contents); + await expect( + tab.withFrameProduction(async () => { + contents.destroyed = true; + throw new Error("capture closed"); + }), + ).rejects.toThrow("capture closed"); + }); +}); diff --git a/packages/desktop/src/features/browser-automation/ipc.ts b/packages/desktop/src/features/browser-automation/ipc.ts index ea1941c318..18e5b84544 100644 --- a/packages/desktop/src/features/browser-automation/ipc.ts +++ b/packages/desktop/src/features/browser-automation/ipc.ts @@ -1,4 +1,3 @@ -import type { Rectangle } from "electron"; import { ipcMain } from "electron"; import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import type { @@ -16,7 +15,7 @@ import { promptShimInstallScript, promptShimRestoreScript, } from "./dialog-handling.js"; -import { executeAutomationCommand } from "./service.js"; +import { BrowserTabClosedError, executeAutomationCommand } from "./service.js"; import { BrowserSnapshotEngine } from "./snapshot-engine.js"; import { listRegisteredPaseoBrowserIds, @@ -92,6 +91,7 @@ interface ConsoleMessageEmitter { } interface BrowserAutomationWebContents extends ConsoleMessageEmitter { + removeListener(event: "destroyed", listener: () => void): void; readonly id: number; readonly debugger: WebContentsDebugger; getURL(): string; @@ -105,9 +105,13 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter { goBack(): void; goForward(): void; reload(): void; - capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise; + beginFrameSubscription(onlyDirty: boolean, callback: (image: TabImage) => void): void; + endFrameSubscription(): void; invalidate(): void; + getBackgroundThrottling(): boolean; + setBackgroundThrottling(allowed: boolean): void; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + insertText(text: string): Promise; } export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents { @@ -128,9 +132,19 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon goBack: () => contents.goBack(), goForward: () => contents.goForward(), reload: () => contents.reload(), - capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions), + captureFrame: (signal) => captureViewportFrame(contents, signal), invalidate: () => contents.invalidate(), + withFrameProduction: async (capture) => { + const previous = contents.getBackgroundThrottling(); + contents.setBackgroundThrottling(false); + try { + return await capture(); + } finally { + if (!contents.isDestroyed()) contents.setBackgroundThrottling(previous); + } + }, sendInputEvent: (event) => contents.sendInputEvent(event), + insertText: (text) => contents.insertText(text), getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [], captureDialogs: (task) => dialogMonitor.capture(task), sendDebugCommand: (command: string, params?: Record) => @@ -143,6 +157,41 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon }; } +function captureViewportFrame( + contents: BrowserAutomationWebContents, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const stop = () => { + signal.removeEventListener("abort", abort); + contents.removeListener("destroyed", destroyed); + if (!contents.isDestroyed()) contents.endFrameSubscription(); + }; + const abort = () => { + stop(); + reject(signal.reason); + }; + const destroyed = () => { + stop(); + reject(new BrowserTabClosedError()); + }; + signal.addEventListener("abort", abort, { once: true }); + contents.once("destroyed", destroyed); + try { + // A resized resident guest can paint while capturePage's surface-copy + // request remains pending. Subscribe to its rendered frames instead. + contents.beginFrameSubscription(false, (image) => { + stop(); + resolve(image); + }); + } catch (error) { + stop(); + reject(error); + } + }); +} + function getCdpQueue(contentsId: number): CdpSessionQueue { const existing = cdpQueuesByContentsId.get(contentsId); if (existing) { diff --git a/packages/desktop/src/features/browser-automation/service.test.ts b/packages/desktop/src/features/browser-automation/service.test.ts index c9246324b2..6bc054c6a5 100644 --- a/packages/desktop/src/features/browser-automation/service.test.ts +++ b/packages/desktop/src/features/browser-automation/service.test.ts @@ -33,11 +33,15 @@ class FakeImage implements TabImage { } class FakeTab implements TabContents { + public withFrameProduction(capture: () => Promise): Promise { + return capture(); + } public readonly loadedUrls: string[] = []; public readonly scripts: string[] = []; public readonly actions: string[] = []; - public readonly capturedViewports: Array<{ stayHidden?: boolean }> = []; + public readonly capturedViewports: AbortSignal[] = []; public readonly debugCommands: Array<{ command: string; params?: Record }> = []; + public readonly insertedText: string[] = []; public readonly inputEvents: IsolatedKeyboardInputEvent[] = []; private readonly captureStartWaiters: Array<() => void> = []; private readonly deferredCaptures: Array<(image: TabImage) => void> = []; @@ -64,10 +68,10 @@ class FakeTab implements TabContents { public networkEntries: unknown[] = []; public consoleMessages: BrowserAutomationConsoleLogEntry[] = []; public dialogsToCapture: BrowserAutomationDialogEvent[] = []; + public paintDelayMs = 0; public captureNeverPaints = false; public captureThrows = false; public captureErrorMessage = "capture failed"; - public viewportCaptureFailuresBeforeSuccess = 0; public deferCaptures = false; public fullPageScreenshotThrows = false; public fullPageScreenshotErrorMessage = "UnknownVizError"; @@ -115,6 +119,9 @@ class FakeTab implements TabContents { public async executeJavaScript(code: string): Promise { this.scripts.push(code); + if (code.includes("requestAnimationFrame") && this.paintDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, this.paintDelayMs)); + } if (code.includes("document.body.innerText")) { return this.bodyText; } @@ -161,14 +168,10 @@ class FakeTab implements TabContents { this.actions.push("reload"); } - public async capturePage(options?: { stayHidden?: boolean }): Promise { - this.capturedViewports.push(options ?? {}); + public async captureFrame(signal: AbortSignal): Promise { + this.capturedViewports.push(signal); this.actions.push("capture"); this.resolveCaptureStartWaiters(); - if (this.viewportCaptureFailuresBeforeSuccess > 0) { - this.viewportCaptureFailuresBeforeSuccess -= 1; - throw new Error(this.captureErrorMessage); - } if (this.captureThrows) { throw new Error(this.captureErrorMessage); } @@ -229,6 +232,10 @@ class FakeTab implements TabContents { this.inputEvents.push(event); } + public async insertText(text: string): Promise { + this.insertedText.push(text); + } + public waitForCaptureStart(count: number): Promise { if (this.capturedViewports.length >= count) { return Promise.resolve(); @@ -1102,17 +1109,31 @@ describe("executeAutomationCommand", () => { ok: true, result: { command: "type", browserId: BROWSER_A, ref: "@e1", x: 40, y: 30 }, }); - expect(browser.tab.debugCommands.at(-1)).toEqual({ - command: "Input.insertText", - params: { text: "Ada" }, - }); - expect(browser.tab.debugCommands.slice(0, 3).map((entry) => entry.command)).toEqual([ + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands.map((entry) => entry.command)).toEqual([ "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", "Input.dispatchMouseEvent", ]); }); + test("type inserts text through the tab, not the window-wide debugger input", async () => { + const browser = new BrowserAutomationHarness(); + + const action = await browser.execute({ + command: "type", + args: { browserId: BROWSER_A, text: "Ada" }, + }); + + expect(action).toEqual({ + requestId: "req-type", + ok: true, + result: { command: "type", browserId: BROWSER_A }, + }); + expect(browser.tab.insertedText).toEqual(["Ada"]); + expect(browser.tab.debugCommands).toEqual([]); + }); + test("keypress dispatches a trusted key to the focused element when ref is omitted", async () => { const browser = new BrowserAutomationHarness(); @@ -1557,7 +1578,8 @@ describe("executeAutomationCommand", () => { height: 480, }, }); - expect(browser.tab.capturedViewports).toEqual([{ stayHidden: false }]); + expect(browser.tab.capturedViewports).toHaveLength(1); + expect(browser.tab.capturedViewports[0].aborted).toBe(true); expect(browser.tab.actions).toEqual(["invalidate", "capture"]); }); @@ -1583,6 +1605,32 @@ describe("executeAutomationCommand", () => { }, }); expect(browser.tab.actions).toEqual(["invalidate", "capture"]); + expect(browser.tab.capturedViewports[0].aborted).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + test("paint and pixel capture share the five-second screenshot budget", async () => { + vi.useFakeTimers(); + try { + const browser = new BrowserAutomationHarness(); + browser.tab.paintDelayMs = 4_000; + browser.tab.captureNeverPaints = true; + let result: unknown; + void browser + .execute({ + command: "screenshot", + args: { browserId: BROWSER_A }, + }) + .then((value) => { + result = value; + return value; + }); + await vi.advanceTimersByTimeAsync(4_999); + expect(result).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + expect(result).toMatchObject({ ok: false, error: { code: "screenshot_no_frame" } }); } finally { vi.useRealTimers(); } @@ -1602,76 +1650,6 @@ describe("executeAutomationCommand", () => { expect(browser.tab.actions).toEqual(["invalidate", "capture"]); }); - test("screenshot retries UnknownVizError until the first viewport frame appears", async () => { - vi.useFakeTimers(); - try { - const browser = new BrowserAutomationHarness(); - browser.tab.captureErrorMessage = "UnknownVizError"; - browser.tab.viewportCaptureFailuresBeforeSuccess = 2; - - const resultPromise = browser.execute({ - command: "screenshot", - args: { browserId: BROWSER_A }, - }); - await vi.advanceTimersByTimeAsync(400); - - await expect(resultPromise).resolves.toEqual({ - requestId: "req-screenshot", - ok: true, - result: { - command: "screenshot", - browserId: BROWSER_A, - mimeType: "image/png", - dataBase64: "iVBORwECAw==", - width: 640, - height: 480, - }, - }); - expect(browser.tab.capturedViewports).toHaveLength(3); - expect(browser.tab.actions).toEqual([ - "invalidate", - "capture", - "invalidate", - "capture", - "invalidate", - "capture", - ]); - } finally { - vi.useRealTimers(); - } - }); - - test("screenshot returns no-frame after UnknownVizError spends the retry budget", async () => { - vi.useFakeTimers(); - try { - const browser = new BrowserAutomationHarness(); - browser.tab.captureThrows = true; - browser.tab.captureErrorMessage = "UnknownVizError"; - - const resultPromise = browser.execute({ - command: "screenshot", - args: { browserId: BROWSER_A }, - }); - await vi.advanceTimersByTimeAsync(5_000); - - await expect(resultPromise).resolves.toEqual({ - requestId: "req-screenshot", - ok: false, - error: { - code: "screenshot_no_frame", - message: "The tab has not painted yet. Retry the screenshot.", - retryable: true, - }, - }); - expect(browser.tab.capturedViewports.length).toBeGreaterThan(1); - expect(browser.tab.actions.filter((action) => action === "invalidate")).toHaveLength( - browser.tab.capturedViewports.length, - ); - } finally { - vi.useRealTimers(); - } - }); - test("overlapping screenshots serialize through the shared capture queue", async () => { const browser = new BrowserAutomationHarness(); browser.tab.deferCaptures = true; diff --git a/packages/desktop/src/features/browser-automation/service.ts b/packages/desktop/src/features/browser-automation/service.ts index 7ab9099db2..fe07adecd3 100644 --- a/packages/desktop/src/features/browser-automation/service.ts +++ b/packages/desktop/src/features/browser-automation/service.ts @@ -17,7 +17,6 @@ import { dispatchTrustedHover, dispatchTrustedKey, dispatchTrustedScroll, - dispatchTrustedText, type ClickInputOptions, type IsolatedKeyboardInputEvent, } from "./trusted-input.js"; @@ -35,9 +34,13 @@ export interface TabContents { goBack(): void; goForward(): void; reload(): void; - capturePage(options?: TabCapturePageOptions): Promise; + captureFrame(signal: AbortSignal): Promise; invalidate(): void; + withFrameProduction(capture: () => Promise): Promise; sendInputEvent(event: IsolatedKeyboardInputEvent): void; + // Commits text into this tab's focused element. CDP Input.insertText would + // commit into whichever element has focus in the Paseo window instead. + insertText(text: string): Promise; getConsoleMessages?(): BrowserAutomationConsoleLogEntry[]; captureDialogs?( task: () => Promise, @@ -50,10 +53,6 @@ export interface TabImage { getSize(): { width: number; height: number }; } -export interface TabCapturePageOptions { - stayHidden?: boolean; -} - export interface BrowserRegistry { listRegisteredBrowserIds(): string[]; listRegisteredBrowserIdsForWorkspace(workspaceId: string): string[]; @@ -97,6 +96,13 @@ async function withDialogCapture( return dialogs.length > 0 ? { ...result, dialogs } : result; } +export class BrowserTabClosedError extends Error { + public constructor() { + super("Browser tab has been closed"); + this.name = "BrowserTabClosedError"; + } +} + class ScreenshotNoFrameError extends Error { public constructor(message = SCREENSHOT_NO_FRAME_MESSAGE) { super(message); @@ -161,8 +167,8 @@ async function runSerializedPixelCapture(capture: () => Promise): Promise< async function capturePixelFrameWithRetry( contents: TabContents, capture: () => Promise, + deadline: number, ): Promise { - const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; while (Date.now() < deadline) { try { contents.invalidate(); @@ -189,15 +195,47 @@ function isKnownNoFrameCaptureError(error: unknown): boolean { ); } +async function waitForPaint(contents: TabContents, deadline: number): Promise { + // A hidden page may have unpainted DOM updates. The first animation callback + // precedes paint; the next frame ensures capture cannot reuse the old surface. + await withPixelCaptureTimeout( + contents.executeJavaScript( + "new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))", + ), + deadline - Date.now(), + ); +} + async function runPaintedPixelCapture( contents: TabContents, capture: () => Promise, ): Promise { - return runSerializedPixelCapture(() => capturePixelFrameWithRetry(contents, capture)); + return runSerializedPixelCapture(() => + contents.withFrameProduction(async () => { + const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; + await waitForPaint(contents, deadline); + return capturePixelFrameWithRetry(contents, capture, deadline); + }), + ); } async function capturePaintedViewport(contents: TabContents): Promise { - return runPaintedPixelCapture(contents, () => contents.capturePage({ stayHidden: false })); + return runSerializedPixelCapture(() => + contents.withFrameProduction(async () => { + const deadline = Date.now() + PIXEL_CAPTURE_TIMEOUT_MS; + const controller = new AbortController(); + try { + await waitForPaint(contents, deadline); + contents.invalidate(); + return await withPixelCaptureTimeout( + contents.captureFrame(controller.signal), + deadline - Date.now(), + ); + } finally { + controller.abort(); + } + }), + ); } function tabInfoFromContents( @@ -1040,7 +1078,7 @@ async function executeType( } await dispatchTrustedClick(cdpSender(target.contents), actionable.target.point); } - await dispatchTrustedText(cdpSender(target.contents), text); + await target.contents.insertText(text); return { requestId, ok: true, @@ -1199,6 +1237,9 @@ async function executeScreenshot( try { image = await capturePaintedViewport(target.contents); } catch (error) { + if (error instanceof BrowserTabClosedError) { + return fail(requestId, "browser_tab_closed", `Browser tab ${browserId} has been closed`); + } if (isScreenshotNoFrameError(error)) { return screenshotNoFrameFailure(requestId, error); } diff --git a/packages/desktop/src/features/browser-automation/trusted-input.ts b/packages/desktop/src/features/browser-automation/trusted-input.ts index 671c5dbda1..8473263a1c 100644 --- a/packages/desktop/src/features/browser-automation/trusted-input.ts +++ b/packages/desktop/src/features/browser-automation/trusted-input.ts @@ -154,13 +154,6 @@ export async function dispatchTrustedScroll( }); } -export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise { - if (text.length === 0) { - return; - } - await send("Input.insertText", { text }); -} - export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void { const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key; let character: string | null = null; diff --git a/packages/desktop/src/features/browser-capture.test.ts b/packages/desktop/src/features/browser-capture.test.ts index d14c7734ee..ea377f51c5 100644 --- a/packages/desktop/src/features/browser-capture.test.ts +++ b/packages/desktop/src/features/browser-capture.test.ts @@ -10,7 +10,7 @@ function image(dataUrl = "data:image/png;base64,capture"): BrowserCaptureImage { } function harness(guest: BrowserCaptureGuest | null = null) { - const clipboard = { write: vi.fn(), writeImage: vi.fn(), writeText: vi.fn() }; + const clipboard = { write: vi.fn(async () => undefined) }; const decodeImage = vi.fn(() => image("data:image/png;base64,clipboard")); const warn = vi.fn(); return { @@ -29,7 +29,7 @@ function harness(guest: BrowserCaptureGuest | null = null) { describe("browser capture service", () => { it("validates and rounds guest-relative bounds before capture", async () => { const capturePage = vi.fn(async () => image()); - const { service } = harness({ isDestroyed: () => false, capturePage }); + const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage }); await expect( service.capture({ @@ -41,9 +41,25 @@ describe("browser capture service", () => { expect(capturePage).toHaveBeenCalledWith({ x: 0, y: 9, width: 20, height: 11 }); }); + it("captures the selected element when the page is zoomed", async () => { + const capturePage = vi.fn(async () => image()); + const { service } = harness({ + isDestroyed: () => false, + getZoomFactor: () => 1.2, + capturePage, + }); + + await service.capture({ + browserId: "browser-1", + hostWebContentsId: 42, + rect: { x: 300, y: 200, width: 120, height: 60 }, + }); + expect(capturePage).toHaveBeenCalledWith({ x: 360, y: 240, width: 144, height: 72 }); + }); + it("rejects invalid or unavailable captures without touching the guest", async () => { const capturePage = vi.fn(async () => image()); - const { service } = harness({ isDestroyed: () => false, capturePage }); + const { service } = harness({ isDestroyed: () => false, getZoomFactor: () => 1, capturePage }); await expect( service.capture({ browserId: "browser-1", hostWebContentsId: 42, rect: { width: 0 } }), @@ -51,16 +67,21 @@ describe("browser capture service", () => { expect(capturePage).not.toHaveBeenCalled(); }); - it("writes text and a decoded image to the clipboard atomically", () => { + it("writes text and a decoded image to the clipboard atomically", async () => { const { service, clipboard } = harness(); - expect(service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" })).toBe( - true, - ); + await expect( + service.copy({ text: "button", imageDataUrl: "data:image/png;base64,value" }), + ).resolves.toBe(true); expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: expect.any(Object), }); - expect(clipboard.writeText).not.toHaveBeenCalled(); - expect(clipboard.writeImage).not.toHaveBeenCalled(); + }); + + it("keeps text-only clipboard writes", async () => { + const { service, clipboard } = harness(); + + await expect(service.copy({ text: "button" })).resolves.toBe(true); + expect(clipboard.write).toHaveBeenCalledWith({ text: "button", image: null }); }); }); diff --git a/packages/desktop/src/features/browser-capture.ts b/packages/desktop/src/features/browser-capture.ts index 792567d424..199fde27e7 100644 --- a/packages/desktop/src/features/browser-capture.ts +++ b/packages/desktop/src/features/browser-capture.ts @@ -5,6 +5,7 @@ export interface BrowserCaptureImage { export interface BrowserCaptureGuest { isDestroyed(): boolean; + getZoomFactor(): number; capturePage(rect: BrowserCaptureRect): Promise; } @@ -15,10 +16,13 @@ export interface BrowserCaptureRect { height: number; } +interface BrowserCaptureClipboardPayload { + text: string | null; + image: TImage | null; +} + interface BrowserCaptureClipboard { - write(input: { text: string; image: TImage }): void; - writeImage(image: TImage): void; - writeText(text: string): void; + write(input: BrowserCaptureClipboardPayload): Promise; } interface BrowserCaptureDependencies { @@ -34,10 +38,12 @@ export interface BrowserCaptureService { hostWebContentsId: number; rect: unknown; }): Promise; - copy(payload: unknown): boolean; + copy(payload: unknown): Promise; } -function captureRect(value: unknown): BrowserCaptureRect | null { +// The renderer measures the element in page CSS pixels, while capturePage +// takes device-independent pixels, so the page zoom scales the region. +function captureRect(value: unknown, zoomFactor: number): BrowserCaptureRect | null { if (!value || typeof value !== "object") return null; const record = value as Record; const coordinates = [record.x, record.y, record.width, record.height]; @@ -48,7 +54,9 @@ function captureRect(value: unknown): BrowserCaptureRect | null { ) { return null; } - const [x, y, width, height] = coordinates as number[]; + const [x, y, width, height] = (coordinates as number[]).map( + (coordinate) => coordinate * zoomFactor, + ); if (width <= 0 || height <= 0) return null; return { x: Math.max(0, Math.round(x)), @@ -77,8 +85,9 @@ export function createBrowserCaptureService( async capture({ browserId, hostWebContentsId, rect }) { if (typeof browserId !== "string" || browserId.trim().length === 0) return null; const guest = dependencies.findGuest(browserId, hostWebContentsId); - const bounds = captureRect(rect); - if (!guest || guest.isDestroyed() || !bounds) return null; + if (!guest || guest.isDestroyed()) return null; + const bounds = captureRect(rect, guest.getZoomFactor()); + if (!bounds) return null; try { const image = await guest.capturePage(bounds); return image.isEmpty() ? null : image.toDataURL(); @@ -88,7 +97,7 @@ export function createBrowserCaptureService( } }, - copy(payload) { + async copy(payload) { const { text, imageDataUrl } = copyPayload(payload); let image: TImage | null = null; if (imageDataUrl) { @@ -99,10 +108,8 @@ export function createBrowserCaptureService( dependencies.warn("image-decode-failed", { error }); } } - if (text && image) dependencies.clipboard.write({ text, image }); - else if (image) dependencies.clipboard.writeImage(image); - else if (text) dependencies.clipboard.writeText(text); - else return false; + if (!text && !image) return false; + await dependencies.clipboard.write({ text, image }); return true; }, }; diff --git a/packages/desktop/src/features/browser-profile.test.ts b/packages/desktop/src/features/browser-profile.test.ts index 029a5ae940..09d7c40b43 100644 --- a/packages/desktop/src/features/browser-profile.test.ts +++ b/packages/desktop/src/features/browser-profile.test.ts @@ -159,7 +159,7 @@ describe("clearPaseoBrowserProfile", () => { "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ], }, ]); diff --git a/packages/desktop/src/features/browser-profile.ts b/packages/desktop/src/features/browser-profile.ts index 307567e512..f8f1d19328 100644 --- a/packages/desktop/src/features/browser-profile.ts +++ b/packages/desktop/src/features/browser-profile.ts @@ -10,7 +10,7 @@ const PASEO_BROWSER_STORAGE_TYPES = [ "localstorage", "serviceworkers", "cachestorage", - "websql", + "shadercache", ] as const; interface BrowserProfileSession { diff --git a/packages/desktop/src/features/browser-webviews/index.test.ts b/packages/desktop/src/features/browser-webviews/index.test.ts index bbeffbe8c3..cac803134a 100644 --- a/packages/desktop/src/features/browser-webviews/index.test.ts +++ b/packages/desktop/src/features/browser-webviews/index.test.ts @@ -19,7 +19,6 @@ class FakeRenderer { } class FakeBrowserGuest { - public readonly backgroundThrottlingCalls: boolean[] = []; private destroyedListener: (() => void) | null = null; private destroyed = false; @@ -33,10 +32,6 @@ class FakeBrowserGuest { return this.destroyed; } - public setBackgroundThrottling(allowed: boolean): void { - this.backgroundThrottlingCalls.push(allowed); - } - public once(event: "destroyed", listener: () => void): void { expect(event).toBe("destroyed"); this.destroyedListener = listener; @@ -187,7 +182,7 @@ describe("browser webview attachment", () => { unregisterPaseoBrowser("browser-shared-hosts"); }); - test("prepares throttling once and removes registration when the guest is destroyed", () => { + test("removes registration when the guest is destroyed", () => { const profileSession = {}; const renderer = new FakeRenderer(31); const guest = new FakeBrowserGuest(601, renderer, profileSession); @@ -201,12 +196,10 @@ describe("browser webview attachment", () => { findWebContents: () => guest, }); - expect(guest.backgroundThrottlingCalls).toEqual([false]); expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup"); guest.destroy(); expect(getPaseoBrowserIdForWebContents(guest)).toBeNull(); - expect(guest.backgroundThrottlingCalls).toEqual([false]); }); }); diff --git a/packages/desktop/src/features/browser-webviews/index.ts b/packages/desktop/src/features/browser-webviews/index.ts index b415f43397..319d4922cb 100644 --- a/packages/desktop/src/features/browser-webviews/index.ts +++ b/packages/desktop/src/features/browser-webviews/index.ts @@ -24,7 +24,6 @@ interface BrowserWebContentsIdentity { interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity { readonly hostWebContents: BrowserWebContentsIdentity | null; readonly session: object; - setBackgroundThrottling(allowed: boolean): void; once(event: "destroyed", listener: () => void): void; } @@ -56,7 +55,8 @@ export function getPaseoBrowserWebviewRegistry(): PaseoBrowserWebviewRegistry { export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void { const webContentsId = contents.id; - contents.setBackgroundThrottling(false); + // Preserve Chromium throttling when the host window is hidden. Browser + // residency and screenshot capture do not require a lifetime override. contents.once("destroyed", () => { browserRegistry.unregisterWebContents(webContentsId); }); diff --git a/packages/desktop/src/integrations/cli-install/path.test.ts b/packages/desktop/src/integrations/cli-install/path.test.ts index 472f066e00..77d340b409 100644 --- a/packages/desktop/src/integrations/cli-install/path.test.ts +++ b/packages/desktop/src/integrations/cli-install/path.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { resolveCliInstallSourcePath } from "./path"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { resolveCliInstallSourcePath, resolveCliShimPath } from "./path"; describe("cli-install-path", () => { it("uses the bundled shim for packaged macOS installs", () => { @@ -56,3 +58,38 @@ describe("cli-install-path", () => { ).toBe("/opt/Paseo/resources/bin/paseo"); }); }); + +describe("CLI executable selection", () => { + const resolveWorkspaceCli = () => + createRequire(import.meta.url).resolve("@getpaseo/cli/bin/paseo"); + + it("uses the workspace CLI for an unpackaged Electron launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: false, + executablePath: "/nix/store/electron/bin/electron", + resolveWorkspaceCli, + }), + ).toBe(resolveWorkspaceCli()); + }); + + it("uses the application shim for a packaged launcher", () => { + expect( + resolveCliShimPath({ + platform: "linux", + isPackaged: true, + executablePath: "/opt/Paseo/paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/opt/Paseo", "resources", "bin", "paseo")); + expect( + resolveCliShimPath({ + platform: "darwin", + isPackaged: true, + executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo", + resolveWorkspaceCli, + }), + ).toBe(path.join("/Applications/Paseo.app", "Contents", "Resources", "bin", "paseo")); + }); +}); diff --git a/packages/desktop/src/integrations/cli-install/path.ts b/packages/desktop/src/integrations/cli-install/path.ts index c9114c2301..0cad7fc757 100644 --- a/packages/desktop/src/integrations/cli-install/path.ts +++ b/packages/desktop/src/integrations/cli-install/path.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + export function resolveCliInstallSourcePath(input: { platform: NodeJS.Platform; isPackaged: boolean; @@ -27,3 +29,19 @@ export function resolveCliInstallSourcePath(input: { return input.executablePath; } + +export function resolveCliShimPath(input: { + platform: NodeJS.Platform; + isPackaged: boolean; + executablePath: string; + resolveWorkspaceCli: () => string; +}): string { + if (!input.isPackaged) return input.resolveWorkspaceCli(); + + const filename = input.platform === "win32" ? "paseo.cmd" : "paseo"; + if (input.platform === "darwin") { + const bundle = input.executablePath.replace(/\/Contents\/MacOS\/.+$/, ""); + return path.join(bundle, "Contents", "Resources", "bin", filename); + } + return path.join(path.dirname(input.executablePath), "resources", "bin", filename); +} diff --git a/packages/desktop/src/integrations/cli-install/paths.ts b/packages/desktop/src/integrations/cli-install/paths.ts index 312a587892..1f7bf98f48 100644 --- a/packages/desktop/src/integrations/cli-install/paths.ts +++ b/packages/desktop/src/integrations/cli-install/paths.ts @@ -1,4 +1,6 @@ import path from "node:path"; +import { createRequire } from "node:module"; +import { resolveCliShimPath } from "./path.js"; import os from "node:os"; import { app } from "electron"; @@ -12,20 +14,10 @@ export function getCliTargetPath(): string { } export function getBundledCliShimPath(): string { - const cliShimFilename = process.platform === "win32" ? "paseo.cmd" : "paseo"; - - if (process.platform === "darwin") { - const electronExePath = app.getPath("exe"); - const appBundle = electronExePath.replace(/\/Contents\/MacOS\/.+$/, ""); - return path.join(appBundle, "Contents", "Resources", "bin", cliShimFilename); - } - - if (process.platform === "win32") { - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); - } - - // Linux - const electronExePath = app.getPath("exe"); - return path.join(path.dirname(electronExePath), "resources", "bin", cliShimFilename); + return resolveCliShimPath({ + platform: process.platform, + isPackaged: app.isPackaged, + executablePath: app.getPath("exe"), + resolveWorkspaceCli: () => createRequire(__filename).resolve("@getpaseo/cli/bin/paseo"), + }); } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index b0610565ca..9acf329216 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -14,6 +14,7 @@ import { app, autoUpdater as electronAutoUpdater, BrowserWindow, + ClipboardItem, clipboard, Menu, ipcMain, @@ -42,6 +43,7 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; +import { showWindowWhenReady } from "./window/show-when-ready.js"; import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { @@ -130,6 +132,12 @@ const bootstrapComplete = new Promise((resolve) => { let bootstrapIsComplete = false; app.setName(APP_NAME); +log.info("[desktop] app startup", { + version: app.getVersion(), + platform: process.platform, + arch: process.arch, + isPackaged: app.isPackaged, +}); interface AttachedBrowserInput { browserId: string; @@ -323,13 +331,6 @@ if (forcedUserDataDir) { } } -// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID -// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the -// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. -if (process.platform === "linux" && process.env.APPIMAGE) { - app.commandLine.appendSwitch("no-sandbox"); -} - // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). @@ -342,6 +343,16 @@ if (electronFlags) { log.info("[electron-flags]", electronFlags); } +if (process.platform === "linux") { + // Keep the desktop/dock identity independent of the wrapped Electron filename. + app.setDesktopName("Paseo.desktop"); + if (!app.commandLine.hasSwitch("class")) app.commandLine.appendSwitch("class", "Paseo"); + log.info("[linux-sandbox]", { + enabled: !app.commandLine.hasSwitch("no-sandbox"), + reason: process.env.PASEO_DESKTOP_SANDBOX_REASON ?? "Chromium default", + }); +} + let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ argv: process.argv, isDefaultApp: process.defaultApp, @@ -519,7 +530,19 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds const browserCapture = createBrowserCaptureService({ findGuest: getPaseoBrowserWebContentsForHostWindow, decodeImage: (dataUrl) => nativeImage.createFromDataURL(dataUrl), - clipboard, + clipboard: { + write: async ({ text, image }) => { + const items: Record = {}; + if (text) items["text/plain"] = text; + if (image) { + const png = image.toPNG(); + const bytes = new Uint8Array(png.byteLength); + bytes.set(png); + items["image/png"] = new Blob([bytes], { type: "image/png" }); + } + await clipboard.write([new ClipboardItem(items)]); + }, + }, warn: (event, details) => log.warn(`[browser-capture] ${event}`, details), }); @@ -615,7 +638,8 @@ async function getEffectiveAppIconPath(): Promise { } async function applyAppIcon(): Promise { - if (process.platform !== "darwin") { + // Packaged apps keep the bundle icon and its macOS system appearance. + if (app.isPackaged || process.platform !== "darwin") { return; } @@ -750,8 +774,8 @@ async function createWindow( registerBrowserWebviewNavigationGuards(contents); }); - mainWindow.once("ready-to-show", () => { - mainWindow.show(); + showWindowWhenReady(mainWindow, { + showOnLoad: process.platform === "linux", }); if (!app.isPackaged) { @@ -1031,7 +1055,10 @@ const quitLifecycle = createQuitLifecycle({ }); // electron-updater forwards this event through Electron's built-in autoUpdater. -electronAutoUpdater.on("before-quit-for-update", quitLifecycle.handleBeforeQuitForUpdate); +electronAutoUpdater.on("before-quit-for-update", () => { + log.info("[auto-updater] before-quit-for-update", { currentVersion: app.getVersion() }); + quitLifecycle.handleBeforeQuitForUpdate(); +}); app.on("before-quit", quitLifecycle.handleBeforeQuit); registerExternalQuitSignals({ signals: process, quit: () => app.quit() }); diff --git a/packages/desktop/src/window/show-when-ready.test.ts b/packages/desktop/src/window/show-when-ready.test.ts new file mode 100644 index 0000000000..756ad24cc2 --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; +import { showWindowWhenReady } from "./show-when-ready.js"; + +class Window extends EventEmitter { + webContents = new EventEmitter(); + shows = 0; + destroyed = false; + show() { + this.shows += 1; + } + isDestroyed() { + return this.destroyed; + } + close() { + this.destroyed = true; + this.emit("closed"); + } +} + +describe("showWindowWhenReady", () => { + it("shows a loaded Linux window even when no initial frame was painted", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + expect(win.shows).toBe(0); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("shows on the first frame without showing again after load", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.emit("ready-to-show"); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(1); + }); + + it("preserves waiting for the initial frame when show on load is disabled", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: false }); + win.webContents.emit("did-finish-load"); + expect(win.shows).toBe(0); + win.emit("ready-to-show"); + expect(win.shows).toBe(1); + }); + + it("keeps a closed window closed when readiness arrives later", () => { + const win = new Window(); + showWindowWhenReady(win, { showOnLoad: true }); + win.close(); + win.webContents.emit("did-finish-load"); + win.emit("ready-to-show"); + expect(win.shows).toBe(0); + }); +}); diff --git a/packages/desktop/src/window/show-when-ready.ts b/packages/desktop/src/window/show-when-ready.ts new file mode 100644 index 0000000000..e49c8518af --- /dev/null +++ b/packages/desktop/src/window/show-when-ready.ts @@ -0,0 +1,25 @@ +interface ReadyToShowWindow { + once(event: "ready-to-show", listener: () => void): unknown; + webContents: { + once(event: "did-finish-load", listener: () => void): unknown; + }; + show(): void; + isDestroyed(): boolean; +} + +export function showWindowWhenReady( + win: ReadyToShowWindow, + options: { showOnLoad: boolean }, +): void { + let shown = false; + const show = () => { + if (shown || win.isDestroyed()) return; + shown = true; + win.show(); + }; + + win.once("ready-to-show", show); + // On Linux a hidden window can wait for compositor frames until it is shown. + // Loading the document is sufficient to reveal it without depending on a frame. + if (options.showOnLoad) win.webContents.once("did-finish-load", show); +} diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index f67d3b68a0..1e6bd41230 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.7.2", + "version": "0.11.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index fc017fd3f6..02e9e28daa 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.7.2", + "version": "0.11.2", "files": [ "dist", "!dist/**/*.map", diff --git a/packages/highlight/src/__tests__/highlighter.test.ts b/packages/highlight/src/__tests__/highlighter.test.ts index 2349854d85..c245d42da1 100644 --- a/packages/highlight/src/__tests__/highlighter.test.ts +++ b/packages/highlight/src/__tests__/highlighter.test.ts @@ -144,6 +144,97 @@ const title = "Hello"; ); }); + it("keeps an Astro expression open through nested template strings", () => { + const code = "

{`a ${`}`} b`}

"; + + const tokens = highlightCode(code, "Nested.astro").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + + it("highlights Vue SFCs across script, template, and style", () => { + const code = [ + '", + "", + "", + "", + "", + ].join("\n"); + + const tokens = highlightCode(code, "Counter.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "let", style: "keyword" }, + { text: "number", style: "type" }, + { text: "template", style: "tag" }, + { text: "v-if", style: "attribute" }, + { text: "count", style: "definition" }, + { text: "1", style: "number" }, + { text: "'a}}b'", style: "string" }, + { text: "inner", style: "definition" }, + { text: "2", style: "number" }, + { text: "badge", style: "class" }, + { text: "color", style: "property" }, + ]), + ); + }); + + it("highlights a Vue SFC that contains an empty interpolation", () => { + const code = [""].join("\n"); + + const tokens = highlightCode(code, "Empty.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "template", style: "tag" }, + { text: "count", style: "variable" }, + ]), + ); + }); + + it("highlights quoted Vue directive values as code", () => { + const code = ""; + + const tokens = highlightCode(code, "Directive.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "count", style: "variable" }, + { text: "0", style: "number" }, + { text: "badge", style: "variable" }, + ]), + ); + expect(tokens).not.toContainEqual({ text: '"count > 0"', style: "string" }); + }); + + it("keeps a Vue interpolation open through nested template strings", () => { + const code = ""; + + const tokens = highlightCode(code, "Nested.vue").flat(); + + expect(tokens).toEqual( + expect.arrayContaining([ + { text: "`}}`", style: "string" }, + { text: " b`", style: "string" }, + ]), + ); + }); + it("highlights TSX code with correct dialect", () => { const code = 'const el =
hello
;'; const result = highlightCode(code, "test.tsx"); diff --git a/packages/highlight/src/__tests__/parsers.test.ts b/packages/highlight/src/__tests__/parsers.test.ts index 1d0d0ebcdd..b24872eb94 100644 --- a/packages/highlight/src/__tests__/parsers.test.ts +++ b/packages/highlight/src/__tests__/parsers.test.ts @@ -25,6 +25,7 @@ describe("isLanguageSupported", () => { expect(isLanguageSupported("test.ex")).toBe(true); expect(isLanguageSupported("Counter.svelte")).toBe(true); expect(isLanguageSupported("Page.astro")).toBe(true); + expect(isLanguageSupported("App.vue")).toBe(true); }); it("returns false for unsupported file extensions", () => { @@ -67,6 +68,7 @@ describe("getSupportedExtensions", () => { expect(extensions).toContain("json"); expect(extensions).toContain("svelte"); expect(extensions).toContain("astro"); + expect(extensions).toContain("vue"); }); }); diff --git a/packages/highlight/src/astro/parser.ts b/packages/highlight/src/astro/parser.ts index 444235488f..202c921252 100644 --- a/packages/highlight/src/astro/parser.ts +++ b/packages/highlight/src/astro/parser.ts @@ -14,6 +14,7 @@ import { import { parser as cssParser } from "@lezer/css"; import { parser as htmlParser } from "@lezer/html"; import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; interface Range { from: number; @@ -23,88 +24,11 @@ interface Range { const jsxParser = jsParser.configure({ dialect: "ts jsx" }); const typescriptParser = jsParser.configure({ dialect: "ts" }); -function isSpace(code: number): boolean { - return code === 32 || code === 9 || code === 10 || code === 13; -} - -function isRegexStart(text: string, position: number): boolean { - let previous = position - 1; - while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; - if (previous < 0) return true; - - const character = text[previous]; - if (/[)\]}<"'`\d]/.test(character)) return false; - const code = text.charCodeAt(previous); - if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; - if (!/[A-Za-z_$]/.test(character)) return true; - - let start = previous; - while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; - const keyword = text.slice(start + 1, previous + 1); - return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( - keyword, - ); -} - -function skipQuotedText(text: string, opening: number): number { - const quote = text.charCodeAt(opening); - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 92) position++; - else if (code === quote) return position; - } - return text.length - 1; -} - -function skipLineComment(text: string, opening: number): number { - const newline = text.indexOf("\n", opening + 2); - return newline >= 0 ? newline : text.length - 1; -} - -function skipBlockComment(text: string, opening: number): number { - const closing = text.indexOf("*/", opening + 2); - return closing >= 0 ? closing + 1 : text.length - 1; -} - -function skipRegex(text: string, opening: number): number { - let isInCharacterClass = false; - for (let position = opening + 1; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 10 || code === 13) return position; - if (code === 92) position++; - else if (isInCharacterClass && code === 93) isInCharacterClass = false; - else if (!isInCharacterClass && code === 91) isInCharacterClass = true; - else if (!isInCharacterClass && code === 47) return position; - } - return text.length - 1; -} - -function findClosingBrace(text: string, opening: number): number { - let depth = 0; - for (let position = opening; position < text.length; position++) { - const code = text.charCodeAt(position); - if (code === 47 && text.charCodeAt(position + 1) === 47) { - position = skipLineComment(text, position); - } else if (code === 47 && text.charCodeAt(position + 1) === 42) { - position = skipBlockComment(text, position); - } else if (code === 47 && isRegexStart(text, position)) { - position = skipRegex(text, position); - } else if (code === 34 || code === 39 || code === 96) { - position = skipQuotedText(text, position); - } else if (code === 123) { - depth++; - } else if (code === 125 && --depth === 0) { - return position; - } - } - return -1; -} - function findExpressions(text: string): Range[] { const ranges: Range[] = []; for (let position = 0; position < text.length; position++) { if (text.charCodeAt(position) !== 123) continue; - const closing = findClosingBrace(text, position); + const closing = findClosingBrace(text, position + 1); if (closing < 0) break; ranges.push({ from: position, to: closing }); position = closing; diff --git a/packages/highlight/src/js-scanner.ts b/packages/highlight/src/js-scanner.ts new file mode 100644 index 0000000000..fb7f70ed77 --- /dev/null +++ b/packages/highlight/src/js-scanner.ts @@ -0,0 +1,100 @@ +// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}` +// interpolations) for the brace that closes it, without parsing the code. + +function isSpace(code: number): boolean { + return code === 32 || code === 9 || code === 10 || code === 13; +} + +function isRegexStart(text: string, position: number): boolean { + let previous = position - 1; + while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--; + if (previous < 0) return true; + + const character = text[previous]; + if (/[)\]}<"'`\d]/.test(character)) return false; + const code = text.charCodeAt(previous); + if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61; + if (!/[A-Za-z_$]/.test(character)) return true; + + let start = previous; + while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--; + const keyword = text.slice(start + 1, previous + 1); + return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test( + keyword, + ); +} + +// Template strings nested deeper than this are treated as running to the end. +const MAX_TEMPLATE_NESTING = 32; + +function skipQuotedText(text: string, opening: number, nesting = 0): number { + const quote = text.charCodeAt(opening); + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 92) position++; + else if (code === quote) return position; + else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) { + position = skipTemplateSubstitution(text, position + 2, nesting); + } + } + return text.length - 1; +} + +function skipTemplateSubstitution(text: string, start: number, nesting: number): number { + if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1; + const closing = findClosingBrace(text, start, () => true, nesting + 1); + return closing >= 0 ? closing : text.length - 1; +} + +function skipLineComment(text: string, opening: number): number { + const newline = text.indexOf("\n", opening + 2); + return newline >= 0 ? newline : text.length - 1; +} + +function skipBlockComment(text: string, opening: number): number { + const closing = text.indexOf("*/", opening + 2); + return closing >= 0 ? closing + 1 : text.length - 1; +} + +function skipRegex(text: string, opening: number): number { + let isInCharacterClass = false; + for (let position = opening + 1; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 10 || code === 13) return position; + if (code === 92) position++; + else if (isInCharacterClass && code === 93) isInCharacterClass = false; + else if (!isInCharacterClass && code === 91) isInCharacterClass = true; + else if (!isInCharacterClass && code === 47) return position; + } + return text.length - 1; +} + +// Finds the first `}` outside nested braces, strings, comments, and regexes +// for which `isEnd` holds. +export function findClosingBrace( + text: string, + start: number, + isEnd: (position: number) => boolean = () => true, + nesting = 0, +): number { + let depth = 0; + for (let position = start; position < text.length; position++) { + const code = text.charCodeAt(position); + if (code === 47 && text.charCodeAt(position + 1) === 47) { + position = skipLineComment(text, position); + } else if (code === 47 && text.charCodeAt(position + 1) === 42) { + position = skipBlockComment(text, position); + } else if (code === 47 && isRegexStart(text, position)) { + position = skipRegex(text, position); + } else if (code === 34 || code === 39 || code === 96) { + position = skipQuotedText(text, position, nesting); + } else if (code === 123) { + depth++; + } else if (code === 125 && depth === 0) { + if (isEnd(position)) return position; + } else if (code === 125) { + depth--; + } + } + return -1; +} diff --git a/packages/highlight/src/parsers.ts b/packages/highlight/src/parsers.ts index 9ccb3129be..06c3129733 100644 --- a/packages/highlight/src/parsers.ts +++ b/packages/highlight/src/parsers.ts @@ -18,6 +18,7 @@ import { parser as elixirParser } from "lezer-elixir"; import type { Parser } from "@lezer/common"; import { csharpLanguage } from "./csharp/language.js"; import { astroParser } from "./astro/parser.js"; +import { vueParser } from "./vue/parser.js"; import { nixLanguage } from "./nix/language.js"; import { parser as svelteBaseParser } from "./svelte/parser.js"; import { configureNesting, defaultNesting } from "./svelte/nesting.js"; @@ -56,6 +57,8 @@ const languagesByExtension: Record = { svelte: language(svelteBaseParser.configure({ wrap: configureNesting(defaultNesting) })), // Astro astro: language(astroParser), + // Vue + vue: language(vueParser), // XML xml: language(xmlParser), // Java diff --git a/packages/highlight/src/vue/parser.ts b/packages/highlight/src/vue/parser.ts new file mode 100644 index 0000000000..5c54825b61 --- /dev/null +++ b/packages/highlight/src/vue/parser.ts @@ -0,0 +1,136 @@ +import { + type Input, + type NestedParse, + type SyntaxNode, + type SyntaxNodeRef, + parseMixed, +} from "@lezer/common"; +import { parser as cssParser } from "@lezer/css"; +import { parser as htmlParser } from "@lezer/html"; +import { parser as jsParser } from "@lezer/javascript"; +import { findClosingBrace } from "../js-scanner.js"; + +interface Range { + from: number; + to: number; +} + +const typescriptParser = jsParser.configure({ dialect: "ts" }); +const expressionParser = jsParser.configure({ top: "SingleExpression" }); + +function findInterpolationEnd(text: string, start: number): number { + return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125); +} + +function findExpressions(text: string): Range[] { + const ranges: Range[] = []; + for (let position = 0; position < text.length; position++) { + if (text.charCodeAt(position) !== 123) continue; + if (text.charCodeAt(position + 1) !== 123) continue; + const closing = findInterpolationEnd(text, position + 2); + if (closing < 0) break; + // Lezer rejects empty inner ranges, so `{{}}` gets no nested parse. + if (closing > position + 2) ranges.push({ from: position + 2, to: closing }); + position = closing + 1; + } + return ranges; +} + +function findDirectiveExpression(value: string): Range | null { + const quote = value[0]; + const isQuoted = (quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote); + const inner = isQuoted ? value.slice(1, -1) : value; + const trimmed = inner.trim(); + if (!trimmed) return null; + const from = (isQuoted ? 1 : 0) + inner.indexOf(trimmed); + return { from, to: from + trimmed.length }; +} + +function getOpenTagAttributes(node: SyntaxNode, input: Input): Record { + const attributes: Record = Object.create(null); + const openTag = node.getChild("OpenTag"); + if (!openTag) return attributes; + + for (const attribute of openTag.getChildren("Attribute")) { + const name = attribute.getChild("AttributeName"); + if (!name) continue; + const value = + attribute.getChild("AttributeValue") || attribute.getChild("UnquotedAttributeValue"); + const key = input.read(name.from, name.to).toLowerCase(); + attributes[key] = value ? input.read(value.from, value.to).replace(/^["']|["']$/g, "") : ""; + } + return attributes; +} + +function isDirectiveAttribute(attribute: string): boolean { + return ( + attribute.startsWith("v-") || + attribute.startsWith(":") || + attribute.startsWith("@") || + attribute.startsWith(".") + ); +} + +function directiveOverlay( + node: SyntaxNodeRef, + parent: SyntaxNode, + input: Input, +): NestedParse | null { + const attributeName = parent.getChild("AttributeName"); + if (!attributeName) return null; + if (!isDirectiveAttribute(input.read(attributeName.from, attributeName.to).toLowerCase())) { + return null; + } + + const range = findDirectiveExpression(input.read(node.from, node.to)); + if (!range) return null; + return { + parser: expressionParser, + overlay: [{ from: node.from + range.from, to: node.from + range.to }], + }; +} + +function textOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const overlays = findExpressions(input.read(node.from, node.to)).map(({ from, to }) => ({ + from: node.from + from, + to: node.from + to, + })); + return overlays.length > 0 ? { parser: expressionParser, overlay: overlays } : null; +} + +function templateOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (parent?.name === "Attribute") return directiveOverlay(node, parent, input); + if (node.name !== "Text") return null; + return textOverlay(node, input); +} + +function scriptLanguage(attributes: Record): NestedParse | null { + if (attributes.src) return null; + + const language = (attributes.lang || attributes.type || "").toLowerCase(); + if (language.includes("tsx")) return { parser: jsParser.configure({ dialect: "ts jsx" }) }; + if (language.includes("typescript") || language === "ts") return { parser: typescriptParser }; + if (language.includes("jsx")) return { parser: jsParser.configure({ dialect: "jsx" }) }; + return { parser: jsParser }; +} + +function scriptOverlay(node: SyntaxNodeRef, input: Input): NestedParse | null { + const parent = node.node.parent; + if (!parent) return null; + return scriptLanguage(getOpenTagAttributes(parent, input)); +} + +function nestedLanguage(node: SyntaxNodeRef, input: Input): NestedParse | null { + const name = node.name; + if (name === "Text" || name === "UnquotedAttributeValue" || name === "AttributeValue") { + return templateOverlay(node, input); + } + if (name === "StyleText") return { parser: cssParser }; + if (name === "ScriptText") return scriptOverlay(node, input); + return null; +} + +export const vueParser = htmlParser.configure({ + wrap: parseMixed(nestedLanguage), +}); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 11b88c513d..2797c32211 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo plugin SDK package", "files": [ "dist", @@ -14,19 +14,44 @@ "default": "./dist/index.js" }, "./server": { - "types": "./dist/server.d.ts", - "source": "./src/server.ts", - "default": "./dist/server.js" + "types": "./dist/server/index.d.ts", + "source": "./src/server/index.ts", + "default": "./dist/server/index.js" }, - "./react-native": { - "types": "./dist/react-native.d.ts", - "source": "./src/react-native.ts", - "default": "./dist/react-native.js" + "./server/provider": { + "types": "./dist/server/provider.d.ts", + "source": "./src/server/provider.ts", + "default": "./dist/server/provider.js" }, - "./host": { - "types": "./dist/host.d.ts", - "source": "./src/host.ts", - "default": "./dist/host.js" + "./server/usage": { + "types": "./dist/server/usage.d.ts", + "source": "./src/server/usage.ts", + "default": "./dist/server/usage.js" + }, + "./server/acp": { + "types": "./dist/server/acp.d.ts", + "source": "./src/server/acp.ts", + "default": "./dist/server/acp.js" + }, + "./client/react-native": { + "types": "./dist/client/react-native.d.ts", + "source": "./src/client/react-native.ts", + "default": "./dist/client/react-native.js" + }, + "./client/host": { + "types": "./dist/client/host.d.ts", + "source": "./src/client/host.ts", + "default": "./dist/client/host.js" + }, + "./client/ui": { + "types": "./dist/client/ui.d.ts", + "source": "./src/client/ui.ts", + "default": "./dist/client/ui.js" + }, + "./client": { + "types": "./dist/client/index.d.ts", + "source": "./src/client/index.ts", + "default": "./dist/client/index.js" } }, "publishConfig": { @@ -41,22 +66,30 @@ "test": "vitest run" }, "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", + "@getpaseo/client": "0.11.2", + "@getpaseo/protocol": "0.11.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.1.0", + "react-native": "0.81.5", "typescript": "^5.9.3", "vitest": "^4.1.6", "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "react": "19.1.0", + "@getpaseo/client": "0.11.2", + "@getpaseo/protocol": "0.11.2", + "react": "~19.1.0", + "react-native": ">=0.81.5", "zod": "^4.4.3" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + } } } diff --git a/packages/plugin/src/attachments.ts b/packages/plugin/src/attachments.ts index ee7c2b0888..27ca706dd7 100644 --- a/packages/plugin/src/attachments.ts +++ b/packages/plugin/src/attachments.ts @@ -1,4 +1,11 @@ import { z } from "zod"; +import type { PluginAttachmentSourceContribution } from "./contracts.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} export const PluginAttachmentItemSchema = z.object({ id: z.string(), diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts new file mode 100644 index 0000000000..5b1ce4f3dc --- /dev/null +++ b/packages/plugin/src/boundaries.test.ts @@ -0,0 +1,129 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { isBuiltin } from "node:module"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import * as server from "./server/index.js"; + +const sourceDirectory = path.dirname(fileURLToPath(import.meta.url)); +const entries = { + ".": "shared", + "./server": "server", + "./server/provider": "server", + "./server/usage": "server", + "./server/acp": "server", + "./client": "client", + "./client/host": "client", + "./client/react-native": "client", + "./client/ui": "client", +} as const; +const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/; +const serverModule = /^server\//; +const clientModule = /^client\//; + +function resolveLocal(importer: string, specifier: string): string { + const base = path.resolve(path.dirname(importer), specifier.replace(/\.js$/, "")); + const result = [base + ".ts", base + ".tsx"].find(existsSync); + if (!result) throw new Error(`Cannot resolve ${specifier} from ${importer}`); + return result; +} + +function boundaryViolations(entry: string, runtime: "shared" | "server" | "client"): string[] { + const pending = [entry]; + const visited = new Set(); + const violations: string[] = []; + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + // Includes type imports, re-exports, import types, and literal dynamic imports. + const imports = ts.preProcessFile(readFileSync(file, "utf8"), true, true).importedFiles; + for (const { fileName: specifier } of imports) { + const label = `${path.relative(sourceDirectory, file)} -> ${specifier}`; + if (specifier.startsWith(".")) { + const local = resolveLocal(file, specifier); + const module = path + .relative(sourceDirectory, local) + .replace(/\\/g, "/") + .replace(/\.tsx?$/, ".js"); + if (runtime !== "client" && clientModule.test(module)) violations.push(label); + if (runtime !== "server" && serverModule.test(module)) violations.push(label); + pending.push(local); + } else if (runtime === "shared") { + if (specifier !== "zod" && specifier !== "@getpaseo/protocol/agent-types") + violations.push(label); + } else if (runtime === "server") { + if (uiDependency.test(specifier) || /^@getpaseo\/plugin\/client(\/|$)/.test(specifier)) + violations.push(label); + } else if (isBuiltin(specifier) || /^@getpaseo\/plugin\/server(\/|$)/.test(specifier)) { + violations.push(label); + } + } + } + return violations; +} + +describe("plugin SDK import boundaries", () => { + it("publishes process capabilities without their Windows implementation helpers", () => { + expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]); + }); + + it("classifies every published entry", () => { + const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort()); + }); + + it.each(Object.entries(entries))( + "%s respects its %s boundary, including type dependencies", + (specifier, runtime) => { + const name = + specifier === "." + ? "index" + : specifier.slice(2) + + (specifier === "./client" || specifier === "./server" ? "/index" : ""); + expect(boundaryViolations(path.join(sourceDirectory, `${name}.ts`), runtime)).toEqual([]); + }, + ); +}); + +describe("plugin example import boundaries", () => { + const examples = path.resolve(sourceDirectory, "../../../plugin-examples"); + const files = readdirSync(examples, { recursive: true }).filter((file) => + /(? { + const [, directory] = path.relative(examples, file).split(path.sep); + if (directory?.startsWith("index.client.")) return "client"; + if (directory?.startsWith("index.server.")) return "server"; + return directory; + }; + + it.each(files)("%s respects runtime ownership, including type imports", (relative) => { + const file = path.join(examples, relative); + const runtime = owner(file); + expect(["shared", "client", "server"]).toContain(runtime); + const violations: string[] = []; + for (const { fileName: specifier } of ts.preProcessFile(readFileSync(file, "utf8"), true, true) + .importedFiles) { + if (specifier.startsWith(".")) { + const imported = owner(resolveLocal(file, specifier)); + if (imported !== "shared" && imported !== runtime) violations.push(specifier); + } else if (specifier.startsWith("@getpaseo/plugin")) { + const entry = specifier.replace("@getpaseo/plugin", ".") as keyof typeof entries; + if ( + entry === "./client/host" || + !(entry in entries) || + (entries[entry] !== "shared" && entries[entry] !== runtime) + ) + violations.push(specifier); + } else if ( + (runtime !== "client" && uiDependency.test(specifier)) || + (runtime !== "server" && isBuiltin(specifier)) + ) { + violations.push(specifier); + } + } + expect(violations).toEqual([]); + }); +}); diff --git a/packages/plugin/src/client/buttons.ts b/packages/plugin/src/client/buttons.ts new file mode 100644 index 0000000000..f5272e7ae4 --- /dev/null +++ b/packages/plugin/src/client/buttons.ts @@ -0,0 +1,57 @@ +import type { ComponentType } from "react"; +import type { PluginHostProps } from "./contracts.js"; + +export type PluginButtonContext = + | { context: "workspace"; workspaceId: string } + | { context: "agent"; workspaceId: string; agentId: string }; + +export type PluginButtonIconProps = PluginHostProps & + PluginButtonContext & { size: number; color: string }; + +export type PluginButtonContentProps = PluginHostProps & PluginButtonContext & { close(): void }; + +export type PluginButtonIcon = string | ComponentType; + +export type PluginButtonBehavior = + | { kind: "action"; onPress(): void | Promise } + | { kind: "menu"; items: readonly PluginButtonMenuEntry[] } + | { kind: "popover"; Content: ComponentType }; + +export type PluginButtonMenuEntry = + | { kind: "separator"; id: string } + | { + kind: "item"; + id: string; + title: string; + icon?: PluginButtonIcon; + visible?: boolean; + disabled?: boolean; + behavior: PluginButtonBehavior; + }; + +export interface PluginButton { + title: string; + icon: PluginButtonIcon; + /** Omit for an icon-only header button. Composer pills use title when omitted. */ + label?: string; + visible?: boolean; + disabled?: boolean; + behavior: PluginButtonBehavior; +} + +export interface PluginButtonRegistration { + /** Updates presentation in place. Supply a complete behavior to replace it. */ + update(patch: Partial): void; + /** Idempotent. Updates after removal do nothing. */ + remove(): void; +} + +export interface PluginHeaderButtonContribution { + id: string; + workspaceId: string; + button: PluginButton; +} + +export interface PluginComposerPillContribution extends PluginHeaderButtonContribution { + agentId: string; +} diff --git a/packages/plugin/src/client-state.tsx b/packages/plugin/src/client/client-state.tsx similarity index 98% rename from packages/plugin/src/client-state.tsx rename to packages/plugin/src/client/client-state.tsx index 9797e8d965..b1c853ecec 100644 --- a/packages/plugin/src/client-state.tsx +++ b/packages/plugin/src/client/client-state.tsx @@ -1,6 +1,6 @@ import { createContext, useContext, type ReactNode } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; -import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "./contracts.js"; +import type { PluginAgentSnapshot, PluginWorkspaceSnapshot } from "../contracts.js"; import { shallowEqual } from "./shallow.js"; export interface PluginClientStateSource { diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts new file mode 100644 index 0000000000..5bc82d8a58 --- /dev/null +++ b/packages/plugin/src/client/contracts.ts @@ -0,0 +1,308 @@ +import type { ComponentType } from "react"; +import type { PaseoApi } from "@getpaseo/client"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { + PluginButtonRegistration, + PluginHeaderButtonContribution, + PluginComposerPillContribution, +} from "./buttons.js"; +import type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineTransformResult, + PluginCleanup, +} from "../contracts.js"; + +export interface PluginHostProps { + theme: PluginTheme; + host: { + id: string; + label: string; + }; + layout: { + compact: boolean; + platform: "ios" | "android" | "web"; + }; +} + +interface PluginNavigableHostProps extends PluginHostProps { + /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ + readonly navigation?: { + /** Present only on Electron. The browser runs locally; serverId selects workspace ownership. */ + readonly openBrowser?: (input: { + readonly url: string; + readonly workspaceId: string; + readonly serverId?: string; + }) => void; + readonly openAgent: (input: { readonly agentId: string; readonly serverId?: string }) => void; + readonly openWorkspace: (input: { + readonly workspaceId: string; + readonly serverId?: string; + }) => void; + }; +} + +/** String keys and values: params travel in the screen's URL. */ +export type PluginScreenParams = Record; + +export interface PluginOpenScreenInput { + screenId: string; + params?: PluginScreenParams; +} + +export interface PluginScreenLocation { + screenId: string; + params: PluginScreenParams; +} + +export interface PluginSurfaceProps extends PluginNavigableHostProps {} + +export interface PluginScreenProps extends PluginSurfaceProps { + /** The params the screen was opened with; `{}` when none. */ + params: PluginScreenParams; +} + +export interface PluginPopoverProps extends PluginHostProps { + close(): void; + openScreen(input: PluginOpenScreenInput): void; +} + +export interface PluginSidebarItemProps extends PluginHostProps { + /** This plugin's screen open on this item's host, with its params, else null. */ + currentScreen: PluginScreenLocation | null; + openScreen(input: PluginOpenScreenInput): void; + /** Anchored to the item on wide layouts; a bottom sheet on compact layouts. */ + openPopover(Content: ComponentType): void; +} + +export interface PluginSidebarItemContribution { + id: string; + /** Settings row label, accessibility label, and default label for SidebarRow. */ + title: string; + Component: ComponentType; +} + +export interface PluginIconProps { + name: string; + size?: number; + color?: string; +} + +export type PluginPanelLocation = "workspace" | "explorer"; + +export interface PluginOpenPanelOptions { + location?: PluginPanelLocation; +} + +interface PluginWorkspacePanelBase { + id: string; + title: string; + icon: string; + locations?: readonly PluginPanelLocation[]; +} + +export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { + context: "workspace"; + workspaceId: string; +} + +export interface PluginAgentPanelProps extends PluginNavigableHostProps { + context: "agent"; + workspaceId: string; + agentId: string; +} + +export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { + workspaceId: string; + agentId?: string; +} + +// COMPAT(pluginSidebarAliases): added in v0.11.0, remove after 2027-03-29 +interface PluginClientContextAliases { + /** @deprecated Use `addScreen`. */ + addSurface(id: string, Component: ComponentType): PluginCleanup; + /** @deprecated Use `addSidebarHeaderItem`. */ + addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; +} + +export interface PluginClientContext extends PluginCommandCapabilities, PluginClientContextAliases { + /** Play a base64-encoded audio file on this client; resolves when playback ends. */ + playAudio(source: { base64: string; mimeType: string }): Promise; + addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup; + addScreen(contribution: PluginScreenContribution): PluginCleanup; + addSidebarHeaderItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addSidebarFooterItem(contribution: PluginSidebarItemContribution): PluginCleanup; + addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; + addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; + addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; + addHeaderButton(contribution: PluginHeaderButtonContribution): PluginButtonRegistration; + addComposerPill(contribution: PluginComposerPillContribution): PluginButtonRegistration; + addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; + addTheme(contribution: PluginThemeContribution): PluginCleanup; + addTimelineTransformer( + contribution: PluginTimelineTransformerContribution, + ): PluginCleanup; + addTimelineRenderer( + contribution: PluginTimelineRendererContribution, + ): PluginCleanup; + openPanel(id: string, options: PluginClientOpenPanelOptions): void; +} + +export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; + +export type PluginWorkspacePanelContribution = + | (PluginWorkspacePanelBase & { + context: "workspace"; + Component: ComponentType; + }) + | (PluginWorkspacePanelBase & { + context: "agent"; + Component: ComponentType; + }); + +export interface PluginSettingsScreenContribution { + id: string; + title: string; + icon: string; + Component: ComponentType; +} + +/** The screen header's title: fixed, or derived from the params the screen was opened with. */ +export type PluginScreenTitle = string | ((params: PluginScreenParams) => string); + +export interface PluginScreenContribution { + id: string; + title: PluginScreenTitle; + Component: ComponentType; +} + +export interface PluginSurfaceContribution { + id: string; + Component: ComponentType; +} + +/** @deprecated Use `PluginSidebarItemContribution` with `addSidebarHeaderItem`. */ +export interface PluginSidebarContribution { + id: string; + title: string; + icon: string; + surface: string; +} + +export type PluginTimelineTransformerContribution< + ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], +> = ItemType extends AgentTimelineItem["type"] + ? { + id: string; + query: { + itemType: ItemType; + }; + transform(input: { + item: Extract; + phase: "streaming" | "complete"; + }): PluginTimelineTransformResult | undefined; + } + : never; + +export interface PluginTimelineItemProps extends PluginHostProps { + agentId: string; + item: { + type: "plugin"; + kind: string; + version: number; + data: Data; + }; + timestamp: Date; +} + +export interface PluginTimelineRendererContribution { + kind: string; + version: number; + schema: Schema; + Component: ComponentType>>; +} + +export interface PluginCommandCapabilities { + paseo: PaseoApi; + rpc( + contract: PluginRpcContract, + input: ZodInput, + ): Promise>; + openScreen(input: PluginOpenScreenInput): void; + /** @deprecated Use `openScreen`. */ + openSurface(id: string): void; + openSettings(id: string): void; +} + +export interface PluginGlobalCommandContext extends PluginCommandCapabilities { + context: "global"; +} + +export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { + context: "workspace"; + workspace: PluginWorkspaceSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +export interface PluginAgentCommandContext extends PluginCommandCapabilities { + context: "agent"; + workspace: PluginWorkspaceSnapshot; + agent: PluginAgentSnapshot; + openPanel(id: string, options?: PluginOpenPanelOptions): void; +} + +interface PluginCommandCenterItemBase { + id: string; + title: string; + icon: string; + keywords?: readonly string[]; +} + +export type PluginCommandCenterItemContribution = + | (PluginCommandCenterItemBase & { + context: "global"; + onSelect(context: PluginGlobalCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "workspace"; + onSelect(context: PluginWorkspaceCommandContext): void | Promise; + }) + | (PluginCommandCenterItemBase & { + context: "agent"; + onSelect(context: PluginAgentCommandContext): void | Promise; + }); + +interface PluginClientSlashCommandBase { + name: string; + description: string; + argumentHint: string; +} + +export type PluginClientSlashCommandContribution = + | (PluginClientSlashCommandBase & { + context: "workspace"; + onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; + }) + | (PluginClientSlashCommandBase & { + context: "agent"; + onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; + }); + +export type SettingsState = ( + | { status: "loading" } + | { status: "error"; error: string } + | { status: "invalid"; error: string; revision: string } + | { status: "ready"; values: ZodOutput; revision: string } +) & { + saving: boolean; + saveError: string | null; + /** Save an entire document against the revision currently displayed. Never throws. */ + save(values: ZodOutput, revision: string): Promise; + reset(): Promise; + reload(): Promise; +}; diff --git a/packages/plugin/src/host.test.ts b/packages/plugin/src/client/host.test.ts similarity index 99% rename from packages/plugin/src/host.test.ts rename to packages/plugin/src/client/host.test.ts index 58aab65603..894fe832a5 100644 --- a/packages/plugin/src/host.test.ts +++ b/packages/plugin/src/client/host.test.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; -import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js"; +import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js"; import { callPluginRpc, searchPluginAttachments } from "./host.js"; const contract = defineRpc({ diff --git a/packages/plugin/src/host.ts b/packages/plugin/src/client/host.ts similarity index 78% rename from packages/plugin/src/host.ts rename to packages/plugin/src/client/host.ts index 6fd4d7aaac..6cd42863e5 100644 --- a/packages/plugin/src/host.ts +++ b/packages/plugin/src/client/host.ts @@ -1,13 +1,13 @@ -import { PluginAttachmentSearchPayloadSchema } from "./attachments.js"; +import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, } from "./runtime-context-bridge.js"; -import type { PluginAttachmentSourceContribution } from "./contracts.js"; +import type { PluginAttachmentSourceContribution } from "../contracts.js"; import { PluginRpcProvider } from "./rpc-context.js"; import { PaseoApiProvider } from "./paseo-context.js"; -import { callPluginRpc } from "./rpc.js"; +import { callPluginRpc } from "../rpc.js"; export async function searchPluginAttachments( source: PluginAttachmentSourceContribution, diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts new file mode 100644 index 0000000000..810462354f --- /dev/null +++ b/packages/plugin/src/client/index.ts @@ -0,0 +1,69 @@ +export type { + PluginHostProps, + PluginSurfaceProps, + PluginScreenProps, + PluginPopoverProps, + PluginScreenParams, + PluginOpenScreenInput, + PluginScreenLocation, + PluginSidebarItemProps, + PluginSidebarItemContribution, + PluginIconProps, + PluginPanelLocation, + PluginOpenPanelOptions, + PluginWorkspacePanelProps, + PluginAgentPanelProps, + PluginClientOpenPanelOptions, + PluginClientContext, + PluginClientContribution, + PluginWorkspacePanelContribution, + PluginSettingsScreenContribution, + PluginSurfaceContribution, + PluginScreenContribution, + PluginScreenTitle, + PluginSidebarContribution, + PluginTimelineTransformerContribution, + PluginTimelineItemProps, + PluginTimelineRendererContribution, + PluginCommandCapabilities, + PluginGlobalCommandContext, + PluginWorkspaceCommandContext, + PluginAgentCommandContext, + PluginCommandCenterItemContribution, + PluginClientSlashCommandContribution, + SettingsState, +} from "./contracts.js"; +export type { + PluginButton, + PluginButtonBehavior, + PluginButtonContentProps, + PluginButtonContext, + PluginButtonIcon, + PluginButtonIconProps, + PluginButtonMenuEntry, + PluginButtonRegistration, + PluginComposerPillContribution, + PluginHeaderButtonContribution, +} from "./buttons.js"; +export { usePaseo } from "./paseo-context.js"; +export { useAgent, useWorkspace } from "./client-state.js"; +export { useRpc } from "./rpc-context.js"; +import type { SettingsDefinition } from "../settings.js"; +import type { SettingsState } from "./contracts.js"; +import type { ZodType } from "zod"; +export declare function useSettings( + definition: SettingsDefinition, +): SettingsState; + +/** Configured app host, including hosts that are currently disconnected. */ +export interface PluginHostSummary { + readonly serverId: string; + readonly label: string; + readonly status: "idle" | "connecting" | "online" | "offline" | "error"; +} +/** Live configured hosts. Supplied by the app's client bundle loader. */ +export declare function useHosts(): readonly PluginHostSummary[]; +/** Borrow an online host's API under this installation's lifetime. */ +export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi; +/** Open an absolute HTTP(S) URL using the client platform’s external opener. */ +export declare function openExternalUrl(url: string): Promise; diff --git a/packages/plugin/src/paseo-context.tsx b/packages/plugin/src/client/paseo-context.tsx similarity index 100% rename from packages/plugin/src/paseo-context.tsx rename to packages/plugin/src/client/paseo-context.tsx diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts new file mode 100644 index 0000000000..ea196c433f --- /dev/null +++ b/packages/plugin/src/client/react-native.ts @@ -0,0 +1,76 @@ +import type { + ComponentType, + FunctionComponent, + ReactNode, + ForwardRefExoticComponent, + RefAttributes, + ReactElement, + Ref, +} from "react"; +import type { + StyleProp, + ViewStyle, + ScrollView as NativeScrollView, + ScrollViewProps, + FlatList as NativeFlatList, + FlatListProps, + TextInput as NativeTextInput, + TextInputProps, +} from "react-native"; +import type { PluginIconProps } from "./contracts.js"; + +export interface ModalProps { + title: string; + icon?: ReactNode; + open: boolean; + onOpenChange(open: boolean): void; + children: ReactNode; +} + +export interface ModalContentProps { + children: ReactNode; + /** Paint the full body below the header. */ + style?: StyleProp; + /** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */ + contentContainerStyle?: StyleProp; + /** Default true. Set false for a bounded body with your own ScrollView or FlatList. */ + scrollable?: boolean; +} + +export interface ModalComponent extends FunctionComponent { + Content: ComponentType; +} + +export type ToastVariant = "default" | "info" | "success" | "warning" | "error"; + +export interface ToastOptions { + variant?: ToastVariant; + durationMs?: number; +} + +export interface ToastApi { + show(message: string, options?: ToastOptions): void; + error(message: string): void; +} + +export declare const Icon: ComponentType; +export declare const Modal: ModalComponent; +export declare function useToast(): ToastApi; +export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; + +export type { PluginIconProps } from "./contracts.js"; + +/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */ +export declare const ScrollView: ForwardRefExoticComponent< + ScrollViewProps & RefAttributes +>; +export declare function FlatList( + props: FlatListProps & { ref?: Ref> }, +): ReactElement; +/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */ +export declare function copyText(text: string): Promise; + +/** Native input focus integrated with modal keyboard positioning. */ +export declare const TextInput: ForwardRefExoticComponent< + TextInputProps & RefAttributes +>; diff --git a/packages/plugin/src/rpc-context.tsx b/packages/plugin/src/client/rpc-context.tsx similarity index 94% rename from packages/plugin/src/rpc-context.tsx rename to packages/plugin/src/client/rpc-context.tsx index 8f7ba514f5..7cd9bb176a 100644 --- a/packages/plugin/src/rpc-context.tsx +++ b/packages/plugin/src/client/rpc-context.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; -import { callPluginRpc, type PluginRpcContract } from "./rpc.js"; +import { callPluginRpc, type PluginRpcContract } from "../rpc.js"; export interface PluginRpcContextValue { invoke(method: string, input: unknown): Promise; diff --git a/packages/plugin/src/runtime-context-bridge.tsx b/packages/plugin/src/client/runtime-context-bridge.tsx similarity index 100% rename from packages/plugin/src/runtime-context-bridge.tsx rename to packages/plugin/src/client/runtime-context-bridge.tsx diff --git a/packages/plugin/src/shallow.test.ts b/packages/plugin/src/client/shallow.test.ts similarity index 100% rename from packages/plugin/src/shallow.test.ts rename to packages/plugin/src/client/shallow.test.ts diff --git a/packages/plugin/src/shallow.ts b/packages/plugin/src/client/shallow.ts similarity index 100% rename from packages/plugin/src/shallow.ts rename to packages/plugin/src/client/shallow.ts diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts new file mode 100644 index 0000000000..6686d8292b --- /dev/null +++ b/packages/plugin/src/client/ui.ts @@ -0,0 +1,86 @@ +import type { ComponentType, ReactNode, Ref } from "react"; + +export interface SettingsSectionProps { + title: string; + info?: ReactNode; + trailing?: ReactNode; + children: ReactNode; + testID?: string; +} +export interface SettingsRowProps { + label: string; + hint?: string; + error?: string | null; + children?: ReactNode; + testID?: string; +} +export interface SettingsSwitchProps extends SettingsRowProps { + value: boolean; + onValueChange(value: boolean): void; + disabled?: boolean; +} +export interface SettingsSelectProps extends SettingsRowProps { + value: Value; + options: readonly { label: string; value: Value }[]; + onValueChange(value: Value): void; + disabled?: boolean; +} +export interface SettingsInputHandle { + focus(): void; + blur(): void; + getText(): string; + replaceText(text: string): void; +} +export interface SettingsInputProps extends SettingsRowProps { + initialValue?: string; + onChangeText(text: string): void; + placeholder?: string; + disabled?: boolean; + secureTextEntry?: boolean; + ref?: Ref; +} +export interface SettingsActionProps extends SettingsRowProps { + actionLabel: string; + onPress(): void; + disabled?: boolean; +} +export declare const SettingsGroup: ComponentType; +export declare const SettingsSection: ComponentType; +export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>; +export declare const SettingsRow: ComponentType; +export declare const SettingsSwitch: ComponentType; +export declare function SettingsSelect( + props: SettingsSelectProps, +): ReactNode; +export declare const SettingsInput: ComponentType; +export declare const SettingsAction: ComponentType; + +export interface ExternalLinkProps { + href: string; + children: ReactNode; + accessibilityLabel?: string; + testID?: string; + onError?: (error: unknown) => void; +} +export declare const ExternalLink: ComponentType; + +export type SidebarIcon = string | ComponentType<{ size: number; color: string }>; +export interface SidebarRowProps { + /** + * Tells rows of one item apart when the item renders several: "bot-2". Unique within the item. + * Omit it when the item renders one row. + */ + id?: string; + /** A Lucide icon name or a component. */ + icon?: SidebarIcon; + /** Defaults to the item's registered title. */ + label?: string; + onPress(): void; + active?: boolean; + /** Right slot. Renders beside the row's pressable, so a button here presses on its own. */ + trailing?: ReactNode; +} +/** A sidebar navigation row. Render it from a sidebar item's `Component`. */ +export declare const SidebarRow: ComponentType; +/** A line between groups of rows. Render it from a sidebar item's `Component`. */ +export declare const SidebarSeparator: ComponentType; diff --git a/packages/plugin/src/use-sync-external-store.d.ts b/packages/plugin/src/client/use-sync-external-store.d.ts similarity index 100% rename from packages/plugin/src/use-sync-external-store.d.ts rename to packages/plugin/src/client/use-sync-external-store.d.ts diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index dbe5116161..98df0758a6 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -1,7 +1,4 @@ -import type { ComponentType } from "react"; -import type { PaseoApi } from "@getpaseo/client"; -import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-types"; -import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; import type { PluginRpcContract } from "./rpc.js"; export interface PluginTheme { @@ -20,34 +17,6 @@ export interface PluginTheme { }; } -export interface PluginHostProps { - theme: PluginTheme; - host: { - id: string; - label: string; - }; - layout: { - compact: boolean; - platform: "ios" | "android" | "web"; - }; -} - -interface PluginNavigableHostProps extends PluginHostProps { - /** Client-owned navigation. Undefined on older hosts; hide dependent affordances when absent. */ - readonly navigation?: { - readonly openAgent: (input: { readonly agentId: string }) => void; - readonly openWorkspace: (input: { readonly workspaceId: string }) => void; - }; -} - -export interface PluginSurfaceProps extends PluginNavigableHostProps {} - -export interface PluginIconProps { - name: string; - size?: number; - color?: string; -} - export interface PluginWorkspaceSnapshot { readonly id: string; readonly projectId: string; @@ -83,91 +52,6 @@ export interface PluginAgentSnapshot { readonly labels: Readonly>; } -export type PluginPanelLocation = "workspace" | "explorer"; - -export interface PluginOpenPanelOptions { - location?: PluginPanelLocation; -} - -interface PluginWorkspacePanelBase { - id: string; - title: string; - icon: string; - locations?: readonly PluginPanelLocation[]; -} - -export interface PluginWorkspacePanelProps extends PluginNavigableHostProps { - context: "workspace"; - workspaceId: string; -} - -export interface PluginAgentPanelProps extends PluginNavigableHostProps { - context: "agent"; - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillProps extends PluginHostProps { - workspaceId: string; - agentId: string; -} - -export interface PluginComposerPillContribution { - id: string; - title: string; - workspaceId: string; - agentId: string; - Component: ComponentType; - onPress(): void | Promise; -} - -export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions { - workspaceId: string; - agentId?: string; -} - -export interface PluginClientContext extends PluginCommandCapabilities { - addSurface(id: string, Component: ComponentType): PluginCleanup; - addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup; - addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup; - addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup; - addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup; - addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup; - addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; - addTheme(contribution: PluginThemeContribution): PluginCleanup; - addTimelineTransformer( - contribution: PluginTimelineTransformerContribution, - ): PluginCleanup; - addTimelineRenderer( - contribution: PluginTimelineRendererContribution, - ): PluginCleanup; - openPanel(id: string, options: PluginClientOpenPanelOptions): void; -} - -export type PluginClientContribution = (client: PluginClientContext) => PluginCleanup; - -export type PluginWorkspacePanelContribution = - | (PluginWorkspacePanelBase & { - context: "workspace"; - Component: ComponentType; - }) - | (PluginWorkspacePanelBase & { - context: "agent"; - Component: ComponentType; - }); - -export interface PluginSurfaceContribution { - id: string; - Component: ComponentType; -} - -export interface PluginSidebarContribution { - id: string; - title: string; - icon: string; - surface: string; -} - export interface PluginThemeColors { background: string; foreground: string; @@ -209,116 +93,4 @@ export interface PluginTimelineTransformResult { items: PluginTimelineItem[]; } -export type PluginTimelineTransformerContribution< - ItemType extends AgentTimelineItem["type"] = AgentTimelineItem["type"], -> = ItemType extends AgentTimelineItem["type"] - ? { - id: string; - query: { - itemType: ItemType; - }; - transform(input: { - item: Extract; - phase: "streaming" | "complete"; - }): PluginTimelineTransformResult | undefined; - } - : never; - -export interface PluginTimelineItemProps extends PluginHostProps { - agentId: string; - item: { - type: "plugin"; - kind: string; - version: number; - data: Data; - }; - timestamp: Date; -} - -export interface PluginTimelineRendererContribution { - kind: string; - version: number; - schema: Schema; - Component: ComponentType>>; -} - -export interface PluginCommandCapabilities { - paseo: PaseoApi; - rpc( - contract: PluginRpcContract, - input: ZodInput, - ): Promise>; - openSurface(id: string): void; -} - -export interface PluginGlobalCommandContext extends PluginCommandCapabilities { - context: "global"; -} - -export interface PluginWorkspaceCommandContext extends PluginCommandCapabilities { - context: "workspace"; - workspace: PluginWorkspaceSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -export interface PluginAgentCommandContext extends PluginCommandCapabilities { - context: "agent"; - workspace: PluginWorkspaceSnapshot; - agent: PluginAgentSnapshot; - openPanel(id: string, options?: PluginOpenPanelOptions): void; -} - -interface PluginCommandCenterItemBase { - id: string; - title: string; - icon: string; - keywords?: readonly string[]; -} - -export type PluginCommandCenterItemContribution = - | (PluginCommandCenterItemBase & { - context: "global"; - onSelect(context: PluginGlobalCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "workspace"; - onSelect(context: PluginWorkspaceCommandContext): void | Promise; - }) - | (PluginCommandCenterItemBase & { - context: "agent"; - onSelect(context: PluginAgentCommandContext): void | Promise; - }); - -interface PluginClientSlashCommandBase { - name: string; - description: string; - argumentHint: string; -} - -export type PluginClientSlashCommandContribution = - | (PluginClientSlashCommandBase & { - context: "workspace"; - onSubmit(context: PluginWorkspaceCommandContext & { args: string }): void | Promise; - }) - | (PluginClientSlashCommandBase & { - context: "agent"; - onSubmit(context: PluginAgentCommandContext & { args: string }): void | Promise; - }); - -export interface PluginHandlerContext { - paseo: PaseoApi; -} - -export interface PluginServerContext { - handle( - contract: PluginRpcContract, - handler: ( - input: ZodOutput, - context: PluginHandlerContext, - ) => ZodInput | Promise>, - ): void; -} - export type PluginCleanup = () => void | Promise; - -export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e54e24e85b..48deede303 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,60 +1,22 @@ -import type { ComponentType } from "react"; -import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js"; - +// Shared SDK entry. Keep runtime-specific imports and re-exports on /client or /server. +export type { + PluginTheme, + PluginWorkspaceSnapshot, + PluginAgentSnapshot, + PluginThemeColors, + PluginThemeContribution, + PluginAttachmentSourceContribution, + PluginTimelineData, + PluginTimelineItem, + PluginTimelineTransformResult, + PluginCleanup, +} from "./contracts.js"; +export { defineSettings, settingsRpc, type SettingsDefinition } from "./settings.js"; export { + defineAttachmentSource, PluginAttachmentItemSchema, PluginAttachmentSearchPayloadSchema, type PluginAttachmentItem, type PluginAttachmentSearchPayload, } from "./attachments.js"; export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js"; - -export function defineAttachmentSource( - definition: Definition, -): Definition { - return definition; -} - -export declare const Icon: ComponentType; -export type { - PluginAttachmentSourceContribution, - PluginAgentCommandContext, - PluginAgentPanelProps, - PluginAgentSnapshot, - PluginCleanup, - PluginCommandCapabilities, - PluginCommandCenterItemContribution, - PluginClientContext, - PluginClientContribution, - PluginClientSlashCommandContribution, - PluginClientOpenPanelOptions, - PluginComposerPillContribution, - PluginComposerPillProps, - PluginServerContribution, - PluginServerContext, - PluginGlobalCommandContext, - PluginHandlerContext, - PluginHostProps, - PluginOpenPanelOptions, - PluginIconProps, - PluginPanelLocation, - PluginTheme, - PluginSidebarContribution, - PluginSurfaceContribution, - PluginSurfaceProps, - PluginThemeColors, - PluginThemeContribution, - PluginTimelineData, - PluginTimelineItem, - PluginTimelineItemProps, - PluginTimelineRendererContribution, - PluginTimelineTransformerContribution, - PluginTimelineTransformResult, - PluginWorkspaceCommandContext, - PluginWorkspacePanelContribution, - PluginWorkspacePanelProps, - PluginWorkspaceSnapshot, -} from "./contracts.js"; -export { usePaseo } from "./paseo-context.js"; -export { useAgent, useWorkspace } from "./client-state.js"; -export { useRpc } from "./rpc-context.js"; diff --git a/packages/plugin/src/migration-doc.test.ts b/packages/plugin/src/migration-doc.test.ts index 3673bf6616..d6b96f2eb4 100644 --- a/packages/plugin/src/migration-doc.test.ts +++ b/packages/plugin/src/migration-doc.test.ts @@ -4,12 +4,12 @@ import { describe, expect, it } from "vitest"; describe("plugin runtime-entry migration guide", () => { it("maps every client registration method", async () => { - const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8"); + const contracts = await readFile(new URL("./client/contracts.ts", import.meta.url), "utf8"); const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1]; if (!context) throw new Error("PluginClientContext was not found"); const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]); const migration = await readFile( - fileURLToPath(new URL("../../../public-docs/plugins/v0.8/migration.md", import.meta.url)), + fileURLToPath(new URL("../../../public-docs/plugins/migration.md", import.meta.url)), "utf8", ); const table = migration.slice( diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/react-native.ts deleted file mode 100644 index d582cb724a..0000000000 --- a/packages/plugin/src/react-native.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ComponentType, FunctionComponent, ReactNode } from "react"; -import type { PluginIconProps } from "./contracts.js"; - -export interface ModalProps { - title: string; - icon?: ReactNode; - open: boolean; - onOpenChange(open: boolean): void; - children: ReactNode; -} - -export interface ModalContentProps { - children: ReactNode; -} - -export interface ModalComponent extends FunctionComponent { - Content: ComponentType; -} - -export type ToastVariant = "default" | "info" | "success" | "warning" | "error"; - -export interface ToastOptions { - variant?: ToastVariant; - durationMs?: number; -} - -export interface ToastApi { - show(message: string, options?: ToastOptions): void; - error(message: string): void; -} - -export declare const Icon: ComponentType; -export declare const Modal: ModalComponent; -export declare function useToast(): ToastApi; -export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string; - -export type { PluginIconProps } from "./contracts.js"; diff --git a/packages/plugin/src/server.test.ts b/packages/plugin/src/server.test.ts deleted file mode 100644 index 5117249ae8..0000000000 --- a/packages/plugin/src/server.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const srcDir = path.dirname(fileURLToPath(import.meta.url)); -const forbiddenSpecifiers = new Set([ - "react", - "react/jsx-runtime", - "react-native", - "use-sync-external-store", - "use-sync-external-store/shim/with-selector", -]); - -function localFile(fromFile: string, specifier: string): string { - const withoutJs = specifier.replace(/\.js$/, ""); - for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) { - const resolved = path.resolve(path.dirname(fromFile), candidate); - if (existsSync(resolved)) return resolved; - } - throw new Error(`Could not resolve ${specifier} from ${fromFile}`); -} - -function valueImportSpecifiers(source: string): string[] { - const specifiers: string[] = []; - const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g; - for (const match of source.matchAll(pattern)) { - if (match[1]) specifiers.push(match[1]); - } - return specifiers; -} - -function collectBareSpecifiers(entry: string): string[] { - const seen = new Set(); - const bare: string[] = []; - const queue = [entry]; - while (queue.length > 0) { - const file = queue.pop(); - if (!file || seen.has(file)) continue; - seen.add(file); - for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) { - if (specifier.startsWith(".")) { - queue.push(localFile(file, specifier)); - continue; - } - bare.push(specifier); - } - } - return bare; -} - -describe("@getpaseo/plugin/server", () => { - it("does not load react or the client hook graph", () => { - const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts")); - expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]); - }); -}); diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server.ts deleted file mode 100644 index 8aa3325a83..0000000000 --- a/packages/plugin/src/server.ts +++ /dev/null @@ -1 +0,0 @@ -export type { PluginHandlerContext } from "./contracts.js"; diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts new file mode 100644 index 0000000000..fc78d2a8c4 --- /dev/null +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -0,0 +1,1455 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawnProcess, terminateProcess } from "../process.js"; +import { Readable, Writable } from "node:stream"; +import { + ClientSideConnection, + CLIENT_METHODS, + PROTOCOL_VERSION, + ndJsonStream, + type Client, + type AgentCapabilities, + type ContentBlock, + type NewSessionResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionConfigOption, + type SessionNotification, + type SessionUpdate, + type ToolCall, + type ToolCallUpdate, + type Stream, +} from "@agentclientprotocol/sdk"; +import { z } from "zod"; +import type { + AcpConfigAccess, + AcpConfigChange, + AcpToolCallSnapshot, + AcpTransformer, + AcpVendorUpdate, + RunAcpProviderOptions, +} from "../acp.js"; +import { + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + negotiateProviderCapabilities, + requireProviderCapabilities, + type ProviderCatalog, + type ProviderCapability, + type ProviderConfigState, + type ProviderConnectRequest, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, + type ProviderLaunch, + type ProviderPermissionResponse, + type ProviderPersistence, + type ProviderSessionConfig, + type ProviderSetting, + type ProviderTimelineItem, +} from "../provider.js"; + +const ADAPTER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "session.configure", + "permission", +] as const; + +interface AcpBoundarySession { + runtime: AcpRuntime; + capabilities: readonly ProviderCapability[]; + mutationLane: Promise; +} + +export async function createAcpProviderConnection( + options: RunAcpProviderOptions, + request: ProviderConnectRequest, +): Promise { + if (!request.versions.includes(PROVIDER_PROTOCOL_VERSION)) { + throw new Error(`ACP adapter requires provider protocol ${PROVIDER_PROTOCOL_VERSION}`); + } + const probe = await AcpRuntime.start({ + options, + launch: request.launch, + boundarySessionId: "capability-probe", + env: {}, + emit: () => undefined, + }); + const supportedCapabilities: ProviderCapability[] = [...ADAPTER_CAPABILITIES]; + if (probe.agentCapabilities.promptCapabilities?.image) { + supportedCapabilities.push("prompt.image"); + } + if (probe.agentCapabilities.sessionCapabilities?.list) { + supportedCapabilities.push("session.list"); + } + if (probe.agentCapabilities.loadSession) { + supportedCapabilities.push("session.persistence"); + } + await probe.close(); + const capabilities = negotiateProviderCapabilities(request.capabilities, supportedCapabilities); + const listeners = new Set<(event: ProviderEvent) => void>(); + const sessions = new Map(); + const inFlight = new Set>(); + let closed = false; + let closePromise: Promise | null = null; + const emit = (event: ProviderEvent) => { + if (closed) return; + const parsed = ProviderEventSchema.parse(event); + for (const listener of listeners) listener(parsed); + }; + const state: AcpConnectionState = { + options, + launch: request.launch, + capabilities, + sessions, + emit, + isClosed: () => closed, + }; + + return { + version: PROVIDER_PROTOCOL_VERSION, + capabilities, + async send(input) { + if (closed) throw new Error("ACP provider connection is closed"); + input = ProviderInputSchema.parse(input); + validateAdmission(input, { capabilities, sessions }); + const operation = Promise.resolve().then(async () => { + if (closed) return undefined; + await dispatch(input, state); + return undefined; + }); + const settled = operation.catch((error) => { + emitOperationFailure(input, emit, error); + }); + inFlight.add(settled); + void settled.finally(() => inFlight.delete(settled)); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() { + if (closePromise) return closePromise; + closed = true; + closePromise = (async () => { + await Promise.all(inFlight); + const active = [...sessions.values()]; + sessions.clear(); + await Promise.all(active.map(({ runtime }) => runtime.closeSession())); + listeners.clear(); + })(); + return closePromise; + }, + }; +} + +interface AcpConnectionState { + launch?: ProviderLaunch; + options: RunAcpProviderOptions; + capabilities: readonly ProviderCapability[]; + sessions: Map; + emit(event: ProviderEvent): void; + isClosed(): boolean; +} + +function validateAdmission( + input: ProviderInput, + state: Pick, +): void { + if (input.type === "session.open") { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + requireProviderCapabilities(state.capabilities, input); + return; + } + if (!("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + return; + } + const session = requireSession(state as AcpConnectionState, input.sessionId); + requireProviderCapabilities(session.capabilities, input); +} + +function emitOperationFailure( + input: ProviderInput, + emit: (event: ProviderEvent) => void, + error: unknown, +): void { + const failure = { message: describeError(error) }; + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "failed", error: failure }, + }); + return; + } + if ("requestId" in input) { + emit({ type: "request.failed", requestId: input.requestId, error: failure }); + return; + } + if ("sessionId" in input) { + emit({ type: "session.runtime_failed", sessionId: input.sessionId, error: failure }); + } +} + +async function dispatch(input: ProviderInput, state: AcpConnectionState): Promise { + switch (input.type) { + case "catalog": + await discover(input, state); + return; + case "sessions": + await listSessions(input, state); + return; + case "session.open": + await openSession(input, state); + return; + case "session.prompt": + await requireSession(state, input.sessionId).runtime.prompt(input); + return; + case "session.interrupt": + await requireSession(state, input.sessionId).runtime.interrupt(); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + case "session.permission": + requireSession(state, input.sessionId).runtime.respondToPermission( + input.permissionId, + input.response, + ); + return; + case "session.configure": { + const session = requireSession(state, input.sessionId); + await mutateSession(session, () => session.runtime.configure(input.changes)); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.close": { + const session = requireSession(state, input.sessionId); + state.sessions.delete(input.sessionId); + await session.runtime.closeSession(); + state.emit({ type: "session.closed", sessionId: input.sessionId }); + state.emit({ type: "request.completed", requestId: input.requestId }); + return; + } + case "session.archive": + case "session.unarchive": + case "session.revert": + state.emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: `${input.type} is not supported by this ACP provider` }, + }); + } +} + +async function discover( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + launch: state.launch, + boundarySessionId: "catalog", + env: {}, + emit: state.emit, + }); + try { + let catalog = await runtime.discover(input.cwd); + const config = runtime.configAccess(); + for (const transformer of state.options.transformers ?? []) { + if (transformer.discover) { + catalog = await transformer.discover(catalog, { sessionId: "catalog", config }); + } + } + state.emit({ type: "catalog", requestId: input.requestId, catalog }); + } finally { + await runtime.closeSession(); + } +} + +async function listSessions( + input: Extract, + state: AcpConnectionState, +): Promise { + const runtime = await AcpRuntime.start({ + options: state.options, + launch: state.launch, + boundarySessionId: "sessions", + env: {}, + emit: state.emit, + }); + try { + const response = await runtime.listSessions(input.cwd); + const selected = input.limit ? response.sessions.slice(0, input.limit) : response.sessions; + state.emit({ + type: "sessions", + requestId: input.requestId, + sessions: selected.map((session) => ({ + persistence: nativePersistence(session.sessionId), + cwd: session.cwd, + title: session.title ?? undefined, + updatedAt: session.updatedAt ?? undefined, + })), + }); + } finally { + await runtime.close(); + } +} + +async function openSession( + input: Extract, + state: AcpConnectionState, +): Promise { + if (state.sessions.has(input.sessionId)) + throw new Error(`Session already exists: ${input.sessionId}`); + const runtime = await AcpRuntime.start({ + options: state.options, + launch: state.launch, + boundarySessionId: input.sessionId, + env: input.config.env, + emit: state.emit, + }); + try { + const capabilities = await runtime.open(input, state.capabilities); + if (state.isClosed()) { + await runtime.closeSession(); + return; + } + state.sessions.set(input.sessionId, { + runtime, + capabilities, + mutationLane: Promise.resolve(), + }); + } catch (error) { + await runtime.close(); + throw error; + } +} + +async function mutateSession( + session: AcpBoundarySession, + operation: () => Promise, +): Promise { + const result = session.mutationLane.then(operation); + session.mutationLane = result.catch(() => undefined); + return result; +} + +function requireSession(state: AcpConnectionState, sessionId: string): AcpBoundarySession { + const session = state.sessions.get(sessionId); + if (!session) throw new Error(`Unknown session: ${sessionId}`); + return session; +} + +interface StartRuntimeOptions { + launch?: ProviderLaunch; + options: RunAcpProviderOptions; + boundarySessionId: string; + env: Readonly>; + emit(event: ProviderEvent): void; +} + +class AcpRuntime { + readonly connection: ClientSideConnection; + agentCapabilities: AgentCapabilities = {}; + nativeSessionId = ""; + private readonly child: ChildProcessWithoutNullStreams | null; + private emit: (event: ProviderEvent) => void; + private readonly messages = new Map(); + private readonly toolCalls = new Map(); + private readonly pendingCompactions = new Set(); + private readonly permissions = new Map< + string, + { request: RequestPermissionRequest; resolve(response: RequestPermissionResponse): void } + >(); + private readonly transformers: readonly AcpTransformer[]; + private configOptions: SessionConfigOption[] = []; + private modes: NewSessionResponse["modes"] = null; + private commandWaiter: (() => void) | null = null; + private messageSequence = 0; + /** + * Fallback ids for streamed chunks that carry no `messageId`, one per + * chunk kind. Consecutive chunks of a kind continue the same timeline + * item; a chunk of the other kind, a tool call, a plan, a user message, + * or a turn boundary ends it (#4699). + */ + private readonly fallbackChunkIds = new Map< + "agent_message_chunk" | "agent_thought_chunk", + string + >(); + private closing = false; + private processFailed = false; + private configTransaction = false; + private stagedTransformerConfig: ProviderConfigState | null = null; + private readonly closeConnector: () => Promise; + private notificationLane: Promise = Promise.resolve(); + private promptLane: Promise = Promise.resolve(); + private activePrompt: { turnId: string; settled: Promise } | null = null; + + static async start(options: StartRuntimeOptions): Promise { + let child: ChildProcessWithoutNullStreams | null = null; + let spawnFailure: Promise | null = null; + let stream: Stream; + let closeConnector = async () => {}; + if (options.options.command) { + // COMPAT(pluginProviderLaunch): added in v0.10.0, remove after 2027-03-29 once plugin host floor >= v0.10.0. + // Standalone callers and older hosts do not send a daemon-resolved launch. + const launch = options.launch ?? { + command: options.options.command[0], + args: options.options.command.slice(1), + env: process.env, + }; + child = spawnProcess(launch.command, launch.args, { + env: { ...launch.env, ...options.env }, + stdio: "pipe", + }); + spawnFailure = new Promise((_resolve, reject) => child!.once("error", reject)); + child.stderr.on("data", () => undefined); + const output = Writable.toWeb(child.stdin) as unknown as Parameters[0]; + const input = Readable.toWeb(child.stdout) as unknown as Parameters[1]; + stream = ndJsonStream(output, input); + } else { + const owned = ownConnectorStream(await options.options.connector()); + stream = owned.stream; + closeConnector = owned.close; + } + const runtime = new AcpRuntime(stream, child, closeConnector, options); + try { + const initialize = withTimeout( + runtime.call( + runtime.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "paseo", version: "1" }, + }), + ), + options.options.acpOptions?.startupTimeoutMs ?? 10_000, + `ACP provider ${options.options.id} did not initialize`, + ); + const initialized = spawnFailure + ? await Promise.race([initialize, spawnFailure]) + : await initialize; + runtime.agentCapabilities = initialized.agentCapabilities ?? {}; + return runtime; + } catch (error) { + await runtime.close(); + throw error; + } + } + + private constructor( + stream: Stream, + child: ChildProcessWithoutNullStreams | null, + closeConnector: () => Promise, + private readonly options: StartRuntimeOptions, + ) { + this.child = child; + this.closeConnector = closeConnector; + this.emit = options.emit; + this.transformers = options.options.transformers ?? []; + const client: Client = { + requestPermission: (request) => this.requestPermission(request), + sessionUpdate: (notification) => + this.enqueueNotification(() => this.sessionUpdate(notification)), + }; + this.connection = new ClientSideConnection( + () => client, + routeVendorNotifications(stream, (method, params) => this.vendorNotification(method, params)), + ); + if (!child) void this.connection.closed.then(() => this.handleUnexpectedTransportClose()); + child?.on("error", (error) => { + this.processFailed = true; + this.settlePendingWork(); + if (this.closing || !this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(error) }, + }); + }); + child?.once("close", (code, signal) => { + if (this.closing || this.processFailed || !this.nativeSessionId) return; + this.processFailed = true; + this.settlePendingWork(); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: `ACP process exited (${signal ?? code ?? "unknown"})` }, + }); + }); + } + + async open( + input: Extract, + connectionCapabilities: readonly ProviderCapability[], + ): Promise { + const mcpServers = toAcpMcpServers(input.config); + const nativeSessionId = readNativeSessionId(input.persistence); + const metadata = { + _paseo: { + systemPrompt: input.config.systemPrompt, + providerOptions: input.config.providerOptions, + toolPolicy: input.config.toolPolicy, + persist: input.config.persist, + }, + }; + let response: Pick; + if (nativeSessionId) { + response = await this.call( + this.connection.loadSession({ + sessionId: nativeSessionId, + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + this.nativeSessionId = nativeSessionId; + } else { + const newSession = await this.call( + this.connection.newSession({ + cwd: input.config.cwd, + mcpServers, + _meta: metadata, + }), + ); + response = newSession; + this.nativeSessionId = newSession.sessionId; + } + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + await this.applyInitialConfig(input.config); + const sessionCapabilities = connectionCapabilities.filter( + (capability) => + capability !== "session.configure" || + this.modes !== null || + this.configOptions.length > 0 || + this.transformers.some((transformer) => transformer.configure !== undefined), + ); + this.emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: sessionCapabilities, + restoration: "core", + persistence: nativePersistence(this.nativeSessionId), + title: input.config.title, + cwd: input.config.cwd, + }); + this.emitConfig(); + if (this.options.options.acpOptions?.waitForInitialCommands) { + await this.waitForInitialCommands(); + } + this.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return sessionCapabilities; + } + + async discover(cwd = process.cwd()): Promise { + const response = await this.call(this.connection.newSession({ cwd, mcpServers: [] })); + this.nativeSessionId = response.sessionId; + this.modes = response.modes; + this.configOptions = response.configOptions ?? []; + return toProviderCatalog(response.modes, response.configOptions ?? []); + } + + prompt(input: Extract): Promise { + const admission = this.promptLane.then(() => this.admitPrompt(input)); + this.promptLane = admission.catch(() => undefined); + return admission; + } + + private async admitPrompt( + input: Extract, + ): Promise { + const active = this.activePrompt; + if (active) { + await this.interrupt(); + await active.settled; + } + const prompt = toAcpPrompt(input.prompt); + const turnId = `acp:${input.prompt.clientMessageId}`; + this.fallbackChunkIds.clear(); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "user_message", + id: input.prompt.clientMessageId, + text: prompt + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + clientMessageId: input.prompt.clientMessageId, + }, + }); + this.emit({ + type: "session.prompt_result", + sessionId: this.options.boundarySessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "started", + }); + const settled = this.call( + this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), + ).then( + (response): void => { + const state = response.stopReason === "cancelled" ? "canceled" : "completed"; + this.fallbackChunkIds.clear(); + this.terminalizeTransientItems(state); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state, + }); + return undefined; + }, + (error): void => { + this.fallbackChunkIds.clear(); + this.terminalizeTransientItems("failed"); + this.emit({ + type: "session.turn", + sessionId: this.options.boundarySessionId, + turnId, + state: "failed", + error: { message: describeError(error) }, + }); + return undefined; + }, + ); + this.activePrompt = { turnId, settled }; + void settled.finally(() => { + if (this.activePrompt?.turnId === turnId) this.activePrompt = null; + }); + } + + interrupt(): Promise { + return this.call(this.connection.cancel({ sessionId: this.nativeSessionId })); + } + + listSessions(cwd?: string) { + return this.call(this.connection.listSessions({ cwd })); + } + + async drainNotifications(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await this.notificationLane; + } + + respondToPermission( + permissionId: string, + response: Extract["response"], + ): void { + const pending = this.permissions.get(permissionId); + if (!pending) throw new Error(`Unknown ACP permission: ${permissionId}`); + const selected = selectPermissionOption(pending.request.options, response); + if (response.selectedActionId !== undefined && !selected) { + throw new Error( + `ACP permission action '${response.selectedActionId}' does not exist or does not match '${response.behavior}' behavior`, + ); + } + this.permissions.delete(permissionId); + pending.resolve({ + outcome: selected + ? { outcome: "selected", optionId: selected.optionId } + : { outcome: "cancelled" }, + }); + this.emit({ + type: "session.permission_resolved", + sessionId: this.options.boundarySessionId, + permissionId, + }); + } + + async configure( + changes: Extract["changes"], + ): Promise { + const ordered: AcpConfigChange[] = []; + if (changes.model !== undefined) ordered.push({ target: "model", value: changes.model }); + if (changes.mode !== undefined) ordered.push({ target: "mode", value: changes.mode }); + if (changes.thinkingOption !== undefined) { + ordered.push({ target: "thinking", value: changes.thinkingOption }); + } + for (const [id, value] of Object.entries(changes.settings ?? {})) { + ordered.push({ target: "setting", id, value }); + } + const previousModes = this.modes ? structuredClone(this.modes) : null; + const previousOptions = structuredClone(this.configOptions); + this.configTransaction = true; + this.stagedTransformerConfig = null; + let committedConfig: ProviderConfigState | null = null; + try { + for (const change of ordered) await this.applyConfigChange(change); + committedConfig = + this.stagedTransformerConfig ?? toProviderConfigState(this.modes, this.configOptions); + } catch (error) { + try { + await this.restoreConfig(previousModes, previousOptions); + } catch (rollbackError) { + const failure = new AggregateError( + [error, rollbackError], + `ACP configuration failed and rollback could not restore the previous state`, + ); + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: describeError(failure) }, + }); + await this.close(); + throw failure; + } + this.modes = previousModes; + this.configOptions = previousOptions; + this.stagedTransformerConfig = null; + throw error; + } finally { + this.configTransaction = false; + } + this.stagedTransformerConfig = null; + this.emitConfigSnapshot(committedConfig); + } + + async closeSession(): Promise { + if (this.nativeSessionId) { + await withTimeout( + this.call(this.connection.closeSession({ sessionId: this.nativeSessionId })), + 1_000, + `ACP session ${this.nativeSessionId} did not close`, + ).catch(() => undefined); + } + await this.close(); + } + + async close(): Promise { + if (this.closing) return; + this.closing = true; + this.settlePendingWork(); + await this.closeConnector(); + const child = this.child; + if (!child || this.processFailed) return; + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise((resolve) => child.once("close", () => resolve())); + await terminateProcess(child, "SIGTERM"); + if (await settlesWithin(closed, 1_000)) return; + await terminateProcess(child); + if (!(await settlesWithin(closed, 1_000))) { + throw new Error(`ACP provider ${this.options.options.id} did not terminate after SIGKILL`); + } + } + + private async applyInitialConfig(config: ProviderSessionConfig): Promise { + if (config.model) await this.applyConfigChange({ target: "model", value: config.model }); + if (config.mode) await this.applyConfigChange({ target: "mode", value: config.mode }); + if (config.thinkingOption) { + await this.applyConfigChange({ target: "thinking", value: config.thinkingOption }); + } + for (const [id, value] of Object.entries(config.settings)) { + await this.applyConfigChange({ target: "setting", id, value }); + } + } + + private async applyConfigChange(change: AcpConfigChange): Promise { + const access = this.configAccess(); + for (const transformer of this.transformers) { + if ( + (await transformer.configure?.(change, { + sessionId: this.options.boundarySessionId, + config: access, + })) === "handled" + ) { + return; + } + } + if (change.target === "mode") { + if (!change.value || !this.modes) { + throw new Error("ACP session does not expose mode configuration"); + } + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: change.value, + }), + ); + this.modes = { ...this.modes, currentModeId: change.value }; + return; + } + let category: "model" | "thought_level" | null = null; + if (change.target === "model") category = "model"; + if (change.target === "thinking") category = "thought_level"; + const configId = + change.target === "setting" + ? change.id + : this.configOptions.find((option) => option.category === category)?.id; + if (!configId) { + throw new Error(`ACP session does not expose ${change.target} configuration`); + } + if (change.value === null) { + throw new Error(`ACP configuration ${configId} cannot be cleared`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId, + ...(typeof change.value === "boolean" + ? { type: "boolean" as const, value: change.value } + : { value: String(change.value) }), + }), + ); + this.configOptions = response.configOptions; + } + + configAccess(): AcpConfigAccess { + return { + read: async () => configValues(this.configOptions), + set: async (id, value) => { + if (typeof value !== "string" && typeof value !== "boolean") { + throw new Error(`ACP configuration ${id} accepts only string or boolean values`); + } + const response = await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + this.configOptions = response.configOptions; + }, + }; + } + + private emitConfig(): void { + if (this.configTransaction) return; + this.emitConfigSnapshot(toProviderConfigState(this.modes, this.configOptions)); + } + + private emitConfigSnapshot(config: ProviderConfigState): void { + this.emit({ + type: "session.config", + sessionId: this.options.boundarySessionId, + config, + }); + } + + private async restoreConfig( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], + ): Promise { + if (modes?.currentModeId && this.modes?.currentModeId !== modes.currentModeId) { + await this.call( + this.connection.setSessionMode({ + sessionId: this.nativeSessionId, + modeId: modes.currentModeId, + }), + ); + } + const currentValues = configValues(this.configOptions); + for (const option of options) { + const value = option.currentValue; + if (currentValues[option.id] === value) continue; + await this.call( + this.connection.setSessionConfigOption({ + sessionId: this.nativeSessionId, + configId: option.id, + ...(typeof value === "boolean" ? { type: "boolean" as const, value } : { value }), + }), + ); + } + } + + private requestPermission(request: RequestPermissionRequest): Promise { + const permissionId = `permission:${request.toolCall.toolCallId}`; + return new Promise((resolve) => { + this.permissions.set(permissionId, { request, resolve }); + this.emit({ + type: "session.permission", + sessionId: this.options.boundarySessionId, + request: { + id: permissionId, + name: request.toolCall.name ?? request.toolCall.title ?? "Tool", + kind: "tool", + title: request.toolCall.title ?? undefined, + input: jsonRecord(request.toolCall.rawInput), + actions: request.options.map((option) => ({ + id: option.optionId, + label: option.name, + behavior: option.kind.startsWith("allow") ? "allow" : "deny", + })), + }, + }); + }); + } + + private sessionUpdate(notification: SessionNotification): void { + if (notification.sessionId !== this.nativeSessionId) return; + this.reduceUpdate(notification.update); + } + + private reduceUpdate(update: SessionUpdate): void { + if ( + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "user_message_chunk" + ) { + if (update.sessionUpdate === "user_message_chunk") { + this.fallbackChunkIds.clear(); + return; + } + // A chunk of one kind ends the other kind's item, whatever its content + // or id: reasoning, then text, then reasoning again are three items. + this.fallbackChunkIds.delete( + update.sessionUpdate === "agent_message_chunk" + ? "agent_thought_chunk" + : "agent_message_chunk", + ); + if (update.content.type !== "text") return; + const id = this.resolveChunkId(update.sessionUpdate, update.messageId); + const text = `${this.messages.get(id) ?? ""}${update.content.text}`; + this.messages.set(id, text); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: update.sessionUpdate === "agent_message_chunk" ? "assistant_message" : "reasoning", + id, + text, + ...(update.sessionUpdate === "agent_message_chunk" + ? { messageId: update.messageId ?? undefined } + : {}), + }, + }); + return; + } + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { + if (update.sessionUpdate === "tool_call") this.fallbackChunkIds.clear(); + this.reduceToolCall(update); + return; + } + if (update.sessionUpdate === "plan") this.fallbackChunkIds.clear(); + this.reduceStateUpdate(update); + } + + private resolveChunkId( + kind: "agent_message_chunk" | "agent_thought_chunk", + messageId: string | null | undefined, + ): string { + if (messageId) { + this.fallbackChunkIds.delete(kind); + return messageId; + } + let id = this.fallbackChunkIds.get(kind); + if (!id) { + id = `${kind}:${++this.messageSequence}`; + this.fallbackChunkIds.set(kind, id); + } + return id; + } + + private reduceStateUpdate(update: SessionUpdate): void { + if (update.sessionUpdate === "plan") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "todo", + id: "acp-plan", + items: update.entries.map((entry, index) => ({ + id: String(index), + text: entry.content, + completed: entry.status === "completed", + status: entry.status, + })), + }, + }); + return; + } + if (update.sessionUpdate === "available_commands_update") { + const commands = update.availableCommands.map((command) => ({ + name: command.name, + description: command.description, + argumentHint: command.input?.hint, + })); + this.emit({ type: "session.commands", sessionId: this.options.boundarySessionId, commands }); + this.commandWaiter?.(); + this.commandWaiter = null; + return; + } + if (update.sessionUpdate === "current_mode_update") { + if (this.modes) this.modes = { ...this.modes, currentModeId: update.currentModeId }; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "config_option_update") { + this.configOptions = update.configOptions; + this.emitConfig(); + return; + } + if (update.sessionUpdate === "usage_update") { + this.emit({ + type: "session.usage", + sessionId: this.options.boundarySessionId, + usage: { + contextWindowUsedTokens: update.used, + contextWindowMaxTokens: update.size, + totalCostUsd: update.cost?.currency === "USD" ? update.cost.amount : undefined, + }, + }); + return; + } + if (update.sessionUpdate === "compaction_update") { + if (update.status === "completed") this.pendingCompactions.delete(update.compactionId); + else this.pendingCompactions.add(update.compactionId); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { + type: "compaction", + id: update.compactionId, + status: update.status === "completed" ? "completed" : "loading", + }, + }); + } + } + + private terminalizeTransientItems(state: "completed" | "failed" | "canceled"): void { + for (const [id, snapshot] of this.toolCalls) { + if (snapshot.status !== "pending" && snapshot.status !== "in_progress") continue; + const completed: AcpToolCallSnapshot = { + ...snapshot, + status: state === "completed" ? "completed" : "failed", + }; + this.toolCalls.set(id, completed); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(completed), + }); + } + for (const id of this.pendingCompactions) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: { type: "compaction", id, status: "completed" }, + }); + } + this.pendingCompactions.clear(); + } + + private reduceToolCall(update: ToolCall | ToolCallUpdate): void { + const current = this.toolCalls.get(update.toolCallId); + let snapshot = mergeToolCallSnapshot(update, current); + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) + snapshot = transformer.toolCall?.(snapshot, context) ?? snapshot; + this.toolCalls.set(snapshot.id, snapshot); + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: toolTimelineItem(snapshot), + }); + } + + private vendorNotification(method: string, params: unknown): void { + const notification = { method, params: jsonValue(params) }; + const context = { sessionId: this.options.boundarySessionId }; + for (const transformer of this.transformers) { + const transformed = transformer.notification?.(notification, context); + if (!transformed) continue; + const updates = Array.isArray(transformed) ? transformed : [transformed]; + for (const update of updates) this.emitVendorUpdate(update); + } + } + + private emitVendorUpdate(update: AcpVendorUpdate): void { + if (update.type === "commands") { + this.emit({ + type: "session.commands", + sessionId: this.options.boundarySessionId, + commands: [...update.commands], + }); + } else if (update.type === "config") { + if (this.configTransaction) this.stagedTransformerConfig = update.config; + else this.emitConfigSnapshot(update.config); + } else if (update.type === "timeline") { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: update.item, + }); + } else { + this.emit({ + type: "session.notice", + sessionId: this.options.boundarySessionId, + notice: update.notice, + }); + } + } + + private call(operation: Promise): Promise { + if (this.connection.signal.aborted) { + return Promise.reject(new Error("ACP transport is closed")); + } + return Promise.race([ + operation, + this.connection.closed.then(() => { + throw new Error("ACP transport closed unexpectedly"); + }), + ]); + } + + private enqueueNotification(operation: () => void): Promise { + const result = this.notificationLane.then(operation); + this.notificationLane = result.catch(() => undefined); + return result; + } + + private handleUnexpectedTransportClose(): void { + if (this.closing || this.processFailed) return; + this.processFailed = true; + this.settlePendingWork(); + if (!this.nativeSessionId) return; + this.emit({ + type: "session.runtime_failed", + sessionId: this.options.boundarySessionId, + error: { message: "ACP transport closed unexpectedly" }, + }); + } + + private settlePendingWork(): void { + for (const permission of this.permissions.values()) { + permission.resolve({ outcome: { outcome: "cancelled" } }); + } + this.permissions.clear(); + this.commandWaiter?.(); + this.commandWaiter = null; + } + + private waitForInitialCommands(): Promise { + const timeoutMs = this.options.options.acpOptions?.initialCommandsTimeoutMs ?? 1_000; + const commands = new Promise((resolve) => { + this.commandWaiter = resolve; + }); + const timeout = new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }); + return Promise.race([commands, timeout]).finally(() => { + this.commandWaiter = null; + }); + } +} + +function routeVendorNotifications( + stream: Stream, + receive: (method: string, params: unknown) => void, +): Stream { + const clientMethods = new Set(Object.values(CLIENT_METHODS)); + return { + writable: stream.writable, + readable: stream.readable.pipeThrough( + new TransformStream({ + transform(message, controller) { + if ("method" in message && !("id" in message) && !clientMethods.has(message.method)) { + // The SDK dispatches extensions asynchronously and can resolve a later response first. + // Run synchronous vendor transforms in wire order, inside the configuration transaction. + try { + receive(message.method, message.params); + } catch (error) { + console.error("Error handling ACP vendor notification", error); + } + } else controller.enqueue(message); + }, + }), + ), + }; +} + +function selectPermissionOption( + options: RequestPermissionRequest["options"], + response: ProviderPermissionResponse, +): RequestPermissionRequest["options"][number] | null { + if (response.selectedActionId !== undefined) { + return ( + options.find( + (option) => + option.optionId === response.selectedActionId && + permissionOptionBehavior(option) === response.behavior, + ) ?? null + ); + } + return options.find((option) => permissionOptionBehavior(option) === response.behavior) ?? null; +} + +function permissionOptionBehavior( + option: RequestPermissionRequest["options"][number], +): ProviderPermissionResponse["behavior"] { + return option.kind.startsWith("allow") ? "allow" : "deny"; +} + +function ownConnectorStream(source: Stream): { + stream: Stream; + close(): Promise; +} { + const reader = source.readable.getReader(); + const writer = source.writable.getWriter(); + let closed = false; + const stream: Stream = { + readable: new ReadableStream({ + async pull(controller) { + try { + const next = await reader.read(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + } catch (error) { + controller.error(error); + } + }, + cancel: (reason) => reader.cancel(reason), + }), + writable: new WritableStream({ + write: (message) => writer.write(message), + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }), + }; + return { + stream, + async close() { + if (closed) return; + closed = true; + await Promise.allSettled([ + reader.cancel(new Error("Paseo closed the ACP connector")), + writer.close(), + ]); + }, + }; +} + +function mergeToolCallSnapshot( + update: ToolCall | ToolCallUpdate, + current: AcpToolCallSnapshot | undefined, +): AcpToolCallSnapshot { + return { + id: update.toolCallId, + name: firstText(update.name, current?.name), + title: firstText(update.title, current?.title, update.name) ?? "Tool call", + kind: firstText(update.kind, current?.kind), + status: firstDefined(update.status, current?.status, "pending") ?? "pending", + input: jsonValue(firstDefined(update.rawInput, current?.input, null)), + output: jsonValue(firstDefined(update.rawOutput, current?.output, null)), + locations: update.locations?.map((location) => location.path) ?? current?.locations ?? [], + }; +} + +function firstDefined(...values: Array): Value | undefined { + return values.find((value) => value !== undefined); +} + +function firstText(...values: Array): string | undefined { + return values.find((value): value is string => typeof value === "string"); +} + +function toAcpMcpServers(config: ProviderSessionConfig) { + return Object.entries(config.mcpServers).map(([name, server]) => { + if (server.type === "stdio") { + return { + name, + command: server.command, + args: server.args ?? [], + env: Object.entries(server.env ?? {}).map(([key, value]) => ({ name: key, value })), + }; + } + return { + type: server.type, + name, + url: server.url, + headers: Object.entries(server.headers ?? {}).map(([key, value]) => ({ name: key, value })), + }; + }); +} + +function toAcpPrompt( + prompt: Extract["prompt"], +): ContentBlock[] { + if (prompt.input.type === "command") { + const suffix = prompt.input.arguments ? ` ${prompt.input.arguments}` : ""; + return [{ type: "text", text: `/${prompt.input.name}${suffix}` }]; + } + return prompt.input.content.map((content) => { + if (content.type === "text" || content.type === "image") return content; + return { type: "text", text: JSON.stringify(content) }; + }); +} + +function nativePersistence(sessionId: string): ProviderPersistence { + return { version: 1, data: { sessionId } }; +} + +function readNativeSessionId(persistence: ProviderPersistence | undefined): string | null { + if ( + !persistence || + typeof persistence.data !== "object" || + persistence.data === null || + Array.isArray(persistence.data) + ) + return null; + return typeof persistence.data.sessionId === "string" ? persistence.data.sessionId : null; +} + +function toProviderConfigState( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderConfigState { + const model = options.find((option) => option.category === "model"); + const thinking = options.find((option) => option.category === "thought_level"); + return { + model: selectedValue(model), + mode: modes?.currentModeId, + thinkingOption: selectedValue(thinking), + models: selectOptions(model).map((option) => ({ id: option.value, label: option.label })), + modes: + modes?.availableModes.map((mode) => ({ + id: mode.id, + label: mode.name, + description: mode.description ?? undefined, + })) ?? [], + thinkingOptions: selectOptions(thinking).map((option) => ({ + id: option.value, + label: option.label, + })), + settings: options + .filter((option) => option.category !== "model" && option.category !== "thought_level") + .map(toProviderSetting), + }; +} + +function toProviderCatalog( + modes: NewSessionResponse["modes"], + options: readonly SessionConfigOption[], +): ProviderCatalog { + const state = toProviderConfigState(modes, options); + return { + models: state.models, + modes: state.modes, + thinkingOptions: state.thinkingOptions, + defaultModel: state.model, + defaultMode: state.mode, + defaultThinkingOption: state.thinkingOption, + }; +} + +function toProviderSetting(option: SessionConfigOption): ProviderSetting { + if (option.type === "boolean") { + return { + type: "toggle", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + }; + } + return { + type: "select", + id: option.id, + label: option.name, + description: option.description ?? undefined, + value: option.currentValue, + options: selectOptions(option), + }; +} + +function selectOptions( + option: SessionConfigOption | undefined, +): Array<{ label: string; value: string }> { + if (!option || option.type === "boolean") return []; + return option.options.flatMap((candidate) => + "group" in candidate + ? candidate.options.map((nested) => ({ label: nested.name, value: nested.value })) + : [{ label: candidate.name, value: candidate.value }], + ); +} + +function selectedValue(option: SessionConfigOption | undefined): string | undefined { + return option && option.type !== "boolean" ? option.currentValue : undefined; +} + +function configValues( + options: readonly SessionConfigOption[], +): Readonly>> { + return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); +} + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { + const input = jsonRecord(snapshot.input); + const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; + const detail = isEdit + ? { + type: "edit" as const, + filePath: + typeof input.filePath === "string" ? input.filePath : (snapshot.locations[0] ?? ""), + oldString: typeof input.oldString === "string" ? input.oldString : undefined, + newString: typeof input.newString === "string" ? input.newString : undefined, + unifiedDiff: typeof input.unifiedDiff === "string" ? input.unifiedDiff : undefined, + } + : { type: "unknown" as const, input: snapshot.input, output: snapshot.output }; + const status = + snapshot.status === "in_progress" || snapshot.status === "pending" + ? "running" + : snapshot.status; + const base = { + type: "tool_call" as const, + id: snapshot.id, + callId: snapshot.id, + name: snapshot.name ?? snapshot.title, + detail, + }; + if (status === "failed") return { ...base, status, error: snapshot.output }; + return { ...base, status, error: null }; +} + +function jsonValue(value: unknown) { + return z.json().parse(value); +} + +function jsonRecord(value: unknown): Record> { + const parsed = jsonValue(value); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return parsed; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + return undefined; + }, + (error) => { + clearTimeout(timeout); + reject(error); + return undefined; + }, + ); + }); +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + return Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]); +} diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts new file mode 100644 index 0000000000..a62b2f2c48 --- /dev/null +++ b/packages/plugin/src/server/acp.test.ts @@ -0,0 +1,1329 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + AgentSideConnection, + PROTOCOL_VERSION, + type Agent, + type SessionUpdate, +} from "@agentclientprotocol/sdk"; +import type { ProviderConnection, ProviderEvent, ProviderInput } from "./provider.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { runAcpProvider, type AcpStreamMessage } from "./acp.js"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function fakeAcp(source: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-acp-test-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "agent.cjs"); + await writeFile(executable, source, "utf8"); + return executable; +} + +async function connect( + executable: string, + capabilities: string[], + transformers?: Parameters[0]["transformers"], +): Promise<{ connection: ProviderConnection; events: ProviderEvent[] }> { + const registration = runAcpProvider({ + id: "test-acp", + label: "Test ACP", + command: [process.execPath, executable], + transformers, + }); + const connection = await registration.connect({ versions: [1], capabilities }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + return { connection, events }; +} + +function openInput( + settings: Record = {}, +): Extract { + return { + type: "session.open", + requestId: "open-1", + sessionId: "session-1", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings, persist: true }, + history: "skip", + }; +} + +async function waitForEvent( + events: ProviderEvent[], + predicate: (event: ProviderEvent) => boolean, +): Promise { + await expect.poll(() => events.find(predicate), { timeout: 3_000 }).toBeTruthy(); + return events.find(predicate)!; +} + +type AcpRequestMessage = Extract; + +interface ConnectorInstance { + readonly requests: AcpRequestMessage[]; + readsCanceled: boolean; + writesClosed: boolean; + closeFromAgent(): void; + request(method: string, params: unknown): Promise; + respond(request: AcpRequestMessage, result: unknown): void; + reject(request: AcpRequestMessage, message: string): void; + notify(method: string, params: unknown): void; +} + +interface ConnectorResponse { + resolve(value: unknown): void; + reject(error: Error): void; +} + +interface ConnectorHarnessOptions { + capabilities?: Record; + configOptions?: unknown[]; + /** Session updates the agent streams for a prompt (keyed by its text) before ending the turn. */ + promptUpdates?(promptText: string): unknown[]; + handleMessage?(instance: ConnectorInstance, message: AcpStreamMessage): boolean; + handleRequest?(instance: ConnectorInstance, request: AcpRequestMessage): boolean; +} + +function countRequests(instance: ConnectorInstance, method: string): number { + return instance.requests.filter((request) => request.method === method).length; +} + +function receiveConnectorMessage( + instance: ConnectorInstance, + message: AcpStreamMessage, + options?: ConnectorHarnessOptions, +): void { + if (!("method" in message)) return; + if ("id" in message) instance.requests.push(message as AcpRequestMessage); + if (options?.handleMessage?.(instance, message)) return; + if (!("id" in message)) return; + const request = message as AcpRequestMessage; + if (options?.handleRequest?.(instance, request)) return; + respondToConnectorRequest(instance, request, options); +} + +function respondToConnectorRequest( + instance: ConnectorInstance, + request: AcpRequestMessage, + options?: ConnectorHarnessOptions, +): void { + if (request.method === "initialize") { + instance.respond(request, { + protocolVersion: request.params + ? (request.params as { protocolVersion: number }).protocolVersion + : PROTOCOL_VERSION, + agentCapabilities: options?.capabilities ?? {}, + }); + } else if (request.method === "session/new" || request.method === "session/load") { + instance.respond(request, { + sessionId: "connector-session", + modes: null, + configOptions: options?.configOptions ?? [], + }); + } else if (request.method === "session/prompt") { + const { prompt } = request.params as { prompt: Array<{ type: string; text?: string }> }; + const promptText = prompt.find((part) => part.type === "text")?.text ?? ""; + for (const update of options?.promptUpdates?.(promptText) ?? []) { + instance.notify("session/update", { sessionId: "connector-session", update }); + } + instance.respond(request, { stopReason: "end_turn" }); + } else if (request.method === "session/close") { + instance.respond(request, {}); + } else if (request.method === "session/set_config_option") { + const params = request.params as { configId: string; value: string | boolean }; + instance.respond(request, { + configOptions: (options?.configOptions ?? []).map((option) => + (option as { id?: string }).id === params.configId + ? Object.assign({}, option, { currentValue: params.value }) + : option, + ), + }); + } +} + +function connectorHarness(options?: ConnectorHarnessOptions) { + const instances: ConnectorInstance[] = []; + const connector = () => { + let controller!: ReadableStreamDefaultController; + let requestSequence = 0; + const responses = new Map(); + const instance: ConnectorInstance = { + requests: [], + readsCanceled: false, + writesClosed: false, + closeFromAgent: () => controller.close(), + request: (method, params) => { + const response = new Promise((resolve, reject) => { + requestSequence += 1; + responses.set(requestSequence, { resolve, reject }); + controller.enqueue({ jsonrpc: "2.0", id: requestSequence, method, params }); + }); + void response.catch(() => undefined); + return response; + }, + respond: (request, result) => controller.enqueue({ jsonrpc: "2.0", id: request.id, result }), + reject: (request, message) => + controller.enqueue({ jsonrpc: "2.0", id: request.id, error: { code: -32603, message } }), + notify: (method, params) => controller.enqueue({ jsonrpc: "2.0", method, params }), + }; + instances.push(instance); + const readable = new ReadableStream({ + start(value) { + controller = value; + }, + cancel() { + instance.readsCanceled = true; + }, + }); + const writable = new WritableStream({ + write(message) { + if (!("method" in message)) { + if (typeof message.id !== "number") return; + const response = responses.get(message.id); + if (!response) return; + responses.delete(message.id); + if ("error" in message) { + response.reject( + new Error( + message.error.data === undefined + ? message.error.message + : `${message.error.message}: ${JSON.stringify(message.error.data)}`, + ), + ); + } else response.resolve(message.result); + return; + } + receiveConnectorMessage(instance, message, options); + }, + close() { + instance.writesClosed = true; + }, + abort() { + instance.writesClosed = true; + }, + }); + return { readable, writable }; + }; + return { connector, instances }; +} + +const basicAgent = `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") setTimeout(() => send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }), 150); + else if (message.method === "session/prompt") send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); +});`; + +describe("runAcpProvider", () => { + it("spawns the daemon-resolved launch for probes, catalogues, listing, and sessions", async () => { + const executable = await fakeAcp(` +if (process.env.LAUNCH_TOKEN !== "resolved" || process.env.CLAUDECODE || process.env.PASEO_NODE_ENV) process.exit(1); +const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + let result; + if (message.method === "initialize") result = { protocolVersion: message.params.protocolVersion, agentCapabilities: { sessionCapabilities: { list: {} } } }; + else if (message.method === "session/new") result = { sessionId: "native", configOptions: [{ id: "model", name: "Model", category: "model", type: "select", currentValue: "resolved", options: [{ value: "resolved", name: process.env.SESSION_TOKEN || "probe" }] }] }; + else if (message.method === "session/list") result = { sessions: [] }; + else return; + send({ jsonrpc: "2.0", id: message.id, result }); +});`); + const registration = runAcpProvider({ + id: "launch-acp", + label: "Launch ACP", + command: ["missing-default-command"], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.list"], + launch: { command: process.execPath, args: [executable], env: { LAUNCH_TOKEN: "resolved" } }, + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + await connection.send({ type: "catalog", requestId: "catalog" }); + expect(await waitForEvent(events, (event) => event.type === "catalog")).toMatchObject({ + catalog: { models: [{ id: "resolved", label: "probe" }] }, + }); + await connection.send({ type: "sessions", requestId: "list" }); + expect(await waitForEvent(events, (event) => event.type === "sessions")).toMatchObject({ + sessions: [], + }); + const input = openInput(); + input.config.env = { SESSION_TOKEN: "session" }; + await connection.send(input); + expect(await waitForEvent(events, (event) => event.type === "session.config")).toMatchObject({ + config: { models: [{ id: "resolved", label: "session" }] }, + }); + } finally { + await connection.close(); + } + }); + + it("rejects connection cleanly when the ACP executable does not exist", async () => { + const registration = runAcpProvider({ + id: "missing-acp", + label: "Missing ACP", + command: [path.join(tmpdir(), `missing-acp-${process.pid}`)], + acpOptions: { startupTimeoutMs: 250 }, + }); + + await expect( + registration.connect({ versions: [1], capabilities: ["prompt.message"] }), + ).rejects.toThrow(/ENOENT|spawn/u); + }); + + it("accepts an official ACP SDK stream connector instead of a command", async () => { + const connector = () => { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agent: Agent = { + initialize: async () => ({ protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} }), + newSession: async () => ({ sessionId: "sdk-session", modes: null, configOptions: [] }), + prompt: async () => ({ stopReason: "end_turn" }), + cancel: async () => undefined, + }; + const agentConnection = new AgentSideConnection(() => agent, { + writable: agentToClient.writable, + readable: clientToAgent.readable, + }); + void agentConnection; + return { + writable: clientToAgent.writable, + readable: agentToClient.readable, + }; + }; + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + + await waitForEvent(events, (event) => event.type === "session.ready"); + expect(connection.capabilities).toEqual(["prompt.message"]); + await connection.close(); + }); + + it("maps permission behavior to the first matching ACP option", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const allow = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-allow", + title: "Allow tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-always", name: "Always allow", kind: "allow_always" }, + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-allow", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-allow", + response: { behavior: "allow" }, + }); + await expect(allow).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-always" }, + }); + + const deny = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-deny", + title: "Deny tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-always", name: "Always reject", kind: "reject_always" }, + { optionId: "reject-once", name: "Reject once", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => event.type === "session.permission" && event.request.id === "permission:tool-deny", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-deny", + response: { behavior: "deny" }, + }); + await expect(deny).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "reject-always" }, + }); + await connection.close(); + }); + + it("rejects an explicit ACP permission option with the wrong behavior", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "sdk-acp", + label: "SDK ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "permission"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const permission = harness.instances[1]!.request("session/request_permission", { + sessionId: "connector-session", + toolCall: { + toolCallId: "tool-explicit", + title: "Explicit tool", + kind: "execute", + status: "pending", + content: [], + rawInput: {}, + }, + options: [ + { optionId: "allow-once", name: "Allow", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }); + await waitForEvent( + events, + (event) => + event.type === "session.permission" && event.request.id === "permission:tool-explicit", + ); + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "missing" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && event.error.message.includes("'missing'"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "reject-once" }, + }); + await expect( + waitForEvent( + events, + (event) => + event.type === "session.runtime_failed" && + event.error.message.includes("'reject-once'") && + event.error.message.includes("does not match 'allow' behavior"), + ), + ).resolves.toBeDefined(); + + await connection.send({ + type: "session.permission", + sessionId: "session-1", + permissionId: "permission:tool-explicit", + response: { behavior: "allow", selectedActionId: "allow-once" }, + }); + await expect(permission).resolves.toEqual({ + outcome: { outcome: "selected", optionId: "allow-once" }, + }); + await connection.close(); + }); + + it("closes both connector directions for the probe and live session", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "owned-acp", + label: "Owned ACP", + connector: harness.connector, + }); + + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + expect(harness.instances).toHaveLength(1); + expect(harness.instances[0]).toMatchObject({ readsCanceled: true, writesClosed: true }); + + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + + expect(harness.instances).toHaveLength(2); + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + }); + + it("publishes runtime failure on unexpected connector EOF", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "eof-acp", + label: "EOF ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + harness.instances[1]!.closeFromAgent(); + + await expect( + waitForEvent(events, (event) => event.type === "session.runtime_failed"), + ).resolves.toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("closed") }, + }); + await connection.close(); + }); + + it("settles an active auto prompt before admitting its auto replacement", async () => { + const order: string[] = []; + let firstPrompt: AcpRequestMessage | null = null; + let activePrompts = 0; + let maximumActivePrompts = 0; + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message)) return false; + const request = "id" in message ? (message as AcpRequestMessage) : null; + if (message.method === "session/prompt" && request) { + const promptNumber = firstPrompt ? "second" : "first"; + order.push(`prompt:${promptNumber}`); + activePrompts += 1; + maximumActivePrompts = Math.max(maximumActivePrompts, activePrompts); + if (!firstPrompt) { + firstPrompt = request; + } else { + activePrompts -= 1; + instance.respond(request, { stopReason: "end_turn" }); + } + return true; + } + if (message.method === "session/cancel") { + order.push("cancel"); + activePrompts -= 1; + instance.respond(firstPrompt!, { stopReason: "cancelled" }); + return true; + } + return false; + }, + }); + const registration = runAcpProvider({ + id: "overlap-acp", + label: "Overlap ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await expect.poll(() => countRequests(harness.instances[1]!, "session/prompt")).toBe(1); + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "second", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "second" }] }, + }, + }); + + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === "acp:second" && + event.state === "completed", + ); + expect(order).toEqual(["prompt:first", "cancel", "prompt:second"]); + expect(maximumActivePrompts).toBe(1); + const firstTerminal = events.findIndex( + (event) => + event.type === "session.turn" && event.turnId === "acp:first" && event.state === "canceled", + ); + const secondAdmission = events.findIndex( + (event) => event.type === "session.prompt_result" && event.clientMessageId === "second", + ); + expect(firstTerminal).toBeGreaterThan(-1); + expect(firstTerminal).toBeLessThan(secondAdmission); + await connection.close(); + }); + + it("serializes configuration mutations", async () => { + let releaseMutation: (() => void) | null = null; + const configOptions = [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "a", + options: [ + { name: "A", value: "a" }, + { name: "B", value: "b" }, + ], + }, + ]; + const harness = connectorHarness({ + capabilities: { loadSession: true }, + configOptions, + handleRequest(instance, request) { + if (request.method !== "session/set_config_option") return false; + const value = (request.params as { value: string }).value; + if (value !== "first") return false; + releaseMutation = () => + instance.respond(request, { + configOptions: configOptions.map((option) => + Object.assign({}, option, { currentValue: value }), + ), + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "lane-acp", + label: "Lane ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message", "session.configure"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-first", + sessionId: "session-1", + changes: { model: "first" }, + }); + await connection.send({ + type: "session.configure", + requestId: "configure-second", + sessionId: "session-1", + changes: { model: "second" }, + }); + await expect + .poll(() => countRequests(harness.instances[1]!, "session/set_config_option")) + .toBe(1); + releaseMutation!(); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "configure-second", + ); + expect( + events + .filter( + (event) => event.type === "request.completed" && event.requestId.startsWith("configure-"), + ) + .map((event) => (event as Extract).requestId), + ).toEqual(["configure-first", "configure-second"]); + + await connection.close(); + }); + + it("does not claim exact permission policy, listing, image, or configuration support", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.configure", + "session.list", + "permission.tool_policy", + ]); + + expect(connection.capabilities).toEqual(["prompt.message", "session.configure"]); + await connection.send(openInput()); + const opened = await waitForEvent(events, (event) => event.type === "session.opened"); + expect(opened).toMatchObject({ capabilities: ["prompt.message"] }); + await waitForEvent(events, (event) => event.type === "session.ready"); + await expect( + connection.send({ + type: "session.configure", + requestId: "configure-unsupported", + sessionId: "session-1", + changes: { model: "not-advertised" }, + }), + ).rejects.toThrow("session.configure"); + await connection.close(); + }); + + it("derives optional connection capabilities from ACP initialization", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { + protocolVersion: message.params.protocolVersion, + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true }, + sessionCapabilities: { list: {} }, + }, + } }); +});`); + const { connection } = await connect(executable, [ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + + expect(connection.capabilities).toEqual([ + "prompt.message", + "prompt.image", + "session.list", + "session.persistence", + ]); + await connection.close(); + }); + + it("acknowledges admission before session creation completes", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + let ready = false; + const admission = connection.send(openInput()).then(() => { + ready = true; + return undefined; + }); + + await admission; + expect(ready).toBe(true); + expect(events).toEqual([]); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("rejects provider inputs after the ACP connection closes", async () => { + const harness = connectorHarness(); + const registration = runAcpProvider({ + id: "closed-acp", + label: "Closed ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ versions: [1], capabilities: [] }); + + await connection.close(); + await expect(connection.send({ type: "catalog", requestId: "too-late" })).rejects.toThrow( + "ACP provider connection is closed", + ); + expect(harness.instances).toHaveLength(1); + }); + + it("waits for delayed session creation and closes its connector after connection close", async () => { + let delayedNew: AcpRequestMessage | null = null; + const harness = connectorHarness({ + handleRequest(_instance, request) { + if (request.method !== "session/new") return false; + delayedNew = request; + return true; + }, + }); + const registration = runAcpProvider({ + id: "delayed-open-acp", + label: "Delayed open ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await expect.poll(() => delayedNew).not.toBeNull(); + + let closeSettled = false; + const closing = connection.close().then(() => { + closeSettled = true; + return undefined; + }); + await Promise.resolve(); + expect(closeSettled).toBe(false); + harness.instances[1]!.respond(delayedNew!, { + sessionId: "late-session", + modes: null, + configOptions: [], + }); + await closing; + + expect(harness.instances[1]).toMatchObject({ readsCanceled: true, writesClosed: true }); + expect(events).toEqual([]); + }); + + it("refuses unsupported steering before sending an ACP prompt", async () => { + const executable = await fakeAcp(basicAgent); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "steer-1", + delivery: "steer", + input: { type: "message", content: [{ type: "text", text: "change course" }] }, + }, + }), + ).rejects.toThrow("prompt.steer"); + expect(events.some((event) => event.type === "session.prompt_result")).toBe(false); + await connection.close(); + }); + + it("keeps configuration observable state atomic when a later field fails", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "verbosity", name: "Verbosity", type: "select", currentValue: "quiet", options: [{ name: "Quiet", value: "quiet" }, { name: "Loud", value: "loud" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "verbosity") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "verbosity rejected" } }); + else if (message.method === "session/set_config_option") { model = message.params.value; send({ jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }); } +});`); + const { connection, events } = await connect(executable, [ + "prompt.message", + "session.configure", + ]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.configure", + requestId: "configure-1", + sessionId: "session-1", + changes: { model: "b", settings: { verbosity: "loud" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "configure-1", + ); + const configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ config: { model: "a" } }); + await connection.close(); + }); + + it.each([1, 64])( + "commits transformer config notifications once and discards them on rollback (%i per response)", + async (notificationCount) => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (...messages) => process.stdout.write(messages.map((message) => JSON.stringify(message) + "\\n").join("")); +let model = "a"; +const options = () => [ + { id: "model", name: "Model", category: "model", type: "select", currentValue: model, options: [{ name: "A", value: "a" }, { name: "B", value: "b" }] }, + { id: "late", name: "Late", type: "select", currentValue: "ok", options: [{ name: "Ok", value: "ok" }, { name: "Fail", value: "fail" }] }, +]; +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: options() } }); + else if (message.method === "session/set_config_option" && message.params.configId === "late") send({ jsonrpc: "2.0", id: message.id, error: { code: -32602, message: "late rejected" } }); + else if (message.method === "session/set_config_option") { + model = message.params.value; + send( + ...Array.from({ length: ${notificationCount} }, () => ({ jsonrpc: "2.0", method: "vendor/config", params: { model } })), + { jsonrpc: "2.0", id: message.id, result: { configOptions: options() } }, + ); + } +});`); + const transformer = { + notification(notification: { method: string; params: unknown }) { + if (notification.method !== "vendor/config") return null; + const params = notification.params as { model: string }; + return { + type: "config" as const, + config: { + model: params.model, + models: [{ id: params.model, label: `Transformer ${params.model}` }], + modes: [], + thinkingOptions: [], + settings: [], + }, + }; + }, + }; + const { connection, events } = await connect( + executable, + ["prompt.message", "session.configure"], + [transformer], + ); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + const initialConfigCount = events.filter((event) => event.type === "session.config").length; + + await connection.send({ + type: "session.configure", + requestId: "transformer-initial-failure", + sessionId: "session-1", + changes: { model: "b", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => + event.type === "request.failed" && event.requestId === "transformer-initial-failure", + ); + expect(events.filter((event) => event.type === "session.config")).toHaveLength( + initialConfigCount, + ); + + await connection.send({ + type: "session.configure", + requestId: "transformer-success", + sessionId: "session-1", + changes: { model: "b" }, + }); + await waitForEvent( + events, + (event) => event.type === "request.completed" && event.requestId === "transformer-success", + ); + let configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ + config: { model: "b", models: [{ label: "Transformer b" }] }, + }); + + await connection.send({ + type: "session.configure", + requestId: "transformer-failure", + sessionId: "session-1", + changes: { model: "a", settings: { late: "fail" } }, + }); + await waitForEvent( + events, + (event) => event.type === "request.failed" && event.requestId === "transformer-failure", + ); + configs = events.filter((event) => event.type === "session.config"); + expect(configs).toHaveLength(initialConfigCount + 1); + expect(configs.at(-1)).toMatchObject({ config: { model: "b" } }); + await connection.close(); + }, + ); + + it("gives discovery transformers the discovered session configuration facade", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "catalog-native", modes: null, configOptions: [{ id: "flavor", name: "Flavor", type: "select", currentValue: "mint", options: [{ name: "Mint", value: "mint" }] }] } }); +});`); + let discovered: Readonly> | null = null; + const { connection, events } = await connect( + executable, + [], + [ + { + async discover(catalog, context) { + discovered = await context.config.read(); + return catalog; + }, + }, + ], + ); + + await connection.send({ type: "catalog", requestId: "catalog-1" }); + await waitForEvent(events, (event) => event.type === "catalog"); + expect(discovered).toEqual({ flavor: "mint" }); + await connection.close(); + }); + + it("drains heavy ACP stderr while initializing", async () => { + const executable = await fakeAcp( + `process.stderr.write("x".repeat(2 * 1024 * 1024));\n${basicAgent}`, + ); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + await connection.close(); + }); + + it("publishes runtime failure when the ACP process dies", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + setTimeout(() => process.exit(17), 20); + } +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const failure = await waitForEvent(events, (event) => event.type === "session.runtime_failed"); + expect(failure).toMatchObject({ + sessionId: "session-1", + error: { message: expect.stringContaining("17") }, + }); + await connection.close(); + }); + + it("escalates shutdown when an ACP process ignores SIGTERM", async () => { + const executable = await fakeAcp(`const readline = require("node:readline"); +process.on("SIGTERM", () => {}); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + else if (message.method === "session/new") send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); +});`); + const { connection, events } = await connect(executable, ["prompt.message"]); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + const startedAt = Date.now(); + await connection.close(); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(900); + expect(Date.now() - startedAt).toBeLessThan(2_500); + }); +}); + +describe("runAcpProvider streamed chunks without messageId (#4699)", () => { + interface Chunk { + kind: "agent_message_chunk" | "agent_thought_chunk"; + text: string; + messageId?: string; + } + + it("continues one timeline item per turn instead of one per chunk", async () => { + const chunksByPrompt: Record = { + first: [ + { kind: "agent_message_chunk", text: "- **Current tem" }, + { kind: "agent_message_chunk", text: "perature**: 25°C" }, + ], + second: [{ kind: "agent_message_chunk", text: "next turn" }], + // An explicit-id text chunk between two id-less thoughts separates them. + third: [ + { kind: "agent_thought_chunk", text: "think A" }, + { kind: "agent_message_chunk", text: "say", messageId: "m-explicit" }, + { kind: "agent_thought_chunk", text: "think B" }, + ], + }; + const harness = connectorHarness({ + promptUpdates: (promptText) => + (chunksByPrompt[promptText] ?? []).map((chunk) => { + const update: Record = { + sessionUpdate: chunk.kind, + content: { type: "text", text: chunk.text }, + }; + if (chunk.messageId) update.messageId = chunk.messageId; + return update; + }), + }); + const registration = runAcpProvider({ + id: "chunk-acp", + label: "Chunk ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + for (const clientMessageId of ["first", "second", "third"]) { + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId, + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: clientMessageId }] }, + }, + }); + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === `acp:${clientMessageId}` && + event.state === "completed", + ); + } + + const items = events.flatMap((event) => + event.type === "timeline.item" && + (event.item.type === "assistant_message" || event.item.type === "reasoning") + ? [{ id: event.item.id, text: event.item.text }] + : [], + ); + expect(items).toEqual([ + { id: "agent_message_chunk:1", text: "- **Current tem" }, + { id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" }, + { id: "agent_message_chunk:2", text: "next turn" }, + { id: "agent_thought_chunk:3", text: "think A" }, + { id: "m-explicit", text: "say" }, + { id: "agent_thought_chunk:4", text: "think B" }, + ]); + await connection.close(); + }); +}); + +function textChunk( + text: string, + kind: "agent_message_chunk" | "agent_thought_chunk" = "agent_message_chunk", + messageId?: string, +): SessionUpdate { + return { + sessionUpdate: kind, + content: { type: "text", text }, + ...(messageId === undefined ? {} : { messageId }), + }; +} + +async function streamChunkTurns( + updates: SessionUpdate[], + ending: "completed" | "canceled" | "failed" = "completed", +) { + let promptCount = 0; + const harness = connectorHarness({ + handleRequest(instance, request) { + if (request.method !== "session/prompt") return false; + promptCount++; + for (const update of promptCount === 1 ? updates : [textChunk("Next turn")]) { + instance.notify("session/update", { sessionId: "connector-session", update }); + } + if (promptCount === 1 && ending === "failed") instance.reject(request, "Prompt failed"); + else + instance.respond(request, { + stopReason: promptCount === 1 && ending === "canceled" ? "cancelled" : "end_turn", + }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "chunk-boundaries", + label: "Chunk boundaries", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + try { + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + for (const clientMessageId of ["first", "second"]) { + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId, + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: clientMessageId }] }, + }, + }); + await waitForEvent( + events, + (event) => + event.type === "session.turn" && + event.turnId === `acp:${clientMessageId}` && + event.state === (clientMessageId === "first" ? ending : "completed"), + ); + } + const messages = new Map(); + for (const event of events) { + if ( + event.type === "timeline.item" && + (event.item.type === "assistant_message" || event.item.type === "reasoning") + ) { + messages.set(event.item.id, { type: event.item.type, text: event.item.text }); + } + } + return [...messages.values()]; + } finally { + await connection.close(); + } +} + +describe("ACP streamed message boundaries", () => { + it.each([ + { + name: "tool call", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool", + title: "Read", + kind: "read", + status: "in_progress", + }, + }, + { name: "plan", update: { sessionUpdate: "plan", entries: [] } }, + { + name: "user message", + update: { sessionUpdate: "user_message_chunk", content: { type: "text", text: "User" } }, + }, + ] satisfies Array<{ name: string; update: SessionUpdate }>)( + "starts a new message after a $name", + async ({ update }) => { + expect( + await streamChunkTurns([ + textChunk("Before"), + update, + textChunk("After "), + textChunk("boundary"), + ]), + ).toEqual([ + { type: "assistant_message", text: "Before" }, + { type: "assistant_message", text: "After boundary" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }, + ); + + it("continues text while an existing tool receives a status update", async () => { + expect( + await streamChunkTurns([ + { + sessionUpdate: "tool_call", + toolCallId: "tool", + title: "Read", + kind: "read", + status: "in_progress", + }, + textChunk("Still "), + { sessionUpdate: "tool_call_update", toolCallId: "tool", status: "completed" }, + textChunk("speaking"), + ]), + ).toEqual([ + { type: "assistant_message", text: "Still speaking" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + + it("keeps explicit messages separate from neighboring anonymous chunks", async () => { + expect( + await streamChunkTurns([ + textChunk("Before"), + textChunk("Explicit ", "agent_message_chunk", "message"), + textChunk("message", "agent_message_chunk", "message"), + textChunk("After"), + ]), + ).toEqual([ + { type: "assistant_message", text: "Before" }, + { type: "assistant_message", text: "Explicit message" }, + { type: "assistant_message", text: "After" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + + it("continues reasoning but starts a new segment after nontext assistant content", async () => { + expect( + await streamChunkTurns([ + textChunk("Think ", "agent_thought_chunk"), + textChunk("first", "agent_thought_chunk"), + { + sessionUpdate: "agent_message_chunk", + content: { type: "image", data: "", mimeType: "image/png" }, + }, + textChunk("Then think", "agent_thought_chunk"), + ]), + ).toEqual([ + { type: "reasoning", text: "Think first" }, + { type: "reasoning", text: "Then think" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }); + + it.each(["completed", "canceled", "failed"] as const)( + "keeps the next turn separate after a %s prompt", + async (ending) => { + expect(await streamChunkTurns([textChunk("First "), textChunk("turn")], ending)).toEqual([ + { type: "assistant_message", text: "First turn" }, + { type: "assistant_message", text: "Next turn" }, + ]); + }, + ); +}); diff --git a/packages/plugin/src/server/acp.ts b/packages/plugin/src/server/acp.ts new file mode 100644 index 0000000000..533453f15e --- /dev/null +++ b/packages/plugin/src/server/acp.ts @@ -0,0 +1,123 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { + ProviderCatalog, + ProviderCommand, + ProviderConfigState, + ProviderNotice, + ProviderRegistration, + ProviderTimelineItem, +} from "./provider.js"; +import { createAcpProviderConnection } from "./acp-internal/connection.js"; + +interface RunAcpProviderBaseOptions { + id: string; + label: string; + description?: string; + icon?: string; + acpOptions?: AcpOptions; + transformers?: readonly AcpTransformer[]; +} + +export type RunAcpProviderOptions = RunAcpProviderBaseOptions & + ( + | { command: readonly [string, ...string[]]; connector?: never } + | { connector: AcpConnector; command?: never } + ); + +export type AcpStreamMessage = + | { jsonrpc: "2.0"; id: string | number | null; method: string; params?: unknown } + | { jsonrpc: "2.0"; method: string; params?: unknown } + | { jsonrpc: "2.0"; id: string | number | null; result: unknown } + | { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; + }; + +export interface AcpStream { + writable: WritableStream; + readable: ReadableStream; +} + +export type AcpConnector = () => AcpStream | Promise; + +export interface AcpOptions { + protocol?: "auto" | "v1"; + startupTimeoutMs?: number; + waitForInitialCommands?: boolean; + initialCommandsTimeoutMs?: number; +} + +export interface AcpContext { + sessionId: string; +} + +export interface AcpConfigAccess { + read(): Promise>>; + set(id: string, value: JsonValue): Promise; +} + +export interface AcpDiscoveryContext extends AcpContext { + config: AcpConfigAccess; +} + +export interface AcpConfigureContext extends AcpContext { + config: AcpConfigAccess; +} + +export type AcpConfigChange = + | { target: "model"; value: string | null } + | { target: "mode"; value: string | null } + | { target: "thinking"; value: string | null } + | { target: "setting"; id: string; value: JsonValue }; + +export interface AcpToolCallSnapshot { + id: string; + name?: string; + title: string; + kind?: string; + status: "pending" | "in_progress" | "completed" | "failed"; + input: JsonValue; + output: JsonValue; + locations: readonly string[]; +} + +export type AcpVendorUpdate = + | { type: "commands"; commands: readonly ProviderCommand[] } + | { type: "config"; config: ProviderConfigState } + | { type: "timeline"; item: ProviderTimelineItem } + | { type: "notice"; notice: ProviderNotice }; + +export interface AcpTransformer { + discover?( + catalog: ProviderCatalog, + context: AcpDiscoveryContext, + ): Promise | ProviderCatalog; + configure?(change: AcpConfigChange, context: AcpConfigureContext): Promise<"handled" | "pass">; + notification?( + notification: { method: string; params: JsonValue }, + context: AcpContext, + ): AcpVendorUpdate | readonly AcpVendorUpdate[] | null; + toolCall?(toolCall: AcpToolCallSnapshot, context: AcpContext): AcpToolCallSnapshot; +} + +export function runAcpProvider(options: RunAcpProviderOptions): ProviderRegistration { + const hasCommand = options.command !== undefined; + const hasConnector = options.connector !== undefined; + if (hasCommand === hasConnector) { + throw new Error("ACP provider requires exactly one command or SDK connector"); + } + if (options.command && (options.command.length === 0 || options.command[0].trim().length === 0)) { + throw new Error("ACP provider command must contain an executable"); + } + return { + id: options.id, + label: options.label, + description: options.description, + icon: options.icon, + command: options.command, + connect(request) { + return createAcpProviderConnection(options, request); + }, + }; +} diff --git a/packages/plugin/src/server/contracts.ts b/packages/plugin/src/server/contracts.ts new file mode 100644 index 0000000000..984b9c117b --- /dev/null +++ b/packages/plugin/src/server/contracts.ts @@ -0,0 +1,45 @@ +import type { PaseoApi } from "@getpaseo/client"; +import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; +import type { PluginRpcContract } from "../rpc.js"; +import type { PluginCleanup } from "../contracts.js"; +import type { ProviderRegistration } from "./provider.js"; +import type { UsageSourceRegistration } from "./usage.js"; +import type { PluginLifecycleRegistration } from "./lifecycle.js"; + +export interface PluginHandlerContext { + paseo: PaseoApi; +} + +export type PluginSettingsState = + | { + status: "ready"; + revision: string; + values: ZodOutput; + } + | { + status: "invalid"; + revision: string; + error: string; + }; + +export interface PluginSettings { + read(): Promise>; + subscribe(listener: (state: PluginSettingsState) => void | Promise): PluginCleanup; +} + +export interface PluginServerContext extends PluginLifecycleRegistration { + registerSettings( + definition: import("../settings.js").SettingsDefinition, + ): PluginSettings; + handle( + contract: PluginRpcContract, + handler: ( + input: ZodOutput, + context: PluginHandlerContext, + ) => ZodInput | Promise>, + ): void; + registerProvider(provider: ProviderRegistration): void; + registerUsageSource(source: UsageSourceRegistration): void; +} + +export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup; diff --git a/packages/plugin/src/server/index.ts b/packages/plugin/src/server/index.ts new file mode 100644 index 0000000000..9180023d19 --- /dev/null +++ b/packages/plugin/src/server/index.ts @@ -0,0 +1,26 @@ +export type { + UsageSourceRegistration, + UsageReport, + UsageWindow, + UsageBalance, + UsageDetail, +} from "./usage.js"; +export type { + PluginHandlerContext, + PluginServerContext, + PluginServerContribution, + PluginSettings, + PluginSettingsState, +} from "./contracts.js"; +export type { + PluginHookContext, + PluginHookWorkspace, + PluginHookAgent, + PluginSessionOpenRequest, + PluginTurnOutcome, + PluginLifecycleEvents, + PluginBeforeRequests, + PluginLifecycleRegistration, +} from "./lifecycle.js"; + +export { spawnProcess, execCommand, terminateProcess } from "./process.js"; diff --git a/packages/plugin/src/server/lifecycle.ts b/packages/plugin/src/server/lifecycle.ts new file mode 100644 index 0000000000..acebde32f1 --- /dev/null +++ b/packages/plugin/src/server/lifecycle.ts @@ -0,0 +1,89 @@ +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentTimelineItem, + AgentSessionConfig, +} from "@getpaseo/protocol/agent-types"; +import type { PaseoApi } from "@getpaseo/client"; +import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages"; + +export interface PluginHookContext { + paseo: PaseoApi; + signal: AbortSignal; +} + +export interface PluginHookWorkspace { + id: string; + projectId: string; + cwd: string; + name: string | null; + archivedAt: string | null; +} + +export interface PluginHookAgent { + id: string; + workspaceId: string | null; + parentAgentId: string | null; + provider: string; + cwd: string; + title: string | null; +} + +export interface PluginSessionOpenRequest { + agentId: string; + workspaceId: string | null; + provider: string; + cwd: string; + reason: "create" | "resume" | "refresh" | "import"; + purpose: "interactive" | "history"; + env: Record; +} + +export type PluginTurnOutcome = + | { kind: "completed" } + | { kind: "failed"; error: { message: string; code?: string } } + | { kind: "canceled"; reason: string }; + +export interface PluginLifecycleEvents { + "agent.turn_started": { agent: PluginHookAgent; turnId: string | null }; + "agent.turn_ended": { + agent: PluginHookAgent; + turnId: string | null; + outcome: PluginTurnOutcome; + timeline: readonly AgentTimelineItem[]; + }; + "agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest }; + "agent.permission_resolved": { + agent: PluginHookAgent; + requestId: string; + resolution: AgentPermissionResponse; + }; + "agent.archived": { agent: PluginHookAgent; archivedAt: string }; + "agent.created": { agent: PluginHookAgent }; + "agent.closed": { agent: PluginHookAgent }; + "workspace.created": { workspace: PluginHookWorkspace }; + "workspace.archived": { workspace: PluginHookWorkspace }; +} + +export interface PluginBeforeRequests { + "agent.create": { config: AgentSessionConfig; env?: Record }; + "agent.session_open": PluginSessionOpenRequest; + "workspace.create": Omit; +} + +export interface PluginLifecycleRegistration { + on( + name: Name, + handler: ( + event: PluginLifecycleEvents[Name], + context: PluginHookContext, + ) => void | Promise, + ): () => void; + before( + name: Name, + handler: ( + input: { request: PluginBeforeRequests[Name] }, + context: PluginHookContext, + ) => PluginBeforeRequests[Name] | void | Promise, + ): () => void; +} diff --git a/packages/plugin/src/server/process-internal/windows-command.test.ts b/packages/plugin/src/server/process-internal/windows-command.test.ts new file mode 100644 index 0000000000..3eb5e2e63b --- /dev/null +++ b/packages/plugin/src/server/process-internal/windows-command.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { quoteWindowsArgument, quoteWindowsCommand } from "./windows-command.js"; + +describe("quoteWindowsCommand", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows path with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("does not double-quote an already-quoted path", () => { + setPlatform("win32"); + expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( + '"C:\\Program Files\\Anthropic\\claude.exe"', + ); + }); + + test("returns the command unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); + }); + + test("escapes ampersands", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); + }); + + test("escapes pipes", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); + }); + + test("does not double percent signs", () => { + setPlatform("win32"); + // cmd.exe only collapses %% → % inside batch files; on the command line + // it stays literal, which corrupts git --format atoms etc. + expect(quoteWindowsCommand("100%")).toBe("100%"); + }); + + test("preserves git --format atoms verbatim", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( + "--format=%^(refname^)%09%^(committerdate:unix^)", + ); + }); + + test("escapes carets", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); + }); + + test("escapes multiple metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( + "build^&^(test^|deploy^)^!^", + ); + }); + + test("quotes commands with spaces after escaping metacharacters", () => { + setPlatform("win32"); + expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( + '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', + ); + }); + + test("returns the command unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); + +describe("quoteWindowsArgument", () => { + const originalPlatform = process.platform; + + function setPlatform(value: string) { + Object.defineProperty(process, "platform", { value, writable: true }); + } + + afterEach(() => { + setPlatform(originalPlatform); + }); + + test("quotes a Windows argument with spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("does not double-quote an already-quoted argument", () => { + setPlatform("win32"); + expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( + '"C:\\Program Files\\Anthropic\\cli.js"', + ); + }); + + test("returns the argument unchanged when there are no spaces", () => { + setPlatform("win32"); + expect(quoteWindowsArgument("--version")).toBe("--version"); + }); + + test("returns the argument unchanged on non-Windows platforms", () => { + setPlatform("darwin"); + expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); + }); +}); diff --git a/packages/server/src/utils/windows-command.ts b/packages/plugin/src/server/process-internal/windows-command.ts similarity index 100% rename from packages/server/src/utils/windows-command.ts rename to packages/plugin/src/server/process-internal/windows-command.ts diff --git a/packages/plugin/src/server/process.test.ts b/packages/plugin/src/server/process.test.ts new file mode 100644 index 0000000000..e9b3000da5 --- /dev/null +++ b/packages/plugin/src/server/process.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { execCommand, spawnProcess } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function writeArgumentReader(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "plugin process arguments ")); + roots.push(root); + const script = path.join(root, "read arguments.mjs"); + await writeFile(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)));\n"); + return script; +} + +const argumentsToPreserve = [ + "argument with spaces", + "--format=%(refname)%09%(committerdate:unix)", + "100%", + "feature&bugfix", + "feature|bugfix", + "feature^bugfix", +]; + +// A bare extensionless command selects the Windows shell; an absolute node.exe path would +// skip that path. The real child checks quoting through the public process capabilities. +test("execCommand delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const { stdout } = await execCommand("node", [script, ...argumentsToPreserve]); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); + +test("spawnProcess delivers shell arguments to the real child unchanged", async () => { + const script = await writeArgumentReader(); + const child = spawnProcess("node", [script, ...argumentsToPreserve], { stdio: "pipe" }); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`Argument reader exited with ${code}`)); + }); + }); + expect(JSON.parse(stdout)).toEqual(argumentsToPreserve); +}); diff --git a/packages/plugin/src/server/process.ts b/packages/plugin/src/server/process.ts new file mode 100644 index 0000000000..b2ba70ac12 --- /dev/null +++ b/packages/plugin/src/server/process.ts @@ -0,0 +1,119 @@ +import { + execFile, + spawn, + type ChildProcess, + type ChildProcessWithoutNullStreams, + type SpawnOptions, +} from "node:child_process"; +import { extname } from "node:path"; +import { promisify } from "node:util"; +import { + isWindowsCommandScript, + quoteWindowsArgument, + quoteWindowsCommand, +} from "./process-internal/windows-command.js"; +const execFileAsync = promisify(execFile); +export interface ExecCommandOptions { + env?: NodeJS.ProcessEnv; + cwd?: string; + encoding?: BufferEncoding; + killSignal?: NodeJS.Signals; + timeout?: number; + maxBuffer?: number; + shell?: boolean | string; + signal?: AbortSignal; +} +interface ExecCommandResult { + stdout: string; + stderr: string; +} +function hasPathSeparator(value: string): boolean { + return value.includes("/") || value.includes("\\"); +} + +function shouldUseWindowsShell( + command: string, + requestedShell?: boolean | string, +): boolean | string { + if (isWindowsCommandScript(command)) { + return true; + } + if (requestedShell !== undefined) { + return requestedShell; + } + return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); +} + +/** Launch a CLI with Windows command-script handling and argv quoting. The caller owns env. */ +export function spawnProcess( + command: string, + args: string[], + options: SpawnOptions & { stdio: "pipe" }, +): ChildProcessWithoutNullStreams; +export function spawnProcess(command: string, args: string[], options?: SpawnOptions): ChildProcess; +export function spawnProcess( + command: string, + args: string[], + options?: SpawnOptions, +): ChildProcess { + const spawnOptions = options ?? {}; + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, spawnOptions.shell); + + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return spawn(resolvedCommand, resolvedArgs, { + ...spawnOptions, + env: options?.env, + shell, + signal: options?.signal, + windowsHide: true, + }); +} + +export async function execCommand( + command: string, + args: string[], + options?: ExecCommandOptions, +): Promise { + const isWindows = process.platform === "win32"; + const shell = shouldUseWindowsShell(command, options?.shell); + const shouldQuoteForShell = isWindows && shell !== false; + const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; + const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; + + return execFileAsync(resolvedCommand, resolvedArgs, { + cwd: options?.cwd, + env: options?.env, + encoding: options?.encoding ?? "utf8", + signal: options?.signal, + killSignal: options?.killSignal, + timeout: options?.timeout, + maxBuffer: options?.maxBuffer, + shell, + windowsHide: true, + }) as Promise; +} + +/** Force-stop a CLI, including descendants behind Windows command-script launchers. */ +export async function terminateProcess( + child: ChildProcess, + signal: NodeJS.Signals = "SIGKILL", +): Promise { + if (process.platform !== "win32") { + child.kill(signal); + return; + } + if (child.pid === undefined) return; + try { + await execCommand("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { + shell: false, + timeout: 5000, + }); + } catch (error) { + // The process can finish between its close check and taskkill. + if (!(error instanceof Error && "code" in error && error.code === 128)) throw error; + } +} diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts new file mode 100644 index 0000000000..33b49ab447 --- /dev/null +++ b/packages/plugin/src/server/provider.ts @@ -0,0 +1,1413 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; + +export const PROVIDER_PROTOCOL_VERSION = 1 as const; + +export const PROVIDER_CAPABILITIES = [ + "prompt.message", + "prompt.command", + "prompt.image", + "prompt.output_schema", + "prompt.steer", + "session.archive", + "session.configure", + "session.list", + "session.persistence", + "session.revert.both", + "session.revert.conversation", + "session.revert.files", + "session.subsession", + "session.unarchive", + "permission", + "permission.tool_policy", + "timeline.plugin", +] as const; + +export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; + +export interface ProviderRegistration { + /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ + getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; + /** Default executable and arguments; the daemon resolves overrides before status/connect. */ + command?: readonly [string, ...string[]]; + status?(request: ProviderStatusRequest): Promise; + id: string; + label: string; + description?: string; + /** Plugin-directory-relative path to a self-contained SVG file. */ + icon?: string; + connect(request: ProviderConnectRequest): Promise; +} + +export type ProviderCatalogOptions = + | { scope: "global"; force?: boolean; launch?: ProviderLaunch } + | { scope: "workspace"; cwd: string; force?: boolean; launch?: ProviderLaunch }; + +export const ProviderLaunchSchema = z + .object({ + /** Resolved executable path. */ + command: z.string().min(1), + args: z.array(z.string()), + /** Complete daemon-owned environment, including overrides and parent-session stripping. */ + env: z.record(z.string(), z.string()), + }) + .strip(); + +export type ProviderLaunch = z.infer; + +export interface ProviderStatusRequest { + launch?: ProviderLaunch; +} + +export const ProviderStatusSchema = z.discriminatedUnion("available", [ + z.object({ available: z.literal(true), diagnostic: z.string().optional() }).strip(), + z.object({ available: z.literal(false), diagnostic: z.string().min(1) }).strip(), +]); + +export type ProviderStatus = z.infer; + +export interface ProviderConnectRequest { + launch?: ProviderLaunch; + versions: readonly number[]; + capabilities: readonly string[]; +} + +export interface ProviderConnection { + readonly version: number; + readonly capabilities: readonly string[]; + send(input: ProviderInput): Promise; + onEvent(listener: (event: ProviderEvent) => void): () => void; + close(): Promise; +} + +export interface ProviderPersistence { + version: number; + data: JsonValue; +} + +export type ProviderMcpServerConfig = + | { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; + alwaysLoad?: boolean; + } + | { + type: "http" | "sse"; + url: string; + headers?: Record; + alwaysLoad?: boolean; + }; + +export interface ProviderToolPolicy { + preapproved: Array<{ kind: "mcp"; server: string; tool: string }>; +} + +export interface ProviderSessionConfig { + cwd: string; + env: Readonly>; + systemPrompt?: string; + mcpServers: Readonly>; + toolPolicy?: ProviderToolPolicy; + model?: string; + mode?: string; + thinkingOption?: string; + settings: Readonly>; + providerOptions?: Readonly>; + title?: string; + persist: boolean; +} + +export interface ProviderConfigChanges { + model?: string | null; + mode?: string | null; + thinkingOption?: string | null; + settings?: Readonly>; +} + +export interface ProviderPrompt { + clientMessageId: string; + delivery: "auto" | "steer"; + input: + | { type: "message"; content: ProviderContent[] } + | { type: "command"; name: string; arguments: string }; + outputSchema?: JsonValue; + clearPendingPermissions?: boolean; +} + +interface ProviderForgeChangeRequestAttachment { + type: "forge_change_request"; + mimeType: "application/paseo-forge-change-request"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderForgeIssueAttachment { + type: "forge_issue"; + mimeType: "application/paseo-forge-issue"; + forge?: string; + number: number; + title: string; + url: string; + body?: string | null; + projectPath?: string; +} + +interface ProviderGitHubPrAttachment { + type: "github_pr"; + mimeType: "application/github-pr"; + number: number; + title: string; + url: string; + body?: string | null; + baseRefName?: string | null; + headRefName?: string | null; +} + +interface ProviderGitHubIssueAttachment { + type: "github_issue"; + mimeType: "application/github-issue"; + number: number; + title: string; + url: string; + body?: string | null; +} + +interface ProviderTextAttachment { + type: "text"; + mimeType: "text/plain"; + contextKind?: string; + title?: string | null; + text: string; + externalResource?: { + provider: string; + providerLabel: string; + resourceType: string; + id: string; + identifier: string; + title: string; + url: string; + }; +} + +interface ProviderReviewAttachment { + type: "review"; + mimeType: "application/paseo-review"; + cwd: string; + mode: "uncommitted" | "base"; + baseRef?: string | null; + comments: Array<{ + filePath: string; + side: "old" | "new"; + lineNumber: number; + body: string; + context: { + hunkHeader: string; + targetLine: ProviderReviewContextLine; + lines: ProviderReviewContextLine[]; + }; + }>; +} + +interface ProviderReviewContextLine { + oldLineNumber: number | null; + newLineNumber: number | null; + type: "add" | "remove" | "context"; + content: string; +} + +export type ProviderContent = + | { type: "text"; text: string } + | { type: "image"; data: string; mimeType: string } + | ProviderForgeChangeRequestAttachment + | ProviderForgeIssueAttachment + | ProviderGitHubPrAttachment + | ProviderGitHubIssueAttachment + | ProviderTextAttachment + | ProviderReviewAttachment + | { + type: "uploaded_file"; + id: string; + fileName: string; + mimeType: string; + size: number; + path: string; + }; + +export type ProviderInput = + | { type: "catalog"; requestId: string; cwd?: string } + | { type: "sessions"; requestId: string; query?: string; cwd?: string; limit?: number } + | { + type: "session.open"; + requestId: string; + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + } + | { type: "session.prompt"; sessionId: string; prompt: ProviderPrompt } + | { type: "session.interrupt"; requestId: string; sessionId: string } + | { + type: "session.permission"; + sessionId: string; + permissionId: string; + response: ProviderPermissionResponse; + } + | { + type: "session.configure"; + requestId: string; + sessionId: string; + changes: ProviderConfigChanges; + } + | { + type: "session.revert"; + requestId: string; + sessionId: string; + token: JsonValue; + scope: "conversation" | "files" | "both"; + } + | { type: "session.archive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence } + | { type: "session.close"; requestId: string; sessionId: string }; + +export interface ProviderThinkingOption { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; +} + +export interface ProviderModel { + id: string; + aliases?: string[]; + isSelectable?: boolean; + label: string; + description?: string; + isDefault?: boolean; + metadata?: Readonly>; + contextWindowMaxTokens?: number; + thinkingOptions?: ProviderThinkingOption[]; + defaultThinkingOptionId?: string; +} + +export interface ProviderMode { + id: string; + label: string; + description?: string; + icon?: string; + colorTier?: string; + isUnattended?: boolean; +} + +export type ProviderSetting = + | { + type: "toggle"; + id: string; + label: string; + description?: string; + value: boolean; + } + | { + type: "select"; + id: string; + label: string; + description?: string; + value: string | null; + options: ReadonlyArray<{ label: string; value: string }>; + }; + +export interface ProviderConfigState { + model?: string; + mode?: string; + thinkingOption?: string; + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions: readonly ProviderThinkingOption[]; + settings: readonly ProviderSetting[]; +} + +export interface ProviderCatalog { + models: readonly ProviderModel[]; + modes: readonly ProviderMode[]; + thinkingOptions?: readonly ProviderThinkingOption[]; + defaultModel?: string; + defaultMode?: string; + defaultThinkingOption?: string; +} + +export interface ProviderSessionSummary { + persistence: ProviderPersistence; + cwd: string; + title?: string; + description?: string; + updatedAt?: string; +} + +export interface ProviderCommand { + name: string; + description: string; + argumentHint?: string; +} + +export interface ProviderError { + message: string; + code?: string; + diagnostic?: string; +} + +export interface ProviderNotice { + id: string; + severity: "info" | "warning" | "error"; + title: string; + description?: string; + dismissed?: boolean; +} + +export interface ProviderPermissionAction { + id: string; + label: string; + behavior: "allow" | "deny"; + variant?: "primary" | "secondary" | "danger"; + intent?: "implement" | "implement_resume" | "dismiss"; +} + +export interface ProviderPermissionRequest { + id: string; + name: string; + kind: "tool" | "plan" | "question" | "mode" | "other"; + title?: string; + description?: string; + input?: Readonly>; + detail?: ProviderToolCallDetail; + suggestions?: Array>; + actions?: ProviderPermissionAction[]; + metadata?: Readonly>; +} + +export type ProviderPermissionResponse = + | { + behavior: "allow"; + selectedActionId?: string; + updatedInput?: Record; + updatedPermissions?: Array>; + } + | { + behavior: "deny"; + selectedActionId?: string; + message?: string; + interrupt?: boolean; + }; + +export interface ProviderUsage { + inputTokens?: number; + cachedInputTokens?: number; + outputTokens?: number; + totalCostUsd?: number; + contextWindowMaxTokens?: number; + contextWindowUsedTokens?: number; +} + +interface ProviderTimelineIdentity { + id: string; + revertToken?: JsonValue; +} + +export type ProviderToolCallDetail = + | { type: "shell"; command: string; cwd?: string; output?: string; exitCode?: number | null } + | { type: "read"; filePath: string; content?: string; offset?: number; limit?: number } + | { + type: "edit"; + filePath: string; + oldString?: string; + newString?: string; + unifiedDiff?: string; + } + | { type: "write"; filePath: string; content?: string } + | { + type: "search"; + query: string; + toolName?: "search" | "grep" | "glob" | "web_search"; + content?: string; + filePaths?: string[]; + webResults?: Array<{ title: string; url: string }>; + annotations?: string[]; + numFiles?: number; + numMatches?: number; + durationMs?: number; + durationSeconds?: number; + truncated?: boolean; + mode?: "content" | "files_with_matches" | "count"; + } + | { + type: "fetch"; + url: string; + prompt?: string; + result?: string; + code?: number; + codeText?: string; + bytes?: number; + durationMs?: number; + } + | { + type: "worktree_setup"; + worktreePath: string; + branchName: string; + log: string; + commands: Array<{ + index: number; + command: string; + cwd: string; + log: string; + status: "running" | "completed" | "failed"; + exitCode: number | null; + durationMs?: number; + }>; + truncated?: boolean; + } + | { + type: "sub_agent"; + subAgentType?: string; + description?: string; + childSessionId?: string; + log: string; + actions?: Array<{ index: number; toolName: string; summary?: string }>; + } + | { + type: "plain_text"; + label?: string; + text?: string; + icon?: + | "wrench" + | "square_terminal" + | "eye" + | "pencil" + | "search" + | "bot" + | "sparkles" + | "brain" + | "mic_vocal"; + } + | { type: "plan"; text: string } + | { type: "unknown"; input: JsonValue; output: JsonValue }; + +type ProviderToolCallItem = ProviderTimelineIdentity & { + type: "tool_call"; + callId: string; + name: string; + detail: ProviderToolCallDetail; + metadata?: Readonly>; +} & ( + | { status: "running" | "completed" | "canceled"; error: null } + | { status: "failed"; error: JsonValue } + ); + +export type ProviderTimelineItem = + | (ProviderTimelineIdentity & { + type: "user_message"; + text: string; + messageId?: string; + clientMessageId?: string; + }) + | (ProviderTimelineIdentity & { type: "assistant_message"; text: string; messageId?: string }) + | (ProviderTimelineIdentity & { type: "reasoning"; text: string }) + | ProviderToolCallItem + | (ProviderTimelineIdentity & { + type: "todo"; + items: Array<{ + text: string; + completed: boolean; + id?: string; + status?: "pending" | "in_progress" | "completed"; + activeForm?: string; + }>; + }) + | (ProviderTimelineIdentity & { type: "error"; message: string }) + | (ProviderTimelineIdentity & { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + }) + | (ProviderTimelineIdentity & { + type: "compaction"; + status: "loading" | "completed"; + trigger?: "auto" | "manual"; + preTokens?: number; + }) + | (ProviderTimelineIdentity & { + type: "plugin"; + pluginId: string; + kind: string; + version: number; + data: JsonValue; + }); + +export type ProviderEvent = + | { type: "catalog"; requestId: string; catalog: ProviderCatalog } + | { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] } + | { type: "request.completed"; requestId: string } + | { type: "request.failed"; requestId: string; error: ProviderError } + | { + type: "session.opened"; + requestId?: string; + sessionId: string; + /** Direct parent in the provider session ID namespace. */ + parentSessionId?: string; + /** ID of the spawning tool call in the direct parent's timeline. */ + toolCallId?: string; + capabilities: readonly string[]; + restoration: "core" | "parent"; + persistence?: ProviderPersistence; + title?: string; + description?: string; + cwd: string; + } + | { type: "session.ready"; requestId?: string; sessionId: string } + | { type: "session.closed"; sessionId: string; error?: ProviderError } + | { type: "session.runtime_failed"; sessionId: string; error: ProviderError } + | { type: "session.persistence"; sessionId: string; persistence: ProviderPersistence } + | { + type: "session.prompt_result"; + sessionId: string; + clientMessageId: string; + result: + | { type: "turn"; turnId: string } + | { type: "steer"; turnId: string } + | { type: "completed" } + | { type: "failed"; error: ProviderError }; + } + | { + type: "session.turn"; + sessionId: string; + turnId: string; + state: "started" | "completed" | "failed" | "canceled"; + error?: ProviderError; + } + | { type: "session.usage"; sessionId: string; turnId?: string; usage: ProviderUsage } + | { type: "session.config"; sessionId: string; config: ProviderConfigState } + | { type: "session.commands"; sessionId: string; commands: ProviderCommand[] } + | { type: "session.permission"; sessionId: string; request: ProviderPermissionRequest } + | { type: "session.permission_resolved"; sessionId: string; permissionId: string } + | { type: "session.notice"; sessionId: string; notice: ProviderNotice } + | { type: "timeline.item"; sessionId: string; item: ProviderTimelineItem; timestamp?: string }; + +export function negotiateProviderCapabilities( + offered: readonly string[], + supported: readonly string[], +): readonly ProviderCapability[] { + const known = new Set(PROVIDER_CAPABILITIES); + const supportedCapabilities = new Set(supported.filter((capability) => known.has(capability))); + return offered.filter( + (capability, index): capability is ProviderCapability => + isProviderCapability(capability) && + supportedCapabilities.has(capability) && + offered.indexOf(capability) === index, + ); +} + +export function isProviderCapability(capability: string): capability is ProviderCapability { + return new Set(PROVIDER_CAPABILITIES).has(capability); +} + +function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCapability[] { + const capabilities: ProviderCapability[] = []; + if (prompt.delivery === "steer") capabilities.push("prompt.steer"); + if (prompt.input.type === "command") capabilities.push("prompt.command"); + else { + capabilities.push("prompt.message"); + if (prompt.input.content.some((part) => part.type === "image")) { + capabilities.push("prompt.image"); + } + } + if (prompt.outputSchema !== undefined) capabilities.push("prompt.output_schema"); + return capabilities; +} + +export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] { + switch (input.type) { + case "catalog": + case "session.interrupt": + case "session.close": + return []; + case "sessions": + return ["session.list"]; + case "session.open": { + const capabilities: ProviderCapability[] = []; + if (input.persistence) capabilities.push("session.persistence"); + if (input.config.toolPolicy) capabilities.push("permission.tool_policy"); + return capabilities; + } + case "session.prompt": + return requiredPromptCapabilities(input.prompt); + case "session.permission": + return ["permission"]; + case "session.configure": + return ["session.configure"]; + case "session.revert": + return [`session.revert.${input.scope}`]; + case "session.archive": + return ["session.archive"]; + case "session.unarchive": + return ["session.unarchive"]; + } +} + +export function requireProviderCapabilities( + capabilities: readonly string[], + input: ProviderInput, +): void { + for (const capability of requiredProviderCapabilities(input)) { + if (!capabilities.includes(capability)) { + throw new Error(`Provider does not support ${capability}`); + } + } +} + +const idSchema = z.string().min(1); +const jsonObjectSchema = z.record(z.string(), z.json()); +const providerErrorSchema = z + .object({ message: z.string(), code: z.string().optional(), diagnostic: z.string().optional() }) + .strip(); +const persistenceSchema = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strip(); +const mcpServerSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("stdio"), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("http"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), + z + .object({ + type: z.literal("sse"), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + alwaysLoad: z.boolean().optional(), + }) + .strict(), +]); +const toolPolicySchema = z + .object({ + preapproved: z.array( + z.object({ kind: z.literal("mcp"), server: idSchema, tool: idSchema }).strict(), + ), + }) + .strict(); +const providerPermissionResponseSchema: z.ZodType = + z.discriminatedUnion("behavior", [ + z + .object({ + behavior: z.literal("allow"), + selectedActionId: z.string().optional(), + updatedInput: jsonObjectSchema.optional(), + updatedPermissions: z.array(jsonObjectSchema).optional(), + }) + .strip(), + z + .object({ + behavior: z.literal("deny"), + selectedActionId: z.string().optional(), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }) + .strip(), + ]); +const sessionConfigSchema = z + .object({ + cwd: z.string(), + env: z.record(z.string(), z.string()), + systemPrompt: z.string().optional(), + mcpServers: z.record(z.string(), mcpServerSchema), + toolPolicy: toolPolicySchema.optional(), + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + settings: jsonObjectSchema, + providerOptions: z.record(z.string(), z.unknown()).optional(), + title: z.string().optional(), + persist: z.boolean(), + }) + .strict(); +const externalResourceSchema = z + .object({ + provider: z.string(), + providerLabel: z.string(), + resourceType: z.string(), + id: z.string(), + identifier: z.string(), + title: z.string(), + url: z.string(), + }) + .strip(); +const reviewContextLineSchema = z + .object({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), + }) + .strip(); +const providerContentSchema: z.ZodType = z.union([ + z.object({ type: z.literal("text"), text: z.string() }).strict(), + z.object({ type: z.literal("image"), data: z.string(), mimeType: z.string() }).strict(), + z + .object({ + type: z.literal("forge_change_request"), + mimeType: z.literal("application/paseo-forge-change-request"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("forge_issue"), + mimeType: z.literal("application/paseo-forge-issue"), + forge: z.string().optional(), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + projectPath: z.string().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_pr"), + mimeType: z.literal("application/github-pr"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("github_issue"), + mimeType: z.literal("application/github-issue"), + number: z.number().int().positive(), + title: z.string(), + url: z.string(), + body: z.string().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("text"), + mimeType: z.literal("text/plain"), + contextKind: z.string().optional(), + title: z.string().nullable().optional(), + text: z.string(), + externalResource: externalResourceSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z + .object({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z + .object({ + hunkHeader: z.string(), + targetLine: reviewContextLineSchema, + lines: z.array(reviewContextLineSchema), + }) + .strip(), + }) + .strip(), + ), + }) + .strip(), + z + .object({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), + }) + .strip(), +]); +const promptSchema = z + .object({ + clientMessageId: idSchema, + delivery: z.enum(["auto", "steer"]), + input: z.union([ + z.object({ type: z.literal("message"), content: z.array(providerContentSchema) }).strict(), + z.object({ type: z.literal("command"), name: idSchema, arguments: z.string() }).strict(), + ]), + outputSchema: z.json().optional(), + clearPendingPermissions: z.boolean().optional(), + }) + .strict(); +const configChangesSchema = z + .object({ + model: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + thinkingOption: z.string().nullable().optional(), + settings: jsonObjectSchema.optional(), + }) + .strict(); + +export const ProviderInputSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ type: z.literal("catalog"), requestId: idSchema, cwd: z.string().optional() }) + .strict(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + query: z.string().optional(), + cwd: z.string().optional(), + limit: z.number().int().positive().optional(), + }) + .strict(), + z + .object({ + type: z.literal("session.open"), + requestId: idSchema, + sessionId: idSchema, + config: sessionConfigSchema, + persistence: persistenceSchema.optional(), + history: z.enum(["replay", "skip"]), + }) + .strict(), + z + .object({ type: z.literal("session.prompt"), sessionId: idSchema, prompt: promptSchema }) + .strict(), + z + .object({ type: z.literal("session.interrupt"), requestId: idSchema, sessionId: idSchema }) + .strict(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + permissionId: idSchema, + response: providerPermissionResponseSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.configure"), + requestId: idSchema, + sessionId: idSchema, + changes: configChangesSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.revert"), + requestId: idSchema, + sessionId: idSchema, + token: z.json(), + scope: z.enum(["conversation", "files", "both"]), + }) + .strict(), + z + .object({ + type: z.literal("session.archive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z + .object({ + type: z.literal("session.unarchive"), + requestId: idSchema, + persistence: persistenceSchema, + }) + .strict(), + z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(), +]); + +const modeSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + icon: z.string().optional(), + colorTier: z.string().optional(), + isUnattended: z.boolean().optional(), + }) + .strip(); +const selectOptionSchema = z + .object({ + id: idSchema, + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); +const modelSchema = z + .object({ + id: idSchema, + aliases: z.array(z.string()).optional(), + isSelectable: z.boolean().optional(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: jsonObjectSchema.optional(), + contextWindowMaxTokens: z.number().optional(), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), + }) + .strip(); +const settingSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("toggle"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.boolean(), + }) + .strip(), + z + .object({ + type: z.literal("select"), + id: idSchema, + label: z.string(), + description: z.string().optional(), + value: z.string().nullable(), + options: z.array(z.object({ label: z.string(), value: z.string() }).strip()), + }) + .strip(), +]); +const configStateSchema = z + .object({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOption: z.string().optional(), + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema), + settings: z.array(settingSchema), + }) + .strip(); +const catalogSchema = z + .object({ + models: z.array(modelSchema), + modes: z.array(modeSchema), + thinkingOptions: z.array(selectOptionSchema).optional(), + defaultModel: z.string().optional(), + defaultMode: z.string().optional(), + defaultThinkingOption: z.string().optional(), + }) + .strip(); +const noticeSchema = z + .object({ + id: idSchema, + severity: z.enum(["info", "warning", "error"]), + title: z.string(), + description: z.string().optional(), + dismissed: z.boolean().optional(), + }) + .strip(); +const commandSchema = z + .object({ name: idSchema, description: z.string(), argumentHint: z.string().optional() }) + .strip(); +const usageSchema = z + .object({ + inputTokens: z.number().optional(), + cachedInputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalCostUsd: z.number().optional(), + contextWindowMaxTokens: z.number().optional(), + contextWindowUsedTokens: z.number().optional(), + }) + .strip(); +const worktreeCommandSchema = z + .object({ + index: z.number().int().positive(), + command: z.string(), + cwd: z.string(), + log: z.string(), + status: z.enum(["running", "completed", "failed"]), + exitCode: z.number().nullable(), + durationMs: z.number().nonnegative().optional(), + }) + .strip(); +const toolCallDetailSchema: z.ZodType = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("shell"), + command: z.string(), + cwd: z.string().optional(), + output: z.string().optional(), + exitCode: z.number().nullable().optional(), + }) + .strip(), + z + .object({ + type: z.literal("read"), + filePath: z.string(), + content: z.string().optional(), + offset: z.number().optional(), + limit: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("edit"), + filePath: z.string(), + oldString: z.string().optional(), + newString: z.string().optional(), + unifiedDiff: z.string().optional(), + }) + .strip(), + z + .object({ type: z.literal("write"), filePath: z.string(), content: z.string().optional() }) + .strip(), + z + .object({ + type: z.literal("search"), + query: z.string(), + toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(), + content: z.string().optional(), + filePaths: z.array(z.string()).optional(), + webResults: z.array(z.object({ title: z.string(), url: z.string() }).strip()).optional(), + annotations: z.array(z.string()).optional(), + numFiles: z.number().optional(), + numMatches: z.number().optional(), + durationMs: z.number().optional(), + durationSeconds: z.number().optional(), + truncated: z.boolean().optional(), + mode: z.enum(["content", "files_with_matches", "count"]).optional(), + }) + .strip(), + z + .object({ + type: z.literal("fetch"), + url: z.string(), + prompt: z.string().optional(), + result: z.string().optional(), + code: z.number().optional(), + codeText: z.string().optional(), + bytes: z.number().optional(), + durationMs: z.number().optional(), + }) + .strip(), + z + .object({ + type: z.literal("worktree_setup"), + worktreePath: z.string(), + branchName: z.string(), + log: z.string(), + commands: z.array(worktreeCommandSchema), + truncated: z.boolean().optional(), + }) + .strip(), + z + .object({ + type: z.literal("sub_agent"), + subAgentType: z.string().optional(), + description: z.string().optional(), + childSessionId: z.string().optional(), + log: z.string(), + actions: z + .array( + z + .object({ + index: z.number().int().positive(), + toolName: z.string(), + summary: z.string().optional(), + }) + .strip(), + ) + .optional(), + }) + .strip(), + z + .object({ + type: z.literal("plain_text"), + label: z.string().optional(), + text: z.string().optional(), + icon: z + .enum([ + "wrench", + "square_terminal", + "eye", + "pencil", + "search", + "bot", + "sparkles", + "brain", + "mic_vocal", + ]) + .optional(), + }) + .strip(), + z.object({ type: z.literal("plan"), text: z.string() }).strip(), + z.object({ type: z.literal("unknown"), input: z.json(), output: z.json() }).strip(), +]); +const timelineIdentityShape = { id: idSchema, revertToken: z.json().optional() }; +const toolCallBaseShape = { + ...timelineIdentityShape, + type: z.literal("tool_call"), + callId: idSchema, + name: z.string(), + detail: toolCallDetailSchema, + metadata: jsonObjectSchema.optional(), +}; +const timelineItemSchema: z.ZodType = z.union([ + z + .object({ + ...timelineIdentityShape, + type: z.literal("user_message"), + text: z.string(), + messageId: z.string().optional(), + clientMessageId: z.string().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("assistant_message"), + text: z.string(), + messageId: z.string().optional(), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("reasoning"), text: z.string() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("running"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("completed"), error: z.null() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("failed"), error: z.json() }).strip(), + z.object({ ...toolCallBaseShape, status: z.literal("canceled"), error: z.null() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("todo"), + items: z.array( + z + .object({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ ...timelineIdentityShape, type: z.literal("error"), message: z.string() }).strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("notification"), + level: z.enum(["info", "warning", "error"]), + message: z.string(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().optional(), + }) + .strip(), + z + .object({ + ...timelineIdentityShape, + type: z.literal("plugin"), + pluginId: idSchema, + kind: z.string(), + version: z.number(), + data: z.json(), + }) + .strip(), +]); +const permissionActionSchema = z + .object({ + id: idSchema, + label: z.string(), + behavior: z.enum(["allow", "deny"]), + variant: z.enum(["primary", "secondary", "danger"]).optional(), + intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), + }) + .strip(); +const providerPermissionRequestSchema: z.ZodType = z + .object({ + id: idSchema, + name: z.string(), + kind: z.enum(["tool", "plan", "question", "mode", "other"]), + title: z.string().optional(), + description: z.string().optional(), + input: jsonObjectSchema.optional(), + detail: toolCallDetailSchema.optional(), + suggestions: z.array(jsonObjectSchema).optional(), + actions: z.array(permissionActionSchema).optional(), + metadata: jsonObjectSchema.optional(), + }) + .strip(); + +export const ProviderEventSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ type: z.literal("catalog"), requestId: idSchema, catalog: catalogSchema }).strip(), + z + .object({ + type: z.literal("sessions"), + requestId: idSchema, + sessions: z.array( + z + .object({ + persistence: persistenceSchema, + cwd: z.string(), + title: z.string().optional(), + description: z.string().optional(), + updatedAt: z.string().optional(), + }) + .strip(), + ), + }) + .strip(), + z.object({ type: z.literal("request.completed"), requestId: idSchema }).strip(), + z + .object({ type: z.literal("request.failed"), requestId: idSchema, error: providerErrorSchema }) + .strip(), + z + .object({ + type: z.literal("session.opened"), + requestId: idSchema.optional(), + sessionId: idSchema, + parentSessionId: idSchema.optional(), + toolCallId: idSchema.optional(), + capabilities: z.array(z.string()), + restoration: z.enum(["core", "parent"]), + persistence: persistenceSchema.optional(), + title: z.string().optional(), + description: z.string().optional(), + cwd: z.string(), + }) + .strip(), + z + .object({ + type: z.literal("session.ready"), + requestId: idSchema.optional(), + sessionId: idSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.closed"), + sessionId: idSchema, + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.runtime_failed"), + sessionId: idSchema, + error: providerErrorSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.persistence"), + sessionId: idSchema, + persistence: persistenceSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.prompt_result"), + sessionId: idSchema, + clientMessageId: idSchema, + result: z.union([ + z.object({ type: z.literal("turn"), turnId: idSchema }).strip(), + z.object({ type: z.literal("steer"), turnId: idSchema }).strip(), + z.object({ type: z.literal("completed") }).strip(), + z.object({ type: z.literal("failed"), error: providerErrorSchema }).strip(), + ]), + }) + .strip(), + z + .object({ + type: z.literal("session.turn"), + sessionId: idSchema, + turnId: idSchema, + state: z.enum(["started", "completed", "failed", "canceled"]), + error: providerErrorSchema.optional(), + }) + .strip(), + z + .object({ + type: z.literal("session.usage"), + sessionId: idSchema, + turnId: idSchema.optional(), + usage: usageSchema, + }) + .strip(), + z + .object({ type: z.literal("session.config"), sessionId: idSchema, config: configStateSchema }) + .strip(), + z + .object({ + type: z.literal("session.commands"), + sessionId: idSchema, + commands: z.array(commandSchema), + }) + .strip(), + z + .object({ + type: z.literal("session.permission"), + sessionId: idSchema, + request: providerPermissionRequestSchema, + }) + .strip(), + z + .object({ + type: z.literal("session.permission_resolved"), + sessionId: idSchema, + permissionId: idSchema, + }) + .strip(), + z + .object({ type: z.literal("session.notice"), sessionId: idSchema, notice: noticeSchema }) + .strip(), + z + .object({ + type: z.literal("timeline.item"), + sessionId: idSchema, + item: timelineItemSchema, + timestamp: z.string().optional(), + }) + .strip(), +]); diff --git a/packages/plugin/src/server/usage.test.ts b/packages/plugin/src/server/usage.test.ts new file mode 100644 index 0000000000..b70e8d6d01 --- /dev/null +++ b/packages/plugin/src/server/usage.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, test } from "vitest"; +import { + balanceToneFromRemaining, + toneFromUsedPct, + usedPctOf, + windowFromUsedPct, + windowFromReportedDuration, +} from "./usage.js"; + +test("shared usage tones preserve the app threshold contract", () => { + expect([null, 69.9, 70, 90, 90.1].map(toneFromUsedPct)).toEqual([ + "default", + "ok", + "warning", + "warning", + "danger", + ]); + expect([null, 0, 0.01].map(balanceToneFromRemaining)).toEqual(["default", "danger", "ok"]); +}); + +test("shared windows retain percentage and reset time", () => { + expect(windowFromUsedPct({ id: "a", label: "A", utilizationPct: 30 })).toEqual({ + id: "a", + label: "A", + usedPct: 30, + remainingPct: 70, + resetsAt: null, + }); + expect(windowFromUsedPct({ id: "b", label: "B", utilizationPct: null })).toEqual({ + id: "b", + label: "B", + usedPct: null, + remainingPct: null, + resetsAt: null, + }); + expect(usedPctOf(25, 100)).toBe(25); + expect(usedPctOf(25, 0)).toBeNull(); +}); + +describe("toneFromUsedPct", () => { + // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what + // the client applies when a window arrives without a tone. + it.each([ + [0, "ok"], + [69.9, "ok"], + [70, "warning"], + [90, "warning"], + [90.1, "danger"], + [100, "danger"], + [150, "danger"], + ])("%s%% used is %s", (usedPct, expected) => { + expect(toneFromUsedPct(usedPct)).toBe(expected); + }); + + it("is neutral when the percentage is unknown", () => { + expect(toneFromUsedPct(null)).toBe("default"); + expect(toneFromUsedPct(undefined)).toBe("default"); + }); +}); + +describe("usedPctOf", () => { + it("computes a percentage of the limit", () => { + expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); + }); + + it("is unknown when either side is missing", () => { + expect(usedPctOf(null, 100)).toBeNull(); + expect(usedPctOf(50, null)).toBeNull(); + }); + + // A zero limit would divide to Infinity and render as a full red bar. + it("is unknown when the limit is zero or negative", () => { + expect(usedPctOf(50, 0)).toBeNull(); + expect(usedPctOf(50, -1)).toBeNull(); + }); +}); + +describe("balanceToneFromRemaining", () => { + // Kept for balances with no limit, where no percentage can be computed. It only + // escalates at exhaustion, which is why anything with a limit should use + // toneFromUsedPct instead. + it("stays ok until nothing is left", () => { + expect(balanceToneFromRemaining(0.01)).toBe("ok"); + expect(balanceToneFromRemaining(0)).toBe("danger"); + expect(balanceToneFromRemaining(null)).toBe("default"); + }); +}); + +test.each([ + [18000, "five_hour", "5-hour", "5h"], + [604800, "weekly", "Weekly", "wk"], + [7200, "7200s", "2-hour", "2h"], + [86400, "86400s", "1-day", "1d"], + [90, "90s", "90-second", "90s"], + [null, "primary", "Primary limit", ""], +])( + "duration %s owns the window's identity and both names", + (durationSeconds, id, label, shortLabel) => { + expect( + windowFromReportedDuration({ + durationSeconds: durationSeconds as number | null, + unknown: { id: "primary", label: "Primary limit", shortLabel: "" }, + utilizationPct: 11, + }), + ).toMatchObject({ id, label, shortLabel, usedPct: 11 }); + }, +); diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts new file mode 100644 index 0000000000..fa23ca9c91 --- /dev/null +++ b/packages/plugin/src/server/usage.ts @@ -0,0 +1,204 @@ +import { createHash } from "node:crypto"; +import { z, type ZodType } from "zod"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; + +export interface UsageWindow { + id: string; + label: string; + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel?: string; + /** Shown in the usage summary until the user pins windows of their own. */ + summary?: boolean; + usedPct?: number | null; + remainingPct?: number | null; + resetsAt?: string | null; + runsOutAt?: string | null; + shortfallPct?: number | null; + tone?: "default" | "ok" | "warning" | "danger"; +} + +export interface UsageBalance { + id: string; + label: string; + used?: number | null; + remaining?: number | null; + limit?: number | null; + unit: "usd" | "credits" | "requests" | "tokens"; + resetsAt?: string | null; + tone?: UsageWindow["tone"]; +} + +export interface UsageDetail { + id: string; + label: string; + value: string; + tone?: UsageWindow["tone"]; +} + +export type UsageProblem = + | { kind: "expired"; expiresAt: string; refreshedBy?: string } + | { kind: "rejected"; status: number; refreshedBy?: string } + | { kind: "no_quota"; detail: string }; + +export type UsageReport = + | { + status: "available"; + planLabel?: string; + windows: UsageWindow[]; + balances?: UsageBalance[]; + details?: UsageDetail[]; + } + | { status: "unavailable"; problem: UsageProblem } + | { status: "error"; error: string }; + +export interface UsageAccount { + /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ + key: string; + label?: string; + /** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */ + harness?: string; + /** Store locator, opaque to the daemon. */ + input: JsonValue; +} + +export const UsageScopeSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("global") }), + z.object({ + kind: z.literal("session"), + provider: z.string(), + model: z.string().optional(), + env: z.record(z.string(), z.string()), + }), +]); +export type UsageScope = z.infer; + +export interface UsageSourceRegistration { + id: string; + label: string; + icon?: string; + input: ZodType; + /** Accounts for this scope only. The same key in any scope identifies the same account. */ + discover(scope: UsageScope): Promise; + /** Re-reads the login store; never writes it. */ + fetch(input: unknown): Promise; +} + +export function windowFromUsedPct(input: { + id: string; + label: string; + shortLabel?: string; + summary?: boolean; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + tone?: UsageWindow["tone"]; +}): UsageWindow { + const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; + const window: UsageWindow = { + id: input.id, + label: input.label, + usedPct, + remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), + resetsAt: input.resetsAt ?? null, + }; + if (input.shortLabel !== undefined) window.shortLabel = input.shortLabel; + if (input.summary) window.summary = true; + if (input.tone) window.tone = input.tone; + return window; +} + +/** + * Numeric provider windows have one identity and vocabulary, independent of response slots. + * Pass null when the provider omits the duration; reset countdowns are not window lengths. + * Named API fields (weekly, monthly, etc.) use windowFromUsedPct instead. + */ +export function windowFromReportedDuration(input: { + durationSeconds: number | null; + /** Stable quota identity and provider name for a model- or feature-scoped limit. */ + scope?: { id: string; label: string }; + /** Neutral identity and names when the provider does not report a positive duration. */ + unknown: { id: string; label: string; shortLabel: string }; + utilizationPct: number | null | undefined; + resetsAt?: string | null; + summary?: boolean; + tone?: UsageWindow["tone"]; +}): UsageWindow { + const duration = input.durationSeconds; + const name = + duration !== null && Number.isFinite(duration) && duration > 0 + ? durationWindowName(duration) + : input.unknown; + const scope = input.scope; + return windowFromUsedPct({ + id: scope ? `${scope.id}:${name.id}` : name.id, + label: scope ? `${scope.label} · ${name.label}` : name.label, + shortLabel: scope + ? `${scope.label}${name.shortLabel ? ` ${name.shortLabel}` : ""}` + : name.shortLabel, + utilizationPct: input.utilizationPct, + resetsAt: input.resetsAt, + summary: input.summary, + tone: input.tone, + }); +} + +function durationWindowName(seconds: number): { id: string; label: string; shortLabel: string } { + if (seconds === 604800) return { id: "weekly", label: "Weekly", shortLabel: "wk" }; + const id = seconds === 18000 ? "five_hour" : `${seconds}s`; + const units = [ + [86400, "day", "d"], + [3600, "hour", "h"], + [60, "minute", "m"], + [1, "second", "s"], + ] as const; + const unit = units.find(([size]) => seconds % size === 0) ?? units[units.length - 1]!; + const amount = seconds / unit[0]; + return { id, label: `${amount}-${unit[1]}`, shortLabel: `${amount}${unit[2]}` }; +} + +/** + * The tone scale for anything measured against a known limit, windows and balances alike. + * + * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the + * client falls back to when a window arrives without a tone. Healthy is "ok" rather than + * "default" because that is what every provider setting a tone has always sent, and it is + * what the bars render today below their thresholds. + */ +export function toneFromUsedPct(usedPct: number | null | undefined): UsageWindow["tone"] { + if (typeof usedPct !== "number") return "default"; + if (usedPct > 90) return "danger"; + if (usedPct >= 70) return "warning"; + return "ok"; +} + +/** + * Tone for a balance with no known limit, where a percentage cannot be computed and the + * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: + * this one stays "ok" until the balance is completely spent. + */ +export function balanceToneFromRemaining( + remaining: number | null | undefined, +): UsageBalance["tone"] { + if (typeof remaining !== "number") return "default"; + if (remaining <= 0) return "danger"; + return "ok"; +} + +/** Percentage of a limit consumed, or null when either side is unknown. */ +export function usedPctOf( + used: number | null | undefined, + limit: number | null | undefined, +): number | null { + if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; + return (used / limit) * 100; +} + +export function hashAccountKey(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function unavailable(problem: UsageProblem): UsageReport { + return { status: "unavailable", problem }; +} diff --git a/packages/plugin/src/settings.ts b/packages/plugin/src/settings.ts new file mode 100644 index 0000000000..30e890b3ef --- /dev/null +++ b/packages/plugin/src/settings.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import type { ZodType } from "zod"; +import { defineRpc } from "./rpc.js"; + +export interface SettingsDefinition { + id: string; + scope: "host"; + version: number; + schema: Schema; + /** Convert a previous stored version to the current schema. Runs on the host. */ + migrate?: (values: unknown, fromVersion: number) => unknown | Promise; +} + +export function defineSettings( + definition: SettingsDefinition, +): SettingsDefinition { + if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID"); + if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported"); + if (!Number.isSafeInteger(definition.version) || definition.version < 1) + throw new Error("Invalid settings version"); + return definition; +} + +export const SettingsReadResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("ready"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("invalid"), revision: z.string(), error: z.string() }), +]); +export const SettingsWriteResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("saved"), revision: z.string(), values: z.json() }), + z.object({ status: z.literal("conflict"), error: z.string() }), + z.object({ status: z.literal("invalid"), error: z.string() }), +]); +export function settingsRpc(id: string) { + return { + read: defineRpc({ + name: `settings.${id}.read`, + input: z.object({}), + output: SettingsReadResultSchema, + }), + write: defineRpc({ + name: `settings.${id}.write`, + input: z.object({ revision: z.string(), values: z.json() }), + output: SettingsWriteResultSchema, + }), + reset: defineRpc({ + name: `settings.${id}.reset`, + input: z.object({ revision: z.string() }), + output: SettingsWriteResultSchema, + }), + }; +} diff --git a/packages/plugin/src/skill-examples.test.ts b/packages/plugin/src/skill-examples.test.ts new file mode 100644 index 0000000000..3a21f58c33 --- /dev/null +++ b/packages/plugin/src/skill-examples.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const repository = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const skill = readFileSync(path.join(repository, "skills/paseo-plugin/SKILL.md"), "utf8"); + +function sectionCode(heading: string): string { + const section = skill.slice(skill.indexOf(`\n## ${heading}\n`)); + const code = /```tsx\n([\s\S]*?)\n```/.exec(section)?.[1]; + if (!code) throw new Error(`No tsx example under "${heading}"`); + return code; +} + +// Checks the example as a plugin client file, with the same options as the plugin examples. +function typeErrors(code: string): string[] { + const configPath = path.join(repository, "packages/plugin/tsconfig.examples.json"); + const { config } = ts.readConfigFile(configPath, ts.sys.readFile); + const { options } = ts.parseJsonConfigFileContent(config, ts.sys, path.dirname(configPath)); + const file = path.join(repository, "plugin-examples/skill-example/client/example.tsx"); + const host = ts.createCompilerHost(options); + const getSourceFile = host.getSourceFile; + host.getSourceFile = (name, language, ...rest) => + path.resolve(name) === file + ? ts.createSourceFile(name, code, language) + : getSourceFile.call(host, name, language, ...rest); + host.fileExists = (name) => path.resolve(name) === file || ts.sys.fileExists(name); + const program = ts.createProgram([file], options, host); + return ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); +} + +describe("paseo-plugin skill examples", () => { + it("composer pill example typechecks against the plugin SDK", () => { + expect(typeErrors(sectionCode("Add a composer pill"))).toEqual([]); + }); +}); diff --git a/packages/plugin/tsconfig.json b/packages/plugin/tsconfig.json index b6c46d8b3d..536cb17750 100644 --- a/packages/plugin/tsconfig.json +++ b/packages/plugin/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2023", "DOM"], + "types": ["node"], "jsx": "react-jsx", "strict": true, "skipLibCheck": true, diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 676e5c5039..8ce97a1025 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", @@ -31,10 +31,12 @@ "pretest": "npm run generate:validators" }, "dependencies": { + "semver": "^7.8.5", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^20.9.0", + "@types/semver": "^7.8.0", "typescript": "^5.2.2", "vitest": "^4.1.6", "zod-aot": "0.20.4" diff --git a/packages/protocol/src/agent-feature-schemas.test.ts b/packages/protocol/src/agent-feature-schemas.test.ts index a14ef5f746..ae76dd507e 100644 --- a/packages/protocol/src/agent-feature-schemas.test.ts +++ b/packages/protocol/src/agent-feature-schemas.test.ts @@ -49,6 +49,22 @@ describe("agent feature schemas", () => { expect(parsed.value).toBe("flex"); }); + it.each(["icon", "label"])( + "preserves the %s desktop trigger for any select feature", + (desktopTrigger) => { + const feature = { + type: "select", + id: "custom-feature", + label: "Custom setting", + icon: "gauge", + value: "first", + options: [{ id: "first", label: "First" }], + desktopTrigger, + }; + expect(AgentFeatureSchema.parse(feature)).toEqual(feature); + }, + ); + it("rejects invalid features", () => { const invalidDiscriminator = AgentFeatureSchema.safeParse({ type: "slider", diff --git a/packages/protocol/src/agent-message-display.ts b/packages/protocol/src/agent-message-display.ts new file mode 100644 index 0000000000..c95f9e8206 --- /dev/null +++ b/packages/protocol/src/agent-message-display.ts @@ -0,0 +1,25 @@ +import type { AgentMessage } from "./agent-message.js"; +import type { ToolCallDetail } from "./agent-types.js"; + +/** Presentation shared by clients; never used to encode provider prompts. */ +export function buildAgentMessageDisplay(message: AgentMessage): { + displayName: string; + detail: ToolCallDetail; +} { + const sender = message.sender.title ?? message.sender.id; + const headings: Record = { + message: `Message from ${sender}`, + finished: `${sender} finished`, + errored: `${sender} errored`, + "permission-required": `${sender} needs permission`, + closed: `${sender} closed`, + }; + return { + displayName: headings[message.event], + detail: { + type: "plain_text", + icon: "bot", + text: `From agent: ${message.sender.id}\n\n${message.text}`, + }, + }; +} diff --git a/packages/protocol/src/agent-message.ts b/packages/protocol/src/agent-message.ts new file mode 100644 index 0000000000..5caf63656d --- /dev/null +++ b/packages/protocol/src/agent-message.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +/** Semantic provenance, independent of provider envelopes and timeline presentation. */ +export const AgentMessageSchema = z.object({ + event: z.enum(["message", "finished", "errored", "permission-required", "closed"]), + sender: z.object({ + id: z.string().min(1), + title: z.string().min(1).optional(), + }), + text: z.string(), +}); + +export type AgentMessage = z.infer; diff --git a/packages/protocol/src/agent-profile.ts b/packages/protocol/src/agent-profile.ts new file mode 100644 index 0000000000..2534984240 --- /dev/null +++ b/packages/protocol/src/agent-profile.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +/** + * A named launch bundle: a provider plus the agent-config values a client would + * otherwise set one control at a time. Field names mirror `AgentSessionConfig` + * so applying a profile is a copy rather than a translation table. + * + * There is deliberately no system prompt here. `AgentSessionConfig.systemPrompt` + * is creation-only, so a profile carrying one would apply when starting a new + * agent and silently do nothing when applied to a running one. + */ +export const AgentProfileSchema = z + .object({ + id: z.string(), + name: z.string(), + /** A key into the client's icon registry, not a glyph. Unknown keys draw the default. */ + icon: z.string().optional(), + /** An identity colour name shared with host badges. Unknown values draw unthemed. */ + color: z.string().optional(), + provider: z.string(), + model: z.string().optional(), + modeId: z.string().optional(), + thinkingOptionId: z.string().optional(), + featureValues: z.record(z.string(), z.unknown()).optional(), + /** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */ + notes: z.string().optional(), + }) + .passthrough(); + +export type AgentProfile = z.infer; + +export const AgentSkillSelectionSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("all") }).strict(), + z.object({ mode: z.literal("custom"), skills: z.array(z.string()) }).strict(), +]); +export type AgentSkillSelection = z.infer; diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index bce5fa7fcd..c7fffe8ce9 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "./agent-message.js"; import type { AgentAttachment } from "./messages.js"; export type AgentProvider = string; @@ -102,6 +103,17 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen return { ...model, defaultThinkingOptionId }; } +/** A provider addresses models by ID; repeated rows retain the first definition. */ +export function normalizeAgentModelCatalog(models: AgentModelDefinition[]): AgentModelDefinition[] { + const ids = new Set(); + const unique = models.filter((model) => { + if (ids.has(model.id)) return false; + ids.add(model.id); + return true; + }); + return unique.length === models.length ? models : unique; +} + export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; @@ -113,6 +125,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } @@ -133,6 +146,8 @@ export interface AgentFeatureSelect { description?: string; tooltip?: string; icon?: string; + /** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */ + desktopTrigger?: "icon" | "label"; value: string | null; options: AgentSelectOption[]; } @@ -297,6 +312,7 @@ export type ToolCallDetail = }; interface ToolCallBase { + agentMessage?: AgentMessage; [key: string]: unknown; type: "tool_call"; callId: string; @@ -363,6 +379,11 @@ export type AgentTimelineItem = | ToolCallTimelineItem | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } + | { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + } | CompactionTimelineItem | PluginTimelineItem; @@ -479,7 +500,7 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; -export type ProviderOptions = Record; +export type ProviderOptions = Record; export interface McpToolRef { kind: "mcp"; diff --git a/packages/protocol/src/browser-automation/rpc-schemas.ts b/packages/protocol/src/browser-automation/rpc-schemas.ts index 55785dd69d..d2c5870e71 100644 --- a/packages/protocol/src/browser-automation/rpc-schemas.ts +++ b/packages/protocol/src/browser-automation/rpc-schemas.ts @@ -498,6 +498,7 @@ export const BrowserAutomationDialogEventSchema = z.object({ export const BrowserAutomationExecuteRequestSchema = z .object({ type: z.literal("browser.automation.execute.request"), + subscriptionId: z.string().optional(), requestId: z.string().min(1), agentId: z.string().min(1).optional(), cwd: z.string().min(1).optional(), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index b9c405b4f1..0947b81027 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,11 @@ export const CLIENT_CAPS = { + helloRejection: "hello_rejection", + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09. + ownedSubscriptions: "owned_subscriptions", + // Clients outside the Paseo app version series can explicitly accept provider IDs. + // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. + explicitEventSubscriptions: "explicit_event_subscriptions", + allProviders: "all_providers", // COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive // agent streams only for their explicit viewed set. Remove after 2027-01-12 // once the supported client floor is >= v0.1.106. @@ -18,15 +25,26 @@ export const CLIENT_CAPS = { // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned // child descriptors and timelines only to clients that understand the new messages. providerSubagents: "provider_subagents", + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire capability. + projectedSubagentTimeline: "projected_subagent_timeline", // COMPAT(projectUpdates): added in v0.1.109, remove gate after 2027-01-15. projectUpdates: "project_updates", // COMPAT(compactProviderSnapshots): added in v0.2.X. Capable clients receive // provider catalogs with shared thinking sets and may revalidate by content hash. // Remove the legacy snapshot encoding after 2027-02-04. compactProviderSnapshots: "compact_provider_snapshots", + providerSnapshotReferences: "provider_snapshot_references", // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove legacy // reconstructed timeline replay after 2027-02-21 once the client floor supports invalidation. timelineReplacementInvalidation: "timeline_replacement_invalidation", + // COMPAT(timelineNotifications): added in v0.7.2. The daemon omits notification + // timeline items for older clients whose strict timeline union rejects them. + // Remove after 2027-03-03 once the supported client floor is >= v0.7.2. + timelineNotifications: "timeline_notifications", + // COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + pluginTimelineItems: "plugin_timeline_items", + // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + workspaceSetupBlocked: "workspace_setup_blocked", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/daemon-endpoints.test.ts b/packages/protocol/src/daemon-endpoints.test.ts index 7f131eec08..3ac1c111ac 100644 --- a/packages/protocol/src/daemon-endpoints.test.ts +++ b/packages/protocol/src/daemon-endpoints.test.ts @@ -7,6 +7,8 @@ import { extractHostPortFromWebSocketUrl, normalizeRelayProtocolVersion, parseConnectionUri, + parseRelayConnectionUri, + serializeRelayConnectionUri, serializeConnectionUri, serializeConnectionUriForStorage, shouldUseTlsForDefaultHostedRelay, @@ -211,3 +213,22 @@ describe("shouldUseTlsForDefaultHostedRelay", () => { expect(shouldUseTlsForDefaultHostedRelay("not-an-endpoint")).toBe(false); }); }); + +describe("relay connection URI", () => { + test("round-trips the offer and password through a direct URI and connect wrapper", () => { + const parts = { + offer: { + v: 2 as const, + serverId: "srv_test", + daemonPublicKeyB64: "abc+/=", + relay: { endpoint: "relay.paseo.sh:443", useTls: true }, + }, + password: "two words", + }; + const uri = serializeRelayConnectionUri(parts); + expect(parseRelayConnectionUri(uri)).toEqual(parts); + expect( + parseRelayConnectionUri(`https://app.paseo.sh/#connect=${encodeURIComponent(uri)}`), + ).toEqual(parts); + }); +}); diff --git a/packages/protocol/src/daemon-endpoints.ts b/packages/protocol/src/daemon-endpoints.ts index 299abcab36..a4b72c9416 100644 --- a/packages/protocol/src/daemon-endpoints.ts +++ b/packages/protocol/src/daemon-endpoints.ts @@ -1,3 +1,5 @@ +import type { ConnectionOfferV2 } from "./connection-offer.js"; + export interface HostPortParts { host: string; port: number; @@ -12,6 +14,55 @@ export interface ParsedConnectionUri extends ConnectionUriParts { password?: string; } +export interface ParsedRelayConnectionUri { + offer: ConnectionOfferV2; + password?: string; +} + +export function parseRelayConnectionUri(input: string): ParsedRelayConnectionUri { + const trimmed = input.trim(); + const connectIndex = trimmed.indexOf("#connect="); + const uri = + connectIndex >= 0 + ? decodeURIComponent(trimmed.slice(connectIndex + "#connect=".length)) + : trimmed; + let url: URL; + try { + url = new URL(uri); + } catch { + throw new Error("Invalid relay connection URI"); + } + if (url.protocol !== "relay:" || !url.hostname || !url.port || url.username || url.password) { + throw new Error("Invalid relay connection URI"); + } + const serverId = decodeURIComponent(url.pathname.slice(1)); + const key = url.searchParams.get("key"); + if (!serverId || !key || serverId.includes("/")) { + throw new Error("Relay connection URI requires a server id and key"); + } + const endpoint = normalizeHostPort(url.host); + const password = url.searchParams.get("password") || undefined; + return { + offer: { + v: 2, + serverId, + daemonPublicKeyB64: key, + relay: { endpoint, useTls: url.searchParams.get("ssl") === "true" }, + }, + ...(password ? { password } : {}), + }; +} + +export function serializeRelayConnectionUri(parts: ParsedRelayConnectionUri): string { + const url = new URL( + `relay://${parts.offer.relay.endpoint}/${encodeURIComponent(parts.offer.serverId)}`, + ); + url.searchParams.set("key", parts.offer.daemonPublicKeyB64); + if (parts.offer.relay.useTls) url.searchParams.set("ssl", "true"); + if (parts.password) url.searchParams.set("password", parts.password); + return url.toString(); +} + export type RelayRole = "server" | "client"; export type RelayProtocolVersion = "1" | "2"; diff --git a/packages/protocol/src/messages.create-agent-internal.test.ts b/packages/protocol/src/messages.create-agent-internal.test.ts new file mode 100644 index 0000000000..5d4e75649f --- /dev/null +++ b/packages/protocol/src/messages.create-agent-internal.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; + +import { SessionInboundMessageSchema } from "./messages.js"; + +describe("create request internal field", () => { + test("accepts internal on create_agent_request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "create_agent_request", + requestId: "create-agent-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); + + test("accepts internal on agent.create.request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + }); + + expect(parsed).toEqual({ + type: "agent.create.request", + requestId: "agent-create-internal", + config: { + provider: "codex", + cwd: "/repo/app", + }, + internal: true, + labels: {}, + }); + }); +}); diff --git a/packages/protocol/src/messages.plugins.test.ts b/packages/protocol/src/messages.plugins.test.ts index e5726b5081..f266b65988 100644 --- a/packages/protocol/src/messages.plugins.test.ts +++ b/packages/protocol/src/messages.plugins.test.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { describe, expect, it } from "vitest"; import { MutableDaemonConfigSchema, @@ -7,6 +8,42 @@ import { } from "./messages.js"; describe("plugin protocol compatibility", () => { + it.each([undefined, { paseo: ">=0.8.0" }])( + "parses plugin catalogs with requirements %j", + (requirements) => { + const message = { + type: "plugin.catalog.get.response", + payload: { + requestId: "catalog", + plugins: [ + { id: "example", clientBundle: "bundle", ...(requirements ? { requirements } : {}) }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }, + ); + it("preserves manifest metadata in plugin list responses", () => { + const message = { + type: "plugin.list.response", + payload: { + requestId: "metadata", + plugins: [ + { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + path: "/plugins/review", + enabled: false, + status: "disabled", + }, + ], + }, + }; + expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); + }); + it("parses plugin timeline append messages and advertises the capability", () => { expect( SessionInboundMessageSchema.parse({ @@ -81,6 +118,22 @@ describe("plugin protocol compatibility", () => { type: "plugin.directory.install.response", payload: { requestId: "request-1", + plugin: { + id: "example-work", + description: "Reviews changes before merge", + path: "/plugins/example", + enabled: true, + status: "running", + }, + }, + }).type, + ).toBe("plugin.directory.install.response"); + + expect( + SessionOutboundMessageSchema.parse({ + type: "plugin.directory.install.response", + payload: { + requestId: "request-old-shape", plugin: { id: "example-work", path: "/plugins/example", @@ -262,3 +315,37 @@ describe("plugin protocol compatibility", () => { ).toThrow(); }); }); + +it("npm metadata leaves legacy plugin rows parseable without extending their closed source enum", () => { + const legacyPlugin = z.object({ + id: z.string(), + path: z.string(), + enabled: z.boolean(), + status: z.enum(["running", "disabled", "failed"]), + source: z.enum(["directory", "git"]).optional(), + }); + const plugin = { + id: "review", + path: "/plugins/review", + enabled: true, + status: "running", + installation: { + identity: { kind: "npm", packageName: "@acme/review", pluginPath: "." }, + currentRevision: "1.2.0", + }, + }; + const message = SessionOutboundMessageSchema.parse({ + type: "plugin.list.response", + payload: { requestId: "npm", plugins: [plugin] }, + }); + expect(legacyPlugin.parse(plugin)).toEqual({ + id: "review", + path: "/plugins/review", + enabled: true, + status: "running", + }); + expect(message).toEqual({ + type: "plugin.list.response", + payload: { requestId: "npm", plugins: [plugin] }, + }); +}); diff --git a/packages/protocol/src/messages.providers-snapshot.test.ts b/packages/protocol/src/messages.providers-snapshot.test.ts index fc87b13b3c..91b08a3cdc 100644 --- a/packages/protocol/src/messages.providers-snapshot.test.ts +++ b/packages/protocol/src/messages.providers-snapshot.test.ts @@ -94,3 +94,20 @@ describe("provider snapshot message schemas", () => { expect(parsed.payload.entries[0]?.enabled).toBe(true); }); }); + +test("accepts a bodyless announcement with separate discovery freshness", async () => { + const { validateWSOutboundMessage } = await import("./validation/ws-outbound.js"); + const message = { + type: "providers_snapshot_update", + payload: { + cwd: "/project", + entries: [], + snapshotHash: "content-hash", + fetchedAt: { codex: "2026-09-06T12:00:00.000Z" }, + generatedAt: "2026-09-06T13:00:00.000Z", + }, + }; + expect(ProvidersSnapshotUpdateMessageSchema.parse(message)).toEqual(message); + const result = validateWSOutboundMessage({ type: "session", message }); + expect(result.success).toBe(true); +}); diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 3394c8e15f..ca3ab86135 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + AgentTimelineItemPayloadSchema, FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, parseServerInfoStatusPayload, @@ -520,3 +521,30 @@ describe("viewed timeline subscription messages", () => { }); }); }); + +test("agent messages are an optional semantic payload on existing tool rows", () => { + const legacy = { + type: "tool_call", + callId: "delivery", + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: "Done" }, + }; + const agentMessage = { + event: "finished", + sender: { id: "server::worker", title: "Reviewer" }, + text: "Done", + }; + expect(AgentTimelineItemPayloadSchema.parse(legacy)).toEqual(legacy); + expect(AgentTimelineItemPayloadSchema.parse({ ...legacy, agentMessage })).toEqual({ + ...legacy, + agentMessage, + }); + expect( + AgentTimelineItemPayloadSchema.safeParse({ + ...legacy, + agentMessage: { ...agentMessage, sender: { id: 42 } }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 954b8acad7..fefc104279 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,9 +1,30 @@ +import { AgentMessageSchema } from "./agent-message.js"; +import { PluginRegistryIdentitySchema } from "./plugin-registry.js"; +import { AgentProfileSchema, AgentSkillSelectionSchema } from "./agent-profile.js"; +export { + AgentProfileSchema, + AgentSkillSelectionSchema, + type AgentProfile, + type AgentSkillSelection, +} from "./agent-profile.js"; +import { PluginIdSchema, PluginRequirementsSchema, PluginSourceSchema } from "./plugin-config.js"; +export { + PluginIdSchema, + PluginRequirementsSchema, + DirectoryPluginSourceSchema, + PluginSourceSchema, + type PluginRequirements, + type PluginSource, +} from "./plugin-config.js"; +import { TerminalProfileSchema } from "./terminal-profile.js"; +export { TerminalProfileSchema, type TerminalProfile } from "./terminal-profile.js"; import { z } from "zod"; import { TerminalActivitySchema } from "./terminal-activity.js"; import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js"; import { AgentProviderSchema } from "./provider-manifest.js"; +import { ProviderPaseoToolsPolicySchema } from "./provider-config.js"; import { TOOL_CALL_ICON_NAMES } from "./agent-types.js"; import { WORKSPACE_LABEL_COLORS } from "./workspace-labels.js"; import { @@ -55,6 +76,7 @@ import { LoopStopResponseSchema, } from "./loop/rpc-schemas.js"; import { + BROWSER_AUTOMATION_COMMAND_NAMES, BrowserAutomationExecuteRequestSchema, BrowserAutomationExecuteResponseSchema, } from "./browser-automation/rpc-schemas.js"; @@ -118,6 +140,7 @@ const MutableDaemonProviderModelSchema = z const MutableDaemonProviderConfigSchema = z .object({ + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), additionalModels: z.array(MutableDaemonProviderModelSchema).optional(), }) @@ -137,47 +160,6 @@ const MutableMetadataGenerationConfigSchema = z }) .passthrough(); -export const TerminalProfileSchema = z - .object({ - id: z.string(), - name: z.string(), - command: z.string(), - args: z.array(z.string()).optional(), - icon: z.string().optional(), - }) - .passthrough(); - -export type TerminalProfile = z.infer; - -/** - * A named launch bundle: a provider plus the agent-config values a client would - * otherwise set one control at a time. Field names mirror `AgentSessionConfig` - * so applying a profile is a copy rather than a translation table. - * - * There is deliberately no system prompt here. `AgentSessionConfig.systemPrompt` - * is creation-only, so a profile carrying one would apply when starting a new - * agent and silently do nothing when applied to a running one. - */ -export const AgentProfileSchema = z - .object({ - id: z.string(), - name: z.string(), - /** A key into the client's icon registry, not a glyph. Unknown keys draw the default. */ - icon: z.string().optional(), - /** An identity colour name shared with host badges. Unknown values draw unthemed. */ - color: z.string().optional(), - provider: z.string(), - model: z.string().optional(), - modeId: z.string().optional(), - thinkingOptionId: z.string().optional(), - featureValues: z.record(z.string(), z.unknown()).optional(), - /** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */ - notes: z.string().optional(), - }) - .passthrough(); - -export type AgentProfile = z.infer; - const MutableBrowserToolsConfigSchema = z .object({ enabled: z.boolean().default(false), @@ -189,26 +171,6 @@ const MutableRelayConfigSchema = z }) .passthrough(); -export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/); - -export const DirectoryPluginSourceSchema = z - .object({ - source: z.literal("directory"), - path: z.string().min(1), - enabled: z.boolean().optional(), - }) - .strict(); - -export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]); - -export type PluginSource = z.infer; - -export const AgentSkillSelectionSchema = z.discriminatedUnion("mode", [ - z.object({ mode: z.literal("all") }).strict(), - z.object({ mode: z.literal("custom"), skills: z.array(z.string()) }).strict(), -]); -export type AgentSkillSelection = z.infer; - export const MutableDaemonConfigSchema = z .object({ // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. @@ -341,6 +303,7 @@ export const AgentFeatureSelectSchema = z.object({ description: z.string().optional(), tooltip: z.string().optional(), icon: z.string().optional(), + desktopTrigger: z.enum(["icon", "label"]).optional(), value: z.string().nullable(), options: z.array(AgentSelectOptionSchema), }); @@ -375,6 +338,7 @@ export const ProviderSnapshotEntrySchema = z.object({ fetchedAt: z.string().optional(), label: z.string().optional(), description: z.string().optional(), + iconSvg: z.string().optional(), defaultModeId: z.string().nullable().optional(), }); @@ -456,7 +420,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [ McpSseServerConfigSchema, ]); -const ProviderOptionsSchema = z.record(z.string(), z.json()); +const ProviderOptionsSchema = z.record(z.string(), z.unknown()); const McpToolRefSchema = z .object({ @@ -662,6 +626,7 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discri ); const ToolCallBasePayloadSchema = z.object({ + agentMessage: AgentMessageSchema.optional(), type: z.literal("tool_call"), callId: z.string(), name: z.string(), @@ -732,6 +697,11 @@ export const AgentTimelineItemPayloadSchema: z.ZodType; @@ -940,6 +912,9 @@ const AgentDirectoryFilterSchema = z.object({ includeArchived: z.boolean().optional(), requiresAttention: z.boolean().optional(), thinkingOptionId: z.string().nullable().optional(), + // Retained for wire parsing; private helpers are excluded from public discovery. + includeInternal: z.boolean().optional(), + includeBackground: z.boolean().optional(), }); export const DeleteAgentRequestMessageSchema = z.object({ @@ -1025,7 +1000,7 @@ const WorkspaceLabelSyncCursorSchema = z.object({ export const WorkspaceLabelListRequestSchema = z.object({ type: z.literal("workspace.label.list.request"), requestId: z.string(), - subscribe: z.object({ subscriptionId: z.string() }), + subscribe: z.object({ subscriptionId: z.string().optional() }).optional(), sync: WorkspaceLabelSyncCursorSchema.optional(), }); export const WorkspaceLabelAssignmentSetRequestSchema = z.object({ @@ -1267,6 +1242,7 @@ export const FetchAgentsRequestMessageSchema = z.object({ type: z.literal("fetch_agents_request"), requestId: z.string(), scope: z.enum(["active"]).optional(), + includeBackground: z.boolean().optional(), filter: AgentDirectoryFilterSchema.optional(), sort: z .array( @@ -1300,6 +1276,7 @@ const WorkspaceStateBucketSchema = z.enum([ ]); export const FetchWorkspacesRequestMessageSchema = z.object({ + includeBackground: z.boolean().optional(), type: z.literal("fetch_workspaces_request"), requestId: z.string(), filter: z @@ -1308,6 +1285,9 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ projectId: z.string().optional(), // Unused: accepted so older clients still parse, but the server does not filter on it. idPrefix: z.string().optional(), + // Retained for wire parsing of older requests. + includeInternal: z.boolean().optional(), + includeBackground: z.boolean().optional(), }) .optional(), sort: z @@ -1344,9 +1324,8 @@ export const FetchAgentHistoryRequestMessageSchema = z.object({ type: z.literal("fetch_agent_history_request"), requestId: z.string(), filter: AgentDirectoryFilterSchema.optional(), - // A ranked free-text query over agent title, workspace name, branch, and - // project name. Present only on history: agent subscriptions filter on - // structure, not on relevance. Ranking replaces `sort` when it is set. + // A free-text filter over agent title, workspace name, branch, and project name. + // Matching rows follow the requested sort and cursor pagination. search: z.string().optional(), sort: z .array( @@ -1387,6 +1366,8 @@ export const SendAgentMessageRequestSchema = z.object({ /** Accepts full ID, unique prefix, or exact full title (server resolves). */ agentId: z.string(), text: z.string(), + /** Opaque sender identity for agent-originated prompts. */ + sourceAgentId: z.string().min(1).optional(), messageId: z.string().optional(), // Client-provided ID for deduplication activeTurnBehavior: ActiveTurnBehaviorSchema.optional(), images: z.array(ImageAttachmentSchema).optional(), @@ -1483,6 +1464,63 @@ export const PluginSourceInstallRequestSchema = z.object({ pluginPath: z.string().min(1).optional(), }); +export const PluginSourceIdentitySchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("directory"), path: z.string() }), + z.object({ + kind: z.literal("git"), + remote: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ + kind: z.literal("npm"), + packageName: z.string(), + pluginPath: z.string(), + registry: PluginRegistryIdentitySchema.optional(), + }), +]); +export const PluginInstallationSchema = z.object({ + identity: PluginSourceIdentitySchema, + currentRevision: z.string().optional(), +}); +export type PluginInstallation = z.infer; +export const PluginUpdateTargetSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("git"), commit: z.string().regex(/^[0-9a-f]{40,64}$/) }), + z.object({ + kind: z.literal("npm"), + version: z.string().min(1), + resolved: z.string().url(), + integrity: z.string().min(1), + }), +]); +export type PluginUpdateTarget = z.infer; +export const PluginUpdateProposalSchema = z.object({ + id: PluginIdSchema, + expected: z.object({ + identity: PluginSourceIdentitySchema, + installationRoot: z.string(), + revision: z.string(), + }), + target: PluginUpdateTargetSchema, +}); +export type PluginUpdateProposal = z.infer; +export const PluginUpdateSelectionSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("git"), ref: z.string().min(1) }), + z.object({ kind: z.literal("npm"), version: z.string().min(1) }), +]); +export type PluginUpdateSelection = z.infer; +export const PluginUpdatePreviewRequestSchema = z.object({ + type: z.literal("plugin.source.update.preview.request"), + requestId: z.string(), + pluginId: PluginIdSchema.optional(), + target: PluginUpdateSelectionSchema.optional(), +}); +export const PluginUpdateApplyRequestSchema = z.object({ + type: z.literal("plugin.source.update.apply.request"), + requestId: z.string(), + proposals: z.array(PluginUpdateProposalSchema).min(1), +}); + export const PluginSourceStatusRequestSchema = z.object({ type: z.literal("plugin.source.status.request"), requestId: z.string(), @@ -1669,6 +1707,8 @@ export type CreateAgentWorktreeTarget = z.infer; +// These connection event streams have no directory bootstrap or timeline membership. +export const SessionEventSubscriptionSchema = z.enum([ + "project.update", + "providers_snapshot_update", + "agent_attention_required", + "agent_permission_request", + "agent_permission_resolved", + "checkout_status_update", + "script_status_update", + "workspace_setup_progress", + "agent.provider_subagents.update", + "terminal_attention_required", + "status.server_info", + "status.daemon_config_changed", + "status.plugin_catalog_changed", + "status.plugin_settings_changed", + "activity_log", + "hub.execution.agent.update", + "hub.execution.agent.stream", +]); +export type SessionEventSubscription = z.infer; +export const SessionEventsSetSubscriptionRequestSchema = z.object({ + type: z.literal("session.events.set_subscription.request"), + requestId: z.string(), + events: z.array(SessionEventSubscriptionSchema), + notifications: z.boolean().optional(), +}); +export const SessionEventsSetSubscriptionResponseSchema = z.object({ + type: z.literal("session.events.set_subscription.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string().optional() }), +}); + +export const BrowserHostRegisterRequestSchema = z.object({ + type: z.literal("browser.host.register.request"), + requestId: z.string(), + hostKind: z.string().min(1), + supportedCommands: z.array(z.enum(BROWSER_AUTOMATION_COMMAND_NAMES)).min(1), +}); +export const BrowserHostRegisterResponseSchema = z.object({ + type: z.literal("browser.host.register.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), +}); + +export const SubscriptionReleaseRequestSchema = z.object({ + type: z.literal("subscription.release.request"), + requestId: z.string(), + subscriptionId: z.string(), +}); +export const SubscriptionReleaseResponseSchema = z.object({ + type: z.literal("subscription.release.response"), + payload: z.object({ requestId: z.string(), subscriptionId: z.string() }), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterRequestSchema, + SubscriptionReleaseRequestSchema, + SessionEventsSetSubscriptionRequestSchema, HubExecutionAgentCreateRequestSchema, HubExecutionAgentValidateRequestSchema, HubExecutionControlRequestSchema, @@ -3076,6 +3247,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PluginSourceInstallRequestSchema, PluginSourceStatusRequestSchema, PluginSourceUpdateRequestSchema, + PluginUpdatePreviewRequestSchema, + PluginUpdateApplyRequestSchema, PluginReloadRequestSchema, PluginEnableRequestSchema, PluginDisableRequestSchema, @@ -3104,6 +3277,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotRequestMessageSchema, ProviderDiagnosticRequestMessageSchema, ProviderUsageListRequestMessageSchema, + UsageListReportsRequestMessageSchema, ResumeAgentRequestMessageSchema, ImportAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, @@ -3112,6 +3286,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + AgentTimelineSearchRequestMessageSchema, AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, @@ -3126,6 +3301,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, + CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, CheckoutCommitRequestSchema, @@ -3169,7 +3345,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectGithubCloneRequestSchema, ArchiveWorkspaceRequestSchema, WorkspaceCreateRequestSchema, + AgentCreateRequestSchema, + CreationSubscribeRequestSchema, WorkspaceClearAttentionRequestSchema, + WorkspaceMarkUnreadRequestSchema, FileExplorerRequestSchema, FileSubscribeRequestSchema, FileUnsubscribeRequestSchema, @@ -3232,6 +3411,7 @@ export type SessionInboundMessage = z.infer; // ============================================================================ export const ActivityLogPayloadSchema = z.object({ + subscriptionId: z.string().optional(), id: z.string(), timestamp: z.coerce.date(), type: z.enum(["transcript", "assistant", "tool_call", "tool_result", "error", "system"]), @@ -3380,6 +3560,7 @@ const ServerCapabilitiesFromUnknownSchema = z export const ServerInfoStatusPayloadSchema = z .object({ status: z.literal("server_info"), + protocolVersion: z.number().int().optional(), serverId: z.string().trim().min(1), hostname: ServerInfoHostnameSchema.optional(), version: ServerInfoVersionSchema.optional(), @@ -3391,15 +3572,30 @@ export const ServerInfoStatusPayloadSchema = z // COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot features: z .object({ + // COMPAT(agentRequestReceipts): added in v0.8.0; remove gate after 2027-03-05. + agentRequestReceipts: z.boolean().optional(), + // COMPAT(workspaceRequestReceipts): added in v0.8.0; remove gate after 2027-03-07. + workspaceRequestReceipts: z.boolean().optional(), + creationLifecycle: z.boolean().optional(), + // COMPAT(hubAgentRpc): added in v0.8.0; remove gate after 2027-03-05. + hubAgentRpc: z.boolean().optional(), + // COMPAT(internalAgents): added in v0.9.0; remove gate after 2027-03-17. + internalAgents: z.boolean().optional(), + // COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17. + internalWorkspaces: z.boolean().optional(), + backgroundWorkspaces: z.boolean().optional(), providersSnapshot: z.boolean().optional(), + usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), - // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. + // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), + // COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05. + workspaceTerminals: z.boolean().optional(), // COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the // feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17 // once the supported daemon floor is >= v0.2.0. @@ -3436,10 +3632,15 @@ export const ServerInfoStatusPayloadSchema = z pluginLogs: z.boolean().optional(), // COMPAT(pluginGitManagement): added in v0.7.0, remove gate after 2027-08-26. pluginGitManagement: z.boolean().optional(), + // COMPAT(pluginSourceInstallation): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports source identifiers. + pluginSourceInstallation: z.boolean().optional(), + // COMPAT(pluginSourceUpdates): added in v0.8.0; remove gate after 2027-03-16 once daemon floor supports reviewed updates. + pluginSourceUpdates: z.boolean().optional(), // COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. // A daemon that predates this flag keeps `addTheme` in the server bundle it compiles, // so a theme plugin cannot start there at all. pluginThemes: z.boolean().optional(), + pluginSettings: z.boolean().optional(), pluginTimelineItems: z.boolean().optional(), // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: z.boolean().optional(), @@ -3485,8 +3686,14 @@ export const ServerInfoStatusPayloadSchema = z agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. + projectedSubagentTimeline: z.boolean().optional(), + // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. + providerSubagentNesting: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: z.boolean().optional(), // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: z.boolean().optional(), // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. @@ -3505,7 +3712,7 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), - // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. + // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. importSessionSearch: z.boolean().optional(), // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // 2027-01-17 once the supported daemon floor is >= v0.2.0. @@ -3514,8 +3721,11 @@ export const ServerInfoStatusPayloadSchema = z forgeProviders: z.boolean().optional(), // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: z.boolean().optional(), + explicitEventSubscriptions: z.boolean().optional(), + ownedSubscriptions: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), + agentMessageProvenance: z.boolean().optional(), // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. @@ -3637,6 +3847,12 @@ export const PluginCatalogChangedStatusPayloadSchema = z.object({ pluginId: PluginIdSchema, }); +export const PluginSettingsChangedStatusPayloadSchema = z.object({ + status: z.literal("plugin_settings_changed"), + pluginId: PluginIdSchema, + settingsId: z.string(), +}); + export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ AgentCreatedStatusPayloadSchema, AgentCreateFailedStatusPayloadSchema, @@ -3646,6 +3862,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [ RestartRequestedStatusPayloadSchema, DaemonConfigChangedStatusPayloadSchema, PluginCatalogChangedStatusPayloadSchema, + PluginSettingsChangedStatusPayloadSchema, ]); export type KnownStatusPayload = z.infer; @@ -3833,6 +4050,9 @@ export const WorkspaceDescriptorPayloadSchema = z pinnedAt: z.string().nullable().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14. labels: z.array(z.string()).optional(), + // Retained for wire parsing of older daemon descriptors. + internal: z.boolean().optional(), + background: z.boolean().optional(), archivingAt: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, // Best-effort workspace status entry timestamp. Old daemons omit the @@ -3875,6 +4095,7 @@ export const AgentUpdateMessageSchema = z.object({ type: z.literal("agent_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema.nullable().optional(), @@ -3882,6 +4103,7 @@ export const AgentUpdateMessageSchema = z.object({ seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), agentId: z.string(), generation: z.string().optional(), @@ -3893,6 +4115,7 @@ export const AgentUpdateMessageSchema = z.object({ export const AgentStreamMessageSchema = z.object({ type: z.literal("agent_stream"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), event: AgentStreamEventPayloadSchema, timestamp: z.string(), @@ -3935,13 +4158,10 @@ export type AgentSearchMatch = z.infer; const AgentDirectoryResponseEntrySchema = z.object({ agent: AgentSnapshotPayloadSchema, project: ProjectPlacementPayloadSchema, - // Relevance of this entry to the request's `search`, lower being better. - // Set only when the request carried a query; a client merging results from - // several hosts needs it to interleave their separately ranked pages. + // Legacy relevance metadata remains accepted from older daemons. + // Current history responses use the requested chronological sort. searchScore: z.number().optional(), - // Where the query matched, so the row can mark it. The ranker computes this - // anyway; sending it keeps the client from re-deriving a second opinion that - // could disagree with the ranking it is explaining. + // Legacy server-generated highlights. Current clients highlight displayed text locally. searchMatches: z.array(AgentSearchMatchSchema).optional(), // COMPAT(directorySync): sequence of this latest directory projection. syncSeq: z.number().int().positive().optional(), @@ -3970,9 +4190,7 @@ export const FetchAgentHistoryResponseMessageSchema = z.object({ requestId: z.string(), entries: z.array(AgentDirectoryResponseEntrySchema), pageInfo: AgentDirectoryPageInfoSchema, - // More sessions matched the request's `search` than the page could hold. - // Distinct from `pageInfo.hasMore`, which promises a fetchable next page — - // a ranked result set has none, and the way on is a narrower query. + // Older daemons truncate relevance-ranked searches instead of returning a cursor. searchTruncated: z.boolean().optional(), }), }); @@ -4039,12 +4257,14 @@ export const WorkspaceUpdateMessageSchema = z.object({ type: z.literal("workspace_update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), workspace: WorkspaceDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), id: z.string(), // COMPAT(workspaceProjects): added in v0.1.97, drop the optional gate when floor >= v0.1.97. @@ -4073,6 +4293,7 @@ export const WorkspaceLabelListResponseSchema = z.object({ type: z.literal("workspace.label.list.response"), payload: z.object({ requestId: z.string(), + subscriptionId: z.string().optional(), labels: z.array(WorkspaceLabelDefinitionSchema), sync: WorkspaceLabelSyncMetadataSchema, }), @@ -4081,6 +4302,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ type: z.literal("workspace.label.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), label: WorkspaceLabelDefinitionSchema, previousName: z.string().optional(), @@ -4088,6 +4310,7 @@ export const WorkspaceLabelUpdateSchema = z.object({ seq: z.number().int().positive(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), name: z.string(), generation: z.string(), @@ -4130,12 +4353,14 @@ export const ProjectUpdateMessageSchema = z.object({ type: z.literal("project.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema, generation: z.string().optional(), seq: z.number().int().positive().optional(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), projectId: z.string(), generation: z.string().optional(), @@ -4156,6 +4381,7 @@ export const ProjectListResponseMessageSchema = z.object({ export const ScriptStatusUpdateMessageSchema = z.object({ type: z.literal("script_status_update"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), scripts: z.array(WorkspaceScriptPayloadSchema), }), @@ -4164,6 +4390,7 @@ export const ScriptStatusUpdateMessageSchema = z.object({ export const WorkspaceSetupProgressMessageSchema = z.object({ type: z.literal("workspace_setup_progress"), payload: z.object({ + subscriptionId: z.string().optional(), workspaceId: z.string(), status: z.enum(["running", "completed", "failed", "blocked"]), detail: WorktreeSetupDetailPayloadSchema, @@ -4425,11 +4652,32 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ export const AgentTimelineReplacementMessageSchema = z.object({ type: z.literal("agent.timeline.replacement"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), epoch: z.string(), }), }); +export const AgentTimelineSearchResponseMessageSchema = z.object({ + type: z.literal("agent.timeline.search.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + epoch: z.string(), + locations: z.array( + z.object({ + seq: z.number().int().nonnegative(), + role: z.enum(["user", "assistant"]), + // Estimated occurrences in the message; the client verifies against rendered text. + // COMPAT(timelineSearchCount): added in v0.9.0, remove optional after 2027-09-22. + count: z.number().int().positive().optional(), + }), + ), + nextCursor: z.number().int().nonnegative().nullable(), + error: z.string().nullable(), + }), +}); + export const AgentTimelineListPromptsResponseMessageSchema = z.object({ type: z.literal("agent.timeline.list_prompts.response"), payload: z.object({ @@ -4450,6 +4698,8 @@ export const AgentTimelineListPromptsResponseMessageSchema = z.object({ export const ProviderSubagentDescriptorPayloadSchema = z.object({ id: z.string(), parentAgentId: z.string(), + // COMPAT(providerSubagentNesting): added in v0.7, remove optional after 2027-03-04. + parentSubagentId: z.string().nullable().optional(), provider: AgentProviderSchema, title: z.string().nullable(), description: z.string().nullable(), @@ -4486,6 +4736,9 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ provider: AgentProviderSchema.nullable(), direction: z.enum(["tail", "before", "after"]), epoch: z.string(), + projection: z.literal("projected").optional(), + startCursor: AgentTimelineCursorSchema.nullable().optional(), + endCursor: AgentTimelineCursorSchema.nullable().optional(), reset: z.boolean(), staleCursor: z.boolean(), gap: z.boolean(), @@ -4501,6 +4754,16 @@ export const ProviderSubagentTimelineResponseMessageSchema = z.object({ item: AgentTimelineItemPayloadSchema, timestamp: z.string(), seq: z.number().int().nonnegative(), + seqStart: z.number().int().nonnegative().optional(), + seqEnd: z.number().int().nonnegative().optional(), + sourceSeqRanges: z + .array( + z.object({ + startSeq: z.number().int().nonnegative(), + endSeq: z.number().int().nonnegative(), + }), + ) + .optional(), }), ), error: z.string().nullable(), @@ -4511,10 +4774,12 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ type: z.literal("agent.provider_subagents.update"), payload: z.discriminatedUnion("kind", [ z.object({ + subscriptionId: z.string().optional(), kind: z.literal("upsert"), subagent: ProviderSubagentDescriptorPayloadSchema, }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("timeline"), parentAgentId: z.string(), subagentId: z.string(), @@ -4525,6 +4790,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ epoch: z.string(), }), z.object({ + subscriptionId: z.string().optional(), kind: z.literal("remove"), parentAgentId: z.string(), subagentId: z.string(), @@ -4535,6 +4801,7 @@ export const ProviderSubagentUpdateMessageSchema = z.object({ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ type: z.literal("agent.timeline.set_subscription.response"), payload: z.object({ + subscriptionId: z.string().optional(), agentIds: z.array(z.string()), requestId: z.string(), }), @@ -4543,6 +4810,7 @@ export const SetAgentTimelineSubscriptionResponseMessageSchema = z.object({ export const AgentAttentionRequiredMessageSchema = z.object({ type: z.literal("agent_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), timestamp: z.string(), @@ -4594,9 +4862,63 @@ export const ClearAgentAttentionResponseMessageSchema = z.object({ }), }); +export const CreationSnapshotSchema = z.object({ + kind: z.enum(["workspace", "agent"]), + idempotencyKey: z.string(), + revision: z.number().int().nonnegative(), + phase: z.enum([ + "accepted", + "workspace_ready", + "agent_ready", + "prompt_started", + "completed", + "failed", + ]), + workspaceId: z.string().nullable(), + agentId: z.string().nullable(), + workspace: WorkspaceDescriptorPayloadSchema.optional(), + agent: AgentSnapshotPayloadSchema.optional(), + error: z.string().nullable(), + setupSkippedReason: z.string().optional(), + errorCode: z.string().optional(), + failedStage: z.enum(["workspace", "agent", "prompt"]).optional(), + outcomeUnknown: z.boolean().optional(), +}); +export type CreationSnapshot = z.infer; +export type AgentCreateRequest = z.infer; +export type WorkspaceInitialAgent = z.infer; + +export const WorkspaceCreationUpdateSchema = z.object({ + type: z.literal("workspace.create.update"), + payload: CreationSnapshotSchema.extend({ subscriptionId: z.string().optional() }), +}); +export const AgentCreationUpdateSchema = WorkspaceCreationUpdateSchema.extend({ + type: z.literal("agent.create.update"), +}); +export const CreationSubscribeResponseSchema = z.object({ + type: z.literal("creation.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + snapshot: CreationSnapshotSchema.nullable(), + error: z.string().nullable(), + }), +}); +export const AgentCreateResponseSchema = z.object({ + type: z.literal("agent.create.response"), + payload: z.object({ + requestId: z.string(), + agent: AgentSnapshotPayloadSchema.nullable(), + error: z.string().nullable(), + creation: CreationSnapshotSchema.optional(), + }), +}); + export const WorkspaceCreateResponseSchema = z.object({ type: z.literal("workspace.create.response"), payload: z.object({ + agent: AgentSnapshotPayloadSchema.optional(), + creation: CreationSnapshotSchema.optional(), workspace: WorkspaceDescriptorPayloadSchema.nullable(), setupTerminalId: z.string().nullable(), setupSkippedReason: z.string().optional(), @@ -4625,6 +4947,17 @@ export const WorkspaceClearAttentionResponseSchema = z.object({ }), }); +export const WorkspaceMarkUnreadResponseSchema = z.object({ + type: z.literal("workspace.mark_unread.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + markedAgentId: z.string().nullable(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SendAgentMessageResponseMessageSchema = z.object({ type: z.literal("send_agent_message_response"), payload: z.object({ @@ -4815,6 +5148,7 @@ export const WriteProjectConfigResponseMessageSchema = z.object({ export const AgentPermissionRequestMessageSchema = z.object({ type: z.literal("agent_permission_request"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), request: AgentPermissionRequestPayloadSchema, }), @@ -4823,6 +5157,7 @@ export const AgentPermissionRequestMessageSchema = z.object({ export const AgentPermissionResolvedMessageSchema = z.object({ type: z.literal("agent_permission_resolved"), payload: z.object({ + subscriptionId: z.string().optional(), agentId: z.string(), requestId: z.string(), resolution: AgentPermissionResponseSchema, @@ -5063,6 +5398,7 @@ const CheckoutPrStatusPayloadSchema = z.object({ }); const CheckoutStatusUpdateMetadataSchema = z.object({ + subscriptionId: z.string().optional(), prStatus: CheckoutPrStatusPayloadSchema.optional(), }); @@ -5093,6 +5429,13 @@ export const SubscribeCheckoutDiffResponseSchema = z.object({ }), }); +export const CheckoutDiffGetResponseSchema = z.object({ + type: z.literal("checkout.diff.get.response"), + payload: CheckoutDiffSubscriptionPayloadSchema.omit({ subscriptionId: true }).extend({ + requestId: z.string(), + }), +}); + export const CheckoutDiffUpdateSchema = z.object({ type: z.literal("checkout_diff_update"), payload: CheckoutDiffSubscriptionPayloadSchema, @@ -5846,6 +6189,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), notModified: z.boolean().optional(), generatedAt: z.string(), requestId: z.string(), @@ -5856,10 +6200,12 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({ export const ProvidersSnapshotUpdateMessageSchema = z.object({ type: z.literal("providers_snapshot_update"), payload: z.object({ + subscriptionId: z.string().optional(), cwd: z.string().optional(), entries: z.array(ProviderSnapshotEntrySchema), compactSnapshot: CompactProviderSnapshotSchema.optional(), snapshotHash: z.string().optional(), + fetchedAt: z.record(z.string(), z.string()).optional(), generatedAt: z.string(), }), }); @@ -5889,6 +6235,13 @@ export const ProviderUsageStatusSchema = z.enum(["available", "unavailable", "er export const ProviderUsageWindowSchema = z.object({ id: z.string(), label: z.string(), + /** + * A few characters naming the window where space is tight, e.g. "5h" or "wk". An empty string + * shows the percent alone; leaving it out shows `label`. + */ + shortLabel: z.string().optional(), + /** Shown in the usage summary until the user pins windows of their own. */ + summary: z.boolean().optional(), usedPct: z.number().nullable().optional(), remainingPct: z.number().nullable().optional(), resetsAt: z.string().nullable().optional(), @@ -5938,6 +6291,59 @@ export const ProviderUsageListResponseMessageSchema = z.object({ }), }); +export const UsageProblemSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("expired"), + expiresAt: z.iso.datetime(), + refreshedBy: z.string().optional(), + }), + z.object({ + kind: z.literal("rejected"), + status: z.number().int(), + refreshedBy: z.string().optional(), + }), + z.object({ kind: z.literal("no_quota"), detail: z.string() }), +]); +export const UsageReportSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), + }), + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), +]); +export const UsageReportEntrySchema = z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: UsageReportSchema, + loginErrors: z + .array( + z.object({ + harness: z.string(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + ) + .optional(), +}); +export const UsageListReportsUpdateMessageSchema = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ requestId: z.string(), report: UsageReportEntrySchema }), +}); +export const UsageListReportsResponseMessageSchema = z.object({ + type: z.literal("usage.list_reports.response"), + payload: z.object({ requestId: z.string(), error: z.string().nullable() }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -6012,7 +6418,7 @@ export const ListTerminalsResponseSchema = z.object({ type: z.literal("list_terminals_response"), payload: z.object({ cwd: z.string().optional(), - terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), + terminals: z.array(TerminalInfoSchema.partial({ cwd: true })), requestId: z.string(), }), }); @@ -6020,6 +6426,9 @@ export const ListTerminalsResponseSchema = z.object({ export const TerminalsChangedSchema = z.object({ type: z.literal("terminals_changed"), payload: z.object({ + subscriptionId: z.string().optional(), + requestId: z.string().optional(), + workspaceId: z.string().optional(), cwd: z.string(), terminals: z.array(TerminalInfoSchema.omit({ cwd: true })), }), @@ -6049,6 +6458,7 @@ export const SubscribeTerminalResponseSchema = z.object({ z.object({ terminalId: z.string(), slot: z.number().int().min(0).max(255), + subscriptionId: z.string().optional(), error: z.null(), requestId: z.string(), }), @@ -6082,13 +6492,17 @@ export const CaptureTerminalResponseSchema = z.object({ export const TerminalStreamExitSchema = z.object({ type: z.literal("terminal_stream_exit"), payload: z.object({ + subscriptionId: z.string().optional(), terminalId: z.string(), + // Observation failure; the underlying terminal process may still be running. + error: z.string().optional(), }), }); export const TerminalAttentionRequiredSchema = z.object({ type: z.literal("terminal_attention_required"), payload: z.object({ + subscriptionId: z.string().optional(), serverId: z.string().optional(), terminalId: z.string(), cwd: z.string(), @@ -6167,6 +6581,7 @@ export const HubExecutionControlResponseSchema = z.object({ export const HubExecutionAgentUpdateSchema = z.object({ type: z.literal("hub.execution.agent.update"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), agent: AgentSnapshotPayloadSchema, @@ -6176,6 +6591,7 @@ export const HubExecutionAgentUpdateSchema = z.object({ export const HubExecutionAgentStreamSchema = z.object({ type: z.literal("hub.execution.agent.stream"), payload: z.object({ + subscriptionId: z.string().optional(), executionId: z.string(), agentId: z.string(), event: AgentStreamEventPayloadSchema, @@ -6232,6 +6648,7 @@ export const PluginCatalogGetResponseSchema = z.object({ z.object({ id: PluginIdSchema, clientBundle: z.string(), + requirements: PluginRequirementsSchema.optional(), }), ), }), @@ -6240,12 +6657,25 @@ export const PluginCatalogGetResponseSchema = z.object({ export const PluginStatusSchema = z.enum(["running", "disabled", "failed"]); export type PluginStatus = z.infer; +export const PluginNpmInstallationSchema = z.object({ + packageName: z.string(), + requestedSpec: z.string(), + version: z.string(), + integrity: z.string(), +}); + export const PluginListItemSchema = z.object({ id: PluginIdSchema, + name: z.string().optional(), + icon: z.string().optional(), + media: z.array(z.string()).optional(), + description: z.string().optional(), path: z.string(), enabled: z.boolean(), status: PluginStatusSchema, source: z.enum(["directory", "git"]).optional(), + npm: PluginNpmInstallationSchema.optional(), + installation: PluginInstallationSchema.optional(), remote: z.string().optional(), ref: z.string().optional(), commit: z.string().optional(), @@ -6293,6 +6723,8 @@ export const PluginSourceInstallResponseSchema = z.object({ export const PluginSourceStatusItemSchema = z.object({ id: PluginIdSchema, source: z.enum(["directory", "git"]), + npm: PluginNpmInstallationSchema.optional(), + installation: PluginInstallationSchema.optional(), path: z.string(), remote: z.string().optional(), ref: z.string().optional(), @@ -6322,6 +6754,33 @@ export const PluginSourceUpdateResponseSchema = z.object({ payload: z.object({ requestId: z.string(), plugins: z.array(PluginSourceUpdateItemSchema) }), }); +export const PluginUpdatePreviewSchema = z.object({ + id: PluginIdSchema, + outcome: z.enum(["update", "current", "installed-newer", "local", "error"]), + current: PluginInstallationSchema.optional(), + target: PluginUpdateTargetSchema.optional(), + links: z.array(z.string()), + proposal: PluginUpdateProposalSchema.optional(), + error: z.string().optional(), +}); +export type PluginUpdatePreview = z.infer; +export const PluginUpdateResultSchema = z.object({ + id: PluginIdSchema, + outcome: z.enum(["updated", "error"]), + plugin: PluginListItemSchema.optional(), + error: z.string().optional(), + warning: z.string().optional(), +}); +export type PluginUpdateResult = z.infer; +export const PluginUpdatePreviewResponseSchema = z.object({ + type: z.literal("plugin.source.update.preview.response"), + payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdatePreviewSchema) }), +}); +export const PluginUpdateApplyResponseSchema = z.object({ + type: z.literal("plugin.source.update.apply.response"), + payload: z.object({ requestId: z.string(), plugins: z.array(PluginUpdateResultSchema) }), +}); + function pluginActionResponse(type: Type) { return z.object({ type: z.literal(type), @@ -6384,6 +6843,9 @@ export const AgentSkillsImportLegacySelectionResponseSchema = z.object({ }); export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ + BrowserHostRegisterResponseSchema, + SubscriptionReleaseResponseSchema, + SessionEventsSetSubscriptionResponseSchema, HubExecutionAgentCreateResponseSchema, HubExecutionAgentValidateResponseSchema, HubExecutionControlResponseSchema, @@ -6398,6 +6860,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ PluginSourceInstallResponseSchema, PluginSourceStatusResponseSchema, PluginSourceUpdateResponseSchema, + PluginUpdatePreviewResponseSchema, + PluginUpdateApplyResponseSchema, PluginReloadResponseSchema, PluginEnableResponseSchema, PluginDisableResponseSchema, @@ -6459,6 +6923,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, AgentTimelineReplacementMessageSchema, + AgentTimelineSearchResponseMessageSchema, AgentTimelineListPromptsResponseMessageSchema, ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, @@ -6469,7 +6934,12 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, WorkspaceCreateResponseSchema, + AgentCreateResponseSchema, + WorkspaceCreationUpdateSchema, + AgentCreationUpdateSchema, + CreationSubscribeResponseSchema, WorkspaceClearAttentionResponseSchema, + WorkspaceMarkUnreadResponseSchema, SendAgentMessageResponseMessageSchema, SetVoiceModeResponseMessageSchema, DaemonGetStatusResponseSchema, @@ -6507,6 +6977,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, + CheckoutDiffGetResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, CheckoutCommitResponseSchema, @@ -6561,6 +7032,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ RefreshProvidersSnapshotResponseMessageSchema, ProviderDiagnosticResponseMessageSchema, ProviderUsageListResponseMessageSchema, + UsageListReportsUpdateMessageSchema, + UsageListReportsResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -6739,6 +7212,10 @@ export type ProviderDiagnosticResponseMessage = z.infer< typeof ProviderDiagnosticResponseMessageSchema >; export type ProviderUsageTone = z.infer; +export type UsageProblem = z.infer; +export type UsageReport = z.infer; +export type UsageReportEntry = z.infer; +export type UsageListReportsResponseMessage = z.infer; export type ProviderUsageStatus = z.infer; export type ProviderUsage = z.infer; export type ProviderUsageWindow = z.infer; @@ -6970,6 +7447,7 @@ export type ProjectGithubCloneRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type WorkspaceClearAttentionRequest = z.infer; +export type WorkspaceMarkUnreadRequest = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; export type FileVersion = z.infer; @@ -7059,11 +7537,20 @@ export const WSHelloMessageSchema = z.object({ clientId: z.string().min(1), clientType: z.enum(["mobile", "browser", "cli", "mcp", "hub"]), protocolVersion: z.number().int(), + auth: z + .discriminatedUnion("kind", [ + z.object({ kind: z.literal("password"), password: z.string() }), + z.object({ kind: z.literal("localCredential"), token: z.string() }), + ]) + .optional(), appVersion: z.string().optional(), capabilities: z .object({ voice: z.boolean().optional(), + [CLIENT_CAPS.helloRejection]: z.boolean().optional(), pushNotifications: z.boolean().optional(), + [CLIENT_CAPS.explicitEventSubscriptions]: z.boolean().optional(), + [CLIENT_CAPS.allProviders]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.selectiveAgentTimeline]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), @@ -7071,7 +7558,9 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.projectUpdates]: z.boolean().optional(), [CLIENT_CAPS.compactProviderSnapshots]: z.boolean().optional(), + [CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(), [CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(), + [CLIENT_CAPS.timelineNotifications]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() @@ -7094,6 +7583,12 @@ export const WSSessionOutboundSchema = z.object({ message: SessionOutboundMessageSchema, }); +export const WSHelloRejectedMessageSchema = z.object({ + type: z.literal("hello.rejected"), + reason: z.enum(["password_required", "incorrect_password", "incompatible_protocol"]), + accepts: z.array(z.literal("password")), +}); + // Complete WebSocket message schemas export const WSInboundMessageSchema = z.discriminatedUnion("type", [ WSPingMessageSchema, @@ -7105,6 +7600,7 @@ export const WSInboundMessageSchema = z.discriminatedUnion("type", [ export const WSOutboundMessageSchema = z.discriminatedUnion("type", [ WSPongMessageSchema, WSSessionOutboundSchema, + WSHelloRejectedMessageSchema, ]); export type WSInboundMessage = z.infer; diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 6bd948e28d..2298d680a4 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -6,8 +6,31 @@ import { ServerInfoStatusPayloadSchema, SessionOutboundMessageSchema, WSHelloMessageSchema, + WorkspaceSetupSnapshotSchema, + WorkspaceSetupProgressMessageSchema, + AgentTimelineEntryPayloadSchema, } from "./messages.js"; +test("terminal listings accept older rows and retain new per-terminal directories", () => { + const response = { + type: "list_terminals_response", + payload: { + requestId: "terminal-list", + cwd: "/workspace", + terminals: [{ id: "terminal", name: "Shell", workspaceId: "workspace" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(response)).toEqual(response); + const withDirectory = { + ...response, + payload: { + ...response.payload, + terminals: [{ ...response.payload.terminals[0], cwd: "/workspace/subdirectory" }], + }, + }; + expect(SessionOutboundMessageSchema.parse(withDirectory)).toEqual(withDirectory); +}); + const LegacySubAgentToolCallSchema = z.object({ type: z.literal("tool_call"), callId: z.string(), @@ -267,4 +290,149 @@ describe("wire schema compatibility", () => { expect(parsed.capabilities.supportsRewindFiles).toBe(false); expect(parsed.capabilities.supportsRewindBoth).toBe(false); }); + + test("notification timeline items parse their level and message", () => { + expect( + AgentTimelineItemPayloadSchema.parse({ + type: "notification", + level: "warning", + message: "Command blocked by user", + }), + ).toEqual({ + type: "notification", + level: "warning", + message: "Command blocked by user", + }); + }); +}); + +test("0.8 timeline and setup capabilities remain optional in the hello", () => { + const hello = { type: "hello", clientId: "compat", clientType: "mobile", protocolVersion: 1 }; + expect(WSHelloMessageSchema.safeParse(hello).success).toBe(true); + expect( + WSHelloMessageSchema.parse({ + ...hello, + capabilities: { plugin_timeline_items: true, workspace_setup_blocked: true }, + }).capabilities, + ).toEqual({ plugin_timeline_items: true, workspace_setup_blocked: true }); +}); + +test("plugin rows and identity merges require a capable receiver", () => { + const item = { + type: "plugin", + id: "task", + pluginId: "tasks", + kind: "tasks", + version: 1, + data: { text: "Working" }, + }; + expect(AgentTimelineItemPayloadSchema.parse(item)).toEqual(item); + const legacyItems = z.object({ + type: z.enum([ + "user_message", + "assistant_message", + "reasoning", + "tool_call", + "todo", + "error", + "notification", + "compaction", + ]), + }); + expect(legacyItems.safeParse(item).success).toBe(false); + const entry = { + provider: "codex", + item, + timestamp: "2026-09-07T00:00:00.000Z", + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + collapsed: ["identity"], + }; + expect(AgentTimelineEntryPayloadSchema.parse(entry)).toEqual(entry); + const legacyCollapsed = z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"])); + expect(legacyCollapsed.safeParse(entry.collapsed).success).toBe(false); +}); + +test("blocked setup preserves the legacy failed shape and optional provenance", () => { + const snapshot = { + status: "blocked", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "fork", + log: "", + commands: [], + }, + blockedSource: { + kind: "change_request", + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }; + expect(WorkspaceSetupSnapshotSchema.parse(snapshot)).toEqual(snapshot); + const legacyStatus = z.enum(["running", "completed", "failed"]); + const legacySnapshot = WorkspaceSetupSnapshotSchema.omit({ blockedSource: true }).extend({ + status: legacyStatus, + }); + expect(legacySnapshot.safeParse(snapshot).success).toBe(false); + const failed = { ...snapshot, status: "failed", error: "Update Paseo to review and run setup." }; + expect(legacySnapshot.safeParse(failed).success).toBe(true); + const progress = { + type: "workspace_setup_progress", + payload: { ...failed, workspaceId: "workspace" }, + }; + expect(WorkspaceSetupProgressMessageSchema.parse(progress)).toEqual(progress); + expect(WorkspaceSetupSnapshotSchema.parse(legacySnapshot.parse(failed))).toEqual( + legacySnapshot.parse(failed), + ); +}); + +test("usage login errors are additive and older reports still parse", () => { + const entry = { + id: "codex:account", + account: {}, + fetchedAt: "2026-10-05T00:00:00.000Z", + sourceId: "codex", + sourceLabel: "Codex", + report: { status: "error", error: "Usage API returned 500" }, + }; + const legacy = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ + requestId: z.string(), + report: z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }), + z.object({ status: z.literal("unavailable"), problem: z.unknown() }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + }), + }); + const oldMessage = { + type: "usage.list_reports.update", + payload: { requestId: "usage", report: entry }, + }; + const newMessage = { + ...oldMessage, + payload: { + ...oldMessage.payload, + report: { + ...entry, + loginErrors: [{ harness: "Codex", report: entry.report }], + }, + }, + }; + expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage); + expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); + expect(legacy.parse(newMessage)).toEqual(oldMessage); }); diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a9e8f87c9a..3cb9b0d5bd 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -10,6 +10,42 @@ import { } from "./messages.js"; describe("workspace message schemas", () => { + test("parses mark-unread request and response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }), + ).toEqual({ + type: "workspace.mark_unread.request", + workspaceId: "workspace-1", + requestId: "req-mark-unread", + }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }), + ).toEqual({ + type: "workspace.mark_unread.response", + payload: { + requestId: "req-mark-unread", + workspaceId: "workspace-1", + markedAgentId: "agent-1", + success: true, + error: null, + }, + }); + }); + test("parses blocked setup status and the explicit setup run RPC", () => { expect( SessionInboundMessageSchema.parse({ diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-call-detail.ts index a922a78442..4b06d49923 100644 --- a/packages/protocol/src/paseo-tool-call-detail.ts +++ b/packages/protocol/src/paseo-tool-call-detail.ts @@ -47,6 +47,7 @@ const AGENT_FIELDS = [ "modeId", "background", "notifyOnFinish", + "internal", "settings", "labels", ] as const; @@ -196,6 +197,7 @@ const FIELD_LABELS: Readonly> = { newMode: "New mode", nextRunAt: "Next run", notifyOnFinish: "Notify on finish", + internal: "Internal", prNumber: "Change request", projectId: "Project", removedDirectory: "Removed directory", diff --git a/packages/protocol/src/paseo-tool-policy-config.test.ts b/packages/protocol/src/paseo-tool-policy-config.test.ts new file mode 100644 index 0000000000..d4a8966207 --- /dev/null +++ b/packages/protocol/src/paseo-tool-policy-config.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; + +import { MutableDaemonConfigPatchSchema, MutableDaemonConfigSchema } from "./messages.js"; +import { ProviderOverrideSchema, ProviderPaseoToolsPolicySchema } from "./provider-config.js"; + +describe("provider Paseo-tool policy", () => { + test("accepts arbitrary tool IDs and leaves an empty policy enabled by default", () => { + expect( + ProviderPaseoToolsPolicySchema.parse({ + disabledTools: ["future_tool", "browser_future_tool"], + }), + ).toEqual({ + disabledTools: ["future_tool", "browser_future_tool"], + }); + expect(ProviderPaseoToolsPolicySchema.parse({})).toEqual({}); + expect(ProviderOverrideSchema.parse({}).paseoTools).toBeUndefined(); + }); + + test("accepts paseoTools on persisted provider overrides", () => { + expect( + ProviderOverrideSchema.parse({ + extends: "claude", + paseoTools: { + enabled: false, + disabledTools: ["create_workspace"], + }, + }).paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["create_workspace"], + }); + }); + + test("accepts paseoTools when reading and patching mutable daemon providers", () => { + expect( + MutableDaemonConfigSchema.parse({ + mcp: { injectIntoAgents: true }, + providers: { + codex: { + paseoTools: { enabled: false, disabledTools: ["future_tool"] }, + }, + }, + }).providers.codex?.paseoTools, + ).toEqual({ + enabled: false, + disabledTools: ["future_tool"], + }); + + expect( + MutableDaemonConfigPatchSchema.parse({ + providers: { + codex: { + paseoTools: { disabledTools: ["browser_future_tool"] }, + }, + }, + }).providers?.codex?.paseoTools, + ).toEqual({ disabledTools: ["browser_future_tool"] }); + }); +}); diff --git a/packages/protocol/src/plugin-config.ts b/packages/protocol/src/plugin-config.ts new file mode 100644 index 0000000000..69816e539e --- /dev/null +++ b/packages/protocol/src/plugin-config.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const PluginIdSchema = z.string().regex(/^[a-z][a-z0-9-]*$/); +// Semver validation belongs at the manifest/runtime boundary, not on the wire. +export const PluginRequirementsSchema = z.object({ paseo: z.string().optional() }); +export type PluginRequirements = z.infer; + +export const DirectoryPluginSourceSchema = z + .object({ + source: z.literal("directory"), + path: z.string().min(1), + enabled: z.boolean().optional(), + }) + .strict(); + +export const PluginSourceSchema = z.discriminatedUnion("source", [DirectoryPluginSourceSchema]); + +export type PluginSource = z.infer; diff --git a/packages/protocol/src/plugin-overview.test.ts b/packages/protocol/src/plugin-overview.test.ts new file mode 100644 index 0000000000..c4023965da --- /dev/null +++ b/packages/protocol/src/plugin-overview.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { pluginOverviewUrl } from "./plugin-overview.js"; + +describe("plugin overview destinations", () => { + it.each([ + undefined, + "", + "./docs", + "../docs", + "/login", + "//example.com", + "#section", + "?login", + "http://example.com", + "mailto:hello@example.com", + "ftp://example.com", + "javascript:alert(1)", + "data:image/png;base64,AAAA", + "vbscript:msgbox(1)", + "javascript:alert(1)", + "javascript%3Aalert(1)", + "javascript%253Aalert(1)", + "java\tscript:alert(1)", + " https://example.com", + "https://example.com\n", + "https:example.com", + "https:///example.com", + "https://paseo.sh@evil.example", + "https://example.com\\@evil.example", + "https://", + ])("leaves %s as text", (url) => { + expect(pluginOverviewUrl(url)).toBeUndefined(); + }); + + it.each([ + "https://example.com/path?q=a&b=c#section", + "HTTPS://example.com/path", + "https://example.com/%3Cscript%3E", + ])("allows %s without decoding it again", (url) => { + expect(pluginOverviewUrl(url)).toBe(url); + }); +}); diff --git a/packages/protocol/src/plugin-overview.ts b/packages/protocol/src/plugin-overview.ts new file mode 100644 index 0000000000..2507b511b0 --- /dev/null +++ b/packages/protocol/src/plugin-overview.ts @@ -0,0 +1,49 @@ +/** Presentation contract for author-controlled registry content, independent of the UI runtime. */ +export const pluginOverviewPolicy = { + rawHtml: false, + elements: [ + "p", + "br", + "hr", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "ul", + "ol", + "li", + "strong", + "em", + "del", + "pre", + "code", + "a", + "img", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + ], + link: { rel: "noopener noreferrer nofollow", target: "_blank" }, +} as const; + +/** Validate decoded Markdown destinations; never resolve against the reader's page or repository. */ +export function pluginOverviewUrl(value: string | undefined): string | undefined { + // URL parsers discard controls and normalize backslashes; reject those ambiguous spellings. + // eslint-disable-next-line no-control-regex + if (!value || !/^https:\/\/[^/?#]/i.test(value) || /[\u0000-\u0020\u007f\\]/.test(value)) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password) + return undefined; + return value; + } catch { + return undefined; + } +} diff --git a/packages/protocol/src/plugin-registry.test.ts b/packages/protocol/src/plugin-registry.test.ts new file mode 100644 index 0000000000..5692c4c56e --- /dev/null +++ b/packages/protocol/src/plugin-registry.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { pluginMediaKind } from "./plugin-registry.js"; + +describe("plugin media kind", () => { + it.each([ + ["https://cdn.example.com/a/preview.png", "image"], + ["https://cdn.example.com/a/preview.JPG", "image"], + ["https://cdn.example.com/a/preview.jpeg", "image"], + ["https://cdn.example.com/a/preview.webp", "image"], + ["https://cdn.example.com/a/preview.gif", "image"], + ["https://cdn.example.com/a/demo.mp4", "video"], + ["https://cdn.example.com/a/demo.WebM", "video"], + ["https://cdn.example.com/a/demo.mp4?raw=true#t=2", "video"], + ["https://cdn.example.com/mp4/preview.png?format=mp4", "image"], + ] as const)("reads %s as %s from its path extension", (url, kind) => { + expect(pluginMediaKind(url)).toBe(kind); + }); +}); diff --git a/packages/protocol/src/plugin-registry.ts b/packages/protocol/src/plugin-registry.ts new file mode 100644 index 0000000000..90854962ba --- /dev/null +++ b/packages/protocol/src/plugin-registry.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const PluginRegistryIdSchema = z + .string() + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/); +export const PluginRegistryIdentitySchema = z.object({ + url: z.string().url(), + id: PluginRegistryIdSchema, +}); +export const PluginRegistriesSchema = z.record( + z.string(), + z.object({ authorization: z.string().min(1) }), +); +export const PluginRegistryArtifactSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("npm"), + package: z.string().regex(/^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/), + version: z.string().regex(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/), + resolved: z.string().url(), + integrity: z.string().regex(/^sha512-[A-Za-z0-9+/]+=*$/), + }), + z.object({ + kind: z.literal("git"), + remote: z.string().min(1), + commit: z.string().regex(/^[0-9a-f]{40,64}$/), + pluginPath: z + .string() + .regex(/^[^\\:]+$/) + .optional(), + }), +]); +export const PublishedPluginSchema = z.object({ + id: PluginRegistryIdSchema, + name: z.string(), + description: z.string(), + license: z.string().optional(), + categories: z.array(z.string()), + author: z.object({ github: z.string(), name: z.string().optional(), npm: z.string().optional() }), + repository: z.object({ url: z.string().url(), commit: z.string().optional() }), + artifact: PluginRegistryArtifactSchema, + icon: z.string().url().optional(), + /** Images and videos in display order; see `pluginMediaKind`. */ + media: z.array(z.string().url()), + submittedAt: z.string(), + reviewedAt: z.string(), + updatedAt: z.string(), + publishedAt: z.string(), + installs: z.number().int().nonnegative().optional(), +}); +const VIDEO_EXTENSIONS = new Set(["mp4", "webm"]); + +/** Whether a registry media entry is a video or an image, from its path extension. */ +export function pluginMediaKind(url: string): "image" | "video" { + const extension = new URL(url).pathname.split(".").pop()?.toLowerCase() ?? ""; + return VIDEO_EXTENSIONS.has(extension) ? "video" : "image"; +} +export const PublishedPluginDetailSchema = PublishedPluginSchema.extend({ readme: z.string() }); +export const PluginRegistryIndexSchema = z.object({ + schemaVersion: z.literal(1), + registry: z.object({ name: z.string(), url: z.string().url() }), + categories: z.array( + z.object({ slug: z.string(), label: z.string(), description: z.string().optional() }), + ), + plugins: z.array(PublishedPluginSchema), + /** Plugin IDs the maintainer features, in display order. An ID may name a plugin that is not listed. */ + featured: z.array(z.string()), + generatedAt: z.string(), +}); +export type PluginRegistryIdentity = z.infer; +export type PluginRegistries = z.infer; + +export function parsePluginRegistryReference( + source: string, + defaultUrl = "https://plugins.paseo.sh", +): PluginRegistryIdentity | null { + const segments = source.split("/"); + if (segments.length === 2 && PluginRegistryIdSchema.safeParse(source).success) + return { url: defaultUrl.replace(/\/+$/, ""), id: source }; + const [host, ...rest] = segments; + if (segments.length !== 3 || !host || !/[.:]/.test(host) || /[@?#\\]/.test(host)) return null; + const id = rest.join("/"); + if (!PluginRegistryIdSchema.safeParse(id).success) return null; + const url = new URL(`https://${host}`); + return { url: url.origin, id }; +} diff --git a/packages/protocol/src/plugin-requirements.test.ts b/packages/protocol/src/plugin-requirements.test.ts new file mode 100644 index 0000000000..e679c8f5d5 --- /dev/null +++ b/packages/protocol/src/plugin-requirements.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { assertPluginCompatibility, validatePluginRequirements } from "./plugin-requirements.js"; + +describe.each(["daemon", "app"] as const)("plugin requirements on %s", (runtime) => { + it("rejects legacy manifests on the first breaking release with migration instructions", () => { + expect(() => assertPluginCompatibility({ id: "legacy", version: "0.8.0", runtime })).toThrow( + /legacy.*<0\.8\.0.*0\.8\.0.*https:\/\/paseo.sh\/docs\/plugins\/migration/, + ); + }); + + it.each([ + [undefined, "0.7.2"], + [">=0.8.0", "0.8.0"], + [">=0.8.0", "0.8.0-beta.1"], + [">=0.8.0", "1.0.0"], + ["^0.8.0", "0.8.4"], + [">=0.8.0-beta.1", "0.8.0-beta.2"], + [">=0.8.0-beta.1", "0.8.0-beta.1"], + [">=0.8.0-beta.1", "0.8.0"], + ["^0.8.0 || ^0.9.0", "0.9.2+build.42"], + ])("accepts %s on %s", (paseo, version) => { + expect(() => + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime }), + ).not.toThrow(); + }); + + it.each([ + [undefined, "0.8.0-beta.1"], + [undefined, "0.9.0"], + [">=0.8.0", "0.7.2"], + ["^0.8.0", "0.9.0"], + ["<0.8.0", "0.8.0-beta.1"], + ])("rejects %s on %s", (paseo, version) => { + expect(() => + assertPluginCompatibility({ id: "test", requirements: { paseo }, version, runtime }), + ).toThrow(`Your ${runtime} is ${version}`); + }); + + it.each(["", " ", "latest", ">=potato", "0.8.0 nonsense"])( + "rejects malformed range %s", + (paseo) => { + expect(() => validatePluginRequirements({ paseo })).toThrow("Invalid requirements.paseo"); + }, + ); + + it.each([null, "unknown"])("fails closed when the runtime version is %s", (version) => { + expect(() => + assertPluginCompatibility({ + id: "test", + requirements: { paseo: "*" }, + version, + runtime, + }), + ).toThrow(`Paseo ${runtime} version is unknown`); + }); +}); diff --git a/packages/protocol/src/plugin-requirements.ts b/packages/protocol/src/plugin-requirements.ts new file mode 100644 index 0000000000..0cdd1f37bd --- /dev/null +++ b/packages/protocol/src/plugin-requirements.ts @@ -0,0 +1,41 @@ +import type { PluginRequirements } from "./messages.js"; +import parse from "semver/functions/parse.js"; +import validRange from "semver/ranges/valid.js"; +import satisfies from "semver/functions/satisfies.js"; + +export function validatePluginRequirements(requirements: PluginRequirements | undefined): void { + const range = requirements?.paseo; + if (range !== undefined && (!range.trim() || validRange(range) === null)) { + throw new Error( + `Invalid requirements.paseo: ${JSON.stringify(range)}. Use an npm semver range such as ">=0.8.0".`, + ); + } +} + +interface PluginCompatibilityInput { + id: string; + requirements?: PluginRequirements; + version: string | null; + runtime: "daemon" | "app"; +} + +export function assertPluginCompatibility(input: PluginCompatibilityInput): void { + validatePluginRequirements(input.requirements); + // COMPAT(plugin-requirements): added in v0.8.0-beta.1; remove after 2027-03-07 once pre-0.8 plugins and catalogs are unsupported. The legacy range excludes 0.8 prereleases and their stable core. + const range = input.requirements?.paseo ?? "<0.8.0"; + const version = input.version ? parse(input.version) : null; + if (!version) { + throw new Error( + `Cannot check plugin "${input.id}" requirements: Paseo ${input.runtime} version is unknown. Update the ${input.runtime}.`, + ); + } + const stableCore = `${version.major}.${version.minor}.${version.patch}`; + if (satisfies(version, range) || satisfies(stableCore, range)) return; + const action = + input.requirements?.paseo === undefined + ? "This plugin has no requirements.paseo and targets Paseo before 0.8. Ask its author to migrate it: https://paseo.sh/docs/plugins/migration" + : `Use a compatible plugin version or update the ${input.runtime}.`; + throw new Error( + `Plugin "${input.id}" requires Paseo ${range}. Your ${input.runtime} is ${input.version}. ${action}`, + ); +} diff --git a/packages/protocol/src/plugin-source-reference.test.ts b/packages/protocol/src/plugin-source-reference.test.ts index 78c455f83d..83f41ce3df 100644 --- a/packages/protocol/src/plugin-source-reference.test.ts +++ b/packages/protocol/src/plugin-source-reference.test.ts @@ -1,9 +1,51 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; import { describe, expect, it } from "vitest"; import { parsePluginSourceReference } from "./plugin-source-reference.js"; describe("plugin source references", () => { it.each([ + ".", + "..", + "./owner/slug", + "../owner/slug", + "/owner/slug", + "~/owner/slug", + "~", + "C:\\", + "C:/", + "D:\\plugins\\repository", + "D:/plugins/repository", + "\\\\server\\share", + "\\\\server\\share\\plugins\\review", + ".\\plugins\\review", + "..\\plugins\\review", + ])("recognizes the explicit directory %s by shape", (source) => { + expect(parsePluginSourceReference(source)).toEqual({ + kind: "directory", + source, + pluginPath: undefined, + }); + expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({ + kind: "directory", + source, + pluginPath: "plugins/review", + }); + }); + + it.each([ + ["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"], + ["npm:review@^1.2.0", "npm:review@^1.2.0", undefined], + ["npm:@team/review@next:plugins/main", "npm:@team/review@next", "plugins/main"], + ["github:owner/repository", "github:owner/repository", undefined], + [ + "git:git@example.test:owner/repository.git", + "git:git@example.test:owner/repository.git", + undefined, + ], + ["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"], + ["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined], ["owner/repository", "owner/repository", undefined], + ["plugins/foo", "plugins/foo", undefined], ["owner/repository:plugins/review", "owner/repository", "plugins/review"], [ "https://example.test:8443/owner/repository.git", @@ -27,9 +69,23 @@ describe("plugin source references", () => { "file:///D:/plugins/repository", "plugins/review", ], - ["D:\\plugins\\repository", "D:\\plugins\\repository", undefined], - ["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"], ])("parses %s", (reference, source, pluginPath) => { - expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath }); + expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath }); }); }); + +it("addresses default and private registries without treating explicit sources as registry IDs", () => { + expect(parsePluginRegistryReference("omercnet/dracula")).toEqual({ + url: "https://plugins.paseo.sh", + id: "omercnet/dracula", + }); + expect(parsePluginRegistryReference("acme/plugin", "https://internal.example/registry/")).toEqual( + { url: "https://internal.example/registry", id: "acme/plugin" }, + ); + expect(parsePluginRegistryReference("internal.example:8443/acme/plugin")).toEqual({ + url: "https://internal.example:8443", + id: "acme/plugin", + }); + expect(parsePluginRegistryReference("github:acme/plugin")).toBeNull(); + expect(parsePluginRegistryReference("npm:@acme/plugin")).toBeNull(); +}); diff --git a/packages/protocol/src/plugin-source-reference.ts b/packages/protocol/src/plugin-source-reference.ts index d50abb7d69..15171531f0 100644 --- a/packages/protocol/src/plugin-source-reference.ts +++ b/packages/protocol/src/plugin-source-reference.ts @@ -1,9 +1,27 @@ +import { parsePluginRegistryReference } from "./plugin-registry.js"; +import type { PluginInstallation } from "./messages.js"; export interface PluginSourceReference { + kind: "directory" | "managed"; source: string; pluginPath: string | undefined; } export function parsePluginSourceReference(reference: string): PluginSourceReference { + const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0]; + const source = prefix ? reference.slice(prefix.length) : reference; + const parsed = splitPluginPath(source, { scp: prefix !== "npm:" }); + if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) { + return { kind: "directory", ...parsed }; + } + if (parsePluginRegistryReference(reference)) + return { kind: "managed", source: reference, pluginPath: undefined }; + return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` }; +} + +function splitPluginPath( + reference: string, + options: { scp: boolean }, +): Pick { const separator = reference.lastIndexOf(":"); if (separator === -1) return { source: reference, pluginPath: undefined }; @@ -18,7 +36,7 @@ export function parsePluginSourceReference(reference: string): PluginSourceRefer if (pathStart === -1 || separator < pathStart) { return { source: reference, pluginPath: undefined }; } - } else { + } else if (options.scp) { const scpSeparator = reference.match(/^[^/@\s]+@[^:\s]+:/)?.[0].length; if (scpSeparator !== undefined && separator === scpSeparator - 1) { return { source: reference, pluginPath: undefined }; @@ -45,3 +63,18 @@ function isPortableRelativePluginPath(pluginPath: string): boolean { if (/^[A-Za-z]:/.test(pluginPath) || pluginPath.includes(":")) return false; return pluginPath.split(/[\\/]/).every((part) => part !== "" && part !== "." && part !== ".."); } + +export function formatPluginIdentity(identity: PluginInstallation["identity"]): string { + if (identity.kind === "directory") return identity.path; + if (identity.registry) return `${new URL(identity.registry.url).host}/${identity.registry.id}`; + const source = identity.kind === "npm" ? `npm:${identity.packageName}` : `git:${identity.remote}`; + return identity.pluginPath === "." ? source : `${source}:${identity.pluginPath}`; +} +export function formatPluginInstallation(installation: PluginInstallation): string { + const identity = formatPluginIdentity(installation.identity); + const revision = + installation.identity.kind === "git" + ? installation.currentRevision?.slice(0, 12) + : installation.currentRevision; + return revision ? `${identity} · ${revision}` : identity; +} diff --git a/packages/protocol/src/provider-config.ts b/packages/protocol/src/provider-config.ts index 2ab879c31f..c94f8ca965 100644 --- a/packages/protocol/src/provider-config.ts +++ b/packages/protocol/src/provider-config.ts @@ -28,6 +28,11 @@ export const ProviderRuntimeSettingsSchema = z.object({ disallowedTools: z.array(z.string()).optional(), }); +export const ProviderPaseoToolsPolicySchema = z.object({ + enabled: z.boolean().optional(), + disabledTools: z.array(z.string()).optional(), +}); + const ProviderProfileThinkingOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -49,10 +54,13 @@ export const ProviderOverrideSchema = z.object({ description: z.string().optional(), command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string(), z.string()).optional(), + options: z.record(z.string(), z.unknown()).optional(), + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. params: z.record(z.string(), z.unknown()).optional(), models: z.array(ProviderProfileModelSchema).optional(), additionalModels: z.array(ProviderProfileModelSchema).optional(), disallowedTools: z.array(z.string()).optional(), + paseoTools: ProviderPaseoToolsPolicySchema.optional(), enabled: z.boolean().optional(), order: z.number().optional(), }); @@ -76,15 +84,10 @@ export const ProviderOverridesSchema = z } const isBuiltinProvider = builtinProviderIdSet.has(providerId); - if (!isBuiltinProvider && !provider.extends) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [providerId, "extends"], - message: `Custom provider "${providerId}" must declare extends.`, - }); - } - - if (!isBuiltinProvider && !provider.label) { + // Config loads before plugin registration. An entry without extends is an override; + // registry construction decides whether its provider exists. Configurable plugin IDs + // use this existing config alphabet; dots/underscores remain registration-only. + if (!isBuiltinProvider && provider.extends && !provider.label) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [providerId, "label"], @@ -127,6 +130,7 @@ export const AgentProviderRuntimeSettingsMapSchema = z export type ProviderCommand = z.infer; export type ProviderRuntimeSettings = z.infer; +export type ProviderPaseoToolsPolicy = z.infer; export type ProviderProfileModel = z.infer; export type ProviderOverride = z.infer; export type ProviderOverrides = z.infer; diff --git a/packages/protocol/src/provider-snapshot-codec.test.ts b/packages/protocol/src/provider-snapshot-codec.test.ts index 2921260851..f1f245c9b7 100644 --- a/packages/protocol/src/provider-snapshot-codec.test.ts +++ b/packages/protocol/src/provider-snapshot-codec.test.ts @@ -18,6 +18,7 @@ function providerEntry(): ProviderSnapshotEntry { provider: "pi", status: "ready", enabled: true, + iconSvg: '', models: [ { provider: "pi", diff --git a/packages/protocol/src/schedule/types.ts b/packages/protocol/src/schedule/types.ts index 62c02bd38d..718c7e05ca 100644 --- a/packages/protocol/src/schedule/types.ts +++ b/packages/protocol/src/schedule/types.ts @@ -33,7 +33,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [ archiveOnFinish: z.boolean().optional(), isolation: z.enum(["local", "worktree"]).optional(), title: z.string().trim().min(1).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).optional(), + providerOptions: z.record(z.string(), z.unknown()).optional(), featureValues: z.record(z.string(), z.unknown()).optional(), systemPrompt: z.string().optional(), mcpServers: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/protocol/src/search/text-match.test.ts b/packages/protocol/src/search/text-match.test.ts index 05037fdee7..8adff41f62 100644 --- a/packages/protocol/src/search/text-match.test.ts +++ b/packages/protocol/src/search/text-match.test.ts @@ -54,6 +54,24 @@ describe("scoreMatch", () => { expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 }); }); + it("does not assemble one query word across whitespace-separated words", () => { + for (const separator of [" ", "\t", "\n", "\u00a0"]) { + expect(scoreMatch("terminal", `term${separator}inal`)).toBeNull(); + } + expect( + scoreMatch("terminal", "Let me diagnose this problem in a diagnose this problem and", { + fuzzy: fuzzyPolicyForToken("terminal"), + }), + ).toBeNull(); + }); + + it("keeps subsequences within a word, including names and later words", () => { + expect(scoreMatch("trmnl", "Fix terminal resizing")).toEqual({ tier: 5, offset: 4, spread: 8 }); + expect(scoreMatch("abc", "AgentBrowserConfig")?.tier).toBe(5); + expect(scoreMatch("pasbab", "paseo-babysit")?.tier).toBe(5); + expect(scoreMatch("confg", "check configuration")?.offset).toBe(6); + }); + it("returns null when query is not found", () => { expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull(); }); @@ -225,7 +243,8 @@ describe("scoreTextFields", () => { // Splitting on whitespace is what the opt-out keeps; only the run-together form goes. expect(scoreTextFields("lab des", fields, { subsequence: false })).not.toBeNull(); expect(scoreTextFields("labdes", fields, { subsequence: false })).toBeNull(); - expect(scoreTextFields("labdes", fields)).not.toBeNull(); + expect(scoreTextFields("labdes", fields)).toBeNull(); + expect(scoreTextFields("lbl dsgn", fields)).not.toBeNull(); }); }); @@ -258,6 +277,10 @@ describe("matchRanges", () => { expect(markUp("confg", "configuration")).toBe("[conf]i[g]uration"); }); + it("highlights only the word selected after an earlier partial subsequence", () => { + expect(markUp("confg", "check configuration")).toBe("check [conf]i[g]uration"); + }); + it("marks the whole word a typo resolved to", () => { // The characters the user got wrong are not in the text to point at, so // the word is the smallest honest thing to mark. diff --git a/packages/protocol/src/search/text-match.ts b/packages/protocol/src/search/text-match.ts index db73acb305..14bce34a41 100644 --- a/packages/protocol/src/search/text-match.ts +++ b/packages/protocol/src/search/text-match.ts @@ -18,11 +18,8 @@ export interface MatchOptions { /** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */ fuzzy?: FuzzyPolicy | null; /** - * Match characters that appear in order but not adjacently, so `pasbab` finds - * `paseo-babysit`. Defaults to on. Turn it off where a near miss has to read as - * no match at all: it widens far enough that most typos hit something, and a - * list that preselects its first row would then act on that row when the user - * presses Enter expecting nothing to happen. + * Match characters in order within one whitespace-delimited word, so `pasbab` + * finds `paseo-babysit`, but `labdes` cannot join "Label as Design". Defaults to on. */ subsequence?: boolean; } @@ -74,6 +71,12 @@ function scoreSubsequenceMatch(query: string, text: string): MatchScore | null { let firstIndex = -1; let lastIndex = -1; for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) { + if (/\s/u.test(text[textIndex])) { + queryIndex = 0; + firstIndex = -1; + lastIndex = -1; + continue; + } if (text[textIndex] !== query[queryIndex]) continue; if (firstIndex === -1) firstIndex = textIndex; lastIndex = textIndex; @@ -286,7 +289,11 @@ export function matchRanges(query: string, text: string, score: MatchScore): Mat if (score.tier === TIER_SUBSEQUENCE) { const indices: number[] = []; let queryIndex = 0; - for (let textIndex = 0; textIndex < t.length && queryIndex < q.length; textIndex += 1) { + for ( + let textIndex = score.offset; + textIndex < t.length && queryIndex < q.length; + textIndex += 1 + ) { if (t[textIndex] !== q[queryIndex]) continue; indices.push(textIndex); queryIndex += 1; diff --git a/packages/protocol/src/terminal-profile.ts b/packages/protocol/src/terminal-profile.ts new file mode 100644 index 0000000000..282084c0c5 --- /dev/null +++ b/packages/protocol/src/terminal-profile.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const TerminalProfileSchema = z + .object({ + id: z.string(), + name: z.string(), + command: z.string(), + args: z.array(z.string()).optional(), + icon: z.string().optional(), + }) + .passthrough(); + +export type TerminalProfile = z.infer; diff --git a/packages/protocol/src/terminal-profiles.test.ts b/packages/protocol/src/terminal-profiles.test.ts index e45f68b293..dece3821ed 100644 --- a/packages/protocol/src/terminal-profiles.test.ts +++ b/packages/protocol/src/terminal-profiles.test.ts @@ -196,11 +196,13 @@ describe("guessTerminalProfileIcon", () => { ["/usr/local/bin/gemini", "gemini"], ["agy", "agy"], ["/usr/local/bin/agy", "agy"], + ["cursor-agent", "cursor"], + ["/usr/local/bin/cursor-agent", "cursor"], ])("guesses %s -> %s", (command, expected) => { expect(guessTerminalProfileIcon(command)).toBe(expected); }); - it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-agent"]])( + it.each([["zsh"], ["bash"], ["fish"], [""], ["/usr/bin/foo"], ["cursor-tool"]])( "returns undefined for unknown command %s", (command) => { expect(guessTerminalProfileIcon(command)).toBeUndefined(); @@ -219,6 +221,26 @@ describe("getTerminalProfileIcon", () => { expect(getTerminalProfileIcon(profile)).toBe("claude"); }); + it("shows the Cursor icon for a profile created in settings with the Cursor CLI", () => { + const profile = { + id: "profile_1", + name: "Cursor", + command: "cursor-agent", + args: [PROMPT_SENTINEL], + }; + expect(getTerminalProfileIcon(profile)).toBe("cursor"); + }); + + it("keeps an explicit icon over the Cursor CLI guess", () => { + const profile = { + id: "profile_1", + name: "Cursor", + command: "cursor-agent", + icon: "claude", + }; + expect(getTerminalProfileIcon(profile)).toBe("claude"); + }); + it("returns undefined when no icon is set and command is unknown", () => { const profile = { id: "1", name: "Foo", command: "zsh" }; expect(getTerminalProfileIcon(profile)).toBeUndefined(); diff --git a/packages/protocol/src/terminal-profiles.ts b/packages/protocol/src/terminal-profiles.ts index 4a811437a2..33d10fe4ed 100644 --- a/packages/protocol/src/terminal-profiles.ts +++ b/packages/protocol/src/terminal-profiles.ts @@ -105,7 +105,14 @@ export function resolveTerminalProfileLaunch( return { name: profile.name, ...substitutePrompt(profile, prompt) }; } -const WELL_KNOWN_COMMAND_ICONS = new Map(KNOWN_PROVIDER_ICON_NAMES.map((name) => [name, name])); +// CLIs whose binary is not named after their icon. Matching stays exact, so a +// command only gets an icon when its whole base name is listed. +const COMMAND_ICON_ALIASES: ReadonlyArray<[string, string]> = [["cursor-agent", "cursor"]]; + +const WELL_KNOWN_COMMAND_ICONS = new Map([ + ...KNOWN_PROVIDER_ICON_NAMES.map((name): [string, string] => [name, name]), + ...COMMAND_ICON_ALIASES, +]); function getCommandBaseName(command: string): string { const lastSlash = command.lastIndexOf("/"); diff --git a/packages/protocol/src/terminal-snapshot.test.ts b/packages/protocol/src/terminal-snapshot.test.ts index 0262f0e45f..96d82a9205 100644 --- a/packages/protocol/src/terminal-snapshot.test.ts +++ b/packages/protocol/src/terminal-snapshot.test.ts @@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => { expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP"); }); }); + +describe("renderTerminalSnapshotToAnsi wide chars", () => { + it("skips wide-char continuation cells so the glyph keeps its two columns", () => { + // The daemon captures "통" as the glyph followed by an empty continuation + // cell. Emitting anything for that cell pushes the rest of the row right. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]], + cursor: { row: 0, col: 5 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!"); + }); + + it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => { + // Old daemons already collapsed the continuation to " ". The serializer + // cannot tell that apart from a real space, so it must not guess. + const state: TerminalState = { + rows: 1, + cols: 10, + scrollback: [], + grid: [cells("통 합 !")], + cursor: { row: 0, col: 6 }, + }; + + expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !"); + }); +}); diff --git a/packages/protocol/src/terminal-snapshot.ts b/packages/protocol/src/terminal-snapshot.ts index f978cd884b..1860067573 100644 --- a/packages/protocol/src/terminal-snapshot.ts +++ b/packages/protocol/src/terminal-snapshot.ts @@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string { for (let index = 0; index < length; index += 1) { const cell = row[index] ?? { char: " " }; + // An empty char marks a wide-char continuation cell. The glyph in the + // previous cell already advances the cursor two columns, so emitting + // anything here would shift the rest of the row one column right. + if (cell.char === "") { + continue; + } const nextStyle = getTerminalStyle(cell); if (!terminalStylesEqual(previousStyle, nextStyle)) { output.push(styleToAnsi(nextStyle)); diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index 6930fe187a..ab597b5acd 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,10 +1,11 @@ +import { buildAgentMessageDisplay } from "./agent-message-display.js"; import type { ToolCallTimelineItem } from "./agent-types.js"; import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< ToolCallTimelineItem, - "name" | "status" | "error" | "metadata" | "detail" + "name" | "status" | "error" | "metadata" | "detail" | "agentMessage" > & { cwd?: string; }; @@ -151,6 +152,9 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay } export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { + if (input.agentMessage) { + return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName }; + } const canonicalDisplay = buildCanonicalDetailDisplay(input); const unknownDetailOverride = buildUnknownDetailOverride(input); const displayName = diff --git a/packages/protocol/tests/fixtures/plugin-overview.json b/packages/protocol/tests/fixtures/plugin-overview.json new file mode 100644 index 0000000000..bbe8575ac8 --- /dev/null +++ b/packages/protocol/tests/fixtures/plugin-overview.json @@ -0,0 +1,1202 @@ +[ + { + "name": "script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "image event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "svg event", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "iframe", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML javascript link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTML block", + "markdown": "
\n\n
", + "links": [], + "images": [] + }, + { + "name": "comment", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "meta refresh", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "base", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "form", + "markdown": "
", + "links": [], + "images": [] + }, + { + "name": "style expression", + "markdown": "

click

", + "links": [], + "images": [] + }, + { + "name": "style block", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "closing script", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTML entities", + "markdown": "<img src=x onerror=alert(1)>", + "links": [], + "images": [] + }, + { + "name": "nested HTML entities", + "markdown": "&lt;script&gt;alert(1)&lt;/script&gt;", + "links": [], + "images": [] + }, + { + "name": "javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript link", + "markdown": "[click](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript image", + "markdown": "![picture](JaVaScRiPt:alert(1))", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference link", + "markdown": "[click][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: JaVaScRiPt:alert(1)", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML link", + "markdown": "[click](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML image", + "markdown": "![picture](data:text/html;base64,PHNjcmlwdD4=)", + "links": [], + "images": [] + }, + { + "name": "data HTML reference link", + "markdown": "[click][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML reference image", + "markdown": "![picture][ref]\n\n[ref]: data:text/html;base64,PHNjcmlwdD4=", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data HTML HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image link", + "markdown": "[click](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image image", + "markdown": "![picture](data:image/png;base64,AAAA)", + "links": [], + "images": [] + }, + { + "name": "data image reference link", + "markdown": "[click][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image reference image", + "markdown": "![picture][ref]\n\n[ref]: data:image/png;base64,AAAA", + "links": [], + "images": [] + }, + { + "name": "data image HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "data image HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript link", + "markdown": "[click](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript image", + "markdown": "![picture](vbscript:msgbox(1))", + "links": [], + "images": [] + }, + { + "name": "vbscript reference link", + "markdown": "[click][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript reference image", + "markdown": "![picture][ref]\n\n[ref]: vbscript:msgbox(1)", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "vbscript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http link", + "markdown": "[click](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http image", + "markdown": "![picture](http://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "http reference link", + "markdown": "[click][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http reference image", + "markdown": "![picture][ref]\n\n[ref]: http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "http HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "http HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp link", + "markdown": "[click](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp image", + "markdown": "![picture](ftp://example.com/path)", + "links": [], + "images": [] + }, + { + "name": "ftp reference link", + "markdown": "[click][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp reference image", + "markdown": "![picture][ref]\n\n[ref]: ftp://example.com/path", + "links": [], + "images": [] + }, + { + "name": "ftp HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "ftp HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto link", + "markdown": "[click](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto image", + "markdown": "![picture](mailto:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "mailto reference link", + "markdown": "[click][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto reference image", + "markdown": "![picture][ref]\n\n[ref]: mailto:attacker@example.com", + "links": [], + "images": [] + }, + { + "name": "mailto HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "mailto HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "protocol relative link", + "markdown": "[click](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative image", + "markdown": "![picture](//example.com/path)", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference link", + "markdown": "[click][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative reference image", + "markdown": "![picture][ref]\n\n[ref]: //example.com/path", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "protocol relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "relative link", + "markdown": "[click](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative image", + "markdown": "![picture](./image.png)", + "links": [], + "images": [] + }, + { + "name": "relative reference link", + "markdown": "[click][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ./image.png", + "links": [], + "images": [] + }, + { + "name": "relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "parent relative link", + "markdown": "[click](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative image", + "markdown": "![picture](../login)", + "links": [], + "images": [] + }, + { + "name": "parent relative reference link", + "markdown": "[click][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative reference image", + "markdown": "![picture][ref]\n\n[ref]: ../login", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "parent relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "root relative link", + "markdown": "[click](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative image", + "markdown": "![picture](/login)", + "links": [], + "images": [] + }, + { + "name": "root relative reference link", + "markdown": "[click][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative reference image", + "markdown": "![picture][ref]\n\n[ref]: /login", + "links": [], + "images": [] + }, + { + "name": "root relative HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "root relative HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "fragment link", + "markdown": "[click](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment image", + "markdown": "![picture](#login)", + "links": [], + "images": [] + }, + { + "name": "fragment reference link", + "markdown": "[click][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment reference image", + "markdown": "![picture][ref]\n\n[ref]: #login", + "links": [], + "images": [] + }, + { + "name": "fragment HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "fragment HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "query link", + "markdown": "[click](?login)", + "links": [], + "images": [] + }, + { + "name": "query image", + "markdown": "![picture](?login)", + "links": [], + "images": [] + }, + { + "name": "query reference link", + "markdown": "[click][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query reference image", + "markdown": "![picture][ref]\n\n[ref]: ?login", + "links": [], + "images": [] + }, + { + "name": "query HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "query HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity javascript link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity javascript HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "entity letter link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "entity letter reference link", + "markdown": "[click][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript:alert(1)", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "entity letter HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested entity link", + "markdown": "[click](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity image", + "markdown": "![picture](javascript&#58;alert(1))", + "links": [], + "images": [] + }, + { + "name": "nested entity reference link", + "markdown": "[click][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript&#58;alert(1)", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested entity HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "percent encoded link", + "markdown": "[click](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded image", + "markdown": "![picture](javascript%3Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%3Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded link", + "markdown": "[click](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded image", + "markdown": "![picture](javascript%253Aalert(1))", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference link", + "markdown": "[click][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded reference image", + "markdown": "![picture][ref]\n\n[ref]: javascript%253Aalert(1)", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "nested percent encoded HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tab scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "tab scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "newline scheme link", + "markdown": "[click](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme image", + "markdown": "![picture](java script:alert(1))", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference link", + "markdown": "[click][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme reference image", + "markdown": "![picture][ref]\n\n[ref]: java script:alert(1)", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "newline scheme HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials link", + "markdown": "[click](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials image", + "markdown": "![picture](https://paseo.sh@evil.example/login)", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference link", + "markdown": "[click][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials reference image", + "markdown": "![picture][ref]\n\n[ref]: https://paseo.sh@evil.example/login", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "HTTPS credentials HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "backslash link", + "markdown": "[click](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash image", + "markdown": "![picture](https:\\evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "backslash reference link", + "markdown": "[click][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash reference image", + "markdown": "![picture][ref]\n\n[ref]: https:\\evil.example/path", + "links": [], + "images": [] + }, + { + "name": "backslash HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "backslash HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "missing authority link", + "markdown": "[click](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority image", + "markdown": "![picture](https:evil.example/path)", + "links": [], + "images": [] + }, + { + "name": "missing authority reference link", + "markdown": "[click][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority reference image", + "markdown": "![picture][ref]\n\n[ref]: https:evil.example/path", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML link", + "markdown": "click", + "links": [], + "images": [] + }, + { + "name": "missing authority HTML image", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mixed case javascript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data HTML autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "data image autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "vbscript autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "http autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "ftp autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "mailto autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "HTTPS link", + "markdown": "[click](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "HTTPS image", + "markdown": "![picture](https://example.com/image.png)", + "links": [], + "images": ["https://example.com/image.png"] + }, + { + "name": "HTTPS autolink", + "markdown": "", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTPS", + "markdown": "https://example.com/path", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "bare HTTP", + "markdown": "http://example.com/path", + "links": [], + "images": [] + }, + { + "name": "deceptive RTL text", + "markdown": "[paseo.sh‮evil](https://example.com/path)", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "title injection", + "markdown": "[click](https://example.com/path \"\\\" onmouseover=alert(1)\")", + "links": ["https://example.com/path"], + "images": [] + }, + { + "name": "table alignment", + "markdown": "| A |\n| :-: |\n| safe |", + "links": [], + "images": [] + }, + { + "name": "heading", + "markdown": "## Heading", + "links": [], + "images": [] + }, + { + "name": "IRC link", + "markdown": "[click](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC image", + "markdown": "![picture](irc://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRC autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "IRCS link", + "markdown": "[click](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS image", + "markdown": "![picture](ircs://example.com/channel)", + "links": [], + "images": [] + }, + { + "name": "IRCS autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "XMPP link", + "markdown": "[click](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP image", + "markdown": "![picture](xmpp:attacker@example.com)", + "links": [], + "images": [] + }, + { + "name": "XMPP autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "tel link", + "markdown": "[click](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel image", + "markdown": "![picture](tel:12345)", + "links": [], + "images": [] + }, + { + "name": "tel autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "file link", + "markdown": "[click](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file image", + "markdown": "![picture](file:///etc/passwd)", + "links": [], + "images": [] + }, + { + "name": "file autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "blob link", + "markdown": "[click](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob image", + "markdown": "![picture](blob:https://example.com/id)", + "links": [], + "images": [] + }, + { + "name": "blob autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "named entity link", + "markdown": "[click](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity image", + "markdown": "![picture](javascript:alert(1))", + "links": [], + "images": [] + }, + { + "name": "named entity autolink", + "markdown": "", + "links": [], + "images": [] + }, + { + "name": "double encoded entity link", + "markdown": "[click](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity image", + "markdown": "![picture](javascript&colon;alert(1))", + "links": [], + "images": [] + }, + { + "name": "double encoded entity autolink", + "markdown": "", + "links": [], + "images": [] + } +] diff --git a/packages/relay/package.json b/packages/relay/package.json index 62eff50fe3..d53a1a870e 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", @@ -11,21 +11,15 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "node": "./dist/index.js", - "import": "./src/index.ts", - "default": "./src/index.ts" + "default": "./dist/index.js" }, "./e2ee": { "types": "./dist/e2ee.d.ts", - "node": "./dist/e2ee.js", - "import": "./src/e2ee.ts", - "default": "./src/e2ee.ts" + "default": "./dist/e2ee.js" }, "./cloudflare": { "types": "./dist/cloudflare-adapter.d.ts", - "node": "./dist/cloudflare-adapter.js", - "import": "./src/cloudflare-adapter.ts", - "default": "./src/cloudflare-adapter.ts" + "default": "./dist/cloudflare-adapter.js" } }, "publishConfig": { diff --git a/packages/relay/src/package-exports.test.ts b/packages/relay/src/package-exports.test.ts new file mode 100644 index 0000000000..2e8dda9395 --- /dev/null +++ b/packages/relay/src/package-exports.test.ts @@ -0,0 +1,49 @@ +import { existsSync, readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const THIS_DIR = path.dirname(fileURLToPath(import.meta.url)); +const RELAY_PACKAGE_ROOT = path.resolve(THIS_DIR, ".."); + +interface PackageManifest { + exports: Record>; +} + +function readManifest(): PackageManifest { + return JSON.parse(readFileSync(path.join(RELAY_PACKAGE_ROOT, "package.json"), "utf8")); +} + +function listPublishedFiles(): Set { + if (!existsSync(path.join(RELAY_PACKAGE_ROOT, "dist/index.js"))) { + execFileSync("npm", ["run", "build"], { cwd: RELAY_PACKAGE_ROOT, stdio: "inherit" }); + } + const output = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: RELAY_PACKAGE_ROOT, + encoding: "utf8", + }); + const [pack] = JSON.parse(output) as Array<{ files: Array<{ path: string }> }>; + return new Set(pack.files.map((file) => file.path)); +} + +function listExportTargets(manifest: PackageManifest): Array<{ label: string; file: string }> { + const targets: Array<{ label: string; file: string }> = []; + for (const [subpath, conditions] of Object.entries(manifest.exports)) { + for (const [condition, target] of Object.entries(conditions)) { + targets.push({ label: `${subpath} [${condition}] -> ${target}`, file: target.slice(2) }); + } + } + return targets; +} + +describe("published relay package", () => { + it("ships every file its exports map resolves to, for every condition", () => { + const published = listPublishedFiles(); + const missing = listExportTargets(readManifest()) + .filter((target) => !published.has(target.file)) + .map((target) => target.label); + + expect(missing).toEqual([]); + }); +}); diff --git a/packages/server/CLAUDE.md b/packages/server/CLAUDE.md index 1324b3842c..34f99a3943 100644 --- a/packages/server/CLAUDE.md +++ b/packages/server/CLAUDE.md @@ -84,7 +84,7 @@ npm run cli -- daemon status # Check daemon status ### Imports -- Use path alias `@server/*` in server package (maps to `./src/`) +- Use relative `.js` paths for imports within shipped server code, including type imports. TypeScript preserves `@server/*` aliases in JavaScript and declarations, and production Node has no alias resolver. Vitest tests support the alias. - No barrel `index.ts` re-exports — they create unnecessary indirection ### Naming diff --git a/packages/server/package.json b/packages/server/package.json index cd00102b58..b80941d56f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.7.2", + "version": "0.11.2", "description": "Paseo backend server", "files": [ "dist/server", @@ -27,6 +27,41 @@ "types": "./dist/server/terminal/agent-hooks/provider-registry.d.ts", "source": "./src/terminal/agent-hooks/provider-registry.ts", "default": "./dist/server/terminal/agent-hooks/provider-registry.js" + }, + "./daemon-control": { + "types": "./dist/server/server/daemon-instance.d.ts", + "source": "./src/server/daemon-instance.ts", + "default": "./dist/server/server/daemon-instance.js" + }, + "./configuration": { + "types": "./dist/server/server/config.d.ts", + "source": "./src/server/config.ts", + "default": "./dist/server/server/config.js" + }, + "./process": { + "types": "./dist/server/utils/spawn.d.ts", + "source": "./src/utils/spawn.ts", + "default": "./dist/server/utils/spawn.js" + }, + "./auth": { + "types": "./dist/server/server/auth.d.ts", + "source": "./src/server/auth.ts", + "default": "./dist/server/server/auth.js" + }, + "./pairing": { + "types": "./dist/server/server/pairing-offer.d.ts", + "source": "./src/server/pairing-offer.ts", + "default": "./dist/server/server/pairing-offer.js" + }, + "./agent-activity": { + "types": "./dist/server/server/agent/activity-curator.d.ts", + "source": "./src/server/agent/activity-curator.ts", + "default": "./dist/server/server/agent/activity-curator.js" + }, + "./agent-response": { + "types": "./dist/server/server/agent/agent-response-loop.d.ts", + "source": "./src/server/agent/agent-response-loop.ts", + "default": "./dist/server/server/agent/agent-response-loop.js" } }, "publishConfig": { @@ -38,7 +73,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "npm run build:lib && npm run build:scripts", "build:clean": "npm run clean && npm run build", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node scripts/build-opencode-bridge-plugin.mjs && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.rmSync('dist/server/skills',{recursive:true,force:true}); fs.cpSync('../../skills','dist/server/skills',{recursive:true}); fs.rmSync('dist/server/builtin-plugins',{recursive:true,force:true}); fs.cpSync('../../plugins','dist/server/builtin-plugins',{recursive:true,filter:(source)=>!source.split(require('node:path').sep).some((segment)=>segment==='node_modules'||segment==='test')&&!/\\.(test|spec)\\.[cm]?[jt]sx?$/.test(source)&&!source.endsWith('.tsbuildinfo')});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui", "start": "node dist/scripts/supervisor-entrypoint.js", @@ -47,13 +82,14 @@ "measure:agent-tools": "tsx scripts/measure-agent-tools-context.ts", "measure:git-observation": "tsx scripts/measure-git-observation.ts", "measure:file-observer": "tsx scripts/measure-file-observer.ts", + "analyze:observer-metrics": "tsx scripts/analyze-observer-metrics.ts", "repro:file-observer-teardown": "tsx scripts/reproduce-file-observer-teardown.ts", "speech:models": "tsx scripts/list-speech-models.ts", "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", @@ -71,14 +107,15 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.7.2", - "@getpaseo/highlight": "0.7.2", - "@getpaseo/plugin": "0.7.2", - "@getpaseo/protocol": "0.7.2", - "@getpaseo/relay": "0.7.2", + "@getpaseo/client": "0.11.2", + "@getpaseo/highlight": "0.11.2", + "@getpaseo/plugin": "0.11.2", + "@getpaseo/protocol": "0.11.2", + "@getpaseo/relay": "0.11.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", + "@opencode/client": "2.0.10", "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.20.0", @@ -88,6 +125,7 @@ "express": "^4.18.2", "fast-deep-equal": "^3.1.3", "lru-cache": "^11.5.1", + "markdown-it": "^10.0.0", "mnemonic-id": "^3.2.7", "node-pty": "1.2.0-beta.15", "openai": "^6.44.0", @@ -98,17 +136,21 @@ "pino-pretty": "^13.1.3", "qrcode": "^1.5.4", "rotating-file-stream": "^3.2.9", + "semver": "^7.8.5", "sherpa-onnx-node": "1.12.28", "strip-ansi": "^7.1.2", "tree-kill": "^1.2.2", + "typescript": "^5.9.3", "uuid": "^9.0.1", "which": "^5.0.0", "ws": "^8.14.2", + "yaml": "2.9.1", "zod": "^4.4.3" }, "devDependencies": { "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", + "@types/markdown-it": "^14.2.0", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", @@ -116,7 +158,6 @@ "@vitest/ui": "^4.1.6", "cross-env": "^10.1.0", "tsx": "^4.6.0", - "typescript": "^5.2.2", "vitest": "^4.1.6" }, "overrides": { diff --git a/packages/server/scripts/analyze-observer-metrics.ts b/packages/server/scripts/analyze-observer-metrics.ts new file mode 100644 index 0000000000..fe014f0e7e --- /dev/null +++ b/packages/server/scripts/analyze-observer-metrics.ts @@ -0,0 +1,137 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; + +interface Sample { + uptimeSeconds: number; + trackedFiles: number; + renames: number; + classifications: number; + pendingClassifications: number; + pendingWork: number; + maxReconciliationMs: number; + eventLoopMaxMs: number; + rssBytes: number; + failures: number; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +// Pino lines are prefixed with a plain-text timestamp; the JSON payload starts +// at the first `{`. Lines that aren't JSON (or aren't ws_runtime_metrics) are skipped. +function parseLogLine(line: string): Record | undefined { + const start = line.indexOf("{"); + if (start < 0) return undefined; + try { + return asRecord(JSON.parse(line.slice(start))); + } catch { + return undefined; + } +} + +function extractObserverRecord(root: Record): Record | undefined { + const workspaceService = asRecord(asRecord(root.git)?.workspaceService); + return asRecord(workspaceService?.fileObserver); +} + +function buildSample(root: Record, observer: Record): Sample { + const eventLoopDelay = asRecord(root.eventLoopDelay); + const memory = asRecord(root.memory); + return { + uptimeSeconds: asNumber(root.uptimeSeconds) ?? 0, + trackedFiles: asNumber(observer.nativeTrackedFileCount) ?? 0, + renames: asNumber(observer.nativeRenameEventCount) ?? 0, + classifications: asNumber(observer.nativeClassificationCount) ?? 0, + pendingClassifications: asNumber(observer.pendingClassificationCount) ?? 0, + pendingWork: asNumber(observer.pendingReconciliationWorkCount) ?? 0, + maxReconciliationMs: asNumber(observer.maxReconciliationDurationMs) ?? 0, + eventLoopMaxMs: asNumber(eventLoopDelay?.maxMs) ?? 0, + rssBytes: asNumber(memory?.rss) ?? 0, + failures: + (asNumber(observer.observerFailureCount) ?? 0) + + (asNumber(observer.reconciliationFailureCount) ?? 0) + + (asNumber(observer.directoryLimitFailureCount) ?? 0), + }; +} + +async function readSamples(path: string): Promise { + const samples: Sample[] = []; + const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); + for await (const line of lines) { + const root = parseLogLine(line); + if (!root) continue; + const observer = extractObserverRecord(root); + if (!observer) continue; + samples.push(buildSample(root, observer)); + } + return samples; +} + +// A daemon restart resets uptime, so a decrease starts a new run. +function splitRuns(samples: Sample[]): Sample[][] { + const runs: Sample[][] = []; + let current: Sample[] = []; + let previous = Number.NEGATIVE_INFINITY; + for (const sample of samples) { + if (sample.uptimeSeconds < previous && current.length > 0) { + runs.push(current); + current = []; + } + current.push(sample); + previous = sample.uptimeSeconds; + } + if (current.length > 0) runs.push(current); + return runs; +} + +function peak(run: Sample[], select: (sample: Sample) => number): number { + return run.reduce((highest, sample) => Math.max(highest, select(sample)), 0); +} + +const logPath = process.argv[2]; +if (!logPath) { + console.error("usage: analyze-observer-metrics "); + process.exit(1); +} + +const runs = splitRuns(await readSamples(logPath)); +const header = [ + "run".padStart(3), + "samples".padStart(7), + "hours".padStart(6), + "peakTracked".padStart(11), + "renames".padStart(10), + "classify".padStart(10), + "peakPendClass".padStart(13), + "peakPendWork".padStart(12), + "maxReconS".padStart(9), + "peakLoopS".padStart(9), + "peakRssMB".padStart(9), + "fails".padStart(5), +].join(" "); +console.log(header); +runs.forEach((run, index) => { + console.log( + [ + String(index + 1).padStart(3), + String(run.length).padStart(7), + (run[run.length - 1].uptimeSeconds / 3600).toFixed(1).padStart(6), + String(peak(run, (s) => s.trackedFiles)).padStart(11), + String(peak(run, (s) => s.renames)).padStart(10), + String(peak(run, (s) => s.classifications)).padStart(10), + String(peak(run, (s) => s.pendingClassifications)).padStart(13), + String(peak(run, (s) => s.pendingWork)).padStart(12), + (peak(run, (s) => s.maxReconciliationMs) / 1000).toFixed(1).padStart(9), + (peak(run, (s) => s.eventLoopMaxMs) / 1000).toFixed(1).padStart(9), + (peak(run, (s) => s.rssBytes) / 1048576).toFixed(0).padStart(9), + String(peak(run, (s) => s.failures)).padStart(5), + ].join(" "), + ); +}); diff --git a/packages/server/scripts/benchmark-startup-git-real-home.ts b/packages/server/scripts/benchmark-startup-git-real-home.ts index ba1d1129e4..4dde4e190d 100644 --- a/packages/server/scripts/benchmark-startup-git-real-home.ts +++ b/packages/server/scripts/benchmark-startup-git-real-home.ts @@ -140,14 +140,15 @@ async function main(): Promise { await client.connect(); const seen = new Set(); client.on("checkout_status_update", (message) => seen.add(message.payload.cwd)); + await client.observeEvents(["checkout_status_update"]).ready; const workspaces = await client.fetchWorkspaces({ - subscribe: { subscriptionId: `startup-git-${scenario}` }, + subscribe: {}, sort: [{ key: "activity_at", direction: "desc" }], page: { limit: 200 }, }); await client.fetchAgents({ scope: "active", - subscribe: { subscriptionId: `startup-agents-${scenario}` }, + subscribe: {}, page: { limit: 200 }, }); diff --git a/packages/server/scripts/build-opencode-bridge-plugin.mjs b/packages/server/scripts/build-opencode-bridge-plugin.mjs index 596f429e51..69f1e774f0 100644 --- a/packages/server/scripts/build-opencode-bridge-plugin.mjs +++ b/packages/server/scripts/build-opencode-bridge-plugin.mjs @@ -4,22 +4,27 @@ import { fileURLToPath } from "node:url"; import { build } from "esbuild"; -const sourcePath = fileURLToPath( - new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url), -); -const outputPath = fileURLToPath( - new URL( - "../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs", - import.meta.url, - ), -); - -await mkdir(path.dirname(outputPath), { recursive: true }); -await build({ - entryPoints: [sourcePath], - bundle: true, - format: "esm", - platform: "neutral", - target: "es2022", - outfile: outputPath, -}); +for (const directory of ["", "v2/"]) { + const sourcePath = fileURLToPath( + new URL( + `../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`, + import.meta.url, + ), + ); + const outputPath = fileURLToPath( + new URL( + `../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`, + import.meta.url, + ), + ); + await mkdir(path.dirname(outputPath), { recursive: true }); + await build({ + entryPoints: [sourcePath], + bundle: true, + format: "esm", + platform: "neutral", + mainFields: ["module", "main"], + target: "es2022", + outfile: outputPath, + }); +} diff --git a/packages/server/scripts/measure-file-observer.ts b/packages/server/scripts/measure-file-observer.ts index 2b9774ec43..49a54faf4d 100644 --- a/packages/server/scripts/measure-file-observer.ts +++ b/packages/server/scripts/measure-file-observer.ts @@ -1,10 +1,14 @@ import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { monitorEventLoopDelay, performance } from "node:perf_hooks"; import { promisify } from "node:util"; -import { createFileObserver, type FileChange } from "../src/server/file-observer/index.js"; +import { + createFileObserver, + type FileChange, + type FileObserverSubscription, +} from "../src/server/file-observer/index.js"; const DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_DIRS", 500); const ROOT_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_ROOTS", 1); @@ -17,8 +21,58 @@ const SUSTAINED_EDIT_INTERVAL_MS = readPositiveInteger( 500, ); const REPETITIONS = readPositiveInteger("PASEO_WATCH_BENCH_REPETITIONS", 1); +const MODE = resolveMode(); +const LATE_IGNORED_DIRS = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_DIRS", 2_000); +const LATE_IGNORED_FILES = readPositiveInteger("PASEO_WATCH_BENCH_LATE_IGNORED_FILES", 20_000); +// directory-churn mode exercises removeSubtree/reconcileSubtree/paths.collapse +// in packages/server/src/server/file-observer/internal/native-recursive.ts, +// which the healthy and late-ignored fixtures never touch: they only +// mkdir/writeFile, and every delete happens after unsubscribe. +const CHURN_DIRECTORY_COUNT = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_DIRECTORIES", 20_000); +const CHURN_FILES_PER_DIRECTORY = readPositiveInteger( + "PASEO_WATCH_BENCH_CHURN_FILES_PER_DIRECTORY", + 3, +); +const CHURN_ROUNDS = readPositiveInteger("PASEO_WATCH_BENCH_CHURN_ROUNDS", 3); +const CHURN_DIRECTORIES_PER_ROUND = readPositiveInteger( + "PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND", + 500, +); +// Sustained rename churn (the production symptom this mode approximates) can +// leave a single reconciliation running for minutes once the tracked set is +// large; give it room to finish instead of calling that a timeout failure. +const CHURN_SETTLE_TIMEOUT_MS = readPositiveInteger( + "PASEO_WATCH_BENCH_CHURN_SETTLE_TIMEOUT_MS", + 300_000, +); +// Sequential `await`-per-entry construction lets the observer's pending +// fs.stat() classifications drain between filesystem calls, so the storm this +// mode exists to reproduce never piles up: the sequential version could never +// push pendingClassificationCount past 32, nowhere near the 2,080 bound +// (32 concurrent + 2,048 queued) a later fix is measured against. Firing +// batches of concurrent mkdir/writeFile calls overwhelms the libuv threadpool +// that both the harness's own fs calls and the observer's classify() stat() +// calls share, which is what produces a real backlog. The batch size is +// derived from this process's actual open-file limit (see +// resolveLateIgnoredBatchSize below) rather than hardcoded, so a replay on a +// machine with a much lower `ulimit -n` (common on Linux/CI, historically on +// macOS too) degrades to a smaller burst instead of throwing EMFILE mid-run. +const LATE_IGNORED_BATCH_SIZE = resolveLateIgnoredBatchSize(); +// Reuses the same descriptor-aware sizing as LATE_IGNORED_BATCH_SIZE above: +// directory-churn mode needs concurrent, not sequential, filesystem calls to +// outrun the audit's 500ms quiet window and force scopes to pile up. +const CHURN_BATCH_SIZE = resolveLateIgnoredBatchSize(); const execFileAsync = promisify(execFile); +type BenchMode = "healthy" | "late-ignored" | "directory-churn"; + +function resolveMode(): BenchMode { + const raw = process.env.PASEO_WATCH_BENCH_MODE; + if (raw === "late-ignored") return "late-ignored"; + if (raw === "directory-churn") return "directory-churn"; + return "healthy"; +} + interface Measurement { backend: string; run: number; @@ -40,6 +94,8 @@ interface Measurement { sustainedNativeClassificationCount: number | null; sustainedNativeShallowScanCount: number | null; sustainedMissedPaths: number; + lateIgnoredTrackedFileCount: number | null; + peakPendingClassificationCount: number; teardownMs: number; startupEvents: number; deliveredEvents: number; @@ -53,6 +109,16 @@ interface Measurement { kernelWatchCount: number | null; scopedReconciliationCount: number | null; fullReconciliationCount: number | null; + // Absolute values (not deltas) read straight off diagnostics: the observer + // instance is fresh per run, so "max"/"last" already scope to this run. + maxReconciliationDurationMs: number; + lastReconciliationDurationMs: number; + // directory-churn mode only; null for healthy/late-ignored. + nativeRenameEventCount: number | null; + churnDirectoryCount: number | null; + churnFileCount: number | null; + churnRounds: number | null; + churnDirectoriesChurned: number | null; } interface FixtureRoot { @@ -65,7 +131,7 @@ async function main(): Promise { const isChild = process.argv.includes("--child"); if (isChild) { - process.stdout.write(`${JSON.stringify(await measure(1))}\n`); + process.stdout.write(`${JSON.stringify(await runMeasurement(1))}\n`); return; } @@ -80,6 +146,10 @@ async function main(): Promise { if (!evaluation.passed) process.exitCode = 1; } +function runMeasurement(run: number): Promise { + return MODE === "directory-churn" ? measureDirectoryChurn(run) : measure(run); +} + async function measureInChild(run: number): Promise { const { stdout } = await execFileAsync( process.execPath, @@ -98,7 +168,7 @@ async function measure(run: number): Promise { const ignoredRoot = join(root, "ignored"); const trackedRoot = join(root, "tracked"); await Promise.all([ - mkdir(ignoredRoot, { recursive: true }), + MODE === "healthy" ? mkdir(ignoredRoot, { recursive: true }) : Promise.resolve(), mkdir(trackedRoot, { recursive: true }), ]); for (let index = 0; index < DIRECTORY_COUNT; index += 1) { @@ -135,7 +205,7 @@ async function measure(run: number): Promise { firstObservedAt.set(event.path, performance.now()); } }, - { ignore: [ignoredRoot] }, + { ignore: MODE === "healthy" ? [ignoredRoot] : [] }, ), ), ); @@ -144,7 +214,40 @@ async function measure(run: number): Promise { const startupEvents = delivered.length; const kernelWatchCount = await readKernelWatchCount(); + let peakPendingClassificationCount = 0; + let lateIgnoredTrackedFileCount: number | null = null; + let samplePending: ReturnType | undefined; + try { + samplePending = setInterval(() => { + peakPendingClassificationCount = Math.max( + peakPendingClassificationCount, + observer.getDiagnostics().pendingClassificationCount, + ); + }, 50); + samplePending.unref(); + + if (MODE === "late-ignored") { + // The whole point: the ignored tree appears only after the observation + // exists. Construction runs in concurrent batches (not one + // mkdir/writeFile at a time) so filesystem events arrive faster than + // the observer can classify them — otherwise nothing ever piles up. + for (const target of roots) { + await mkdir(target.ignoredRoot, { recursive: true }); + await runInBatches(LATE_IGNORED_DIRS, LATE_IGNORED_BATCH_SIZE, (index) => + mkdir(join(target.ignoredRoot, `package-${index}`), { recursive: true }), + ); + await runInBatches(LATE_IGNORED_FILES, LATE_IGNORED_BATCH_SIZE, (index) => + writeFile( + join(target.ignoredRoot, `package-${index % LATE_IGNORED_DIRS}`, `module-${index}.js`), + `${index}\n`, + ), + ); + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + lateIgnoredTrackedFileCount = observer.getDiagnostics().nativeTrackedFileCount; + } + for (let index = 0; index < IGNORED_FILE_COUNT; index += 1) { const target = roots[index % roots.length]; await writeFile(join(target.ignoredRoot, `artifact-${index}.txt`), `${index}\n`); @@ -263,6 +366,8 @@ async function measure(run: number): Promise { sustainedMissedPaths: sustainedPaths.filter( (path) => !delivered.some((event) => event.path === path), ).length, + lateIgnoredTrackedFileCount, + peakPendingClassificationCount, teardownMs, startupEvents, deliveredEvents: delivered.length, @@ -282,14 +387,307 @@ async function measure(run: number): Promise { diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount, fullReconciliationCount: diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount, + maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs, + lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs, + nativeRenameEventCount: null, + churnDirectoryCount: null, + churnFileCount: null, + churnRounds: null, + churnDirectoriesChurned: null, }; } finally { + if (samplePending) clearInterval(samplePending); await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); await observer.close(); await rm(base, { recursive: true, force: true }); } } +// directory-churn mode. Unlike healthy/late-ignored, this builds one large +// tracked tree and then repeatedly makes whole known directories disappear — +// the only way to invoke removeSubtree, reconcileSubtree, and paths.collapse +// in native-recursive.ts. +async function measureDirectoryChurn(run: number): Promise { + const totalChurnTargets = CHURN_ROUNDS * CHURN_DIRECTORIES_PER_ROUND; + if (totalChurnTargets > CHURN_DIRECTORY_COUNT) { + throw new Error( + `PASEO_WATCH_BENCH_CHURN_ROUNDS (${CHURN_ROUNDS}) * ` + + `PASEO_WATCH_BENCH_CHURN_DIRECTORIES_PER_ROUND (${CHURN_DIRECTORIES_PER_ROUND}) = ` + + `${totalChurnTargets} must not exceed PASEO_WATCH_BENCH_CHURN_DIRECTORIES ` + + `(${CHURN_DIRECTORY_COUNT})`, + ); + } + + const base = await mkdtemp(join(tmpdir(), "paseo-watch-churn-")); + const root = join(base, "root"); + await mkdir(root, { recursive: true }); + const observer = createFileObserver(); + let subscription: FileObserverSubscription | null = null; + + try { + // 1. Build a large, entirely-tracked tree before subscribing. Nothing + // here is ignored: removeSubtree and reconcileSubtree scale with the size + // of `this.files` / `this.directories`, so the fixture needs a big + // tracked set for their cost to show up at all. + await runInBatches(CHURN_DIRECTORY_COUNT, CHURN_BATCH_SIZE, (index) => + mkdir(join(root, `dir-${index}`)), + ); + await runInBatches( + CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY, + CHURN_BATCH_SIZE, + (flatIndex) => { + const directoryIndex = Math.floor(flatIndex / CHURN_FILES_PER_DIRECTORY); + const fileIndex = flatIndex % CHURN_FILES_PER_DIRECTORY; + return writeFile( + join(root, `dir-${directoryIndex}`, `file-${fileIndex}.txt`), + `${flatIndex}\n`, + ); + }, + ); + + const rssBefore = process.memoryUsage().rss; + const cpuBefore = process.cpuUsage(); + const diagnosticsBefore = observer.getDiagnostics(); + const eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); + eventLoopDelay.enable(); + const delivered: FileChange[] = []; + let error: Error | null = null; + + const setupStarted = performance.now(); + subscription = await observer.subscribe(root, (nextError, events) => { + error ??= nextError; + delivered.push(...events); + }); + const setupMs = performance.now() - setupStarted; + + // 2. Let the initial inventory settle before churning anything. + const initialSettled = await waitFor(() => { + const diagnostics = observer.getDiagnostics(); + return ( + diagnostics.pendingEventCount === 0 && + diagnostics.pendingReconciliationWorkCount === 0 && + diagnostics.reconciliationInFlightCount === 0 + ); + }, 120_000); + if (!initialSettled) { + throw new Error("Timed out waiting for directory-churn initial inventory to settle"); + } + if (error) throw error; + const startupEvents = delivered.length; + const kernelWatchCount = await readKernelWatchCount(); + + // 3. Drive directory churn. Each round deletes half of a batch of known + // directories outright (their parent's next shallow scan finds them gone + // and calls removeSubtree) and replaces the other half atomically in + // place: rm the old directory, then rename a freshly built sibling onto + // the same path. The backend's classify() stat() resolves after the + // rename completes, sees a directory at a path it already knew as a + // directory, and treats it as the directory itself changing — + // populating recursiveScopes and driving reconcileSubtree. Firing every + // round's operations concurrently, faster than the audit's 500ms quiet + // window, is what forces the resulting scopes onto one large + // reconciliation instead of many small ones, which is what the + // production incident this mode approximates looked like. + const diagnosticsBeforeChurn = observer.getDiagnostics(); + const churnCpuBefore = process.cpuUsage(); + const churnStarted = performance.now(); + let nextDirectoryIndex = 0; + for (let round = 0; round < CHURN_ROUNDS; round += 1) { + const batch: number[] = []; + for (let i = 0; i < CHURN_DIRECTORIES_PER_ROUND; i += 1) { + batch.push(nextDirectoryIndex); + nextDirectoryIndex += 1; + } + const deleteCount = Math.floor(batch.length / 2); + const deleteTargets = batch.slice(0, deleteCount); + const replaceTargets = batch.slice(deleteCount); + await runInBatches(deleteTargets.length, CHURN_BATCH_SIZE, (offset) => + rm(join(root, `dir-${deleteTargets[offset]}`), { recursive: true, force: true }), + ); + await runInBatches(replaceTargets.length, CHURN_BATCH_SIZE, (offset) => + replaceDirectoryAtomically( + join(root, `dir-${replaceTargets[offset]}`), + CHURN_FILES_PER_DIRECTORY, + ), + ); + } + const churnSettled = await waitFor(() => { + const diagnostics = observer.getDiagnostics(); + return ( + diagnostics.pendingEventCount === 0 && + diagnostics.pendingReconciliationWorkCount === 0 && + diagnostics.reconciliationInFlightCount === 0 + ); + }, CHURN_SETTLE_TIMEOUT_MS); + if (!churnSettled) { + throw new Error( + `Timed out waiting for directory-churn reconciliation to settle within ` + + `${CHURN_SETTLE_TIMEOUT_MS}ms`, + ); + } + const churnDurationMs = performance.now() - churnStarted; + const churnCpu = process.cpuUsage(churnCpuBefore); + const churnCpuMs = (churnCpu.user + churnCpu.system) / 1_000; + if (error) throw error; + const diagnosticsAfterChurn = observer.getDiagnostics(); + + const teardownStarted = performance.now(); + await subscription.unsubscribe(); + const teardownMs = performance.now() - teardownStarted; + const diagnosticsAfter = observer.getDiagnostics(); + const cpu = process.cpuUsage(cpuBefore); + eventLoopDelay.disable(); + + return { + backend: "production", + run, + setupMs, + editLatencyMs: 0, + editLatencyP50Ms: 0, + editLatencyP95Ms: 0, + editLatencyP99Ms: 0, + burstDurationMs: churnDurationMs, + burstCpuMs: churnCpuMs, + sustainedDurationMs: 0, + sustainedCpuMs: 0, + sustainedScopedReconciliationCount: null, + sustainedFullReconciliationCount: null, + sustainedNativeEventCount: null, + sustainedNativeChangeEventCount: null, + sustainedNativeRenameEventCount: null, + sustainedNativePathlessEventCount: null, + sustainedNativeClassificationCount: null, + sustainedNativeShallowScanCount: null, + sustainedMissedPaths: 0, + lateIgnoredTrackedFileCount: null, + peakPendingClassificationCount: 0, + teardownMs, + startupEvents, + deliveredEvents: delivered.length, + // Nothing is ignored in this mode's fixture; see the comment at the + // CHURN_DIRECTORY_COUNT declaration above. + ignoredEvents: 0, + missedTrackedPaths: 0, + cpuUserMs: cpu.user / 1_000, + cpuSystemMs: cpu.system / 1_000, + rssDeltaMiB: (process.memoryUsage().rss - rssBefore) / 1024 / 1024, + eventLoopDelayP99Ms: eventLoopDelay.percentile(99) / 1_000_000, + eventLoopDelayMaxMs: eventLoopDelay.max / 1_000_000, + kernelWatchCount, + scopedReconciliationCount: + diagnosticsAfter.scopedReconciliationCount - diagnosticsBefore.scopedReconciliationCount, + fullReconciliationCount: + diagnosticsAfter.fullReconciliationCount - diagnosticsBefore.fullReconciliationCount, + maxReconciliationDurationMs: diagnosticsAfter.maxReconciliationDurationMs, + lastReconciliationDurationMs: diagnosticsAfter.lastReconciliationDurationMs, + nativeRenameEventCount: + diagnosticsAfterChurn.nativeRenameEventCount - + diagnosticsBeforeChurn.nativeRenameEventCount, + churnDirectoryCount: CHURN_DIRECTORY_COUNT, + churnFileCount: CHURN_DIRECTORY_COUNT * CHURN_FILES_PER_DIRECTORY, + churnRounds: CHURN_ROUNDS, + churnDirectoriesChurned: totalChurnTargets, + }; + } finally { + if (subscription) await subscription.unsubscribe(); + await observer.close(); + await rm(base, { recursive: true, force: true }); + } +} + +// Builds a fresh sibling directory, then rm+rename it onto `directory`. This +// is the atomic-replace pattern dev-server builds use to swap output +// directories in place, and the specific shape that drives reconcileSubtree: +// see the comment inside measureDirectoryChurn's churn loop above. +async function replaceDirectoryAtomically(directory: string, fileCount: number): Promise { + const staging = `${directory}.replacement`; + await mkdir(staging); + for (let fileIndex = 0; fileIndex < fileCount; fileIndex += 1) { + await writeFile(join(staging, `file-${fileIndex}.txt`), `${fileIndex}\n`); + } + await rm(directory, { recursive: true, force: true }); + await rename(staging, directory); +} + +function resolveLateIgnoredBatchSize(): number { + // A batch this small still produces a genuine burst instead of degenerating + // back toward the sequential, one-at-a-time construction this code + // replaces — see the comment on LATE_IGNORED_BATCH_SIZE above for why a + // burst is required at all. + const floor = 64; + // Never exceed this regardless of how generous the descriptor limit is. + // 2,000 already produces peak pending classifications in the ~2,700-3,200 + // range on a high-limit machine, comfortably clearing the 2,080 bound the + // later fix imposes, so there is no reason to risk more file descriptors + // for a larger, noisier number. + const ceiling = 2_000; + // Used when this runtime cannot report its own descriptor limit at all. + // Small enough to be safe under a default Linux/CI soft limit of roughly + // 1,024-4,096, still large enough to produce a real burst. + const fallback = 512; + const softLimit = readOpenFileSoftLimit(); + if (softLimit === null) return fallback; + // Leave headroom for everything else the process holds open at the same + // time: stdio, the fixture's own already-created directories/files, the + // observer's native watch handle, and Node's own bookkeeping fds. + const quarter = Math.floor(softLimit / 4); + return Math.min(ceiling, Math.max(floor, quarter)); +} + +function readOpenFileSoftLimit(): number | null { + // Node's diagnostic report is the only cross-platform way to read this + // process's own open-file (RLIMIT_NOFILE) soft limit without shelling out + // to `ulimit -n`, which is a shell builtin with no direct Node API. + // `header.fileDescriptorSoftLimit` is checked first for forward/backward + // compatibility, but it does not exist on this script's tested runtime + // (verified empirically: `undefined` on Node v24.15.0/darwin). The field + // that does exist there, and is expected to exist on Linux too since it + // comes from the same libuv getrlimit() call, is + // `userLimits.open_files.soft`. + let report: unknown; + try { + report = process.report?.getReport(); + } catch { + return null; + } + if (typeof report !== "object" || report === null) return null; + const asRecord = report as Record; + return ( + parseDescriptorLimit(readNestedField(asRecord, ["header", "fileDescriptorSoftLimit"])) ?? + parseDescriptorLimit(readNestedField(asRecord, ["userLimits", "open_files", "soft"])) + ); +} + +function readNestedField(source: Record, path: string[]): unknown { + let current: unknown = source; + for (const key of path) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as Record)[key]; + } + return current; +} + +function parseDescriptorLimit(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; + if (typeof value === "string" && value.trim().toLowerCase() === "unlimited") { + return Number.POSITIVE_INFINITY; + } + return null; +} + +async function runInBatches( + count: number, + batchSize: number, + createEntry: (index: number) => Promise, +): Promise { + for (let start = 0; start < count; start += batchSize) { + const end = Math.min(start + batchSize, count); + const batch: Promise[] = []; + for (let index = start; index < end; index += 1) batch.push(createEntry(index)); + await Promise.all(batch); + } +} + async function readKernelWatchCount(): Promise { if (process.platform !== "linux") return null; let count = 0; @@ -311,6 +709,11 @@ function fixtureDescription() { sustainedEdits: SUSTAINED_EDIT_COUNT, sustainedEditIntervalMs: SUSTAINED_EDIT_INTERVAL_MS, repetitions: REPETITIONS, + mode: MODE, + churnDirectories: CHURN_DIRECTORY_COUNT, + churnFilesPerDirectory: CHURN_FILES_PER_DIRECTORY, + churnRounds: CHURN_ROUNDS, + churnDirectoriesPerRound: CHURN_DIRECTORIES_PER_ROUND, platform: process.platform, arch: process.arch, node: process.version, @@ -321,6 +724,12 @@ function evaluate(results: Measurement[]): { passed: boolean; failures: string[]; } { + if (MODE === "directory-churn") { + // This phase exists to characterize a known-bad code path (removeSubtree + // and reconcileSubtree scanning the whole tracked set per disappeared + // directory), not to gate CI on a threshold yet. + return { passed: true, failures: [] }; + } const failures: string[] = []; for (const result of results) { evaluateStandaloneResult(result, failures); @@ -332,7 +741,10 @@ function evaluateStandaloneResult(result: Measurement, failures: string[]): void if (result.missedTrackedPaths !== 0) { failures.push(`observer missed ${result.missedTrackedPaths} tracked paths`); } - if (result.ignoredEvents !== 0) { + // In late-ignored mode, nonzero ignoredEvents is the bug reproducing, not a + // regression: the observer is deliberately given the ignored tree only + // after it subscribes, so it has no way to exclude it yet. + if (MODE !== "late-ignored" && result.ignoredEvents !== 0) { failures.push(`observer emitted ${result.ignoredEvents} excluded events`); } if (result.sustainedMissedPaths !== 0) { diff --git a/packages/server/scripts/supervisor-entrypoint.env.test.ts b/packages/server/scripts/supervisor-entrypoint.env.test.ts new file mode 100644 index 0000000000..d87bc678e8 --- /dev/null +++ b/packages/server/scripts/supervisor-entrypoint.env.test.ts @@ -0,0 +1,85 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { + resolveSherpaLoaderEnv, + sherpaLoaderEnvKey, +} from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; + +const loaderKey = sherpaLoaderEnvKey(); +const loaderEnv = resolveSherpaLoaderEnv(); + +// Agents and terminals inherit the daemon worker's environment, so the local speech +// runtime's library directory must stay scoped to the speech worker. +test.runIf(loaderKey && loaderEnv)( + "daemon worker environment does not carry the local speech library directory", + async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-supervisor-env-")); + const reportPath = path.join(root, "worker-env.json"); + const preloadPath = path.join(root, "report-worker-env.mjs"); + await writeFile( + preloadPath, + ` + import { writeFileSync } from "node:fs"; + if (/daemon-worker\\.(ts|js)$/.test(process.argv[1] ?? "")) { + writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ value: process.env[${JSON.stringify(loaderKey)}] ?? "" })); + process.exit(0); + } + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + // Start from a parent environment that does not already list the speech library directory. + const envLoaderKey = + Object.keys(env).find((key) => key.toLowerCase() === loaderKey!.toLowerCase()) ?? loaderKey!; + const parentValue = (env[envLoaderKey] ?? "") + .split(path.delimiter) + .filter((entry) => entry && entry !== loaderEnv!.libDir) + .join(path.delimiter); + env[envLoaderKey] = parentValue; + const child = spawn( + process.execPath, + ["--import", "tsx", fileURLToPath(new URL("./supervisor-entrypoint.ts", import.meta.url))], + { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { + ...env, + HOME: root, + USERPROFILE: root, + PASEO_HOME: path.join(root, "home"), + NODE_OPTIONS: `--import=${pathToFileURL(preloadPath).href}`, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + await expect + .poll(async () => readFile(reportPath, "utf8").catch(() => null), { + timeout: 20_000, + message: output, + }) + .not.toBeNull(); + const report = JSON.parse(await readFile(reportPath, "utf8")) as { value: string }; + expect(report.value).toBe(parentValue); + } finally { + if (child.exitCode === null) { + const closed = new Promise((resolve) => child.once("close", resolve)); + child.kill("SIGKILL"); + await closed; + } + await rm(root, { recursive: true, force: true }); + } + }, + 30_000, +); diff --git a/packages/server/scripts/supervisor-entrypoint.ts b/packages/server/scripts/supervisor-entrypoint.ts index b379e6179f..ee3746accf 100644 --- a/packages/server/scripts/supervisor-entrypoint.ts +++ b/packages/server/scripts/supervisor-entrypoint.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from "url"; -import { existsSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync } from "node:fs"; import path from "node:path"; import { acquirePidLock, @@ -9,22 +9,21 @@ import { updatePidLock, } from "../src/server/pid-lock.js"; import { resolvePaseoHome } from "../src/server/paseo-home.js"; +import { daemonLogPath } from "../src/server/daemon-instance.js"; +import { PRIVATE_FILE_MODE } from "../src/server/private-files.js"; import { loadPersistedConfig } from "../src/server/persisted-config.js"; import { runSupervisor } from "./supervisor.js"; import { resolveSupervisorLogFile } from "./supervisor-log-config.js"; -import { applySherpaLoaderEnv } from "../src/server/speech/providers/local/sherpa/sherpa-runtime-env.js"; process.title = "Paseo Supervisor"; interface DaemonRunnerConfig { devMode: boolean; - reclaimStalePidLock: boolean; workerArgs: string[]; } function parseConfig(argv: string[]): DaemonRunnerConfig { let devMode = false; - let reclaimStalePidLock = false; const workerArgs: string[] = []; for (const arg of argv) { @@ -33,13 +32,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig { continue; } if (arg === "--reclaim-stale-pid-lock") { - reclaimStalePidLock = true; - continue; + throw new Error( + "--reclaim-stale-pid-lock was removed: stop the existing supervisor before starting another.", + ); } workerArgs.push(arg); } - return { devMode, reclaimStalePidLock, workerArgs }; + return { devMode, workerArgs }; } function resolveWorkerEntry(): string { @@ -107,8 +107,6 @@ async function main(): Promise { ? resolvePackagedNodeEntrypointRunnerPath(fileURLToPath(import.meta.url)) : null; - applySherpaLoaderEnv(workerEnv); - const paseoHome = resolvePaseoHome(workerEnv); const persistedConfig = loadPersistedConfig(paseoHome); const supervisorLogFile = resolveSupervisorLogFile(paseoHome, persistedConfig, workerEnv); @@ -116,13 +114,10 @@ async function main(): Promise { try { await acquirePidLock(paseoHome, null, { ownerPid: process.pid, - reclaimStaleDesktopLock: config.reclaimStalePidLock, }); } catch (error) { if (error instanceof PidLockError) { - process.stderr.write(`${error.message}\n`); - process.exit(1); - return; + failStartup(error.message, error.message); } throw error; } @@ -174,16 +169,41 @@ async function main(): Promise { : undefined, restartOnCrash: true, logFile: supervisorLogFile, - onWorkerReady: async ({ listen }) => { - await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid }); + onWorkerReady: async ({ listen, serverId }) => { + await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid }); }, + onWorkerExit: () => + updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }), onSupervisorExit: releaseLock, }); requestSupervisorShutdown = supervisor.requestShutdown; } -void main().catch((error) => { - const message = error instanceof Error ? (error.stack ?? error.message) : String(error); - process.stderr.write(`${message}\n`); +// The supervisor opens its log only after config and the PID lock succeed. A background +// launch discards stderr, so earlier failures also go to the log the launcher points at. +function failStartup(detail: string, summary: string): never { + process.stderr.write(`${detail}\n`); + try { + const logPath = daemonLogPath(resolvePaseoHome(process.env)); + mkdirSync(path.dirname(logPath), { recursive: true }); + appendFileSync( + logPath, + `${JSON.stringify({ + level: "fatal", + time: new Date().toISOString(), + pid: process.pid, + name: "DaemonRunner", + msg: summary, + })}\n`, + { mode: PRIVATE_FILE_MODE }, + ); + } catch { + // stderr already carries the failure. + } process.exit(1); +} + +void main().catch((error) => { + if (error instanceof Error) failStartup(error.stack ?? error.message, error.message); + failStartup(String(error), String(error)); }); diff --git a/packages/server/scripts/supervisor.logging.test.ts b/packages/server/scripts/supervisor.logging.test.ts index 9b4c622860..6f9754994a 100644 --- a/packages/server/scripts/supervisor.logging.test.ts +++ b/packages/server/scripts/supervisor.logging.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -23,6 +23,13 @@ async function runSupervisorFixture(options: { workerSource: string; restartOnCrash?: boolean; timeoutMs?: number; + /** POSIX RLIMIT_FSIZE for the supervisor, in 512-byte blocks: writes past it fail like a full disk. */ + fileSizeLimitBlocks?: number; + /** Occupy the log path with a directory so the supervisor cannot open daemon.log. */ + blockLogPath?: boolean; + /** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */ + closeOutput?: boolean; + env?: NodeJS.ProcessEnv; }): Promise<{ code: number | null; signal: NodeJS.Signals | null; @@ -36,7 +43,13 @@ async function runSupervisorFixture(options: { const workerPath = path.join(tempDir, "worker.mjs"); const runnerPath = path.join(tempDir, "runner.mjs"); - await writeFile(workerPath, options.workerSource); + if (options.blockLogPath) { + await mkdir(logPath); + } + await writeFile( + workerPath, + `process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`, + ); await writeFile( runnerPath, ` @@ -59,12 +72,30 @@ async function runSupervisorFixture(options: { ); const startedAt = Date.now(); - const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { + const runnerArgs = [process.execPath, "--import", "tsx", runnerPath]; + const [command, ...args] = + options.fileSizeLimitBlocks === undefined + ? runnerArgs + : [ + "/bin/sh", + "-c", + `ulimit -f ${options.fileSizeLimitBlocks} && exec "$@"`, + "sh", + ...runnerArgs, + ]; + const child = spawn(command, args, { cwd: repoRoot, - env: { ...process.env }, + // A Node color-conflict warning can hit closed stderr before runSupervisor installs + // its pipe-error handlers. This fixture exercises the supervisor's own output. + env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined }, stdio: ["ignore", "pipe", "pipe"], }); + if (options.closeOutput) { + child.stdout.destroy(); + child.stderr.destroy(); + } + let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -95,10 +126,19 @@ async function runSupervisorFixture(options: { }); }); - const log = await readFile(logPath, "utf8"); + const log = await readLogIfWritten(logPath); return { code, signal, elapsedMs: Date.now() - startedAt, log, stdout, stderr }; } +async function readLogIfWritten(logPath: string): Promise { + try { + return await readFile(logPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + describe("supervisor durable logging", () => { test("resolves rotation defaults", () => { const paseoHome = path.join(path.sep, "tmp", "paseo-home"); @@ -168,6 +208,37 @@ describe("supervisor durable logging", () => { expect(result.stderr).toContain('"worker-json-stderr"'); }); + test.each([ + { colors: "inherited", env: {} }, + { colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } }, + ])( + "keeps supervising the worker when nobody reads its stdout or stderr with $colors colors", + async ({ env }) => { + const result = await runSupervisorFixture({ + closeOutput: true, + env, + workerSource: ` + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.stdout.write("first stdout line\\n"); + process.stderr.write("first stderr line\\n"); + setTimeout(() => { + process.stdout.write("later stdout line\\n"); + process.send?.({ type: "paseo:shutdown", reason: "closed_output_probe" }); + }, 500); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + expect(result.log).toContain("first stderr line\n"); + expect(result.log).toContain("later stdout line\n"); + expect(result.log).toContain('"reason":"closed_output_probe"'); + }, + ); + test("preserves raw non-JSON stdout and stderr lines", async () => { const result = await runSupervisorFixture({ workerSource: ` @@ -345,4 +416,55 @@ describe("supervisor durable logging", () => { expect(result.log).toContain("Supervisor exiting"); }, ); + + // POSIX-only: RLIMIT_FSIZE is how the test makes daemon.log writes fail. + test.skipIf(isPlatform("win32"))( + "keeps supervising the worker when daemon.log can no longer be written", + async () => { + const result = await runSupervisorFixture({ + fileSizeLimitBlocks: 128, + workerSource: ` + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + const line = '{"level":30,"msg":"' + "x".repeat(1000) + '"}\\n'; + for (let i = 0; i < 200; i += 1) process.stdout.write(line); + setTimeout(() => { + process.send?.({ type: "paseo:shutdown", reason: "log_write_failure_probe" }); + }, 1000); + setInterval(() => {}, 1000); + `, + }); + + expect(result.stderr).not.toContain("Unhandled 'error' event"); + expect(result.code).toBe(0); + expect(result.signal).toBeNull(); + }, + ); + + test("resumes writing daemon.log once the log path is writable again", async () => { + const result = await runSupervisorFixture({ + blockLogPath: true, + workerSource: ` + import { rmdirSync } from "node:fs"; + + process.on("message", (message) => { + if (message?.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.stdout.write("line while daemon.log is blocked\\n"); + setTimeout(() => { + rmdirSync(process.argv[1].replace(/worker\\.mjs$/, "daemon.log")); + process.stdout.write("line after daemon.log is writable\\n"); + setTimeout(() => { + process.send?.({ type: "paseo:shutdown", reason: "log_recovery_probe" }); + }, 200); + }, 500); + setInterval(() => {}, 1000); + `, + }); + + expect(result.code).toBe(0); + expect(result.log).toContain("line after daemon.log is writable\n"); + expect(result.log).toContain('"reason":"log_recovery_probe"'); + }); }); diff --git a/packages/server/scripts/supervisor.readiness.test.ts b/packages/server/scripts/supervisor.readiness.test.ts new file mode 100644 index 0000000000..bfd02b1402 --- /dev/null +++ b/packages/server/scripts/supervisor.readiness.test.ts @@ -0,0 +1,122 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +async function runWorker(worker: string, publicationDelay = 0) { + const home = await mkdtemp(path.join(tmpdir(), "paseo-readiness-")); + const workerPath = path.join(home, "worker.mjs"); + const runnerPath = path.join(home, "runner.mjs"); + const eventsPath = path.join(home, "events.jsonl"); + const supervisor = new URL("./supervisor.ts", import.meta.url).href; + await writeFile(workerPath, worker); + await writeFile( + runnerPath, + ` + import { appendFile } from "node:fs/promises"; + import { runSupervisor } from ${JSON.stringify(supervisor)}; + const record = (event) => appendFile(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n"); + runSupervisor({ + name: "ReadinessTest", startupMessage: "starting", restartOnCrash: true, + resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [], + onWorkerReady: async ({ listen }) => { + await new Promise(resolve => setTimeout(resolve, ${publicationDelay})); + await record(listen); + }, + onWorkerExit: () => record(null), + }); + `, + ); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ); + const child = spawn(process.execPath, ["--import", "tsx", runnerPath], { + cwd: fileURLToPath(new URL("../../../", import.meta.url)), + env: { ...env, HOME: home, PASEO_HOME: home }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + try { + const code = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(output)); + }, 5_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timer); + resolve(exitCode); + }); + }); + const events = await readFile(eventsPath, "utf8").catch(() => ""); + return { + code, + output, + events: events + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)), + }; + } finally { + await rm(home, { recursive: true, force: true }); + } +} + +test("a worker that exits before ready fails the supervisor without respawning", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.exit(1); + `); + expect(result.code).toBe(1); + expect(result.output).not.toContain("Restarting worker"); +}); + +test("a replacement worker must independently reach ready", async () => { + const result = await runWorker(` + import { existsSync, writeFileSync } from "node:fs"; + const marker = process.argv[1] + ".started"; + if (existsSync(marker)) process.exit(0); + writeFileSync(marker, "started"); + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }); + process.send({ type: "paseo:restart" }); + `); + expect(result.code).toBe(1); + expect(result.events, result.output).toEqual(["test-endpoint", null, null]); +}); + +test("exit clears publication after an in-flight ready write", async () => { + const result = await runWorker( + ` + process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0)); + `, + 100, + ); + expect(result.code).toBe(0); + expect(result.events.at(-1)).toBeNull(); +}); + +test("requested shutdown before first readiness exits successfully", async () => { + const result = await runWorker(` + process.on("message", message => { + if (message.type === "paseo:graceful-shutdown") process.exit(0); + }); + process.send({ type: "paseo:shutdown", reason: "cancelled_start" }); + `); + expect(result.code).toBe(0); + expect(result.events).toEqual([null]); +}); diff --git a/packages/server/scripts/supervisor.ts b/packages/server/scripts/supervisor.ts index f2d9fc7665..ec56822619 100644 --- a/packages/server/scripts/supervisor.ts +++ b/packages/server/scripts/supervisor.ts @@ -1,7 +1,9 @@ import { fork, spawn, type ChildProcess } from "child_process"; -import { mkdirSync } from "node:fs"; import path from "node:path"; -import { createStream as createRotatingFileStream } from "rotating-file-stream"; +import { + createStream as createRotatingFileStream, + type RotatingFileStream, +} from "rotating-file-stream"; import { signalProcessTree } from "../src/utils/tree-kill.js"; const WORKER_HEARTBEAT_INTERVAL_MS = 1_000; @@ -23,6 +25,7 @@ type WorkerLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -50,7 +53,8 @@ interface SupervisorOptions { args: string[]; env?: NodeJS.ProcessEnv; } | null; - onWorkerReady?: (message: { listen: string }) => Promise | void; + onWorkerReady?: (message: { listen: string; serverId: string }) => Promise | void; + onWorkerExit?: () => Promise | void; restartOnCrash?: boolean; onSupervisorExit?: () => Promise | void; logFile?: SupervisorLogFileOptions; @@ -77,11 +81,14 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null { }; } if (type === "paseo:ready") { - const listen = (msg as { listen?: unknown }).listen; + const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown }; if (typeof listen !== "string" || listen.trim().length === 0) { return null; } - return { type: "paseo:ready", listen }; + if (typeof serverId !== "string" || serverId.trim().length === 0) { + return null; + } + return { type: "paseo:ready", listen, serverId }; } if (type === "paseo:restart") { const reason = (msg as { reason?: unknown }).reason; @@ -105,17 +112,67 @@ function toRotatingFileStreamSize(size: string): string { return `${value}${unit}`; } -function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) { - if (!options) { - return null; - } +// daemon.log is best effort: a full disk or a replaced log path must not take the +// supervisor, and with it the worker, down. A failed stream is dropped and a new one +// is opened on the next write, so logging resumes once the file is writable again. +function createDurableLog( + options: SupervisorLogFileOptions | undefined, + reportFailure: (message: string) => void, +) { + let stream: RotatingFileStream | null = null; + let failing = false; - mkdirSync(path.dirname(options.path), { recursive: true }); - return createRotatingFileStream(path.basename(options.path), { - path: path.dirname(options.path), - size: toRotatingFileStreamSize(options.rotate.maxSize), - maxFiles: options.rotate.maxFiles, - }); + const open = (logFile: SupervisorLogFileOptions): RotatingFileStream => { + const next = createRotatingFileStream(path.basename(logFile.path), { + path: path.dirname(logFile.path), + size: toRotatingFileStreamSize(logFile.rotate.maxSize), + maxFiles: logFile.rotate.maxFiles, + }); + next.on("error", (error) => { + if (stream === next) { + stream = null; + } + if (!failing) { + failing = true; + reportFailure( + `Cannot write ${logFile.path}, will retry on the next log line: ${error.message}`, + ); + } + }); + return next; + }; + + return { + write(chunk: string | Buffer): void { + if (!options) { + return; + } + stream ??= open(options); + stream.write(chunk, (error) => { + if (!error) { + failing = false; + } + }); + }, + close(): Promise { + return new Promise((resolve) => { + if (!stream) { + resolve(); + return; + } + stream.end(() => resolve()); + }); + }, + }; +} + +// The program that started the daemon can close its end of stdout or stderr, as +// `paseo daemon run | head -1` does. Drop the output nobody reads and keep supervising +// the worker; daemon.log still records it. +function ignoreClosedOutput(error: NodeJS.ErrnoException): void { + if (error.code !== "EPIPE") { + throw error; + } } export function runSupervisor(options: SupervisorOptions): SupervisorController { @@ -129,11 +186,15 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController let restarting = false; let shuttingDown = false; let exiting = false; + let lifecycleFailed = false; + let publication = Promise.resolve(); let forceKillTimer: NodeJS.Timeout | null = null; - const logStream = createSupervisorLogStream(options.logFile); + const durableLog = createDurableLog(options.logFile, (message) => { + process.stderr.write(`[${options.name}] ${message}\n`); + }); const writeDurableChunk = (chunk: string | Buffer): void => { - logStream?.write(chunk); + durableLog.write(chunk); }; const writeLifecycleLog = (message: string, fields: Record = {}): void => { @@ -154,14 +215,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController writeLifecycleLog(message); }; - const closeLogStream = (): Promise => - new Promise((resolve) => { - if (!logStream) { - resolve(); - return; - } - logStream.end(resolve); - }); + const closeLogStream = (): Promise => durableLog.close(); const exitSupervisor = (code: number): void => { if (exiting) { @@ -244,6 +298,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController } const currentChild = child; + let reachedReady = false; + // Serialize endpoint writes with exit/clear before allowing another worker to spawn. const heartbeat = setInterval(() => { const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" }; if (currentChild.connected) { @@ -276,18 +332,26 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController child.on("message", (msg: unknown) => { const lifecycleMessage = parseLifecycleMessage(msg); - if (!lifecycleMessage) { + if (!lifecycleMessage || child !== currentChild) { return; } if (lifecycleMessage.type === "paseo:ready") { + reachedReady = true; writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen }); - Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch( - (error) => { + publication = publication + .then(() => + options.onWorkerReady?.({ + listen: lifecycleMessage.listen, + serverId: lifecycleMessage.serverId, + }), + ) + .catch((error) => { + lifecycleFailed = true; const message = error instanceof Error ? error.message : String(error); log(`Worker ready callback failed: ${message}`); - }, - ); + requestShutdown("endpoint_publication_failed"); + }); return; } @@ -303,35 +367,57 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController requestRestart(reason); }); + child.on("error", (error) => { + lifecycleFailed = true; + log(`Worker spawn failed: ${error.message}`); + exitSupervisor(1); + }); + child.on("exit", (code, signal) => { clearInterval(heartbeat); clearForceKillTimer(); - const exitDescriptor = describeExit(code, signal); - writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); + child = null; + publication = publication + .then(() => options.onWorkerExit?.()) + .catch((error) => { + lifecycleFailed = true; + log(`Worker exit callback failed: ${String(error)}`); + }); + void publication.then(() => { + const exitDescriptor = describeExit(code, signal); + writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor }); - if (shuttingDown) { - log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); - exitSupervisor(0); + if (lifecycleFailed || (!reachedReady && !shuttingDown)) { + log(`Worker exited before successful readiness (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(1); + return; + } + + if (shuttingDown) { + log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`); + exitSupervisor(0); + return; + } + + const crashed = + restartOnCrash && + ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); + + if (restarting || crashed) { + restarting = false; + log( + crashed + ? `Worker crashed (${exitDescriptor}). Restarting worker...` + : `Worker exited (${exitDescriptor}). Restarting worker...`, + ); + spawnWorker(); + return; + } + + log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); + exitSupervisor(typeof code === "number" ? code : 1); return; - } - - const crashed = - restartOnCrash && - ((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM")); - - if (restarting || crashed) { - restarting = false; - log( - crashed - ? `Worker crashed (${exitDescriptor}). Restarting worker...` - : `Worker exited (${exitDescriptor}). Restarting worker...`, - ); - spawnWorker(); - return; - } - - log(`Worker exited (${exitDescriptor}). Supervisor exiting.`); - exitSupervisor(typeof code === "number" ? code : 1); + }); }); }; @@ -389,7 +475,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController writeLifecycleLog("Supervisor shutdown requested", { reason }); log(`${reason}. Stopping worker...`); if (!child) { - exitSupervisor(0); + void publication.then(() => { + exitSupervisor(lifecycleFailed ? 1 : 0); + return; + }); return; } requestWorkerShutdown(reason); @@ -402,6 +491,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController process.on("SIGINT", () => forwardSignal("SIGINT")); process.on("SIGTERM", () => forwardSignal("SIGTERM")); + process.stdout.on("error", ignoreClosedOutput); + process.stderr.on("error", ignoreClosedOutput); process.stdout.write(`[${options.name}] ${options.startupMessage}\n`); writeLifecycleLog(options.startupMessage); diff --git a/packages/server/scripts/test-mcp-inject.ts b/packages/server/scripts/test-mcp-inject.ts index be5d5f6db4..e9ec1deaa7 100644 --- a/packages/server/scripts/test-mcp-inject.ts +++ b/packages/server/scripts/test-mcp-inject.ts @@ -151,7 +151,7 @@ async function main(): Promise { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "mcp-inject-real-claude" }, + subscribe: {}, }); const results: ProviderRunResult[] = []; diff --git a/packages/server/scripts/voice-main-daemon-e2e.ts b/packages/server/scripts/voice-main-daemon-e2e.ts index 27ca9d247a..1e59d052e3 100644 --- a/packages/server/scripts/voice-main-daemon-e2e.ts +++ b/packages/server/scripts/voice-main-daemon-e2e.ts @@ -47,7 +47,7 @@ async function runVoiceRoundTrip(params: { const client = new DaemonClient({ url: `${params.daemonUrl}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` }, + subscribe: {}, }); const mode = await client.setVoiceMode(true, params.voiceAgentId); @@ -77,7 +77,7 @@ async function runVoiceRoundTrip(params: { }); }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(activeVoiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.agentId !== activeVoiceAgentId) return; if (msg.payload.event.type !== "timeline") return; @@ -88,6 +88,8 @@ async function runVoiceRoundTrip(params: { } }); + await offStream.ready; + const chunkBytes = 4800; for (let offset = 0; offset < pcm.length; offset += chunkBytes) { const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes)); diff --git a/packages/server/scripts/voice-roundtrip-debug.ts b/packages/server/scripts/voice-roundtrip-debug.ts index 108b3cb035..17ca7b6b19 100644 --- a/packages/server/scripts/voice-roundtrip-debug.ts +++ b/packages/server/scripts/voice-roundtrip-debug.ts @@ -49,7 +49,7 @@ async function main(): Promise { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } }); + await client.fetchAgents({ subscribe: {} }); const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-")); const voiceAgent = await client.createAgent({ @@ -88,7 +88,7 @@ async function main(): Promise { } }); - const offStream = client.on("agent_stream", (msg) => { + const offStream = client.subscribeAgentTimeline(voiceAgentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; @@ -100,6 +100,8 @@ async function main(): Promise { }); }); + await offStream.ready; + let audioChunkCount = 0; const firstAudio = new Promise((resolve) => { const offAudio = client.on("audio_output", (msg) => { diff --git a/packages/server/src/executable-resolution/executable-resolution.test.ts b/packages/server/src/executable-resolution/executable-resolution.test.ts index c202b4d15d..2ee0bd90df 100644 --- a/packages/server/src/executable-resolution/executable-resolution.test.ts +++ b/packages/server/src/executable-resolution/executable-resolution.test.ts @@ -3,12 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { - executableExists, - findExecutable, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./executable-resolution.js"; +import { executableExists, findExecutable } from "./executable-resolution.js"; import { windowsExecutableResolution } from "./windows.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -58,6 +53,15 @@ afterEach(() => { }); describe("findExecutable", () => { + test.runIf(isPlatform("linux"))( + "propagates a failed lookup process instead of reporting a missing executable", + async () => { + await expect(findExecutable("x".repeat(1024 * 1024))).rejects.toMatchObject({ + code: "E2BIG", + }); + }, + ); + describe.skipIf(isPlatform("win32"))("POSIX", () => { test("finds an extensionless executable and skips an earlier non-executable candidate", async () => { const executableDir = makeTempDir(); @@ -208,118 +212,3 @@ describe("executableExists", () => { expect(executableExists("/missing/codex", () => false)).toBeNull(); }); }); - -describe("quoteWindowsCommand", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows path with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("does not double-quote an already-quoted path", () => { - setPlatform("win32"); - expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe( - '"C:\\Program Files\\Anthropic\\claude.exe"', - ); - }); - - test("returns the command unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex"); - }); - - test("escapes ampersands", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix"); - }); - - test("escapes pipes", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix"); - }); - - test("does not double percent signs", () => { - setPlatform("win32"); - // cmd.exe only collapses %% → % inside batch files; on the command line - // it stays literal, which corrupts git --format atoms etc. - expect(quoteWindowsCommand("100%")).toBe("100%"); - }); - - test("preserves git --format atoms verbatim", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("--format=%(refname)%09%(committerdate:unix)")).toBe( - "--format=%^(refname^)%09%^(committerdate:unix^)", - ); - }); - - test("escapes carets", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix"); - }); - - test("escapes multiple metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("build&(test|deploy)!")).toBe( - "build^&^(test^|deploy^)^!^", - ); - }); - - test("quotes commands with spaces after escaping metacharacters", () => { - setPlatform("win32"); - expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe( - '"C:\\Program Files\\My Tool^&Stuff\\run 100%.cmd"', - ); - }); - - test("returns the command unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); - -describe("quoteWindowsArgument", () => { - const originalPlatform = process.platform; - - function setPlatform(value: string) { - Object.defineProperty(process, "platform", { value, writable: true }); - } - - afterEach(() => { - setPlatform(originalPlatform); - }); - - test("quotes a Windows argument with spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("does not double-quote an already-quoted argument", () => { - setPlatform("win32"); - expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe( - '"C:\\Program Files\\Anthropic\\cli.js"', - ); - }); - - test("returns the argument unchanged when there are no spaces", () => { - setPlatform("win32"); - expect(quoteWindowsArgument("--version")).toBe("--version"); - }); - - test("returns the argument unchanged on non-Windows platforms", () => { - setPlatform("darwin"); - expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code"); - }); -}); diff --git a/packages/server/src/executable-resolution/executable-resolution.ts b/packages/server/src/executable-resolution/executable-resolution.ts index a6edcc6e7b..59c218511b 100644 --- a/packages/server/src/executable-resolution/executable-resolution.ts +++ b/packages/server/src/executable-resolution/executable-resolution.ts @@ -1,12 +1,13 @@ import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +import type { ProcessEnvRecord } from "../server/paseo-env.js"; import { execCommand } from "../utils/spawn.js"; -import { isWindowsCommandScript } from "../utils/windows-command.js"; import { windowsExecutableResolution } from "./windows.js"; -export { quoteWindowsArgument, quoteWindowsCommand } from "../utils/windows-command.js"; - -type Which = (command: string, options: { all: true }) => Promise; +type Which = ( + command: string, + options: { all: true; path?: string; pathExt?: string }, +) => Promise; const require = createRequire(import.meta.url); const which = require("which") as Which; @@ -16,29 +17,38 @@ function hasPathSeparator(value: string): boolean { return value.includes("/") || value.includes("\\"); } -async function enumerateCandidates(name: string): Promise { +async function enumerateCandidates(name: string, env?: ProcessEnvRecord): Promise { if (process.platform !== "win32" && existsSync("/usr/bin/which")) { - return enumerateCandidatesViaSystemWhich(name); + return enumerateCandidatesViaSystemWhich(name, env); } - return enumerateCandidatesViaLibrary(name); + return enumerateCandidatesViaLibrary(name, env); } -async function enumerateCandidatesViaSystemWhich(name: string): Promise { +async function enumerateCandidatesViaSystemWhich( + name: string, + env?: ProcessEnvRecord, +): Promise { try { const { stdout } = await execCommand("/usr/bin/which", ["-a", name], { + baseEnv: env, timeout: 3000, killSignal: "SIGKILL", }); return Array.from(new Set(stdout.trim().split("\n").filter(Boolean))); - } catch { - return []; + } catch (error) { + // which exits 1 for a missing command. A failed lookup is not evidence of absence. + if (error instanceof Error && "code" in error && error.code === 1) return []; + throw error; } } -async function enumerateCandidatesViaLibrary(name: string): Promise { +async function enumerateCandidatesViaLibrary( + name: string, + env?: ProcessEnvRecord, +): Promise { let candidates: string[]; try { - candidates = await which(name, { all: true }); + candidates = await which(name, { all: true, path: env?.PATH, pathExt: env?.PATHEXT }); } catch (error) { // `which` throws ENOENT when the command is absent from PATH. if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -59,14 +69,16 @@ async function enumerateCandidatesViaLibrary(name: string): Promise { export async function probeExecutable( executablePath: string, - timeoutMs = PROBE_TIMEOUT_MS, + options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS, ): Promise { + const { probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS, env } = + typeof options === "number" ? { probeTimeoutMs: options } : options; try { await execCommand(executablePath, ["--version"], { + baseEnv: env, timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, - shell: isWindowsCommandScript(executablePath), }); return true; } catch (error) { @@ -108,10 +120,17 @@ export function executableExists( return exists(executablePath) ? executablePath : null; } +export interface ExecutableResolutionOptions { + probeTimeoutMs?: number; + env?: ProcessEnvRecord; +} + export async function findExecutable( name: string, - probeTimeoutMs = PROBE_TIMEOUT_MS, + options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS, ): Promise { + const { probeTimeoutMs = PROBE_TIMEOUT_MS, env } = + typeof options === "number" ? { probeTimeoutMs: options } : options; const trimmed = name.trim(); if (!trimmed) { return null; @@ -119,20 +138,21 @@ export async function findExecutable( if (process.platform === "win32") { return windowsExecutableResolution.find(trimmed, { - enumeratePathCandidates: enumerateCandidates, - probeExecutable, + enumeratePathCandidates: (command) => enumerateCandidates(command, env), + probeExecutable: (command, timeout) => + probeExecutable(command, { probeTimeoutMs: timeout, env }), exists: existsSync, probeTimeoutMs, }); } if (hasPathSeparator(trimmed)) { - return (await probeExecutable(trimmed, probeTimeoutMs)) ? trimmed : null; + return (await probeExecutable(trimmed, { probeTimeoutMs, env })) ? trimmed : null; } - const candidates = await enumerateCandidates(trimmed); + const candidates = await enumerateCandidates(trimmed, env); for (const candidate of candidates) { - if (await probeExecutable(candidate, probeTimeoutMs)) { + if (await probeExecutable(candidate, { probeTimeoutMs, env })) { return candidate; } } diff --git a/packages/server/src/server/agent-history-search.test.ts b/packages/server/src/server/agent-history-search.test.ts index 036efa7abb..35108fff58 100644 --- a/packages/server/src/server/agent-history-search.test.ts +++ b/packages/server/src/server/agent-history-search.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { type AgentHistorySearchCandidate, - describeAgentHistoryMatches, - rankAgentHistoryCandidates, - scoreAgentHistoryCandidate, + matchesAgentHistoryQuery, } from "./agent-history-search.js"; function candidate(input: { @@ -39,157 +37,63 @@ function candidate(input: { } as unknown as AgentHistorySearchCandidate & { agent: { id: string; updatedAt: string } }; } -const byUpdatedAtDesc = ( - left: { agent: { updatedAt: string } }, - right: { agent: { updatedAt: string } }, -) => right.agent.updatedAt.localeCompare(left.agent.updatedAt); +describe("matchesAgentHistoryQuery", () => { + it("rejects scattered letters across words while keeping within-word subsequences", () => { + expect( + matchesAgentHistoryQuery( + "terminal", + candidate({ + title: "Let me diagnose this problem in a diagnose this problem and", + }), + ), + ).toBe(false); + expect(matchesAgentHistoryQuery("trmnl", candidate({ title: "Fix terminal resizing" }))).toBe( + true, + ); + }); -describe("scoreAgentHistoryCandidate", () => { it("matches the workspace name", () => { expect( - scoreAgentHistoryCandidate("stripe", candidate({ workspaceName: "Add Stripe billing" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("stripe", candidate({ workspaceName: "Add Stripe billing" })), + ).toBe(true); }); it("matches the agent title", () => { expect( - scoreAgentHistoryCandidate("entitlements", candidate({ title: "Reshape entitlements" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("entitlements", candidate({ title: "Reshape entitlements" })), + ).toBe(true); }); it("matches the branch name", () => { - expect( - scoreAgentHistoryCandidate("billing", candidate({ branch: "add-stripe-billing" })), - ).not.toBeNull(); + expect(matchesAgentHistoryQuery("billing", candidate({ branch: "add-stripe-billing" }))).toBe( + true, + ); }); it("matches the project name", () => { - expect( - scoreAgentHistoryCandidate("paseo", candidate({ projectName: "getpaseo/paseo" })), - ).not.toBeNull(); + expect(matchesAgentHistoryQuery("paseo", candidate({ projectName: "getpaseo/paseo" }))).toBe( + true, + ); }); it("requires every token to match somewhere", () => { const entry = candidate({ workspaceName: "Add Stripe billing", branch: "main" }); - expect(scoreAgentHistoryCandidate("stripe main", entry)).not.toBeNull(); - expect(scoreAgentHistoryCandidate("stripe rosetta", entry)).toBeNull(); + expect(matchesAgentHistoryQuery("stripe main", entry)).toBe(true); + expect(matchesAgentHistoryQuery("stripe rosetta", entry)).toBe(false); }); it("tolerates a typo", () => { expect( - scoreAgentHistoryCandidate("bulling", candidate({ workspaceName: "Add Stripe billing" })), - ).not.toBeNull(); + matchesAgentHistoryQuery("bulling", candidate({ workspaceName: "Add Stripe billing" })), + ).toBe(true); }); it("tolerates a transposed branch name", () => { - expect(scoreAgentHistoryCandidate("mian", candidate({ branch: "main" }))).not.toBeNull(); - expect(scoreAgentHistoryCandidate("rain", candidate({ branch: "main" }))).toBeNull(); + expect(matchesAgentHistoryQuery("mian", candidate({ branch: "main" }))).toBe(true); + expect(matchesAgentHistoryQuery("rain", candidate({ branch: "main" }))).toBe(false); }); - it("returns null for a blank query so an empty box never filters", () => { - expect(scoreAgentHistoryCandidate(" ", candidate({ title: "anything" }))).toBeNull(); - }); -}); - -describe("rankAgentHistoryCandidates", () => { - it("drops candidates that do not match", () => { - const ranked = rankAgentHistoryCandidates( - "stripe", - [candidate({ title: "Add Stripe billing" }), candidate({ title: "Fix terminal resize" })], - byUpdatedAtDesc, - ); - expect(ranked.map((result) => result.candidate.agent.title)).toEqual(["Add Stripe billing"]); - }); - - it("ranks a whole-word hit above a hit buried inside a word", () => { - const ranked = rankAgentHistoryCandidates( - "bill", - [candidate({ title: "unbilled usage report" }), candidate({ title: "bill the customer" })], - byUpdatedAtDesc, - ); - expect(ranked.map((result) => result.candidate.agent.title)).toEqual([ - "bill the customer", - "unbilled usage report", - ]); - }); - - it("ranks the workspace name above the project name that every session shares", () => { - const ranked = rankAgentHistoryCandidates( - "paseo", - [ - candidate({ title: "unrelated work", projectName: "getpaseo/paseo" }), - candidate({ workspaceName: "paseo", projectName: "getpaseo/paseo" }), - ], - byUpdatedAtDesc, - ); - expect(ranked[0].candidate.project.workspaceName).toBe("paseo"); - }); - - it("ranks every exact hit above a typo hit", () => { - const ranked = rankAgentHistoryCandidates( - "billing", - [ - candidate({ title: "bulling the customer" }), - candidate({ title: "prorated billing edge cases" }), - ], - byUpdatedAtDesc, - ); - expect(ranked.map((result) => result.candidate.agent.title)).toEqual([ - "prorated billing edge cases", - "bulling the customer", - ]); - }); - - it("breaks ties with the caller's ordering", () => { - const ranked = rankAgentHistoryCandidates( - "stripe", - [ - candidate({ title: "stripe one", updatedAt: "2026-08-01T00:00:00.000Z" }), - candidate({ title: "stripe two", updatedAt: "2026-08-06T00:00:00.000Z" }), - ], - byUpdatedAtDesc, - ); - expect(ranked.map((result) => result.candidate.agent.title)).toEqual([ - "stripe two", - "stripe one", - ]); - }); -}); - -describe("describeAgentHistoryMatches", () => { - it("says which field matched and where", () => { - expect( - describeAgentHistoryMatches("stripe", candidate({ workspaceName: "Add Stripe billing" })), - ).toEqual([{ field: "workspace", ranges: [{ start: 4, length: 6 }] }]); - }); - - it("describes each field a multi-token query landed on", () => { - expect( - describeAgentHistoryMatches( - "stripe main", - candidate({ workspaceName: "Add Stripe billing", branch: "main" }), - ), - ).toEqual([ - { field: "workspace", ranges: [{ start: 4, length: 6 }] }, - { field: "branch", ranges: [{ start: 0, length: 4 }] }, - ]); - }); - - it("merges overlapping spans two tokens found in one field", () => { - expect( - describeAgentHistoryMatches("bill billing", candidate({ title: "prorated billing" })), - ).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]); - }); - - it("marks the whole word behind a typo", () => { - expect( - describeAgentHistoryMatches("bulling", candidate({ title: "customer billing" })), - ).toEqual([{ field: "title", ranges: [{ start: 9, length: 7 }] }]); - }); - - it("returns nothing when the candidate does not match", () => { - expect( - describeAgentHistoryMatches("rosetta", candidate({ title: "customer billing" })), - ).toEqual([]); + it("keeps candidates for a blank query", () => { + expect(matchesAgentHistoryQuery(" ", candidate({ title: "anything" }))).toBe(true); }); }); diff --git a/packages/server/src/server/agent-history-search.ts b/packages/server/src/server/agent-history-search.ts index 9960fb3d1e..ff49284e1a 100644 --- a/packages/server/src/server/agent-history-search.ts +++ b/packages/server/src/server/agent-history-search.ts @@ -1,188 +1,26 @@ -import type { - AgentSearchMatch, - AgentSnapshotPayload, - ProjectPlacementPayload, -} from "@getpaseo/protocol/messages"; -import { - compareMatchScores, - fuzzyPolicyForToken, - type MatchRange, - type MatchScore, - matchRanges, - scoreMatch, - tokenizeQuery, -} from "@getpaseo/protocol/search/text-match"; - -/** - * History search ranks what the daemon already knows about a session: the four - * names attached to it. Transcripts are deliberately out — they are unbounded, - * and a partial-transcript index would answer "not found" for sessions that do - * contain the phrase, which is worse than never claiming to search them. - * - * Field order is the tie-break order. Workspace and agent titles are what - * people actually recall; a project name matches every session in the repo, so - * it ranks last and only decides ties. - */ +import type { AgentSnapshotPayload, ProjectPlacementPayload } from "@getpaseo/protocol/messages"; +import { scoreTextFields } from "@getpaseo/protocol/search/text-match"; export interface AgentHistorySearchCandidate { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; } -/** In tie-break order; the index into this list is a field's rank. */ -const SEARCH_FIELDS = ["workspace", "title", "branch", "project"] as const; - -type SearchField = (typeof SEARCH_FIELDS)[number]; - -function searchableFields(candidate: AgentHistorySearchCandidate): string[] { - const { agent, project } = candidate; - return [ - project.workspaceName ?? "", - agent.title ?? "", - project.checkout.currentBranch ?? "", - project.projectName, - ]; -} - -interface FieldMatch { - score: MatchScore; - fieldRank: number; - token: string; -} - -function bestFieldMatch(token: string, fields: string[]): FieldMatch | null { - const fuzzy = fuzzyPolicyForToken(token); - let best: FieldMatch | null = null; - for (let fieldRank = 0; fieldRank < fields.length; fieldRank += 1) { - const field = fields[fieldRank]; - if (!field) continue; - const score = scoreMatch(token, field, { fuzzy }); - if (!score) continue; - if (!best || compareMatchScores(score, best.score) < 0) { - best = { score, fieldRank, token }; - } - } - return best; -} - -/** Overlapping spans from different tokens would double-mark the same glyphs. */ -function mergeRanges(ranges: MatchRange[]): MatchRange[] { - const sorted = [...ranges].sort((left, right) => left.start - right.start); - const merged: MatchRange[] = []; - for (const range of sorted) { - const last = merged.at(-1); - if (last && range.start <= last.start + last.length) { - last.length = Math.max(last.length, range.start + range.length - last.start); - continue; - } - merged.push({ ...range }); - } - return merged; -} - -/** - * One number so the wire can carry relevance and several hosts' pages can be - * merged by it. Tier dominates, then which field matched, then how deep into - * that field the match sat. - */ -function toSearchScore(matches: readonly FieldMatch[]): number { - let total = 0; - for (const match of matches) { - total += match.score.tier * 1_000 + match.fieldRank * 100 + Math.min(match.score.offset, 99); - } - return total; -} - -/** - * Every token has to match something, so adding a word always narrows. Tokens - * may land on different fields — "stripe main" finds the Stripe workspace on - * the main branch. - */ -export function scoreAgentHistoryCandidate( +/** Search the names people recall, never an incomplete subset of transcripts. */ +export function matchesAgentHistoryQuery( query: string, - candidate: AgentHistorySearchCandidate, -): number | null { - const matches = matchAgentHistoryCandidate(query, candidate); - return matches ? toSearchScore(matches) : null; -} - -function matchAgentHistoryCandidate( - query: string, - candidate: AgentHistorySearchCandidate, -): FieldMatch[] | null { - const tokens = tokenizeQuery(query); - if (tokens.length === 0) return null; - - const fields = searchableFields(candidate); - const matches: FieldMatch[] = []; - for (const token of tokens) { - const match = bestFieldMatch(token, fields); - if (!match) return null; - matches.push(match); - } - return matches; -} - -/** - * Where the query landed in each field, for the rows that will actually be - * rendered. The ranker already knows this; sending it means the client never - * has to re-derive a second opinion that could disagree with the ranking it is - * explaining. - */ -export function describeAgentHistoryMatches( - query: string, - candidate: AgentHistorySearchCandidate, -): AgentSearchMatch[] { - const matches = matchAgentHistoryCandidate(query, candidate); - if (!matches) return []; - - const fields = searchableFields(candidate); - const rangesByField = new Map(); - for (const match of matches) { - const field = SEARCH_FIELDS[match.fieldRank]; - const ranges = matchRanges(match.token, fields[match.fieldRank], match.score); - rangesByField.set(field, [...(rangesByField.get(field) ?? []), ...ranges]); - } - - return [...rangesByField.entries()].map(([field, ranges]) => ({ - field, - ranges: mergeRanges(ranges), - })); -} - -export interface RankedAgentHistoryCandidate { - candidate: T; - searchScore: number; -} - -/** - * Ranks the whole candidate set rather than a page of it. The daemon holds - * every persisted agent in memory already, so search is complete by - * construction and the client never has to warn that it only looked at what it - * had loaded. - * - * A searched response is one page: the best `limit` matches, and a flag saying - * more matched. There is no search cursor. Offsets into a ranking that is - * recomputed per request duplicate and skip rows as history mutates underneath - * them, and a keyset cursor would buy the ability to walk past result 200 of a - * relevance order — which is browsing, not searching. Narrowing the query is - * the answer, and the global top K is exactly recoverable from each host's own - * top K, which is what lets several hosts merge without a federated pager. - */ -export function rankAgentHistoryCandidates( - query: string, - candidates: readonly T[], - compareTies: (left: T, right: T) => number, -): RankedAgentHistoryCandidate[] { - const ranked: RankedAgentHistoryCandidate[] = []; - for (const candidate of candidates) { - const searchScore = scoreAgentHistoryCandidate(query, candidate); - if (searchScore === null) continue; - ranked.push({ candidate, searchScore }); - } - ranked.sort((left, right) => { - if (left.searchScore !== right.searchScore) return left.searchScore - right.searchScore; - return compareTies(left.candidate, right.candidate); - }); - return ranked; + { agent, project }: AgentHistorySearchCandidate, +): boolean { + return ( + scoreTextFields( + query, + [ + project.workspaceName ?? "", + agent.title ?? "", + project.checkout.currentBranch ?? "", + project.projectName, + ], + { typoTolerant: true }, + ) !== null + ); } diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 7256a0669d..58a8a8b4f1 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -39,6 +39,45 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow { } describe("curateAgentActivity", () => { + it("keeps reasoning before the assistant text that followed it", () => { + const timeline: AgentTimelineItem[] = [ + { type: "reasoning", text: "Check the commands first." }, + { type: "assistant_message", text: "I will list the commands." }, + toolCallItem({ + callId: "shell-order", + name: "Bash", + detail: { + type: "shell", + command: "ls packages/cli/src/commands/agent/", + output: "", + exitCode: 0, + }, + }), + ]; + + expect(curateAgentActivity(timeline)).toBe( + "[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/", + ); + }); + + it("preserves alternating text kinds, labels, and fragment concatenation", () => { + const timeline: AgentTimelineItem[] = [ + { type: "assistant_message", text: "First message." }, + { type: "reasoning", text: "First " }, + { type: "reasoning", text: "thought." }, + { type: "assistant_message", text: "Second " }, + { type: "assistant_message", text: "message." }, + { type: "reasoning", text: "Second thought." }, + ]; + + expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe( + "[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.", + ); + expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe( + "Second message.\n[Thought] Second thought.", + ); + }); + it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ { type: "user_message", text: "Hello" }, @@ -354,46 +393,44 @@ second line'`, expect(result.attachment.text).toContain("[Assistant] Done."); }); - it("selects the fork boundary before collapsing later tool updates", () => { - const result = buildAgentForkContextAttachment({ - boundaryMessageId: "assistant-1", - rows: [ - row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), - row( - 2, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "running", - detail: { - type: "plain_text", - label: "before boundary", - }, + it("rejects a checkpoint whose projected tool state changed later", () => { + expect(() => + buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "Run it", messageId: "user-1" }), + row( + 2, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "running", + detail: { + type: "plain_text", + label: "before boundary", + }, + }), + ), + row(3, { + type: "assistant_message", + text: "Partial result.", + messageId: "assistant-1", }), - ), - row(3, { - type: "assistant_message", - text: "Partial result.", - messageId: "assistant-1", - }), - row( - 4, - toolCallItem({ - callId: "terminal-1", - name: "terminal", - status: "completed", - detail: { - type: "plain_text", - label: "after boundary", - }, - }), - ), - ], - }); - - expect(result.attachment.text).toContain("[Terminal] before boundary"); - expect(result.attachment.text).toContain("[Assistant] Partial result."); - expect(result.attachment.text).not.toContain("after boundary"); + row( + 4, + toolCallItem({ + callId: "terminal-1", + name: "terminal", + status: "completed", + detail: { + type: "plain_text", + label: "after boundary", + }, + }), + ), + ], + }), + ).toThrow("Fork from a later completed response"); }); it("selects a synthetic assistant error by its timeline cursor", () => { @@ -440,3 +477,49 @@ second line'`, ).toThrow("Selected assistant message is no longer available."); }); }); + +it("bounds fork history to 100,000 characters while preserving the newest messages", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { type: "user_message", text: "old-message " + "x".repeat(1_100_000) }), + row(2, { type: "assistant_message", messageId: "checkpoint", text: "Latest answer." }), + row(3, { type: "user_message", text: "Beyond the selected checkpoint." }), + ], + boundaryMessageId: "checkpoint", + }); + expect(result.attachment.text.length).toBeLessThanOrEqual(100_000); + expect(result.attachment.text).toContain("[Earlier chat history omitted]"); + expect(result.attachment.text).toContain("[Assistant] Latest answer."); + expect(result.attachment.text).not.toContain("old-message"); + expect(result.attachment.text).not.toContain("Beyond the selected checkpoint."); + expect(result.attachment.text).toMatch(/^\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("bounds one oversized fork message and metadata without losing its beginning and end", () => { + const result = buildAgentForkContextAttachment({ + rows: [ + row(1, { + type: "assistant_message", + text: "Beginning. " + "x".repeat(1_100_000) + " The end.", + }), + ], + agentTitle: "T".repeat(200_000), + cwd: "/" + "p".repeat(200_000), + }); + expect(result.attachment.text.length).toBe(100_000); + expect(result.attachment.text).toContain("[Assistant] Beginning."); + expect(result.attachment.text).toContain("[Message truncated]"); + expect(result.attachment.text).toContain("The end."); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); +}); + +it("preserves every message in fork history below the character budget", () => { + const text = "Complete conversation " + "x".repeat(90_000); + const result = buildAgentForkContextAttachment({ + rows: [row(1, { type: "user_message", text })], + }); + expect(result.attachment.text).toBe( + `\nChat history from a previous Paseo agent.\n\n[User] ${text}\n`, + ); +}); diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index da3318c1a3..efd0a8e48d 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -8,6 +8,9 @@ import { projectTimelineRows } from "./timeline-projection.js"; const DEFAULT_MAX_ITEMS = 0; const MAX_TOOL_INPUT_CHARS = 400; const MAX_TOOL_SUMMARY_CHARS = 200; +const MAX_FORK_CONTEXT_CHARS = 100_000; +const MAX_FORK_METADATA_CHARS = 1_000; +const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; interface ActivityCuratorOptions { maxItems?: number; @@ -22,36 +25,20 @@ interface ActivityEntry { type TextAgentAttachment = Extract; -function appendText(buffer: string, text: string): string { - const normalized = text.trim(); - if (!normalized) { - return buffer; - } - if (!buffer) { - return normalized; - } - return `${buffer}\n${normalized}`; -} - function activityEntry(text: string): ActivityEntry { return { text }; } -function flushBuffers( +function appendTextEntry( entries: ActivityEntry[], - buffers: { message: string; thought: string }, + item: Extract, options?: ActivityCuratorOptions, ) { - if (buffers.message.trim()) { - const text = buffers.message.trim(); - entries.push(activityEntry(options?.labelAssistantMessages ? `[Assistant] ${text}` : text)); - } - if (buffers.thought.trim()) { - const text = buffers.thought.trim(); - entries.push(activityEntry(`[Thought] ${text}`)); - } - buffers.message = ""; - buffers.thought = ""; + const text = item.text.trim(); + if (!text) return; + let prefix = options?.labelAssistantMessages ? "[Assistant] " : ""; + if (item.type === "reasoning") prefix = "[Thought] "; + entries.push(activityEntry(`${prefix}${text}`)); } function formatToolInputJson(input: unknown): string | null { @@ -144,7 +131,6 @@ function curateProjectedActivityEntries( const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items; const entries: ActivityEntry[] = []; - const buffers = { message: "", thought: "" }; for (const item of recentItems) { if (!shouldIncludeItem(item, options)) { @@ -153,17 +139,13 @@ function curateProjectedActivityEntries( switch (item.type) { case "user_message": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[User] ${item.text.trim()}`)); break; case "assistant_message": - buffers.message = appendText(buffers.message, item.text); - break; case "reasoning": - buffers.thought = appendText(buffers.thought, item.text); + appendTextEntry(entries, item, options); break; case "tool_call": { - flushBuffers(entries, buffers, options); entries.push(formatToolCallEntry(item, options)); if (item.detail.type === "sub_agent" && item.detail.log.trim()) { entries.push(activityEntry(item.detail.log.trim())); @@ -171,7 +153,6 @@ function curateProjectedActivityEntries( break; } case "todo": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Tasks]")); for (const entry of item.items) { const checkbox = entry.completed ? "[x]" : "[ ]"; @@ -180,18 +161,14 @@ function curateProjectedActivityEntries( } break; case "error": - flushBuffers(entries, buffers, options); entries.push(activityEntry(`[Error] ${item.message}`)); break; case "compaction": - flushBuffers(entries, buffers, options); entries.push(activityEntry("[Compacted]")); break; } } - flushBuffers(entries, buffers, options); - return entries; } @@ -230,12 +207,12 @@ function selectForkContextRows(input: { boundaryCursor: { epoch: string; seq: number } | null; boundaryMessageId: string | null; } { + const projectedRows = projectTimelineRows({ rows: input.rows, mode: "projected" }); const boundaryCursor = input.cursorBoundary?.cursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; if (!boundaryCursor && !boundaryMessageId) { - const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { - items: projected.map((entry) => entry.item), + items: projectedRows.map((entry) => entry.item), boundaryCursor: null, boundaryMessageId: null, }; @@ -248,8 +225,8 @@ function selectForkContextRows(input: { throw new Error("Selected timeline position is no longer available."); } const boundaryIndex = boundaryCursor - ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) - : input.rows.findLastIndex( + ? projectedRows.findIndex((row) => row.seqEnd === boundaryCursor.seq) + : projectedRows.findLastIndex( (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, ); if (boundaryIndex < 0) { @@ -259,8 +236,13 @@ function selectForkContextRows(input: { : "Selected assistant message is no longer available.", ); } - const selectedRows = input.rows.slice(0, boundaryIndex + 1); - const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" }); + const boundarySeq = projectedRows[boundaryIndex].seqEnd; + if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + throw new Error( + "This checkpoint changed after it was created. Fork from a later completed response instead.", + ); + } + const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq); return { items: projected.map((entry) => entry.item), @@ -271,7 +253,7 @@ function selectForkContextRows(input: { function trimContextMetadata(value: string | null | undefined): string | null { const trimmed = value?.trim(); - return trimmed ? trimmed : null; + return trimmed ? trimmed.slice(0, MAX_FORK_METADATA_CHARS) : null; } function buildForkContextText(input: { @@ -291,6 +273,30 @@ function buildForkContextText(input: { return `\n${header.join("\n")}\n\n${input.body}\n`; } +function fitForkContextEntries(entries: ActivityEntry[], budget: number): string { + const length = entries.reduce((total, entry) => total + entry.text.length + 1, -1); + if (length <= budget) return entries.map((entry) => entry.text).join("\n"); + + const selected: string[] = []; + let remaining = budget - FORK_HISTORY_OMITTED.length; + for (let index = entries.length - 1; index >= 0; index--) { + const text = entries[index]!.text; + const size = text.length + (selected.length > 0 ? 1 : 0); + if (size > remaining) { + if (selected.length === 0) { + const marker = "\n[Message truncated]\n"; + const available = remaining - marker.length; + const head = Math.ceil(available / 2); + selected.push(text.slice(0, head) + marker + text.slice(-(available - head))); + } + break; + } + selected.push(text); + remaining -= size; + } + return FORK_HISTORY_OMITTED + selected.toReversed().join("\n"); +} + export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; cursorBoundary?: ForkCursorBoundary | null; @@ -314,10 +320,10 @@ export function buildAgentForkContextAttachment(input: { includeKinds: ["user_message", "assistant_message", "tool_call"], includeExternalToolInput: false, }); + const metadata = { agentTitle: input.agentTitle, cwd: input.cwd }; + const budget = MAX_FORK_CONTEXT_CHARS - buildForkContextText({ body: "", ...metadata }).length; const body = - entries.length > 0 - ? entries.map((entry) => entry.text).join("\n") - : "No chat history to display."; + entries.length > 0 ? fitForkContextEntries(entries, budget) : "No chat history to display."; return { attachment: { type: "text", diff --git a/packages/server/src/server/agent/agent-configuration-validator.ts b/packages/server/src/server/agent/agent-configuration-validator.ts index 1135d25e0b..0893f3a692 100644 --- a/packages/server/src/server/agent/agent-configuration-validator.ts +++ b/packages/server/src/server/agent/agent-configuration-validator.ts @@ -3,7 +3,6 @@ import type { HubExecutionAgentValidationIssue } from "@getpaseo/protocol/messag import type { ProviderSnapshotEntry } from "./agent-sdk-types.js"; import { filterSelectableAgentModels } from "./agent-sdk-types.js"; -import { ProviderOptionsValidationError } from "./provider-options.js"; export interface AgentConfigurationValidationInput { provider: string; @@ -16,13 +15,11 @@ export interface AgentConfigurationValidationInput { interface AgentConfigurationValidationContext { input: AgentConfigurationValidationInput; provider: ProviderSnapshotEntry; - validateOptions(options: ProviderOptions | undefined): ProviderOptions | undefined; } export function validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions, }: AgentConfigurationValidationContext): HubExecutionAgentValidationIssue[] { const issues: HubExecutionAgentValidationIssue[] = []; const models = filterSelectableAgentModels(provider.models); @@ -53,17 +50,5 @@ export function validateAgentConfigurationAgainstProvider({ }); } - try { - validateOptions(input.providerOptions); - } catch (error) { - if (!(error instanceof ProviderOptionsValidationError)) throw error; - issues.push( - ...error.issues.map((issue) => ({ - path: ["providerOptions", ...issue.path], - message: issue.message, - })), - ); - } - return issues; } diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index e4943a079d..d75fa8739a 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -6,6 +6,7 @@ import { expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +import { startAgentRun } from "./agent-prompt.js"; import { AgentStorage } from "./agent-storage.js"; import type { AgentClient, @@ -69,7 +70,7 @@ test("loads archived records for history and active records with the interactive await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger }); await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger }); - expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]); + expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]); } finally { await Promise.all([ manager.closeAgent(archivedId).catch(() => undefined), @@ -80,3 +81,121 @@ test("loads archived records for history and active records with the interactive await rm(root, { recursive: true, force: true }); } }); + +test("resuming a stored agent keeps its unread flag and its last-activity time", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-loading-resume-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(root, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + + const agentId = "00000000-0000-4000-8000-000000000401"; + const lastActive = "2026-01-02T03:04:05.000Z"; + const markedUnread = "2026-01-09T03:04:05.000Z"; + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: root }, agentId, { + workspaceId: "workspace-a", + }); + await manager.closeAgent(agent.id); + await manager.flush(); + await storage.flush(); + + const stored = await storage.get(agentId); + if (!stored) { + throw new Error("expected a stored agent"); + } + // The agent finished days ago, and was marked unread later without being opened, which + // moves `updatedAt` on its own. Clients already hold that newer time, and + // `acceptAgentDirectoryUpdate` drops anything older, so the resumed agent must not come + // back carrying only `lastActivityAt`. + await storage.upsert({ + ...stored, + updatedAt: markedUnread, + lastActivityAt: lastActive, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: lastActive, + }); + + await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger }); + await manager.flush(); + await storage.flush(); + + // Loading the runtime is neither the agent working nor the user reading the chat. + // Forging either rewrites the workspace's "last used" and drops it out of Ready to review. + const resumed = await storage.get(agentId); + expect(resumed?.requiresAttention).toBe(true); + expect(resumed?.attentionReason).toBe("finished"); + expect(resumed?.updatedAt).toBe(markedUnread); + expect(resumed?.lastActivityAt).toBe(markedUnread); + expect(manager.getAgent(agentId)?.attention.requiresAttention).toBe(true); + expect(manager.getAgent(agentId)?.updatedAt.toISOString()).toBe(markedUnread); + } finally { + await manager.closeAgent(agentId).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +test("loads an archived agent's history after its working directory is removed", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-")); + const worktree = path.join(root, "managed-worktree"); + await mkdir(worktree, { recursive: true }); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(root, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + + const agentId = "00000000-0000-4000-8000-000000000501"; + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, { + workspaceId: "workspace-worktree", + }); + await startAgentRun(manager, agent.id, "what did you change", logger, {}); + // Dispatching a run does not finish it: the provider appends the reply to its + // history afterwards, and the turn is finalized only once that append lands. + // Archive after the turn is finalized so the transcript this test reads back is + // already on disk when the worktree goes away. + const finished = await manager.waitForAgentEvent(agent.id); + expect(finished.status).toBe("idle"); + await manager.archiveAgent(agent.id); + await manager.closeAgent(agent.id); + await manager.flush(); + await storage.flush(); + + // Archiving the workspace removes the worktree it owned. The agent's history is + // persisted and reading it must not depend on that directory still being there. + await rm(worktree, { recursive: true, force: true }); + + const loaded = await ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + + expect(loaded.id).toBe(agentId); + // The transcript is replayed from the provider's persisted history, so the reply the + // agent gave before the worktree went away is still readable. + const replies = manager + .getTimeline(agentId) + .filter((item) => item.type === "assistant_message"); + expect(replies.length).toBeGreaterThan(0); + expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe( + true, + ); + } finally { + await manager.closeAgent(agentId).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index f87f38911c..248e8d2b1b 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -6,6 +6,7 @@ import type { AgentStorage } from "./agent-storage.js"; import { buildConfigOverrides, buildSessionConfig, + extractAttention, extractTimestamps, isStoredAgentProviderAvailable, toAgentPersistenceHandle, @@ -109,11 +110,14 @@ export async function ensureAgentLoaded( handle, buildConfigOverrides(record), agentId, - extractTimestamps(record), + { ...extractTimestamps(record), attention: extractAttention(record) }, record.archivedAt ? { purpose: "history" } : undefined, ); deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); } else { + // No provider handle to resume: this starts the agent's first session rather than + // bringing one back, so it stamps activity and carries no stored attention. Records + // without a handle never got far enough to accumulate either. const config = buildSessionConfig(record, { validProviders, }); diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index d3fb7c4b58..9dc194fa11 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -366,7 +366,16 @@ function getProjectedTimelineItems(rows: AgentTimelineRow[]): AgentTimelineItem[ } function expectContiguousRowSeqs(rows: AgentTimelineRow[], expected: number[]): void { - expect(rows.map((row) => row.seq)).toEqual(expected); + const projected = projectTimelineRows({ rows, mode: "projected" }); + const covered = projected.flatMap((row) => + row.sourceSeqRanges.flatMap((range) => + Array.from( + { length: range.endSeq - range.startSeq + 1 }, + (_, index) => range.startSeq + index, + ), + ), + ); + expect([...new Set(covered)].sort((a, b) => a - b)).toEqual(expected); } function expectContiguousLiveSeqs(events: AgentManagerEvent[], expected: number[]): void { @@ -524,11 +533,10 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(1); expect(events).toHaveLength(2); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "x" }, - { type: "assistant_message", text: "x".repeat(999) }, + { type: "assistant_message", text: "x".repeat(1000) }, ]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { @@ -567,10 +575,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "reasoning", text: "r" }, - { type: "reasoning", text: "r".repeat(99) }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "reasoning", text: "r".repeat(100) }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -604,8 +609,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, ]); expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2, 3]); @@ -617,15 +621,14 @@ describe("target coalesced behavior", () => { // The "b" burst arrives inside the window that just flushed, so it gets no // leading flush of its own and coalesces whole. await vi.advanceTimersByTimeAsync(BEFORE_COALESCE_WINDOW_MS); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(3); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); await vi.advanceTimersByTimeAsync(1); const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a".repeat(499) }, + { type: "assistant_message", text: "a".repeat(500) }, runningToolCall, { type: "assistant_message", text: "b".repeat(500) }, ]); @@ -665,10 +668,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - toolCall({ output: "chunk-0" }), - toolCall({ output: "chunk-199" }), - ]); + expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -695,9 +695,8 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - toolCall({ callId: "tool-1", output: "one-a" }), - toolCall({ callId: "tool-2", output: "two-b" }), toolCall({ callId: "tool-1", output: "one-b" }), + toolCall({ callId: "tool-2", output: "two-b" }), ]); expect(events).toHaveLength(3); expectContiguousRowSeqs(rows, [1, 2, 3]); @@ -876,14 +875,8 @@ describe("target coalesced behavior", () => { const firstEvents = getTimelineStreamEvents(harness.events, first.agentId); const secondEvents = getTimelineStreamEvents(harness.events, second.agentId); - expect(getTimelineItems(firstRows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "a" }, - ]); - expect(getTimelineItems(secondRows)).toEqual([ - { type: "assistant_message", text: "b" }, - { type: "assistant_message", text: "b" }, - ]); + expect(getTimelineItems(firstRows)).toEqual([{ type: "assistant_message", text: "aa" }]); + expect(getTimelineItems(secondRows)).toEqual([{ type: "assistant_message", text: "bb" }]); expect(firstEvents).toHaveLength(2); expect(secondEvents).toHaveLength(2); expectContiguousRowSeqs(firstRows, [1, 2]); @@ -916,10 +909,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "codex" }, - { type: "assistant_message", text: "claude" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "codexclaude" }]); expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([ { type: "timeline", provider: "codex", item: { type: "assistant_message", text: "codex" } }, { @@ -991,10 +981,7 @@ describe("target coalesced behavior", () => { const events = getTimelineStreamEvents(harness.events, agentId); // The empty chunk is dropped before the coalescer, so " " leads the burst. - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: " " }, - { type: "assistant_message", text: "\n\tdone" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: " \n\tdone" }]); expect(events).toHaveLength(2); expectContiguousRowSeqs(rows, [1, 2]); expectContiguousLiveSeqs(events, [1, 2]); @@ -1189,9 +1176,7 @@ describe("target coalesced behavior", () => { const timelineEvents = getTimelineStreamEvents(idempotencyHarness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a" }, - { type: "assistant_message", text: "bc" }, - TOOL_CALL, + { type: "assistant_message", text: "abc" }, TOOL_CALL, ]); expect(timelineEvents).toHaveLength(4); @@ -1243,8 +1228,7 @@ describe("target coalesced behavior", () => { const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId); const timelineEvents = getTimelineStreamEvents(reentryHarness.events, agentId); expect(getTimelineItems(rowsAfterSecondFlush)).toEqual([ - { type: "assistant_message", text: "A" }, - { type: "assistant_message", text: "B" }, + { type: "assistant_message", text: "AB" }, ]); expect(timelineEvents).toHaveLength(2); expectContiguousRowSeqs(rowsAfterSecondFlush, [1, 2]); @@ -1274,20 +1258,19 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const events = getTimelineStreamEvents(harness.events, agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "flush" }, - { type: "assistant_message", text: "-pending" }, + { type: "assistant_message", text: "flush-pending" }, ]); expect(events).toHaveLength(2); await vi.advanceTimersByTimeAsync(100); - expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2); + expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(1); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2); } finally { harness.cleanup(); } }); - test("history hydration is uncoalesced and immediate", async () => { + test("history hydration projects immediately without waiting for a streaming window", async () => { vi.useFakeTimers(); const harness = createHarness(); try { @@ -1298,20 +1281,16 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); expectContiguousRowSeqs(rows, [1, 2, 3, 4]); expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0); await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, - { type: "reasoning", text: "r1" }, - { type: "reasoning", text: "r2" }, + { type: "assistant_message", text: "a1a2" }, + { type: "reasoning", text: "r1r2" }, ]); } finally { harness.cleanup(); @@ -1362,10 +1341,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); const streamEvents = getStreamEvents(harness.events, agentId); - expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "hello " }, - { type: "assistant_message", text: "world" }, - ]); + expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]); expect(getProjectedTimelineItems(rows)).toEqual([ { type: "assistant_message", text: "hello world" }, ]); @@ -1417,8 +1393,7 @@ describe("target coalesced behavior", () => { const rows = await harness.manager.getTimelineRows(agentId); expect(getTimelineItems(rows)).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); @@ -1434,8 +1409,7 @@ describe("target coalesced behavior", () => { await vi.advanceTimersByTimeAsync(100); expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([ - { type: "assistant_message", text: "a1" }, - { type: "assistant_message", text: "a2" }, + { type: "assistant_message", text: "a1a2" }, { type: "reasoning", text: "r1r2" }, { type: "assistant_message", text: "b1b2" }, ]); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 5416a8d2ce..da1cfc8196 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,3 +1,8 @@ +import { + prepareAgentMessage, + formatAgentMessage, + formatSystemNotificationPrompt, +} from "./agent-messages/index.js"; import { expect, test, vi } from "vitest"; import { spawn } from "node:child_process"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; @@ -18,7 +23,8 @@ import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { toAgentPayload } from "./agent-projections.js"; import { projectTimelineRows } from "./timeline-projection.js"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; +import { startAgentRun } from "./agent-prompt.js"; +import { StaleProviderSessionError } from "./stale-provider-session-error.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { @@ -36,6 +42,7 @@ import type { AgentProvider, AgentPersistenceHandle, AgentRunOptions, + AgentResumeSessionOptions, AgentRunResult, AgentSession, AgentSessionConfig, @@ -68,6 +75,12 @@ function deferred(): Deferred { return { promise, resolve, reject }; } +async function drainAsyncGenerator(generator: AsyncGenerator): Promise { + for await (const _ of generator) { + // Drain provider events while AgentManager subscribers observe them. + } +} + function waitForAgentLifecycle( manager: AgentManager, agentId: string, @@ -519,6 +532,32 @@ class TestAgentSession implements AgentSession { async close(): Promise {} } +class ResumeTrackingTestAgentClient extends TestAgentClient { + private readonly retryStarted = deferred(); + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeOverrides.push(config); + const signalRetryStarted = () => this.retryStarted.resolve(); + return new (class extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + signalRetryStarted(); + return await super.startTurn(); + } + })({ + provider: this.provider, + cwd: config?.cwd ?? process.cwd(), + daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt, + }); + } + + waitForRetryStart(): Promise { + return this.retryStarted.promise; + } +} + class McpCapableTestAgentSession extends TestAgentSession { override readonly capabilities = { ...TEST_CAPABILITIES, @@ -658,6 +697,137 @@ test("uses an injected timeline store without making it a production requirement } }); +test("refreshing an agent replaces the injected timeline store instead of appending a second copy", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rehydrate-")); + const store = new RecordingTimelineStore(); + class HistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "continue" }, + }; + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "done" }, + }; + } + } + class HistoryClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new HistorySession(config); + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new HistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + const manager = new AgentManager({ + clients: { codex: new HistoryClient() }, + durableTimelineStore: store, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + + // What session.handleRefreshAgentRequest does for a loaded agent, twice over. + for (let refresh = 0; refresh < 2; refresh += 1) { + await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true }); + await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }); + await manager.flush(); + } + + const rows = await manager.getTimelineRows(agent.id); + expect(rows.map((row) => row.item)).toEqual([ + { type: "user_message", text: "continue" }, + { type: "assistant_message", text: "done" }, + ]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("a failed history replay leaves the committed timeline intact", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-replay-fail-")); + const store = new RecordingTimelineStore(); + let failReplay = false; + class FlakyHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "user_message", text: "continue" }, + }; + if (failReplay) { + throw new Error("history stream closed"); + } + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "done" }, + }; + } + } + class FlakyHistoryClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new FlakyHistorySession(config); + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new FlakyHistorySession({ provider: "codex", cwd: config?.cwd ?? workdir }); + } + } + const manager = new AgentManager({ + clients: { codex: new FlakyHistoryClient() }, + durableTimelineStore: store, + logger, + }); + let agentId: string | null = null; + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = agent.id; + await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true }); + await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }); + await manager.flush(); + const committed = await manager.getTimelineRows(agent.id); + + failReplay = true; + await manager.reloadAgentSession(agent.id, undefined, { rehydrateFromDisk: true }); + await expect( + manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }), + ).rejects.toThrow("history stream closed"); + await manager.flush(); + + expect(await manager.getTimelineRows(agent.id)).toEqual(committed); + + // A retry once the provider recovers replaces that history rather than stacking on it. + failReplay = false; + await manager.hydrateTimelineFromProvider(agent.id, { broadcast: true }); + await manager.flush(); + expect((await manager.getTimelineRows(agent.id)).map((row) => row.item)).toEqual([ + { type: "user_message", text: "continue" }, + { type: "assistant_message", text: "done" }, + ]); + } finally { + if (agentId) await manager.closeAgent(agentId).catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("retries provider history hydration after a stream failure", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-retry-")); let attempts = 0; @@ -1623,7 +1793,7 @@ test("reload leaves a closed durable snapshot when shutdown starts during the sw }).toMatchObject({ agents: [], record: { lastStatus: "closed" }, - replacementSessionClosed: true, + replacementSessionClosed: false, }); } finally { client.finishClosing(); @@ -1633,7 +1803,7 @@ test("reload leaves a closed durable snapshot when shutdown starts during the sw } }); -test("reload closes both sessions when the closed snapshot cannot be persisted", async () => { +test("reload does not create a replacement when the closed snapshot cannot be persisted", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-persist-failure-test-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -1672,7 +1842,7 @@ test("reload closes both sessions when the closed snapshot cannot be persisted", }).toEqual({ agents: [], originalSessionClosed: true, - replacementSessionClosed: true, + replacementSessionClosed: false, }); } finally { client.finishClosing(); @@ -2244,74 +2414,256 @@ test("setAgentMode persists the selected mode across session reload", async () = expect(reloaded.currentModeId).toBe("full-access"); }); -test("reloadAgentSession completes when the previous session close hangs", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); - const storagePath = join(workdir, "agents"); - const storage = new AgentStorage(storagePath, logger); - - class HangingCloseSession extends TestAgentSession { - closeCalled = false; - - override async close(): Promise { - this.closeCalled = true; - await new Promise(() => {}); +test("reload releases the original writer before resuming the same session", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-writer-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ExclusiveWriterClient extends TestAgentClient { + current: CloseRecordingTestAgentSession | undefined; + override async createSession(config: AgentSessionConfig): Promise { + this.current = new CloseRecordingTestAgentSession(config); + return this.current; } - } - - class HangingCloseClient extends TestAgentClient { - readonly firstSession = new HangingCloseSession({ - provider: "codex", - cwd: workdir, - }); - resumeSessionCalls = 0; - - override async createSession(): Promise { - return this.firstSession; - } - override async resumeSession( - _handle: AgentPersistenceHandle, + handle: AgentPersistenceHandle, config?: Partial, ): Promise { - this.resumeSessionCalls += 1; - return new TestAgentSession({ + if (this.current && !this.current.closed) { + throw new Error("thread already has an active writer"); + } + this.current = new CloseRecordingTestAgentSession({ provider: "codex", cwd: config?.cwd ?? workdir, }); + this.current.describePersistence = () => ({ provider: "codex", sessionId: handle.sessionId }); + return this.current; } } - - const client = new HangingCloseClient(); - const manager = new AgentManager({ - clients: { - codex: client, - }, - registry: storage, - logger, - rescueTimeouts: { reloadSessionCloseMs: 10 }, - idFactory: () => "00000000-0000-4000-8000-000000000302", - }); - + const client = new ExclusiveWriterClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); try { - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId: undefined }, - ); - - const reloaded = await manager.reloadAgentSession(snapshot.id); - - expect(reloaded.id).toBe(snapshot.id); - expect(client.firstSession.closeCalled).toBe(true); - expect(client.resumeSessionCalls).toBe(1); + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + for (let i = 0; i < 3; i++) { + const reloaded = await manager.reloadAgentSession(created.id, undefined, { + rehydrateFromDisk: true, + }); + expect(reloaded.id).toBe(created.id); + expect(reloaded.persistence?.sessionId).toBe(created.persistence?.sessionId); + } + await manager.closeAgent(created.id); } finally { + await client.current?.close(); + await storage.flush(); rmSync(workdir, { recursive: true, force: true }); } }); +test("opening during reload waits for the replacement", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-open-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const reloading = manager.reloadAgentSession(created.id); + await client.waitForCloseToStart(); + let opened = false; + const opening = ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }).then((agent) => { + opened = true; + return agent; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(opened).toBe(false); + client.finishClosing(); + const replacement = await reloading; + expect((await opening).session).toBe(replacement.session); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("closing during reload leaves the replacement closed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-race-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const reloading = manager.reloadAgentSession(created.id); + await client.waitForCloseToStart(); + const closing = manager.closeAgent(created.id); + client.finishClosing(); + await Promise.all([reloading, closing]); + expect(manager.getAgent(created.id)).toBeNull(); + expect(client.replacementSessionClosed).toBe(true); + expect(await storage.get(created.id)).toMatchObject({ lastStatus: "closed" }); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("retrying a timed-out reload waits for the original close to finish", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + expect(manager.getAgent(created.id)?.lifecycle).toBe("error"); + const retry = manager.reloadAgentSession(created.id); + client.finishClosing(); + expect((await retry).id).toBe(created.id); + expect(client.originalSessionClosed).toBe(true); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("failed reload retains the closed agent for a later resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-recovery-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class FailingResumeClient extends TestAgentClient { + fail = true; + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + if (this.fail) throw new Error("resume unavailable"); + return super.resumeSession(handle, config); + } + } + const client = new FailingResumeClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Keep me" }, + undefined, + { workspaceId: undefined }, + ); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("resume unavailable"); + expect(manager.getAgent(created.id)).toBeNull(); + expect(await storage.get(created.id)).toMatchObject({ + lastStatus: "closed", + title: "Keep me", + persistence: created.persistence, + }); + client.fail = false; + const recovered = await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(recovered.id).toBe(created.id); + expect((await storage.get(created.id))?.title).toBe("Keep me"); + await manager.closeAgent(created.id); + } finally { + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test.each(["hang", "reject"])( + "reload does not resume when the previous session close fails: %s", + async (failure) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-close-timeout-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class HangingCloseSession extends TestAgentSession { + closeCalled = false; + + override async close(): Promise { + this.closeCalled = true; + if (failure === "reject") throw new Error("close rejected"); + await new Promise(() => {}); + } + } + + class HangingCloseClient extends TestAgentClient { + readonly firstSession = new HangingCloseSession({ + provider: "codex", + cwd: workdir, + }); + resumeSessionCalls = 0; + + override async createSession(): Promise { + return this.firstSession; + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + + const client = new HangingCloseClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + idFactory: () => "00000000-0000-4000-8000-000000000302", + }); + + try { + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + }, + undefined, + { workspaceId: undefined }, + ); + + for (let i = 0; i < 2; i++) { + await expect(manager.reloadAgentSession(snapshot.id)).rejects.toThrow( + failure === "hang" ? "Timed out closing previous session" : "close rejected", + ); + } + expect(client.firstSession.closeCalled).toBe(true); + expect(client.resumeSessionCalls).toBe(0); + expect(manager.getAgent(snapshot.id)?.session).toBe(client.firstSession); + } finally { + await manager.flush(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + test("cancelAgentRun preserves running state when the provider interrupt hangs", async () => { const fixture = await createControlledInterruptFixture({ name: "interrupt-timeout", @@ -2785,10 +3137,12 @@ test("reloadAgentSession preserves the live session when its replacement cannot } } + let paseoToolPolicy = { disabledTools: ["list_agents"] }; const manager = new AgentManager({ clients: { codex: new UnsupportedReloadClient() }, registry: new AgentStorage(join(workdir, "agents"), logger), logger, + resolvePaseoToolPolicy: () => paseoToolPolicy, }); try { @@ -2797,6 +3151,7 @@ test("reloadAgentSession preserves the live session when its replacement cannot "00000000-0000-4000-8000-000000000109", { workspaceId: undefined }, ); + paseoToolPolicy = { disabledTools: ["create_agent"] }; await expect( manager.reloadAgentSession(created.id, { @@ -2806,10 +3161,13 @@ test("reloadAgentSession preserves the live session when its replacement cannot }), ).rejects.toThrow("Provider 'codex' does not support MCP servers"); - expect(replacement.closed).toBe(true); + expect(replacement.closed).toBe(false); expect(original.closed).toBe(false); expect(manager.getAgent(created.id)?.session).toBe(original); expect(manager.getAgent(created.id)?.lifecycle).toBe("idle"); + expect(manager.getPaseoToolPolicy(created.id)).toEqual({ + disabledTools: ["list_agents"], + }); } finally { rmSync(workdir, { recursive: true, force: true }); } @@ -2942,6 +3300,168 @@ test("createAgent allows best-effort internal MCP when the provider session repo rmSync(workdir, { recursive: true, force: true }); }); +test("uses each provider's current policy for new sessions and snapshots it by agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const policies = new Map([ + ["codex", { disabledTools: ["list_agents"] }], + ["claude", { enabled: false }], + ]); + + class CaptureClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + supportsNativePaseoTools: true, + }; + readonly launchContexts: AgentLaunchContext[] = []; + readonly configs: AgentSessionConfig[] = []; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.configs.push(config); + if (launchContext) this.launchContexts.push(launchContext); + return new TestAgentSession(config); + } + } + + const codex = new CaptureClient("codex"); + const claude = new CaptureClient("claude"); + const policyInputs: Array<{ callerAgentId?: string; paseoToolPolicy?: unknown }> = []; + const paseoTools: PaseoToolCatalog = { + tools: new Map(), + getTool: () => undefined, + executeTool: async () => { + throw new Error("No tools registered in test catalog"); + }, + }; + const manager = new AgentManager({ + clients: { codex, claude }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: (provider) => policies.get(provider), + paseoToolCatalogFactory: async (context) => { + policyInputs.push(context); + return paseoTools; + }, + }); + + const codexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000107", + { workspaceId: undefined }, + ); + const claudeAgent = await manager.createAgent( + { provider: "claude", cwd: workdir }, + "00000000-0000-4000-8000-000000000108", + { workspaceId: undefined }, + ); + + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + ]); + expect(codex.launchContexts[0]?.paseoTools).toBe(paseoTools); + expect(claude.launchContexts[0]?.paseoTools).toBeUndefined(); + expect(codex.configs[0]?.mcpServers?.paseo).toBeUndefined(); + expect(claude.configs[0]?.mcpServers).toBeUndefined(); + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toEqual({ enabled: false }); + + policies.set("codex", { disabledTools: ["create_agent"] }); + const nextCodexAgent = await manager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000111", + { workspaceId: undefined }, + ); + + expect(manager.getPaseoToolPolicy(codexAgent.id)).toEqual({ + disabledTools: ["list_agents"], + }); + expect(manager.getPaseoToolPolicy(nextCodexAgent.id)).toEqual({ + disabledTools: ["create_agent"], + }); + expect(policyInputs).toEqual([ + { callerAgentId: codexAgent.id, paseoToolPolicy: { disabledTools: ["list_agents"] } }, + { + callerAgentId: nextCodexAgent.id, + paseoToolPolicy: { disabledTools: ["create_agent"] }, + }, + ]); + + await manager.archiveAgent(claudeAgent.id); + expect(manager.getPaseoToolPolicy(claudeAgent.id)).toBeUndefined(); + + rmSync(workdir, { recursive: true, force: true }); +}); + +test("keeps the global Paseo-tools gate outside provider policy and MCP injection", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + + class McpClient extends TestAgentClient { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsMcpServers: true, + }; + lastConfig: AgentSessionConfig | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.lastConfig = config; + return new TestAgentSession(config); + } + } + + const enabledClient = new McpClient(); + const enabledManager = new AgentManager({ + clients: { codex: enabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + resolvePaseoToolPolicy: () => ({ disabledTools: ["list_agents"] }), + }); + const enabledAgent = await enabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000109", + { workspaceId: undefined }, + ); + + expect(enabledClient.lastConfig?.mcpServers?.paseo).toEqual({ + type: "http", + url: `http://127.0.0.1:6767/mcp/agents?callerAgentId=${enabledAgent.id}`, + }); + + const disabledClient = new McpClient(); + let catalogFactoryCalls = 0; + const disabledManager = new AgentManager({ + clients: { codex: disabledClient }, + registry: storage, + logger, + mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents", + paseoToolsEnabled: false, + resolvePaseoToolPolicy: () => ({ enabled: true }), + paseoToolCatalogFactory: () => { + catalogFactoryCalls += 1; + return paseoTools; + }, + }); + const disabledAgent = await disabledManager.createAgent( + { provider: "codex", cwd: workdir }, + "00000000-0000-4000-8000-000000000110", + { workspaceId: undefined }, + ); + + expect(disabledClient.lastConfig?.mcpServers).toBeUndefined(); + expect(catalogFactoryCalls).toBe(0); + expect(disabledManager.getPaseoToolPolicy(disabledAgent.id)).toEqual({ enabled: false }); + + rmSync(workdir, { recursive: true, force: true }); +}); + test("resumeAgentFromPersistence replaces stored internal paseo MCP with current runtime URL", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -3327,6 +3847,212 @@ test("updateProviderRegistry removes providers omitted from the next registry", expect(removedClient.createSessionCalls).toBe(0); }); +test("retires loaded agents when their plugin provider is replaced", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class OriginalPluginClient extends TestAgentClient { + session: CloseRecordingTestAgentSession | null = null; + + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new CloseRecordingTestAgentSession(config); + session.describePersistence = () => ({ + provider, + sessionId: "plugin-session", + }); + this.session = session; + return session; + } + } + + const original = new OriginalPluginClient(); + const replacement = new TestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: original }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + retiredProviders: [provider], + }); + + await manager.waitForAgentClose(created.id); + expect(original.session?.closed).toBe(true); + expect(manager.getAgent(created.id)).toBeNull(); + + const resumed = await ensureAgentLoaded(created.id, { + agentManager: manager, + agentStorage: storage, + logger, + }); + expect(resumed.id).toBe(created.id); + expect(replacement.resumeOverrides).toEqual([ + expect.objectContaining({ cwd: workdir, provider }), + ]); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("a prompt after provider replacement reopens the stale session", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class StalePluginSession extends CloseRecordingTestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + throw new StaleProviderSessionError("stale-bridge"); + } + } + + class StalePluginClient extends TestAgentClient { + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new StalePluginSession(config); + session.describePersistence = () => ({ + provider, + sessionId: "plugin-session", + }); + return session; + } + } + + const staleClient = new StalePluginClient(); + const replacement = new ResumeTrackingTestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: staleClient }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + }); + + const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger, { + runOptions: { clientMessageId: "stale-session-retry" }, + }); + expect(dispatch.disposition).toBe("turn_started"); + await replacement.waitForRetryStart(); + const result = await manager.waitForAgentEvent(created.id); + expect(result.status).toBe("idle"); + expect(replacement.resumeOverrides).toHaveLength(1); + const timeline = manager.getTimeline(created.id); + expect(timeline.filter((item) => item.type === "user_message")).toMatchObject([ + { text: "continue after reload", clientMessageId: "stale-session-retry" }, + ]); + expect( + timeline.some( + (item) => item.type === "assistant_message" && item.text.includes("[System Error]"), + ), + ).toBe(false); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("a replacement prompt recovers when the retired session fails to start", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const provider = "plugin-provider"; + + class StaleReplacementSession extends TestAgentSession { + private starts = 0; + + override async startTurn(): Promise<{ turnId: string }> { + this.starts += 1; + if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge"); + return { turnId: "initial-turn" }; + } + + override async interrupt(): Promise { + if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge"); + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + turnId: "initial-turn", + }); + } + } + + class StaleReplacementClient extends TestAgentClient { + constructor() { + super(provider); + } + + override async createSession(config: AgentSessionConfig): Promise { + const session = new StaleReplacementSession(config); + session.describePersistence = () => ({ provider, sessionId: "plugin-session" }); + return session; + } + } + + const replacement = new ResumeTrackingTestAgentClient(provider); + const manager = new AgentManager({ + clients: { [provider]: new StaleReplacementClient() }, + providerDefinitions: { [provider]: { enabled: true } }, + registry: storage, + logger, + rescueTimeouts: { interruptSessionMs: 20 }, + }); + const created = await manager.createAgent({ provider, cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + try { + const initial = manager.streamAgent(created.id, "initial"); + void drainAsyncGenerator(initial); + await manager.waitForAgentRunStart(created.id); + + manager.updateProviderRegistry({ + providerDefinitions: { [provider]: { enabled: true } }, + clients: { [provider]: replacement }, + }); + + const dispatch = await startAgentRun(manager, created.id, "replacement", logger, { + replaceRunning: true, + }); + expect(dispatch.disposition).toBe("turn_started"); + await replacement.waitForRetryStart(); + const result = await manager.waitForAgentEvent(created.id); + expect(result.status).toBe("idle"); + expect(replacement.resumeOverrides).toHaveLength(1); + } finally { + await manager.closeAgent(created.id).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("createAgent passes explicit model strings through to the provider", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -4141,6 +4867,53 @@ test("persists live mode, model, and thinking changes without an external snapsh expect(persisted?.runtimeInfo?.model).toBe("gpt-5.4"); }); +test("model changes persist the resolved thinking and fresh provider handle before another turn", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-model-selection-")); + class ModelSession extends TestAgentSession { + selection = { model: "source", thinkingOptionId: "medium" as string | undefined }; + async setModel(model: string | null) { + this.selection = { model: model ?? "default", thinkingOptionId: undefined }; + this.pushEvent({ + type: "thinking_option_changed", + provider: "codex", + thinkingOptionId: null, + }); + } + describePersistence() { + return { provider: "codex" as const, sessionId: this.id, metadata: { ...this.selection } }; + } + } + class ModelClient extends TestAgentClient { + async createSession(config: AgentSessionConfig) { + return new ModelSession(config); + } + } + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new ModelClient() }, + registry: storage, + logger, + }); + try { + const agent = await manager.createAgent( + { provider: "codex", cwd: workdir, model: "source", thinkingOptionId: "medium" }, + undefined, + { workspaceId: undefined }, + ); + await manager.setAgentModel(agent.id, "target"); + await manager.flush(); + const stored = await storage.get(agent.id); + expect(stored?.config?.model).toBe("target"); + expect(stored?.config?.thinkingOptionId).toBeUndefined(); + expect(stored?.persistence?.metadata?.model).toBe("target"); + expect(stored?.persistence?.metadata?.thinkingOptionId).toBeUndefined(); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("later explicit config mutations win over events emitted by earlier mutations", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-config-mutation-order-")); class ConfigMutationSession extends TestAgentSession { @@ -4188,8 +4961,96 @@ test("later explicit config mutations win over events emitted by earlier mutatio expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high"); }); +test("setAgentThinkingOption adopts the session's effective clamped level", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-")); + class ClampingSession extends TestAgentSession { + async setThinkingOption(): Promise {} + + override async getRuntimeInfo() { + return { + ...(await super.getRuntimeInfo()), + thinkingOptionId: "high", + }; + } + } + class ClampingClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ClampingSession(config); + } + } + + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new ClampingClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000135", + }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + thinkingOptionId: "low", + }, + undefined, + { workspaceId: undefined }, + ); + + await manager.setAgentThinkingOption(snapshot.id, "medium"); + await manager.flush(); + + const agent = manager.getAgent(snapshot.id); + expect(agent?.config.thinkingOptionId).toBe("high"); + expect(agent?.runtimeInfo?.thinkingOptionId).toBe("high"); + const persisted = await storage.get(snapshot.id); + expect(persisted?.config?.thinkingOptionId).toBe("high"); +}); + +test("setAgentThinkingOption surfaces a failed state read and keeps the previous level", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-read-failure-")); + class UnreadableStateSession extends TestAgentSession { + async setThinkingOption(): Promise {} + + override async getRuntimeInfo(): Promise { + throw new Error("state unavailable"); + } + } + class UnreadableStateClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new UnreadableStateSession(config); + } + } + + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new UnreadableStateClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000136", + }); + const snapshot = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + thinkingOptionId: "low", + }, + undefined, + { workspaceId: undefined }, + ); + + await expect(manager.setAgentThinkingOption(snapshot.id, "medium")).rejects.toThrow( + "state unavailable", + ); + await manager.flush(); + + expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low"); + const persisted = await storage.get(snapshot.id); + expect(persisted?.config?.thinkingOptionId).toBe("low"); +}); + test("session config drift events update state through the stream channel", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); let capturedSession: TestAgentSession | null = null; class ConfigEventClient extends TestAgentClient { override async createSession(config: AgentSessionConfig): Promise { @@ -4202,6 +5063,7 @@ test("session config drift events update state through the stream channel", asyn clients: { codex: new ConfigEventClient(), }, + registry: storage, logger, idFactory: () => "00000000-0000-4000-8000-000000000133", }); @@ -4256,6 +5118,7 @@ test("session config drift events update state through the stream channel", asyn const agent = manager.getAgent(snapshot.id); expect(agent?.currentModeId).toBe("build"); + expect(agent?.config.modeId).toBe("build"); expect(agent?.config.thinkingOptionId).toBe("high"); expect(agent?.availableModes).toEqual([ { id: "plan", label: "Plan" }, @@ -4267,6 +5130,10 @@ test("session config drift events update state through the stream channel", asyn thinkingOptionId: "high", }); expect(streams.map((event) => event.type)).toEqual([]); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.lastModeId).toBe("build"); + expect(persisted?.config?.modeId).toBe("build"); }); test("setLabels merges and persists labels", async () => { @@ -4517,14 +5384,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status", const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt); expect(archivedRecord.archivedAt).toBe(archivedAt); - expect(archivedRecord.lastStatus).toBe("idle"); + expect(archivedRecord.lastStatus).toBe("closed"); expect(archivedRecord.requiresAttention).toBe(false); expect(archivedRecord.attentionReason).toBeNull(); expect(archivedRecord.attentionTimestamp).toBeNull(); const persisted = await storage.get(snapshot.id); expect(persisted?.archivedAt).toBe(archivedAt); - expect(persisted?.lastStatus).toBe("idle"); + expect(persisted?.lastStatus).toBe("closed"); expect(persisted?.requiresAttention).toBe(false); expect(persisted?.attentionReason).toBeNull(); expect(persisted?.attentionTimestamp).toBeNull(); @@ -4569,6 +5436,56 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( expect(last.lifecycle).toBe("closed"); }); +test("markAgentUnread dispatches stored attention to every subscriber", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + }); + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Stored unread dispatch" }, + undefined, + { workspaceId: "workspace-1" }, + ); + await manager.closeAgent(created.id); + + const firstClientEvents: ManagedAgent[] = []; + const secondClientEvents: ManagedAgent[] = []; + for (const events of [firstClientEvents, secondClientEvents]) { + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === created.id) { + events.push(event.agent); + } + }, + { replayState: false }, + ); + } + + await manager.markAgentUnread(created.id); + + for (const events of [firstClientEvents, secondClientEvents]) { + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + id: created.id, + lifecycle: "closed", + workspaceId: "workspace-1", + attention: { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(Date), + }, + }); + } + expect(await storage.get(created.id)).toMatchObject({ + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: expect.any(String), + }); +}); + 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"); @@ -4757,14 +5674,17 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "one", text: "one", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "two", text: "two", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "three", text: "three", }); @@ -4838,21 +5758,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store }); await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row one", - }, - }, { seq: 2, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + collapsed: ["assistant_merge"], timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "row two", - }, + item: { type: "assistant_message", text: "row onerow two" }, }, ]); }); @@ -4914,7 +5827,7 @@ test("getAgent does not expose committed history internals once manager owns the expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); }); -test("coalesces assistant chunks and persists the canonical row", async () => { +test("streams coalesced assistant chunks and retains the projected message", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -4971,8 +5884,8 @@ test("coalesces assistant chunks and persists the canonical row", async () => { } // The coalescer flushes the first chunk on the leading edge, so "final " ships - // as its own row and "reply" follows on the trailing window. Clients read the - // projected timeline, which merges the two back into one assistant message. + // as its own event and "reply" follows on the trailing window. History retains + // their complete projected assistant message. const assistantTimelineEvents = streamEvents.filter( (event) => event.itemType === "assistant_message", ); @@ -4995,18 +5908,15 @@ test("coalesces assistant chunks and persists the canonical row", async () => { expect(manager.getTimeline(snapshot.id)).toEqual([ { type: "assistant_message", - text: "final ", - }, - { - type: "assistant_message", - text: "reply", + text: "final reply", }, ]); const fetched = await manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 0, }); - expect(fetched.rows).toHaveLength(2); + expect(fetched.rows).toHaveLength(1); + expect(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 }); expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch); expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.item)).toEqual( [ @@ -5042,28 +5952,34 @@ test("fetchTimeline supports older-history pagination with before seq", async () await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fourth", text: "fourth", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "fifth", text: "fifth", }); const result = await manager.fetchTimeline(snapshot.id, { direction: "before", cursor: { + epoch: manager.fetchTimeline(snapshot.id).epoch, seq: 5, }, limit: 2, @@ -5100,14 +6016,17 @@ test("does not trim committed history", async () => { await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "first", text: "first", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "second", text: "second", }); await manager.appendTimelineItem(snapshot.id, { type: "assistant_message", + messageId: "third", text: "third", }); @@ -5205,8 +6124,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi await manager.hydrateTimelineFromProvider(snapshot.id, { force: true }); expect(manager.getTimeline(snapshot.id)).toEqual([ - { type: "assistant_message", text: "chunk one " }, - { type: "assistant_message", text: "chunk two" }, + { type: "assistant_message", text: "chunk one chunk two" }, { type: "reasoning", text: "internal" }, { type: "tool_call", @@ -6809,6 +7727,81 @@ test("listAgents excludes internal agents", async () => { const agents = manager.listAgents(); expect(agents).toHaveLength(1); expect(agents[0]?.config.title).toBe("Normal Agent"); + + // Opting in lists both, and the internal one is marked. + const everyAgent = manager.listAgents({ includeInternal: true }); + expect(everyAgent.map((agent) => [agent.config.title, agent.internal])).toEqual([ + ["Normal Agent", false], + ["Internal Agent", true], + ]); +}); + +test("workspace ownership does not turn an ordinary agent into a disposable helper", async () => { + const agentId = "00000000-0000-4000-8000-000000000115"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const beforeHookCalls: string[] = []; + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => agentId, + pluginLifecycle: { + emit: () => {}, + before: async (name, request) => { + beforeHookCalls.push(name); + return request; + }, + }, + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Helper" }, undefined, { + workspaceId: "wks_internal", + }); + + expect(manager.getAgent(agentId)?.internal).toBe(false); + expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]); + expect(await storage.get(agentId)).not.toBeNull(); + // Workspace ownership preserves ordinary agent lifecycle hooks. + expect(beforeHookCalls).toContain("agent.create"); +}); + +test("global subscribers receive internal agent events only when they opt in", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000116"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new TestAgentClient() }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Agent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + + const hidden: string[] = []; + const optedIn: string[] = []; + const unsubscribeHidden = manager.subscribe((event) => { + if (event.type === "agent_state") hidden.push(event.agent.id); + }); + const unsubscribeOptedIn = manager.subscribe( + (event) => { + if (event.type === "agent_state") optedIn.push(event.agent.id); + }, + { includeInternal: true }, + ); + + expect(hidden).toEqual([]); + expect(optedIn).toEqual([internalAgentId]); + + await manager.archiveAgent(internalAgentId); + expect(hidden).toEqual([]); + expect(optedIn.length).toBeGreaterThan(1); + unsubscribeHidden(); + unsubscribeOptedIn(); }); test("getAgent returns internal agents by ID", async () => { @@ -6841,6 +7834,124 @@ test("getAgent returns internal agents by ID", async () => { expect(agent?.internal).toBe(true); }); +test("archiveAgent closes an internal agent without writing a record", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000108"; + 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: () => internalAgentId, + }); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Internal Agent", + internal: true, + }, + undefined, + { workspaceId: undefined }, + ); + + const { archivedAt } = await manager.archiveAgent(internalAgentId); + + expect(Number.isNaN(Date.parse(archivedAt))).toBe(false); + expect(manager.getAgent(internalAgentId)).toBeNull(); + expect(await storage.get(internalAgentId)).toBeNull(); + expect(await storage.list()).toEqual([]); + expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({ + record: expect.objectContaining({ + id: internalAgentId, + title: "Internal Agent", + internal: true, + archivedAt, + lastStatus: "idle", + }), + lastMessage: null, + }); + expect(manager.getRetiredInternalAgent("00000000-0000-4000-8000-000000000999")).toBeNull(); +}); + +test("archiveAgent on an internal parent still archives its attached children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000109"; + const childAgentId = "00000000-0000-4000-8000-000000000110"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Parent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Child" }, undefined, { + workspaceId: undefined, + labels: { "paseo.parent-agent-id": parentAgentId }, + }); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + const child = await storage.get(childAgentId); + expect(child?.archivedAt).toEqual(expect.any(String)); + expect(await storage.get(parentAgentId)).toBeNull(); +}); + +test("archiveAgent on a parent archives its live internal children", async () => { + const parentAgentId = "00000000-0000-4000-8000-000000000111"; + const childAgentId = "00000000-0000-4000-8000-000000000112"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const ids = [parentAgentId, childAgentId]; + let agentCounter = 0; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => ids[agentCounter++] ?? randomUUID(), + }); + + await manager.createAgent({ provider: "codex", cwd: workdir, title: "Parent" }, undefined, { + workspaceId: undefined, + }); + // An internal child is never in storage, so only the live scan can reach it. + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Child", internal: true }, + undefined, + { workspaceId: undefined, labels: { "paseo.parent-agent-id": parentAgentId } }, + ); + expect(manager.getAgent(childAgentId)).not.toBeNull(); + + await manager.archiveAgent(parentAgentId); + + expect(manager.getAgent(parentAgentId)).toBeNull(); + expect(manager.getAgent(childAgentId)).toBeNull(); + expect(manager.getRetiredInternalAgent(childAgentId)?.record.archivedAt).toEqual( + expect.any(String), + ); + expect(await storage.get(childAgentId)).toBeNull(); +}); + test("subscribe does not emit state events for internal agents to global subscribers", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -7251,9 +8362,20 @@ test("streamAgent clears pending run when startTurn fails before a turn id exist { workspaceId: undefined }, ); - await expect(manager.runAgent(agent.id, "fail before turn id")).rejects.toThrow( - "Invalid request: missing field `text`", - ); + await expect( + manager.runAgent(agent.id, "fail before turn id", { + clientMessageId: "rejected-prompt", + }), + ).rejects.toThrow("Invalid request: missing field `text`"); + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: "fail before turn id", + clientMessageId: "rejected-prompt", + messageId: "rejected-prompt", + }, + { type: "assistant_message", text: "[System Error] Invalid request: missing field `text`" }, + ]); await expect(manager.runAgent(agent.id, "second turn")).resolves.toEqual( expect.objectContaining({ @@ -7432,6 +8554,53 @@ test("fires onAgentArchived for stored-only snapshot archives", async () => { expect(archivedIds).toEqual([storedOnly.id]); }); +test("native archive and restore release the loaded session writer", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-native-archive-writer-")); + class WriterClient extends NativeArchiveRecordingClient { + session: CloseRecordingTestAgentSession | undefined; + override async createSession(config: AgentSessionConfig): Promise { + this.session = new CloseRecordingTestAgentSession(config); + return this.session; + } + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return this.createSession({ provider: "codex", cwd: workdir, ...config }); + } + override async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + if (!this.session?.closed) throw new Error("native thread has an active writer"); + await super.archiveNativeSession(handle); + } + override async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + if (!this.session?.closed) throw new Error("native thread has an active writer"); + await super.unarchiveNativeSession(handle); + } + } + const client = new WriterClient(); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.archiveAgent(agent.id); + expect(client.archivedHandles).toHaveLength(1); + // Opening archived history can retain a writer, including records archived by older daemons. + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + expect(client.session?.closed).toBe(false); + await manager.unarchiveSnapshot(agent.id); + expect(client.unarchivedHandles).toHaveLength(1); + expect((await storage.get(agent.id))?.archivedAt).toBeNull(); + expect(client.session?.closed).toBe(true); + await ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger }); + expect(client.session?.closed).toBe(false); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("unarchiveSnapshot skips native provider unarchive for active records", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-active-")); const storagePath = join(workdir, "agents"); @@ -8356,6 +9525,7 @@ test("respondToPermission updates currentModeId after plan approval", async () = class PlanModeTestClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; + resumedModeId: string | null | undefined; async isAvailable(): Promise { return true; @@ -8365,14 +9535,19 @@ test("respondToPermission updates currentModeId after plan approval", async () = return new PlanModeTestSession(); } - async resumeSession(): Promise { + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + this.resumedModeId = overrides?.modeId; return new PlanModeTestSession(); } } + const client = new PlanModeTestClient(); const manager = new AgentManager({ clients: { - codex: new PlanModeTestClient(), + codex: client, }, registry: storage, logger, @@ -8416,6 +9591,13 @@ test("respondToPermission updates currentModeId after plan approval", async () = await manager.flush(); const persisted = await storage.get(snapshot.id); expect(persisted?.lastModeId).toBe("acceptEdits"); + + // The provider-managed switch must also reach config.modeId — otherwise a + // reload resumes the session with the stale creation-time mode ("plan"). + expect(persisted?.config?.modeId).toBe("acceptEdits"); + const reloaded = await manager.reloadAgentSession(snapshot.id); + expect(reloaded.config.modeId).toBe("acceptEdits"); + expect(client.resumedModeId).toBe("acceptEdits"); }); test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { @@ -8952,7 +10134,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () = } }); -test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => { +test("a stored-only archive waits for an in-flight resume and then closes it", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -8976,16 +10158,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a }); await manager.closeAgent(agent.id); - const load = ensureUnarchivedAgentLoaded(agent.id, { + const load = ensureAgentLoaded(agent.id, { agentManager: manager, agentStorage: storage, logger, }); await resumeStarted.promise; - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; - await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`); + await expect(load).resolves.toMatchObject({ id: agent.id }); expect(manager.getAgent(agent.id)).toBeNull(); expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String)); } finally { @@ -8995,7 +10178,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a } }); -test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => { +test("a stored-only archive closes a shared resume before a later protected load returns", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const resumeStarted = deferred(); @@ -9030,8 +10213,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share agentStorage: storage, logger, }); - await manager.archiveSnapshot(agent.id, new Date().toISOString()); + const archive = manager.archiveSnapshot(agent.id, new Date().toISOString()); resumeAllowed.resolve(); + await archive; await sharedLoad; await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`); @@ -9269,7 +10453,7 @@ test("concurrent explicit closes tear down the runtime once", async () => { } }); -test("provider close failure still persists and emits a resumable closed agent", async () => { +test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-")); const storage = new AgentStorage(join(workdir, "agents"), logger); const client = new (class extends TestAgentClient { @@ -9289,12 +10473,10 @@ test("provider close failure still persists and emits a resumable closed agent", "00000000-0000-4000-8000-000000000217", { workspaceId: undefined }, ); - const closed = waitForAgentLifecycle(manager, created.id, "closed"); - await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed"); - await closed; + expect(manager.getAgent(created.id)?.session).toBe(created.session); const stored = await storage.get(created.id); - expect(stored).toMatchObject({ lastStatus: "closed" }); + expect(stored).toMatchObject({ lastStatus: "idle" }); expect(stored?.archivedAt).toBeFalsy(); await expect( @@ -9640,7 +10822,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi ]); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; - expect(timeline).toEqual([ + expect(timeline).toMatchObject([ { seq: 1, timestamp: expect.any(String), @@ -10000,7 +11182,7 @@ test("listImportableSessions returns healthy rows alongside thrown and timed-out }); const resultPromise = manager.listImportableSessions(); - await vi.advanceTimersByTimeAsync(8_000); + await vi.advanceTimersByTimeAsync(90_000); await expect(resultPromise).resolves.toEqual({ sessions: [ @@ -10018,7 +11200,7 @@ test("listImportableSessions returns healthy rows alongside thrown and timed-out { provider: "codex", message: "codex listing failed" }, { provider: "pi", - message: "Timed out listing importable sessions for provider 'pi' after 8000ms", + message: "Timed out listing importable sessions for provider 'pi' after 90000ms", }, ], }); @@ -10309,3 +11491,380 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls" expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled(); }); + +test("concurrent native restores run once before resuming the same agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const restoreStarted = deferred(); + const restoreAllowed = deferred(); + const operations: string[] = []; + const client = new (class extends TestAgentClient { + async unarchiveNativeSession(): Promise { + operations.push("restore"); + restoreStarted.resolve(); + await restoreAllowed.promise; + } + override async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, + ): Promise { + operations.push(`resume:${options?.purpose}`); + return super.resumeSession(handle, config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let agentId: string | undefined; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + agentId = created.id; + await manager.archiveAgent(agentId); + const first = manager.unarchiveSnapshot(agentId); + await restoreStarted.promise; + const second = manager.unarchiveSnapshot(agentId); + const load = ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + restoreAllowed.resolve(); + await Promise.all([first, second, load]); + expect(operations).toEqual(["restore", "resume:interactive"]); + expect((await storage.get(agentId))?.archivedAt).toBeNull(); + expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId); + } finally { + restoreAllowed.resolve(); + if (agentId) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("commits startup notices once on create and after restored history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-")); + const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" }; + const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" }; + const toolOutput: AgentTimelineItem = { + type: "tool_call", + callId: "startup-output", + name: "shell", + status: "completed", + error: null, + detail: { + type: "shell", + command: "print output", + output: "x".repeat(1024 * 1024), + exitCode: 0, + }, + }; + const limitedToolOutput: AgentTimelineItem = { + ...toolOutput, + detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 }, + }; + class NotifyingSession extends TestAgentSession { + readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }]; + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: history, + timestamp: "2026-09-21T00:00:00.000Z", + }; + yield { + type: "timeline", + provider: "codex", + item: toolOutput, + timestamp: "2026-09-21T01:00:00.000Z", + }; + yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] }; + } + } + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig) { + return new NotifyingSession(config); + } + override async resumeSession() { + return new NotifyingSession({ provider: "codex", cwd: workdir }); + } + })(); + const store = new RecordingTimelineStore(); + const manager = new AgentManager({ + clients: { codex: client }, + durableTimelineStore: store, + logger, + }); + const ids: string[] = []; + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {}); + ids.push(created.id); + expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]); + const resumed = await manager.resumeAgentFromPersistence({ + provider: "codex", + sessionId: "existing", + metadata: { cwd: workdir }, + }); + ids.push(resumed.id); + await manager.hydrateTimelineFromProvider(resumed.id); + expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([ + history, + limitedToolOutput, + notice, + ]); + await manager.hydrateTimelineFromProvider(resumed.id, { force: true }); + expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([ + history, + limitedToolOutput, + notice, + ]); + await manager.flush(); + expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice })); + } finally { + for (const id of ids) await manager.closeAgent(id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("failed startup history closes the session without registering an agent", async () => { + let closed = false; + class FailingHistorySession extends TestAgentSession { + readonly initialTimeline = [ + { + item: { type: "notification", level: "info", message: "Runtime v2" } as const, + timestamp: "2026-09-22T00:00:00.000Z", + }, + ]; + override async *streamHistory(): AsyncGenerator { + yield { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "Partial history" }, + }; + throw new Error("History unavailable"); + } + override async close() { + closed = true; + await super.close(); + } + } + const client = new (class extends TestAgentClient { + override async resumeSession() { + return new FailingHistorySession({ provider: "codex", cwd: tmpdir() }); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + try { + await expect( + manager.resumeAgentFromPersistence({ + provider: "codex", + sessionId: "failed-history", + metadata: { cwd: tmpdir() }, + }), + ).rejects.toThrow("History unavailable"); + expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true }); + } finally { + for (const agent of manager.listAgents()) await manager.closeAgent(agent.id); + } +}); + +test("usage session is a pure read of the live adapter and disappears on close", async () => { + const descriptor = { + provider: "claude", + model: "opus", + env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" }, + sessionKey: "launch-1", + }; + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new (class extends TestAgentSession { + usageSession() { + return descriptor; + } + })(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + expect(manager.usageSession("missing")).toBeNull(); + const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, { + workspaceId: undefined, + }); + try { + expect(manager.usageSession(agent.id)).toBe(descriptor); + expect(manager.usageSession(agent.id)).toBe(descriptor); + } finally { + await manager.closeAgent(agent.id); + } + expect(manager.usageSession(agent.id)).toBeNull(); +}); + +test.each(["live", "history"])( + "agent messages become notification tools during %s ingestion", + async (path) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-provenance-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const item: AgentTimelineItem = { + type: "user_message", + text: '\nReview <changes> & report back.\n', + messageId: "provider-message", + }; + const codex = fakeCodexEmitting( + path === "live" ? { turnItems: [item] } : { historyItems: [item] }, + ); + const manager = new AgentManager({ clients: { codex }, registry: storage, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + if (path === "live") await manager.runAgent(agent.id, { text: "start" }); + else await manager.hydrateTimelineFromProvider(agent.id, { force: true }); + expect(manager.getTimeline(agent.id)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + callId: "paseo-agent-message:delivery-1", + agentMessage: { + event: "message", + sender: { id: "remote::sender" }, + text: "Review & report back.", + }, + name: "agent_message", + status: "completed", + detail: { + type: "plain_text", + icon: "bot", + text: "Review & report back.", + }, + }), + ); + expect(manager.getTimeline(agent.id).filter((entry) => entry.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + }, +); + +test.each([false, true])( + "accepted agent prompt appears once with provider echo=%s", + async (echo) => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-acceptance-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "remote::sender" }, + "accepted-delivery", + ); + const codex = fakeCodexEmitting({ + turnItems: echo + ? [ + { + type: "user_message", + text: String(delivery.prompt), + clientMessageId: delivery.messageId, + messageId: "native-echo", + }, + ] + : [], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([ + expect.objectContaining({ + callId: "paseo-agent-message:accepted-delivery", + status: "completed", + }), + ]); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "user_message")).toEqual( + [], + ); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); + +test("a rejected agent prompt never becomes a delivered notification", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-message-rejected-")); + const delivery = prepareAgentMessage( + "Please review", + { kind: "agent-message", agentId: "sender" }, + "rejected", + ); + class RejectingSession extends TestAgentSession { + override async startTurn(): Promise<{ turnId: string }> { + throw new Error("provider rejected delivery"); + } + } + const client = new (class extends TestAgentClient { + override async createSession(config: AgentSessionConfig) { + return new RejectingSession(config); + } + })(); + const manager = new AgentManager({ clients: { codex: client }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + const run = manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + }); + await expect(run.next()).rejects.toThrow("provider rejected delivery"); + expect(manager.getTimeline(agent.id).filter((item) => item.type === "tool_call")).toEqual([]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull(); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test.each([false, true])( + "human envelope examples remain human after echo with correlation=%s", + async (correlated) => { + const workdir = mkdtempSync(join(tmpdir(), "human-envelope-example-")); + const example = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(example, undefined, "human-submission"); + const codex = fakeCodexEmitting({ + turnItems: [ + { + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + ...(correlated ? { clientMessageId: "human-submission" } : {}), + }, + ], + }); + const manager = new AgentManager({ clients: { codex }, logger }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + try { + for await (const _event of manager.streamAgent(agent.id, delivery.prompt, { + clientMessageId: delivery.messageId, + })) { + } + expect(manager.getTimeline(agent.id)).toEqual([ + { + type: "user_message", + text: example, + clientMessageId: "human-submission", + messageId: "human-submission", + }, + ]); + expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeInstanceOf(Date); + } finally { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f227c286fe..f0a2208186 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,3 +1,7 @@ +import { projectTimelineRows } from "./timeline-projection.js"; +import type { PluginLifecycle } from "../plugins/lifecycle/index.js"; +import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js"; +import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server"; import { randomUUID } from "node:crypto"; import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; @@ -13,7 +17,8 @@ import { PARENT_AGENT_ID_LABEL, } from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; -import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; +import type { ToolPolicy } from "@getpaseo/protocol/agent-types"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -22,6 +27,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type AgentResumePurpose, type AgentResumeSessionOptions, type AgentFeature, type AgentLaunchContext, @@ -49,6 +55,7 @@ import { type ListImportableSessionsOptions, } from "./agent-sdk-types.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { AgentOwner } from "./agent-owner.js"; import { @@ -72,20 +79,23 @@ import { type PendingForegroundRun, } from "./agent-run-state.js"; import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js"; -import { isSystemInjectedEnvelope } from "./agent-prompt.js"; +import { projectAgentMessage } from "./agent-messages/index.js"; +import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; +import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js"; import { ProviderSubagentStore, type ProviderSubagentDescriptor, type ProviderSubagentStoreEvent, } from "./provider-subagents/store.js"; import { withTimeout } from "../../utils/promise-timeout.js"; +import { extractAttention } from "../persistence-hooks.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; -const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 8_000; +const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000; const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, supportsSessionPersistence: true, @@ -131,14 +141,39 @@ export type AgentRunCancellationResult = | { status: "settled" } | { status: "refused" }; +/** A session that will run in a directory needs that directory to be there. */ +async function assertUsableWorkingDirectory(cwd: string): Promise { + try { + const stats = await stat(cwd); + if (!stats.isDirectory()) { + throw new Error(`Working directory is not a directory: ${cwd}`); + } + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + throw new Error(`Working directory does not exist: ${cwd}`, { cause: error }); + } + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to access working directory: ${cwd}`, { cause: error }); + } +} + interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined; } interface NormalizeConfigOptions { resolveDefaultModel?: boolean; env?: Record; + /** Defaults to interactive. A history load reads persisted state and runs nothing. */ + purpose?: AgentResumePurpose; } interface TimeoutOptions { @@ -156,10 +191,13 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { provider: record.provider, cwd: record.cwd, }; + // lastModeId is the last live mode — it also covers provider-side switches + // that never reach record.config.modeId. + const modeId = record.lastModeId ?? record.config?.modeId; + if (modeId != null) config.modeId = modeId; if (!record.config) { return config; } - if (record.config.modeId != null) config.modeId = record.config.modeId; if (record.config.model != null) config.model = record.config.model; if (record.config.thinkingOptionId != null) { config.thinkingOptionId = record.config.thinkingOptionId; @@ -206,6 +244,8 @@ export type AgentSubscriber = (event: AgentManagerEvent) => void; export interface SubscribeOptions { agentId?: string; replayState?: boolean; + /** Receive events for internal agents too. Off by default for global subscribers. */ + includeInternal?: boolean; } interface HydrateTimelineOptions { @@ -258,11 +298,6 @@ interface AgentManagerRescueTimeouts { interface ProviderEnabledFlag { enabled: boolean; derivedFromProviderId?: string | null; - validateOptions?: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions?: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy?: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -283,6 +318,8 @@ export interface CreateAgentOptions { } export interface AgentManagerOptions { + pluginLifecycle?: PluginLifecycle; + /** An internal workspace makes every agent created inside it internal. */ clients?: ProviderClientMap; providerDefinitions?: ProviderEnabledMap; idFactory?: () => string; @@ -295,6 +332,7 @@ export interface AgentManagerOptions { mcpAuthToken?: string; paseoToolsEnabled?: boolean; paseoToolCatalogFactory?: PaseoToolCatalogFactory; + resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined; appendSystemPrompt?: string; agentStreamCoalesceWindowMs?: number; rescueTimeouts?: AgentManagerRescueTimeouts; @@ -330,7 +368,7 @@ export interface WaitForAgentStartOptions { signal?: AbortSignal; } -type AttentionState = +export type AttentionState = | { requiresAttention: false } | { requiresAttention: true; @@ -532,6 +570,7 @@ function attachPersistenceCwd( interface SubscriptionRecord { callback: AgentSubscriber; agentId: string | null; + includeInternal: boolean; } interface SteerEventBarrier { @@ -616,13 +655,12 @@ function buildExplicitTimelineSeedForRegister( function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] { const rows: AgentTimelineRow[] = []; for (const entry of entries) { - if (entry.item.type === "user_message" && isSystemInjectedEnvelope(entry.item.text)) { - continue; - } + const item = projectAgentMessage(entry.item); + if (!item) continue; rows.push({ seq: rows.length + 1, timestamp: entry.timestamp ?? new Date().toISOString(), - item: limitAgentTimelineItemContent(entry.item), + item: limitAgentTimelineItemContent(item), }); } return rows; @@ -658,7 +696,7 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str } function shouldDetachFromArchivedParent( - parent: StoredAgentRecord, + parent: Pick, child: StoredAgentRecord, ): boolean { const isCrossWorkspace = @@ -678,7 +716,25 @@ function detachedAgentLabelPatch(labels: Record): AgentLabelPatc return patch; } +/** + * How long an archived internal agent stays readable by id. Storage plays this + * role for public agents; internal agents never reach it, and a one-shot helper + * can finish and auto-archive before its creator's `waitForFinish` arrives. + */ +const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000; +const RETIRED_INTERNAL_AGENT_LIMIT = 500; + +export interface RetiredInternalAgent { + record: ArchivedStoredAgentRecord; + lastMessage: string | null; +} + export class AgentManager { + private readonly pluginLifecycle: PluginLifecycle | undefined; + private readonly retiredInternalAgents = new Map< + string, + RetiredInternalAgent & { expiresAt: number } + >(); private readonly clients = new Map(); private readonly providerEnabled = new Map(); private readonly providerDefinitions = new Map(); @@ -698,12 +754,17 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); + private readonly reloadedSessionCloses = new WeakMap>(); private readonly lifecycleMutationTails = new Map>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; private readonly mcpAuthToken: string | null; private paseoToolsEnabled = true; private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null; + private readonly paseoToolPolicies = new Map(); + private readonly resolvePaseoToolPolicy: ( + provider: AgentProvider, + ) => ProviderPaseoToolsPolicy | undefined; private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private onAgentArchived?: AgentArchivedCallback; @@ -714,6 +775,7 @@ export class AgentManager { private acceptingAgentRegistrations = true; constructor(options: AgentManagerOptions) { + this.pluginLifecycle = options.pluginLifecycle; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.durableTimelineStore = options?.durableTimelineStore; @@ -722,6 +784,7 @@ export class AgentManager { this.mcpBaseUrl = options?.mcpBaseUrl ?? null; this.mcpAuthToken = options?.mcpAuthToken ?? null; this.configurePaseoTools(options); + this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined); this.appendSystemPrompt = options.appendSystemPrompt ?? ""; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); this.rescueTimeouts = { @@ -757,6 +820,7 @@ export class AgentManager { updateProviderRegistry(input: { providerDefinitions: ProviderEnabledMap; clients: ProviderClientMap; + retiredProviders?: readonly AgentProvider[]; }): void { this.providerEnabled.clear(); this.providerDefinitions.clear(); @@ -773,6 +837,18 @@ export class AgentManager { this.clients.set(provider, client); } } + + for (const provider of input.retiredProviders ?? []) { + for (const agent of this.agents.values()) { + if (agent.provider !== provider) continue; + void this.closeAgent(agent.id).catch((error) => { + this.logger.warn( + { err: error, agentId: agent.id, provider }, + "Failed to close agent after provider retirement", + ); + }); + } + } } getRegisteredProviderIds(): AgentProvider[] { @@ -803,6 +879,10 @@ export class AgentManager { this.paseoToolCatalogFactory = factory; } + getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined { + return this.paseoToolPolicies.get(agentId); + } + /** * Capability token the daemon's own MCP clients must present to the Agent MCP * endpoint when a daemon password is configured. Read by the per-client @@ -888,6 +968,7 @@ export class AgentManager { const record: SubscriptionRecord = { callback, agentId: targetAgentId, + includeInternal: options?.includeInternal === true, }; this.subscribers.add(record); @@ -901,9 +982,9 @@ export class AgentManager { }); } } else { - // For global subscribers, skip internal agents during replay + // Global subscribers skip internal agents unless they opted in. for (const agent of this.agents.values()) { - if (agent.internal) { + if (agent.internal && !record.includeInternal) { continue; } callback({ @@ -923,12 +1004,26 @@ export class AgentManager { return this.subscribers.size; } - listAgents(): ManagedAgent[] { + listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] { return Array.from(this.agents.values()) - .filter((agent) => !agent.internal) + .filter((agent) => options?.includeInternal === true || !agent.internal) .map((agent) => Object.assign({}, agent)); } + /** Archived internal agents still readable by id; see getRetiredInternalAgent. */ + listRetiredInternalAgents(): RetiredInternalAgent[] { + const now = Date.now(); + const retired: RetiredInternalAgent[] = []; + for (const [agentId, entry] of this.retiredInternalAgents) { + if (entry.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + continue; + } + retired.push({ record: entry.record, lastMessage: entry.lastMessage }); + } + return retired; + } + async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, ): Promise { @@ -1099,12 +1194,19 @@ export class AgentManager { } } + usageSession(id: string) { + const agent = this.agents.get(id); + return agent?.session?.usageSession?.() ?? null; + } + getAgent(id: string): ManagedAgent | null { const agent = this.agents.get(id); return agent ? { ...agent } : null; } async waitForAgentClose(agentId: string): Promise { + // Loading during reload must wait for the replacement, not resume another writer. + await this.lifecycleMutationTails.get(agentId); await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined); } @@ -1116,7 +1218,10 @@ export class AgentManager { async getTimelineRows(id: string): Promise { this.requireAgent(id); if (this.durableTimelineStore) { - return await this.durableTimelineStore.getCommittedRows(id); + return projectTimelineRows({ + rows: await this.durableTimelineStore.getCommittedRows(id), + mode: "projected", + }).map((entry) => Object.assign({ seq: entry.seqEnd }, entry)); } return this.timelineStore.getRows(id); } @@ -1174,33 +1279,50 @@ export class AgentManager { ): Promise { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); + if (this.pluginLifecycle && !config.internal) { + const request = await this.pluginLifecycle.before("agent.create", { + config, + env: options.env, + }); + config = { ...request.config, internal: config.internal }; + options = { ...options, env: request.env }; + } await this.deleteAgentState(resolvedAgentId); - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( config, resolvedAgentId, - options?.env, + { env: options?.env }, ); this.requireEnabledProvider(storedConfig.provider); const client = await this.requireAvailableClient({ provider: storedConfig.provider, }); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, storedConfig.cwd, + paseoToolPolicy, options?.env, + { reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); await this.requireExternalMcpSupport(session, storedConfig); - return this.registerSession(session, storedConfig, resolvedAgentId, { + const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, owner: options.owner, historyPrimed: true, }); + if (!agent.internal) { + this.pluginLifecycle?.emit("agent.created", { + agent: describeHookAgent({ ...agent, title: agent.config.title }), + }); + } + return agent; } private buildCreateSessionOptions(options?: { @@ -1224,11 +1346,24 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, ): Promise { + const resolvedAgentId = validateAgentId( + agentId ?? this.idFactory(), + "resumeAgentFromPersistence", + ); return this.trackAgentRegistrationOperation( - this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions), + this.runLifecycleMutation(resolvedAgentId, () => + this.resumeAgentFromPersistenceInternal( + handle, + overrides, + resolvedAgentId, + options, + resumeOptions, + ), + ), ); } @@ -1243,6 +1378,7 @@ export class AgentManager { labels?: Record; workspaceId?: string; owner?: AgentOwner; + attention?: AttentionState; }, resumeOptions?: AgentResumeSessionOptions, ): Promise { @@ -1257,11 +1393,21 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + // Decide residency from durable state inside the lifecycle lane. A loader may + // have read the record before a queued archive or restore completed. Residency is + // settled before the config is prepared, because a history load reads an archived + // agent whose working directory may be gone. + const record = this.registry ? await this.registry.get(resolvedAgentId) : null; + const currentResumeOptions = record + ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } + : resumeOptions; + const purpose = currentResumeOptions?.purpose ?? "interactive"; + + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( mergedConfig, resolvedAgentId, + { purpose }, ); - const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1269,18 +1415,31 @@ export class AgentManager { `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); } - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + undefined, + { + reason: "resume", + purpose, + workspaceId: options?.workspaceId ?? null, + }, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, providerLaunchConfig, launchContext, - resumeOptions, + currentResumeOptions, ); await this.requireExternalMcpSupport(session, storedConfig); return this.registerSession(session, storedConfig, resolvedAgentId, { ...options, persistence: handle, + restoring: true, }); } @@ -1310,14 +1469,22 @@ export class AgentManager { throw new Error(`Provider '${input.provider}' does not support importing sessions`); } - const { storedConfig, launchConfig } = await this.prepareSessionConfig( + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( { provider: input.provider, cwd: input.cwd, }, resolvedAgentId, ); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); + this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + paseoToolPolicy, + undefined, + { reason: "import", purpose: "interactive", workspaceId: input.workspaceId }, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1369,7 +1536,9 @@ export class AgentManager { options?: { rehydrateFromDisk?: boolean }, ): Promise { return this.trackAgentRegistrationOperation( - this.reloadAgentSessionInternal(agentId, overrides, options), + this.runLifecycleMutation(agentId, () => + this.reloadAgentSessionInternal(agentId, overrides, options), + ), ); } @@ -1397,26 +1566,46 @@ export class AgentManager { ...overrides, provider, } as AgentSessionConfig; - const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd); + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( + refreshConfig, + agentId, + ); + const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId); + const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId); + const launchContext = await this.buildLaunchContext( + agentId, + client, + storedConfig.cwd, + paseoToolPolicy, + undefined, + { reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId }, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); + if ( + Object.keys(storedConfig.mcpServers ?? {}).length > 0 && + existing.session.capabilities.supportsMcpServers !== true + ) { + throw new Error(`Provider '${provider}' does not support MCP servers`); + } - const session = handle - ? await client.resumeSession(handle, providerLaunchConfig, launchContext) - : await client.createSession(providerLaunchConfig, launchContext); - await this.requireExternalMcpSupport(session, storedConfig); - + let session: AgentSession | undefined; + let closedExisting: ManagedAgentClosed | undefined; let handedToRegistration = false; try { + // A persisted thread can have only one writer, even when its turn is idle. + await this.closeReloadedSession(existing.session, agentId); + await this.drainSessionEvents(agentId); + this.cancelRunningProviderSubagents(agentId); + closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); + await this.persistSnapshot(closedExisting); this.assertAcceptingAgentRegistrations(); - this.cancelRunningProviderSubagents(agentId); - const closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); - try { - await this.persistSnapshot(closedExisting); - } finally { - await this.closeReloadedSession(existing.session, agentId); - } + this.paseoToolPolicies.set(agentId, paseoToolPolicy); + session = handle + ? await client.resumeSession(handle, providerLaunchConfig, launchContext) + : await client.createSession(providerLaunchConfig, launchContext); + await this.requireExternalMcpSupport(session, storedConfig); + this.assertAcceptingAgentRegistrations(); if (rehydrateFromDisk) { // Wipe the in-memory timeline so registerSession mints a new epoch and @@ -1440,35 +1629,51 @@ export class AgentManager { lastUsage: preservedLastUsage, lastError: preservedLastError, attention: preservedAttention, + restoring: true, }); + } catch (error) { + if (closedExisting) { + this.emitClosedAgent(closedExisting, { persist: false }); + } else if (this.agents.get(agentId) === existing) { + existing.lifecycle = "error"; + existing.lastError = error instanceof Error ? error.message : String(error); + this.emitState(existing); + } + throw error; } finally { if (!handedToRegistration) { - await this.closeUnregisteredSession(session); + if (hadPreviousPaseoToolPolicy) { + this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy); + } else { + this.paseoToolPolicies.delete(agentId); + } + if (session) { + await this.closeUnregisteredSession(session); + } } } } private async closeReloadedSession(session: AgentSession, agentId: string): Promise { - try { - const result = await this.waitWithTimeout({ - operation: session.close(), - timeoutMs: this.rescueTimeouts.reloadSessionCloseMs, - onLateError: (error) => { - this.logger.warn( - { err: error, agentId }, - "Previous session close failed after refresh timeout", - ); - }, - }); - - if (result === "timed_out") { + let operation = this.reloadedSessionCloses.get(session); + if (!operation) { + operation = session.close(); + this.reloadedSessionCloses.set(session, operation); + // Keep pending closes across request timeouts; a retry must await the same release. + void operation.catch(() => this.reloadedSessionCloses.delete(session)); + } + const result = await this.waitWithTimeout({ + operation, + timeoutMs: this.rescueTimeouts.reloadSessionCloseMs, + onLateError: (error) => { this.logger.warn( - { agentId, timeoutMs: this.rescueTimeouts.reloadSessionCloseMs }, - "Timed out closing previous session during refresh", + { err: error, agentId }, + "Previous session close failed after refresh timeout", ); - } - } catch (error) { - this.logger.warn({ err: error, agentId }, "Failed to close previous session during refresh"); + }, + }); + if (result === "timed_out") { + throw new Error("Timed out closing previous session during refresh"); } } @@ -1502,13 +1707,52 @@ export class AgentManager { } } + /** + * The archived snapshot of an internal agent, for a while after archive. + * Null once it has expired or was never an internal agent. + */ + getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null { + const entry = this.retiredInternalAgents.get(agentId); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + this.retiredInternalAgents.delete(agentId); + return null; + } + return { record: entry.record, lastMessage: entry.lastMessage }; + } + + private retireInternalAgent(entry: RetiredInternalAgent): void { + const now = Date.now(); + for (const [agentId, retired] of this.retiredInternalAgents) { + if (retired.expiresAt <= now) { + this.retiredInternalAgents.delete(agentId); + } + } + while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) { + const oldest = this.retiredInternalAgents.keys().next().value; + if (oldest === undefined) { + break; + } + this.retiredInternalAgents.delete(oldest); + } + this.retiredInternalAgents.set(entry.record.id, { + ...entry, + expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS, + }); + } + closeAgent(agentId: string): Promise { const existing = this.inFlightAgentCloses.get(agentId); if (existing) { return existing; } - const close = this.closeAgentRuntime(agentId); + const close = this.runLifecycleMutation(agentId, async () => { + // A preceding reload or archive may already have closed the durable agent. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); + }); this.inFlightAgentCloses.set(agentId, close); const clearClose = () => { if (this.inFlightAgentCloses.get(agentId) === close) { @@ -1534,14 +1778,11 @@ export class AgentManager { "agent.manager.close.start", ); await this.drainSessionEvents(agentId); + // Retain ownership until shutdown succeeds. A failed close may still own a + // native writer, so publishing a resumable closed snapshot would orphan it. + await agent.session.close(); this.cancelRunningProviderSubagents(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - let closeError: unknown; - try { - await agent.session.close(); - } catch (error) { - closeError = error; - } let persistError: unknown; try { @@ -1559,9 +1800,6 @@ export class AgentManager { "agent.manager.close.complete", ); - if (closeError !== undefined) { - throw closeError; - } if (persistError !== undefined) { throw persistError; } @@ -1585,8 +1823,28 @@ export class AgentManager { return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId)); } - private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> { + private async archiveAgentUnlocked( + agentId: string, + requestedArchivedAt?: string, + ): Promise<{ archivedAt: string }> { const agent = this.requireAgent(agentId); + if (agent.internal) { + // Nothing about an internal agent is on disk, and archiving must not put + // it there. Its final snapshot is kept in memory for a while (see + // getRetiredInternalAgent); the runtime is closed and the committed + // timeline dropped, so `getAgent` returns null from here on. + const archivedAt = requestedArchivedAt ?? new Date().toISOString(); + const lastMessage = await this.getLastAssistantMessage(agentId); + const record = buildArchivedAgentRecord( + toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }), + { archivedAt, updatedAt: archivedAt }, + ); + await this.closeAgentRuntime(agentId); + await this.deleteAgentState(agentId); + this.retireInternalAgent({ record, lastMessage }); + await this.cascadeArchiveChildren(record); + return { archivedAt }; + } if (!this.registry) { throw new Error("Agent storage is not configured"); } @@ -1599,12 +1857,13 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found in storage after snapshot`); } - const { archivedAt } = await this.markRecordArchived(stored); + const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt); agent.updatedAt = new Date(archivedAt); - await this.closeAgent(agentId); + await this.closeAgentRuntime(agentId); + await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive"); this.discardRetainedAgentState(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(stored); return { archivedAt }; } @@ -1613,16 +1872,15 @@ export class AgentManager { // label pointing back at the caller. Archiving the parent cascades to those // children so subagent fleets don't outlive their orchestrator. Detached // handoff agents omit this label, so they stand outside the cascade. - private async cascadeArchiveChildren(parentAgentId: string): Promise { + private async cascadeArchiveChildren( + parent: Pick, + ): Promise { const registry = this.registry; if (!registry) { return; } + const parentAgentId = parent.id; const records = await registry.list(); - const parent = records.find((record) => record.id === parentAgentId); - if (!parent) { - throw new Error(`Archived parent ${parentAgentId} not found in storage`); - } for (const record of records) { if (record.archivedAt) { continue; @@ -1648,25 +1906,40 @@ export class AgentManager { } else if (this.agents.has(currentChild.id)) { await this.archiveAgentUnlocked(currentChild.id); } else { - await this.archiveSnapshot(currentChild.id, new Date().toISOString()); + await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString()); } }); } + // Internal children never reach storage, so the scan above cannot see them. + // They have no tab to keep open and no listing to detach into, so they + // always archive with the parent. + for (const child of Array.from(this.agents.values())) { + if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) { + continue; + } + await this.runLifecycleMutation(child.id, async () => { + const current = this.agents.get(child.id); + if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) { + return; + } + await this.archiveAgentUnlocked(current.id); + }); + } } - private async markRecordArchived(record: StoredAgentRecord): Promise { - const registry = this.requireRegistry(); - const archivedAt = new Date().toISOString(); - const archivedRecord = buildArchivedAgentRecord(record, { archivedAt, updatedAt: archivedAt }); - - await registry.upsert(archivedRecord); - - await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); + private async markRecordArchived( + record: StoredAgentRecord, + archivedAt = new Date().toISOString(), + ): Promise { + const archivedRecord = await this.persistArchivedRecord(record, { + archivedAt, + updatedAt: archivedAt, + }); if (this.agents.has(record.id)) { this.notifyAgentState(record.id); } else if (!archivedRecord.internal) { - this.dispatchArchivedStoredAgent(archivedRecord); + this.dispatchStoredAgentState(archivedRecord); } await this.fireAgentArchived(record.id); @@ -1674,6 +1947,21 @@ export class AgentManager { return archivedRecord; } + private async persistArchivedRecord( + record: StoredAgentRecord, + options: { archivedAt: string; updatedAt?: string }, + ): Promise { + const archivedRecord = buildArchivedAgentRecord(record, options); + await this.requireRegistry().upsert(archivedRecord); + if (!record.archivedAt && !record.internal) { + this.pluginLifecycle?.emit("agent.archived", { + agent: describeHookAgent(archivedRecord), + archivedAt: archivedRecord.archivedAt, + }); + } + return archivedRecord; + } + private async fireAgentArchived(agentId: string): Promise { const callback = this.onAgentArchived; if (!callback) { @@ -1686,8 +1974,9 @@ export class AgentManager { } } - private dispatchArchivedStoredAgent(record: StoredAgentRecord): void { + private dispatchStoredAgentState(record: StoredAgentRecord): void { const updatedAt = new Date(record.updatedAt); + const attention = extractAttention(record); this.dispatch({ type: "agent_state", agent: { @@ -1721,7 +2010,7 @@ export class AgentManager { lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, lastUsage: undefined, lastError: record.lastError ?? undefined, - attention: { requiresAttention: false }, + attention, internal: record.internal, labels: record.labels, }, @@ -1758,6 +2047,7 @@ export class AgentManager { if (agent.runtimeInfo) { agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId }; } + this.refreshSessionPersistence(agent); this.touchUpdatedAt(agent); this.emitState(agent); } @@ -1778,11 +2068,17 @@ export class AgentManager { } await this.drainSessionEvents(agentId); - agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; + let effectiveThinkingOptionId = normalizedThinkingOptionId; + const runtimeInfo = await agent.session.getRuntimeInfo(); + if (runtimeInfo.thinkingOptionId !== undefined) { + effectiveThinkingOptionId = runtimeInfo.thinkingOptionId; + } + + agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined; if (agent.runtimeInfo) { agent.runtimeInfo = { ...agent.runtimeInfo, - thinkingOptionId: normalizedThinkingOptionId, + thinkingOptionId: effectiveThinkingOptionId, }; } this.touchUpdatedAt(agent); @@ -1931,13 +2227,64 @@ export class AgentManager { } } - async archiveSnapshot(agentId: string, archivedAt: string): Promise { - const registry = this.requireRegistry(); - const liveAgent = this.getAgent(agentId); + async markAgentUnread(agentId: string): Promise { + const liveAgent = this.agents.get(agentId); if (liveAgent) { - await this.persistSnapshot(liveAgent, { - internal: liveAgent.internal, - }); + const isFinished = liveAgent.lifecycle === "idle"; + const hasPendingPermissions = liveAgent.pendingPermissions.size > 0; + const canMarkUnread = + isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions; + if (!canMarkUnread) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + liveAgent.attention = { + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: new Date(), + }; + await this.persistSnapshot(liveAgent); + this.emitState(liveAgent, { persist: false }); + return; + } + + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed"; + const canMarkUnread = + record && !record.internal && !record.archivedAt && !record.requiresAttention; + if (!canMarkUnread || !hasFinishedStatus) { + throw new Error(`Agent is no longer finished and read: ${agentId}`); + } + const updatedAt = this.nextStoredUpdatedAt(record); + const nextRecord: StoredAgentRecord = { + ...record, + updatedAt, + requiresAttention: true, + attentionReason: "finished", + attentionTimestamp: updatedAt, + }; + await registry.upsert(nextRecord); + this.dispatchStoredAgentState(nextRecord); + } + + async archiveSnapshot(agentId: string, archivedAt: string): Promise { + return this.runLifecycleMutation(agentId, () => + this.archiveSnapshotUnlocked(agentId, archivedAt), + ); + } + + private async archiveSnapshotUnlocked( + agentId: string, + archivedAt: string, + ): Promise { + const registry = this.requireRegistry(); + // A stored-only archive can have waited behind a persisted resume. Reuse the + // live archive transition so its newly acquired runtime is closed as well. + if (this.agents.has(agentId)) { + await this.archiveAgentUnlocked(agentId, archivedAt); + const archivedRecord = await registry.get(agentId); + if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`); + return archivedRecord; } const record = await registry.get(agentId); @@ -1945,22 +2292,15 @@ export class AgentManager { throw new Error(`Agent not found: ${agentId}`); } - const nextRecord = buildArchivedAgentRecord(record, { archivedAt }); - await registry.upsert(nextRecord); + const nextRecord = await this.persistArchivedRecord(record, { archivedAt }); await this.syncNativeArchiveState(record.provider, record.persistence, "archive"); - if (this.agents.has(agentId)) { - this.notifyAgentState(agentId); - } else { - this.discardRetainedAgentState(agentId); - if (!nextRecord.internal) { - this.dispatchArchivedStoredAgent(nextRecord); - } - } + this.discardRetainedAgentState(agentId); + if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord); await this.fireAgentArchived(agentId); - await this.cascadeArchiveChildren(agentId); + await this.cascadeArchiveChildren(record); return nextRecord; } @@ -1968,6 +2308,15 @@ export class AgentManager { async unarchiveSnapshot( agentId: string, updates?: { workspaceId?: string; labels?: AgentLabelPatch }, + ): Promise { + return this.runLifecycleMutation(agentId, () => + this.unarchiveSnapshotUnlocked(agentId, updates), + ); + } + + private async unarchiveSnapshotUnlocked( + agentId: string, + updates?: { workspaceId?: string; labels?: AgentLabelPatch }, ): Promise { const registry = this.requireRegistry(); const record = await registry.get(agentId); @@ -1975,6 +2324,9 @@ export class AgentManager { return false; } + // Close and native restore share the lifecycle lane with persisted resume. + // No new history or interactive runtime can acquire the writer between them. + if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId); await this.syncNativeArchiveState(record.provider, record.persistence, "restore"); await registry.upsert({ @@ -2110,7 +2462,7 @@ export class AgentManager { return false; } if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId); + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted"); this.emitState(agent); } const dispatch = (event: AgentStreamEvent): void => { @@ -2196,9 +2548,22 @@ export class AgentManager { if (pendingRun.settled) { throw error; } + if (isStaleProviderSessionError(error)) { + pendingRun.start = { status: "failed", error: error.message }; + agent.pendingReplacement = false; + if (!agent.activeForegroundTurnId) agent.lifecycle = "idle"; + this.runs.settleForegroundRun(agentId, pendingRun.token); + throw error; + } agent.pendingReplacement = false; const errorMsg = error instanceof Error ? error.message : "Failed to start turn"; pendingRun.start = { status: "failed", error: errorMsg }; + // A terminal rejection belongs after the submitted prompt even though no provider turn exists. + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", { + messageId: options.clientMessageId, + }); + } await this.handleStreamEvent(agent, { type: "turn_failed", provider: agent.provider, @@ -2288,7 +2653,7 @@ export class AgentManager { ) : undefined; if (options?.clientMessageId) { - this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted", { messageId: options.clientMessageId, turnId, providerMessageId: @@ -2594,7 +2959,7 @@ export class AgentManager { if (!clientMessageId) { return; } - this.recordSubmittedPrompt(agent, prompt, clientMessageId, { + this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", { messageId: clientMessageId, turnId: expectedTurnId, }); @@ -2721,6 +3086,9 @@ export class AgentManager { response: AgentPermissionResponse, ): Promise { const agent = this.requireAgent(agentId); + if (agent.inFlightPermissionResponses.has(requestId)) { + throw new Error("A response to this permission request is already being submitted"); + } agent.inFlightPermissionResponses.add(requestId); try { @@ -2915,8 +3283,11 @@ export class AgentManager { epoch: this.timelineStore.getEpoch(agentId), }); } - await this.refreshRuntimeInfo(agent); + // Rewind stages provider events under the run lock; publish its final state directly. + this.refreshSessionPersistence(agent); + await this.refreshSessionState(agent, { emit: false }); await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); this.logger.info( { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.complete", @@ -3187,6 +3558,12 @@ export class AgentManager { lastUsage?: AgentUsage; lastError?: string; attention?: AttentionState; + /** + * Bringing a known agent back, rather than starting a new one. Its timestamps and + * attention come from what was already recorded, and installing the session is not + * activity in it. + */ + restoring?: boolean; initialTitle?: string | null; publishWhenReady?: boolean; workspaceId?: string; @@ -3222,11 +3599,31 @@ export class AgentManager { options, }); + // Read history before publishing the agent: a provider failure must leave the + // session unregistered so the registration catch closes it. + const startupHistory: AgentStreamEvent[] = []; + if (session.initialTimeline?.length && !managed.historyPrimed) { + for await (const event of session.streamHistory()) { + startupHistory.push(limitAgentStreamEventContent(event)); + } + } + this.assertAcceptingAgentRegistrations(); this.agents.set(resolvedAgentId, managed); registered = true; // Initialize previousStatus to track transitions this.previousStatuses.set(resolvedAgentId, managed.lifecycle); + if (session.initialTimeline?.length) { + if (!managed.historyPrimed) { + // Legacy/imported chats need their existing history before startup rows. + await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory); + } else { + for (const entry of buildImportedTimelineRows(session.initialTimeline)) { + this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp }); + } + } + this.refreshSessionPersistence(managed); + } await this.refreshRuntimeInfo(managed, { emit: false }); this.assertAgentRegistrationActive(managed); await this.persistSnapshot(managed, { @@ -3240,7 +3637,12 @@ export class AgentManager { await this.refreshSessionState(managed, { emit: false }); this.assertAgentRegistrationActive(managed); managed.lifecycle = "idle"; - this.touchUpdatedAt(managed); + // Stamping now over a restored timestamp rewrote the workspace's "last used" in the + // sidebar every time a chat was reopened, because workspace `statusEnteredAt` is + // re-derived from persisted agent `updatedAt` on every daemon start. + if (!options?.restoring) { + this.touchUpdatedAt(managed); + } await this.persistSnapshot(managed); this.assertAgentRegistrationActive(managed); this.emitState(managed, { persist: false }); @@ -3435,6 +3837,7 @@ export class AgentManager { private discardRetainedAgentState(agentId: string): void { this.timelineStore.delete(agentId); + this.paseoToolPolicies.delete(agentId); for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } @@ -3442,6 +3845,11 @@ export class AgentManager { private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { this.emitState(agent, options); + if (!agent.internal) { + this.pluginLifecycle?.emit("agent.closed", { + agent: describeHookAgent({ ...agent, title: agent.config.title }), + }); + } } private subscribeToSession(agent: ActiveManagedAgent): void { if (agent.unsubscribeSession) { @@ -3616,6 +4024,19 @@ export class AgentManager { return this.registry; } + /** + * Provider-side mode switches (ACP current_mode_update, in-session + * commands, permission-driven transitions) must land in config.modeId too — + * reloadAgentSession and the persisted record derive the resumed session's + * mode from it, so leaving it stale silently downgrades the mode on resume. + */ + private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void { + agent.currentModeId = modeId; + if (modeId != null) { + agent.config.modeId = modeId; + } + } + private async refreshSessionState( agent: ActiveManagedAgent, options?: { emit?: boolean }, @@ -3628,7 +4049,7 @@ export class AgentManager { } try { - agent.currentModeId = await agent.session.getCurrentMode(); + this.applyObservedMode(agent, await agent.session.getCurrentMode()); } catch { agent.currentModeId = null; } @@ -3704,10 +4125,9 @@ export class AgentManager { for await (const rawEvent of agent.session.streamHistory()) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "timeline") { - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } else if (event.type === "provider_subagent") { providerSubagentEvents.push(event); } @@ -3751,52 +4171,70 @@ export class AgentManager { private async primeTimelineFromLegacyProviderHistory( agent: ActiveManagedAgent, broadcast: boolean | (() => boolean), + history: + | AsyncIterable + | Iterable = agent.session.streamHistory(), ): Promise { const deferredBroadcast = typeof broadcast === "function"; - const timelineEvents: Array<{ - event: Extract; - row: AgentTimelineRow; - }> = []; - const providerSubagentEvents: AgentManagerEvent[] = []; + const historyEvents: Extract[] = []; + const historySubagentEvents: Extract[] = []; agent.historyPrimed = false; try { - for await (const rawEvent of agent.session.streamHistory()) { + // Collect the whole replay before touching either store. A stream that fails + // halfway then leaves the committed timeline as it was, instead of a partial + // copy the next attempt would append to. + for await (const rawEvent of history) { const event = limitAgentStreamEventContent(rawEvent); if (event.type === "provider_subagent") { - const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; - if (deferredBroadcast) { - providerSubagentEvents.push(managerEvent); - } else if (broadcast) { - this.dispatch(managerEvent); - } + historySubagentEvents.push(event); continue; } if (event.type !== "timeline") { continue; } - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - const row = this.recordTimeline( - agent.id, - event.item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - if (deferredBroadcast) { - timelineEvents.push({ event, row }); - } else if (broadcast) { - this.dispatchStream(agent.id, event, { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }); - } + const item = projectAgentMessage(event.item); + if (!item) continue; + historyEvents.push({ ...event, item }); } } catch (error) { this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history"); throw error; } + + // The replay is the timeline, so drop the rows a previous hydration committed. + // Keeping them would leave getTimelineRows reading one copy per hydration. + await this.deleteCommittedTimeline(agent.id); + + const timelineEvents: Array<{ + event: Extract; + row: AgentTimelineRow; + }> = []; + const providerSubagentEvents: AgentManagerEvent[] = []; + for (const event of historySubagentEvents) { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update }; + if (deferredBroadcast) { + providerSubagentEvents.push(managerEvent); + } else if (broadcast) { + this.dispatch(managerEvent); + } + } + for (const event of historyEvents) { + const row = this.recordTimeline( + agent.id, + event.item, + event.timestamp ? { timestamp: event.timestamp } : undefined, + ); + if (deferredBroadcast) { + timelineEvents.push({ event, row }); + } else if (broadcast) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } + } agent.historyPrimed = true; if (typeof broadcast !== "function" || !broadcast()) { @@ -3989,7 +4427,7 @@ export class AgentManager { this.emitState(agent); return undefined; case "mode_changed": - agent.currentModeId = event.currentModeId; + this.applyObservedMode(agent, event.currentModeId); agent.availableModes = event.availableModes; if (agent.runtimeInfo) { agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId }; @@ -4005,7 +4443,7 @@ export class AgentManager { agent.cwd, ); } - agent.currentModeId = event.runtimeInfo.modeId ?? agent.currentModeId; + this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId); flags.shouldDispatchEvent = false; this.emitState(agent); return undefined; @@ -4066,14 +4504,18 @@ export class AgentManager { private onStreamThreadStarted(agent: ActiveManagedAgent): void { const previousSessionId = agent.persistence?.sessionId ?? null; + this.refreshSessionPersistence(agent); + if (agent.persistence?.sessionId !== previousSessionId) { + this.emitState(agent); + } + void this.refreshRuntimeInfo(agent); + } + + private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { agent.persistence = attachPersistenceCwd(handle, agent.cwd); - if (agent.persistence?.sessionId !== previousSessionId) { - this.emitState(agent); - } } - void this.refreshRuntimeInfo(agent); } private async onStreamTimelineEvent(params: { @@ -4084,12 +4526,14 @@ export class AgentManager { }): Promise { const { agent, event, options, flags } = params; - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { + const item = projectAgentMessage(event.item); + if (!item) { flags.shouldDispatchEvent = false; flags.shouldNotifyWaiters = false; return; } + event.item = item; if ( event.item.type === "user_message" && event.item.clientMessageId && @@ -4278,6 +4722,7 @@ export class AgentManager { ): void { const hadPendingPermissions = agent.pendingPermissions.size > 0; agent.pendingPermissions.set(event.request.id, event.request); + this.refreshSessionPersistence(agent); if (!hadPendingPermissions && !agent.internal) { this.broadcastAgentAttention(agent, "permission"); } @@ -4292,6 +4737,7 @@ export class AgentManager { }): void { const { agent, event, options, flags } = params; agent.pendingPermissions.delete(event.requestId); + this.refreshSessionPersistence(agent); if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { agent.bufferedPermissionResolutions.set(event.requestId, event); flags.shouldDispatchEvent = false; @@ -4358,19 +4804,34 @@ export class AgentManager { agent: ActiveManagedAgent, prompt: AgentPromptInput, clientMessageId: string, + outcome: "accepted" | "rejected", options?: { messageId?: string; providerMessageId?: string; turnId?: string }, ): void { - if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { - return; - } - this.touchUpdatedAt(agent); - agent.lastUserMessageAt = new Date(); - const item: AgentTimelineItem = { + const item = projectAgentMessage({ type: "user_message", text: submittedPromptText(prompt), clientMessageId, ...(options?.messageId ? { messageId: options.messageId } : {}), - }; + }); + if (!item) return; + // Human attempts stay in history on rejection; delivery notifications require acceptance. + if (outcome === "rejected" && item.type !== "user_message") return; + this.recordSubmittedPromptItem(agent, item, options); + } + + private recordSubmittedPromptItem( + agent: ActiveManagedAgent, + item: AgentTimelineItem, + options?: { providerMessageId?: string; turnId?: string }, + ): void { + if ( + item.type === "user_message" && + item.clientMessageId && + this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId) + ) + return; + this.touchUpdatedAt(agent); + if (item.type === "user_message") agent.lastUserMessageAt = new Date(); this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options); } @@ -4383,11 +4844,14 @@ export class AgentManager { if (!clientMessageId) return null; let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); if (!existing) { - this.recordSubmittedPrompt(agent, item.text, clientMessageId, { - messageId: clientMessageId, - ...(messageId ? { providerMessageId: messageId } : {}), - ...(turnId ? { turnId } : {}), - }); + this.recordSubmittedPromptItem( + agent, + { ...item, messageId: clientMessageId }, + { + ...(messageId ? { providerMessageId: messageId } : {}), + ...(turnId ? { turnId } : {}), + }, + ); existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); } if (!existing || existing.item.type !== "user_message") return null; @@ -4685,6 +5149,14 @@ export class AgentManager { "agent.manager.dispatch_stream", ); this.dispatch({ type: "agent_stream", agentId, event, ...metadata }); + if (this.pluginLifecycle && agent && !agent.internal && event.type !== "timeline") { + publishAgentStream( + this.pluginLifecycle, + describeHookAgent({ ...agent, title: agent.config.title }), + event, + this.timelineStore.getItems(agentId), + ); + } } private dispatch(event: AgentManagerEvent): void { @@ -4713,8 +5185,12 @@ export class AgentManager { ) { continue; } - // Skip internal agents for global subscribers (those without a specific agentId) - if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) { + // Global subscribers (no agentId) skip internal agents unless they opted in. + if ( + !subscriber.agentId && + !subscriber.includeInternal && + this.eventBelongsToInternalAgent(event) + ) { continue; } subscriber.callback(event); @@ -4741,23 +5217,11 @@ export class AgentManager { // Always resolve cwd to absolute path for consistent history file lookup if (normalized.cwd) { normalized.cwd = resolve(normalized.cwd); - try { - const cwdStats = await stat(normalized.cwd); - if (!cwdStats.isDirectory()) { - throw new Error(`Working directory is not a directory: ${normalized.cwd}`); - } - } catch (error) { - if ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ) { - throw new Error(`Working directory does not exist: ${normalized.cwd}`, { cause: error }); - } - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to access working directory: ${normalized.cwd}`, { cause: error }); + // Only a session that will run in the directory needs it to still be there. Reading + // an archived agent's history runs nothing, and must survive the worktree it ran in + // being removed when its workspace was archived. + if (options.purpose !== "history") { + await assertUsableWorkingDirectory(normalized.cwd); } } @@ -4779,22 +5243,15 @@ export class AgentManager { private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig { const definition = this.providerDefinitions.get(config.provider); - if (config.providerOptions !== undefined && !definition?.validateOptions) { - throw new Error(`Provider '${config.provider}' does not accept providerOptions`); - } - const validatedOptions = definition?.validateOptions?.(config.providerOptions); - const withOptions = definition?.applyOptions - ? definition.applyOptions(config, validatedOptions) - : config; - this.validateToolPolicyServers(withOptions); - if (withOptions.toolPolicy && !definition?.applyToolPolicy) { + this.validateToolPolicyServers(config); + if (config.toolPolicy && !definition?.applyToolPolicy) { throw new Error( `Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`, ); } return definition?.applyToolPolicy - ? definition.applyToolPolicy(withOptions, withOptions.toolPolicy) - : withOptions; + ? definition.applyToolPolicy(config, config.toolPolicy) + : config; } private validateToolPolicyServers(config: AgentSessionConfig): void { @@ -4830,18 +5287,27 @@ export class AgentManager { private async prepareSessionConfig( config: AgentSessionConfig, agentId: string, - env?: Record, + options: { env?: Record; purpose?: AgentResumePurpose } = {}, ): Promise { - const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env: options.env, + purpose: options.purpose, + }); + const paseoToolPolicy = this.paseoToolsEnabled + ? this.resolvePaseoToolPolicy(storedConfig.provider) + : { enabled: false }; const launchConfig = this.applyDaemonAppendSystemPrompt( withRuntimePaseoMcpServer({ config: storedConfig, agentId, - mcpBaseUrl: this.mcpBaseUrl, + mcpBaseUrl: + this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy) + ? this.mcpBaseUrl + : null, mcpAuthToken: this.mcpAuthToken, }), ); - return { storedConfig, launchConfig }; + return { storedConfig, launchConfig, paseoToolPolicy }; } private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { @@ -4861,8 +5327,27 @@ export class AgentManager { agentId: string, client: AgentClient, cwd: string, + paseoToolPolicy: ProviderPaseoToolsPolicy | undefined, env?: Record, + opening?: { + reason: PluginSessionOpenRequest["reason"]; + purpose: PluginSessionOpenRequest["purpose"]; + workspaceId?: string | null; + }, ): Promise { + if (this.pluginLifecycle) { + const request: PluginSessionOpenRequest = { + agentId, + provider: client.provider, + cwd, + workspaceId: opening?.workspaceId ?? null, + reason: opening?.reason ?? "resume", + purpose: opening?.purpose ?? "interactive", + env: { ...env }, + }; + const transformed = await this.pluginLifecycle.before("agent.session_open", request); + env = transformed.env; + } const context: AgentLaunchContext = { agentId, env: { @@ -4873,10 +5358,14 @@ export class AgentManager { }; if ( this.paseoToolsEnabled && + isPaseoToolPolicyEnabled(paseoToolPolicy) && client.capabilities.supportsNativePaseoTools && this.paseoToolCatalogFactory ) { - context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId }); + context.paseoTools = await this.paseoToolCatalogFactory({ + callerAgentId: agentId, + paseoToolPolicy, + }); } return context; } diff --git a/packages/server/src/server/agent/agent-mcp.e2e.test.ts b/packages/server/src/server/agent/agent-mcp.e2e.test.ts index 0374c4eafc..cf9711a9df 100644 --- a/packages/server/src/server/agent/agent-mcp.e2e.test.ts +++ b/packages/server/src/server/agent/agent-mcp.e2e.test.ts @@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise rawClient.close() }; } +interface OfflineMcpDaemon { + client: McpClient; + stop: () => Promise; +} + +async function startOfflineMcpDaemon(): Promise { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + const removeDirectories = async () => { + await rm(paseoHome, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + }; + const port = await getAvailablePort(); + const daemon = await createPaseoDaemon( + { + listen: `127.0.0.1:${port}`, + paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + }, + pino({ level: "silent" }), + ).catch(async (error: unknown) => { + await removeDirectories(); + throw error; + }); + try { + await daemon.start(); + const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); + return { + client, + stop: async () => { + await client.close(); + await daemon.stop(); + await removeDirectories(); + }, + }; + } catch (error) { + await daemon.stop(); + await removeDirectories(); + throw error; + } +} + +type WorkspaceCreation = { workspaceId: unknown } | { error: unknown }; + +async function createLocalWorkspace(client: McpClient, cwd: string): Promise { + const result = await client.callTool({ + name: "create_workspace", + args: { isolation: "local", path: cwd }, + }); + if (result.isError) { + const content = result.content?.[0]; + return { error: content && "text" in content ? content.text : undefined }; + } + return { workspaceId: getStructuredContent(result)?.workspaceId }; +} + +async function listWorkspaces(client: McpClient): Promise { + const result = await client.callTool({ name: "list_workspaces", args: {} }); + const workspaces = getStructuredContent(result)?.workspaces; + if (!Array.isArray(workspaces)) return []; + return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId })); +} + interface LaunchRecorder { recordedLaunches: AgentSessionConfig[]; } @@ -146,22 +215,19 @@ function createMcpRecordingAgentClients(recorder: LaunchRecorder) { }; } -async function assertAgentNotRunning(options: { - client: McpClient; - agentId: string; -}): Promise { - const statusResult = await options.client.callTool({ - name: "get_agent_status", - args: { agentId: options.agentId }, - }); - const payload = getStructuredContent(statusResult); - if (!payload) { - throw new Error("get_agent_status returned no structured payload"); - } - const status = payload.status; - if (status === "running" || status === "initializing") { - throw new Error(`Agent still running after blocking create_agent (status=${status})`); - } +async function waitForAgentFinish(options: { client: McpClient; agentId: string }): Promise { + await expect + .poll( + async () => { + const result = await options.client.callTool({ + name: "get_agent_status", + args: { agentId: options.agentId }, + }); + return getStructuredContent(result)?.status; + }, + { timeout: 10000 }, + ) + .toBe("idle"); } describe("agent MCP end-to-end (offline)", () => { @@ -207,7 +273,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt, - background: false, }, }); @@ -215,7 +280,7 @@ describe("agent MCP end-to-end (offline)", () => { agentId = typeof payload?.agentId === "string" ? payload.agentId : null; expect(agentId).toBeTruthy(); - await assertAgentNotRunning({ client, agentId: agentId! }); + await waitForAgentFinish({ client, agentId: agentId! }); if (existsSync(filePath)) { const contents = await readFile(filePath, "utf8"); @@ -235,6 +300,27 @@ describe("agent MCP end-to-end (offline)", () => { } }, 30_000); + test("create_workspace with local isolation adopts only an existing directory", async () => { + const daemon = await startOfflineMcpDaemon(); + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-")); + const missingPath = path.join(root, "does-not-exist"); + const filePath = path.join(root, "regular-file"); + await writeFile(filePath, "not a directory\n", "utf8"); + try { + expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({ + error: expect.stringContaining(`Directory not found: ${missingPath}`), + }); + expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({ + error: expect.stringContaining(`Directory not found: ${filePath}`), + }); + const created = await createLocalWorkspace(daemon.client, root); + expect(await listWorkspaces(daemon.client)).toEqual([created]); + } finally { + await daemon.stop(); + await rm(root, { recursive: true, force: true }); + } + }, 30_000); + test("password-protected daemon authorizes the agent MCP via the capability token", async () => { const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-")); const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); @@ -287,7 +373,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -362,7 +447,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -386,7 +470,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const disabledPayload = getStructuredContent(disabledResult); @@ -451,7 +534,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "claude/claude-test-model", mode: "bypassPermissions", initialPrompt: "reply with done and stop", - background: true, }, }); const payload = getStructuredContent(result); @@ -511,7 +593,6 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); @@ -682,7 +763,6 @@ describe("agent MCP end-to-end (offline)", () => { const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`); - let agentId: string | null = null; try { const result = await client.callTool({ name: "create_agent", @@ -692,29 +772,13 @@ describe("agent MCP end-to-end (offline)", () => { provider: "codex/gpt-5.4-mini", mode: "full-access", initialPrompt: "Run exactly: sleep 30", - background: true, }, }); - const payload = getStructuredContent(result); - agentId = typeof payload?.agentId === "string" ? payload.agentId : null; - expect(agentId).toBeTruthy(); - - await assertAgentNotRunning({ client, agentId: agentId! }); - const statusResult = await client.callTool({ - name: "get_agent_status", - args: { agentId }, - }); - const statusPayload = getStructuredContent(statusResult); - expect(statusPayload?.status).toBe("error"); - const snapshot = statusPayload?.snapshot; - const lastError = - snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined; - expect(lastError).toContain("Initial turn failed to start"); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toContain("Initial turn failed to start"); + expect(getStructuredContent(result)?.agentId).toBeUndefined(); } finally { - if (agentId) { - await client.callTool({ name: "kill_agent", args: { agentId } }); - } await client.close(); await daemon.stop(); await rm(paseoHome, { recursive: true, force: true }); @@ -790,7 +854,6 @@ describe("agent MCP end-to-end (offline)", () => { initialPrompt: "say done and stop", worktreeName: "mcp-worktree-setup-test", baseBranch: "main", - background: true, }, }), timeoutMs: 2500, diff --git a/packages/server/src/server/agent/agent-messages/index.ts b/packages/server/src/server/agent/agent-messages/index.ts new file mode 100644 index 0000000000..1543bf33b4 --- /dev/null +++ b/packages/server/src/server/agent/agent-messages/index.ts @@ -0,0 +1,191 @@ +import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { + AgentPromptContentBlock, + AgentPromptInput, + AgentTimelineItem, +} from "../agent-sdk-types.js"; + +const agentIdSchema = z.string().min(1); +const sourceSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("agent-message"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + }), + z.object({ + kind: z.literal("agent-notification"), + agentId: agentIdSchema, + title: z.string().min(1).optional(), + event: z.enum(["finished", "errored", "permission-required", "closed"]), + }), +]); +export type AgentPromptSource = z.infer; + +interface AgentMessage { + id: string; + source: AgentPromptSource | null; + text: string; +} + +function escapeXml(value: string): string { + return value.replace(/[&<>"']/g, (character) => { + switch (character) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +function unescapeXml(value: string): string | null { + if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null; + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => { + switch (entity) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + default: + return "'"; + } + }); +} + +export function formatAgentMessage(message: AgentMessage): string { + const { source } = message; + const attributes = source + ? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"` + : 'kind="user-message"'; + const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : ""; + const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : ""; + return `\n${escapeXml(message.text)}\n`; +} + +export function parseAgentMessage(text: string): AgentMessage | null { + const envelope = /^]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec( + text, + ); + if (!envelope) return null; + const attributes = new Map(); + for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) { + const value = unescapeXml(match[2]); + if (value === null || attributes.has(match[1])) return null; + attributes.set(match[1], value); + } + if (attributes.get("version") !== "1") return null; + const id = attributes.get("message-id"); + const body = unescapeXml(envelope[2]); + if (!id || body === null) return null; + if (attributes.get("kind") === "user-message") { + if (attributes.has("source-agent-id") || attributes.has("event")) return null; + return { id, source: null, text: body }; + } + const source = sourceSchema.safeParse({ + kind: attributes.get("kind"), + agentId: attributes.get("source-agent-id"), + event: attributes.get("event"), + title: attributes.get("source-agent-title"), + }); + if (!source.success) return null; + return { id, source: source.data, text: body }; +} + +function renderMessageText(block: AgentPromptContentBlock): string[] { + if (block.type === "image") return []; + if (block.type === "text") return [block.text]; + return [renderPromptAttachmentAsText(block)]; +} + +export function prepareAgentMessage( + prompt: AgentPromptInput, + source?: AgentPromptSource, + id?: string, +): { prompt: AgentPromptInput; messageId?: string } { + if (!source) { + const submittedText = + typeof prompt === "string" + ? prompt + : prompt + .flatMap((block) => + block.type === "text" && !("mimeType" in block) ? [block.text] : [], + ) + .join("\n") + .trim(); + // Quote reserved envelopes only when they arrived through the human send path. + // This provenance must survive provider history, not only the accepted local row. + if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) { + return { prompt, messageId: id }; + } + } + const messageId = id ?? randomUUID(); + const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n"); + // Render textual attachments before wrapping, so provider replay still contains one + // complete envelope instead of an envelope followed by provider-rendered context. + const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text }); + return { + messageId, + prompt: + typeof prompt === "string" + ? envelope + : [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")], + }; +} + +/** The same projection owns provider echoes, acceptance, import, and replay. */ +export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null { + if (item.type !== "user_message") return item; + const message = parseAgentMessage(item.text); + if (!message) { + // COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once + // legacy notification history is retired. Schedules still use this envelope. + return isSystemInjectedEnvelope(item.text) ? null : item; + } + const { source } = message; + if (!source) { + return { ...item, text: message.text, clientMessageId: message.id }; + } + return { + type: "tool_call", + callId: `paseo-agent-message:${message.id}`, + agentMessage: { + event: source.kind === "agent-message" ? "message" : source.event, + sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) }, + text: message.text, + }, + // COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08 + // once clients can accept a dedicated timeline variant. Older clients still + // parse this existing shape and can expand the delivered body. + name: "agent_message", + status: "completed", + error: null, + detail: { type: "plain_text", text: message.text, icon: "bot" }, + }; +} + +/** + * Wrap a body in … so the receiving agent + * recognizes the prompt as system-injected context — not a user turn. + * Used by schedule fires; agent messages use the versioned envelope. + */ +export function formatSystemNotificationPrompt(reason: string): string { + return `\n${reason}\n`; +} + +const SYSTEM_ENVELOPE_PATTERN = /^\n[\s\S]*\n<\/paseo-system>$/; + +export function isSystemInjectedEnvelope(text: string): boolean { + return SYSTEM_ENVELOPE_PATTERN.test(text); +} diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index f72bd84db1..ec4186b022 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -407,6 +407,17 @@ describe("toAgentPayload", () => { }); }); + it("carries a stored lastError onto the payload", () => { + const record = toStoredAgentRecord( + createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }), + ); + + expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom"); + expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty( + "lastError", + ); + }); + it("omits lastUsage when not available", () => { const agent = createManagedAgent({ lastUsage: undefined }); const payload = toAgentPayload(agent); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index bd4042796a..3f2a9b7775 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -19,7 +19,11 @@ import type { } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; -import { isStoredAgentProviderAvailable, toAgentPersistenceHandle } from "../persistence-hooks.js"; +import { + isStoredAgentProviderAvailable, + resolveStoredAgentUpdatedAt, + toAgentPersistenceHandle, +} from "../persistence-hooks.js"; export type { ManagedAgent }; interface ProjectionOptions { @@ -135,6 +139,7 @@ export function toAgentPayload( persistence: projectPersistenceHandleForWire(agent.persistence), title: options?.title ?? null, labels: agent.labels, + ...(agent.internal ? { internal: true } : {}), }; const usage = sanitizeUsage(agent.lastUsage); @@ -208,7 +213,7 @@ export function buildStoredAgentPayload( } as const; const createdAt = new Date(record.createdAt); - const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record)); + const updatedAt = new Date(resolveStoredAgentUpdatedAt(record)); const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const runtimeInfo = buildStoredRuntimeInfo(record); @@ -243,7 +248,9 @@ export function buildStoredAgentPayload( attentionReason: record.attentionReason ?? null, attentionTimestamp: record.attentionTimestamp ?? null, archivedAt: record.archivedAt ?? null, + ...(record.internal ? { internal: true } : {}), labels: normalizeLabels(record.labels), + ...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}), ...(providerAvailable ? {} : { providerUnavailable: true }), }; } @@ -287,23 +294,6 @@ export function toRecentProviderSessionDescriptorPayload( }; } -export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { - const timestamps = [record.updatedAt, record.lastActivityAt] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map((value) => ({ - raw: value, - parsed: Date.parse(value), - })) - .filter((value) => !Number.isNaN(value.parsed)); - - if (timestamps.length === 0) { - return record.updatedAt; - } - - timestamps.sort((a, b) => b.parsed - a.parsed); - return timestamps[0].raw; -} - function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; if (config.modeId) { diff --git a/packages/server/src/server/agent/agent-prompt.test.ts b/packages/server/src/server/agent/agent-prompt.test.ts index 864813a140..dcdfdb1cbf 100644 --- a/packages/server/src/server/agent/agent-prompt.test.ts +++ b/packages/server/src/server/agent/agent-prompt.test.ts @@ -1,3 +1,11 @@ +import { + formatSystemNotificationPrompt, + isSystemInjectedEnvelope, + parseAgentMessage, + formatAgentMessage, + prepareAgentMessage, + projectAgentMessage, +} from "./agent-messages/index.js"; import { expect, it, test, vi } from "vitest"; import pino, { type Logger } from "pino"; import { randomUUID } from "node:crypto"; @@ -8,12 +16,7 @@ import { join } from "node:path"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; -import { - formatSystemNotificationPrompt, - isSystemInjectedEnvelope, - setupFinishNotification, - waitForAgentRunStartWithTimeout, -} from "./agent-prompt.js"; +import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js"; import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; import type { AgentClient, @@ -88,7 +91,7 @@ function createFinishNotificationScenario( Reflect.set(callerAgent, "lifecycle", "idle"); Reflect.set(callerAgent, "config", { title: "Caller Agent" }); - const agentManager: AgentManager = Object.create(AgentManager.prototype); + const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); Reflect.set(agentManager, "getAgent", (agentId: string) => { if (agentId === "child-agent") { return childAgent; @@ -219,6 +222,11 @@ function createFinishNotificationScenario( type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); }, async finishChildAndReadParentPrompt() { const parentPrompt = new Promise((resolve) => { @@ -272,11 +280,16 @@ test("finish notifications tell the parent the child's last assistant message", scenario.startWatchingChild(); const parentPrompt = await scenario.finishChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt( - "Agent child-agent (Child Agent) finished.\n\n\nImplemented the cleanup and all checks pass.\n", - ), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "finished", + }, + text: "Agent child-agent (Child Agent) finished.\n\n\nImplemented the cleanup and all checks pass.\n", + }); expect(scenario.steerAttemptCount()).toBe(1); }); @@ -303,9 +316,16 @@ test("closing a watched child notifies the caller", async () => { scenario.startWatchingChild(); const parentPrompt = await scenario.closeChildAndReadParentPrompt(); - expect(parentPrompt).toEqual( - formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."), - ); + expect(parseAgentMessage(parentPrompt)).toEqual({ + id: expect.any(String), + source: { + kind: "agent-notification", + agentId: "child-agent", + title: "Child Agent", + event: "closed", + }, + text: "Agent child-agent (Child Agent) was closed.", + }); }); test("finish notifications survive permission responses", async () => { @@ -318,9 +338,9 @@ test("finish notifications survive permission responses", async () => { expect(scenario.parentPrompts()).toHaveLength(1); }); expect(scenario.parentPrompts()[0]).toContain("needs permission."); - const permissionPayload = scenario - .parentPrompts()[0] - .match(/\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match( + /\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; expect(permissionPayload).toBeDefined(); expect(JSON.parse(permissionPayload!)).toEqual({ agentId: "child-agent", @@ -377,7 +397,9 @@ test("finish notifications report every concurrently pending permission", async await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2)); expect( scenario.parentPrompts().map((prompt) => { - const payload = prompt.match(/\n([\s\S]+?)\n<\/permission-request>/)?.[1]; + const payload = parseAgentMessage(prompt)!.text.match( + /\n([\s\S]+?)\n<\/permission-request>/, + )?.[1]; return JSON.parse(payload!).requestId; }), ).toEqual(["permission-1", "permission-2"]); @@ -468,7 +490,7 @@ it("does not notify archived callers", async () => { const streamAgentSpy = vi.fn(() => (async function* noop() {})()); const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})()); - const agentManager: AgentManager = Object.create(AgentManager.prototype); + const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() }); Reflect.set( agentManager, "getAgent", @@ -523,6 +545,11 @@ it("does not notify archived callers", async () => { type: "agent_state", agent: childAgent, }); + subscriber?.({ + type: "agent_stream", + agentId: "child-agent", + event: { type: "turn_completed", provider: "codex" }, + }); await vi.waitFor(() => { expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent"); @@ -762,3 +789,75 @@ test("waiting for a run start still gives up at the run start budget", async () await scenario.cleanup(); } }); + +test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages", () => { + const message = { + id: "delivery-1", + source: { + kind: "agent-message" as const, + agentId: 'host::agent<&"', + title: 'QA & "reviewer"', + }, + text: 'Review & "quotes"\n\nnested', + }; + const encoded = formatAgentMessage(message); + expect(parseAgentMessage(encoded)).toEqual(message); + expect(parseAgentMessage(`Example: ${encoded}`)).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="2"'))).toBeNull(); + expect(parseAgentMessage(encoded.replace("<", "<"))).toBeNull(); + expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull(); +}); + +test("agent envelope includes rendered attachment context and preserves images", () => { + const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" }; + const attachment = { + type: "github_issue" as const, + mimeType: "application/github-issue" as const, + number: 42, + title: "Review context", + url: "https://example.com/issues/42", + body: "Attached context", + }; + const source = { kind: "agent-message" as const, agentId: "remote:sender" }; + expect( + prepareAgentMessage( + [{ type: "text", text: "First" }, image, attachment, { type: "text", text: "Second" }], + source, + "message", + ), + ).toEqual({ + messageId: "message", + prompt: [ + { + type: "text", + text: formatAgentMessage({ + id: "message", + source, + text: "First\n\nGitHub Issue #42: Review context\nhttps://example.com/issues/42\n\nAttached context\n\nSecond", + }), + }, + image, + ], + }); +}); + +test("a human pasting an agent envelope stays a human message through provider replay", () => { + const pasted = formatAgentMessage({ + id: "example", + source: { kind: "agent-message", agentId: "sender" }, + text: "hello", + }); + const delivery = prepareAgentMessage(pasted, undefined, "human-submission"); + expect( + projectAgentMessage({ + type: "user_message", + text: String(delivery.prompt), + messageId: "provider-echo", + }), + ).toEqual({ + type: "user_message", + text: pasted, + messageId: "provider-echo", + clientMessageId: "human-submission", + }); +}); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 96f5ce5ee3..4044fd84bc 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -1,3 +1,4 @@ +import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js"; import type { Logger } from "pino"; import type { @@ -8,6 +9,7 @@ import type { import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +import { isStaleProviderSessionError } from "./stale-provider-session-error.js"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; @@ -21,9 +23,13 @@ export type AgentRunController = Pick< | "replaceAgentRun" | "steerOrReplaceActiveTurn" | "streamAgent" ->; +> & { + reloadAgentSession(agentId: string): Promise; +}; export interface StartAgentRunOptions { + /** Called after selecting the run, before consuming its events. */ + onDispatch?: (disposition: PromptDispatchDisposition) => void; replaceRunning?: boolean; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -78,6 +84,14 @@ async function startOrReplaceRun( return { iterator, replaced }; } +async function drainAgentRunIterator( + iterator: AsyncGenerator, +): Promise { + for await (const _ of iterator) { + // Events are broadcast via AgentManager subscribers. + } +} + export async function startAgentRun( agentManager: AgentRunController, agentId: string, @@ -102,10 +116,32 @@ export async function startAgentRun( // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { + options?.onDispatch?.("out_of_band"); return { disposition: "out_of_band" }; } + try { + return await startAgentRunInner(agentManager, agentId, prompt, logger, options); + } catch (error) { + if (!isStaleProviderSessionError(error)) throw error; + logger.info({ agentId, err: error }, "Provider session went stale; reopening from persistence"); + // The live session belongs to a retired plugin runtime. Reload swaps in a + // fresh session on the current runtime while preserving history and labels. + await agentManager.reloadAgentSession(agentId); + return await startAgentRunInner(agentManager, agentId, prompt, logger, options); + } +} + +async function startAgentRunInner( + agentManager: AgentRunController, + agentId: string, + prompt: AgentPromptInput, + logger: Logger, + options?: StartAgentRunOptions, +): Promise<{ disposition: PromptDispatchDisposition }> { + const snapshot = agentManager.getAgent(agentId); const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options); if (steered?.disposition === "steered") { + options?.onDispatch?.("steered"); return steered; } const { iterator, replaced } = steered @@ -120,10 +156,20 @@ export async function startAgentRun( }, "agent.session.start_stream.iterator_returned", ); + options?.onDispatch?.("turn_started"); void (async () => { try { - for await (const _ of iterator) { - // Events are broadcast via AgentManager subscribers. + try { + await drainAgentRunIterator(iterator); + } catch (error) { + if (!isStaleProviderSessionError(error)) throw error; + logger.info( + { agentId, err: error }, + "Provider session went stale; reopening from persistence", + ); + await agentManager.reloadAgentSession(agentId); + const retry = await startOrReplaceRun(agentManager, agentId, prompt, options); + await drainAgentRunIterator(retry.iterator); } logger.trace( { @@ -166,27 +212,14 @@ export async function unarchiveAgentState( return true; } -/** - * Wrap a body in … so the receiving agent - * recognizes the prompt as system-injected context — not a user turn. - * Used by chat mentions, schedule fires, and notify-on-finish. - */ -export function formatSystemNotificationPrompt(reason: string): string { - return `\n${reason}\n`; -} - -const SYSTEM_ENVELOPE_PATTERN = /^\n[\s\S]*\n<\/paseo-system>$/; - -export function isSystemInjectedEnvelope(text: string): boolean { - return SYSTEM_ENVELOPE_PATTERN.test(text); -} - export interface SendPromptToAgentParams { + onDispatch?: StartAgentRunOptions["onDispatch"]; agentManager: AgentManager; agentStorage: AgentStorage; agentId: string; /** Prompt to dispatch to the provider (may include image blocks or wrapped text). */ prompt: AgentPromptInput; + source?: AgentPromptSource; messageId?: string; activeTurnBehavior?: ActiveTurnBehavior; runOptions?: AgentRunOptions; @@ -204,10 +237,12 @@ export interface SendPromptToAgentParams { } export interface StartCreatedAgentInitialPromptParams { + agentStorage: AgentStorage; agentManager: AgentManager; agentId: string; snapshot?: ManagedAgent; prompt: AgentPromptInput | null; + source?: AgentPromptSource; runOptions?: AgentRunOptions; logger: Logger; } @@ -229,6 +264,7 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000; export async function waitForAgentRunStartWithTimeout( agentManager: AgentManager, agentId: string, + signal?: AbortSignal, ): Promise { const provider = agentManager.getAgent(agentId)?.provider ?? "provider"; const startAbort = new AbortController(); @@ -243,12 +279,26 @@ export async function waitForAgentRunStartWithTimeout( ); try { - await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal }); + await agentManager.waitForAgentRunStart(agentId, { + signal: signal ? AbortSignal.any([startAbort.signal, signal]) : startAbort.signal, + }); } finally { clearTimeout(startTimeout); } } +async function resolvePromptSource( + source: AgentPromptSource | undefined, + manager: Pick, + storage: AgentStorage, +): Promise { + if (!source) return undefined; + const title = ( + manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title + )?.trim(); + return title ? { ...source, title } : source; +} + /** * Full send-prompt orchestration: (optional unarchive) → load → (optional * mode change) → start run. @@ -266,29 +316,44 @@ export async function sendPromptToAgent( const unarchive = params.unarchive ?? true; const record = await params.agentStorage.get(params.agentId); + let archivedAtToRestore: string | null = null; if (record?.archivedAt) { if (!unarchive) { return { disposition: "turn_started" }; } - await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId); + if (await unarchiveAgentState(params.agentStorage, params.agentManager, params.agentId)) { + archivedAtToRestore = record.archivedAt; + } } - await ensureAgentLoaded(params.agentId, { - agentManager: params.agentManager, - agentStorage: params.agentStorage, - logger: params.logger, - }); + try { + await ensureAgentLoaded(params.agentId, { + agentManager: params.agentManager, + agentStorage: params.agentStorage, + logger: params.logger, + }); + } catch (error) { + // A send that could not load the agent leaves it where it was: still archived. + // Concurrent sends share this load, so none of them holds a live session. + if (archivedAtToRestore) { + await params.agentManager.archiveSnapshot(params.agentId, archivedAtToRestore); + } + throw error; + } if (params.sessionMode) { await params.agentManager.setAgentMode(params.agentId, params.sessionMode); } - const runOptions = params.messageId - ? { ...params.runOptions, clientMessageId: params.messageId } + const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage); + const delivery = prepareAgentMessage(params.prompt, source, params.messageId); + const runOptions = delivery.messageId + ? { ...params.runOptions, clientMessageId: delivery.messageId } : params.runOptions; - return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, { + return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, { replaceRunning: true, + onDispatch: params.onDispatch, activeTurnBehavior: params.activeTurnBehavior, clearPendingPermissions: params.clearPendingPermissions, runOptions, @@ -307,14 +372,17 @@ export async function startCreatedAgentInitialPrompt( return currentSnapshot; } + const delivery = prepareAgentMessage( + params.prompt, + await resolvePromptSource(params.source, params.agentManager, params.agentStorage), + params.runOptions?.clientMessageId, + ); const dispatchResult = await startAgentRun( params.agentManager, params.agentId, - params.prompt, + delivery.prompt, params.logger, - { - runOptions: params.runOptions, - }, + { runOptions: { ...params.runOptions, clientMessageId: delivery.messageId } }, ); if (dispatchResult.disposition === "turn_started") { @@ -334,10 +402,26 @@ export interface SetupFinishNotificationParams { childAgentId: string; callerAgentId: string; requireParentOwnership?: boolean; + /** A replacement must observe its new start, not the displaced running snapshot. */ + waitForTurnStart?: boolean; logger: Logger; } -type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed"; +type FinishNotificationReason = + | "finished" + | "errored" + | "needs permission" + | "was closed" + | "was canceled"; + +const finishNotificationEvents = { + finished: "finished", + errored: "errored", + "needs permission": "permission-required", + "was closed": "closed", + // Use the existing failure event so older clients still parse agent-message metadata. + "was canceled": "errored", +} as const; const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000; @@ -382,25 +466,41 @@ interface NotifySafelyOptions { permissionRequest?: AgentPermissionRequest; } -export function setupFinishNotification(params: SetupFinishNotificationParams): void { +// A caller waits on a child through one armed notification. Arming again, such as a +// follow-up prompt while the child still runs, replaces the earlier one so the child's +// next finish reaches the caller once. +const armedFinishNotifications = new WeakMap void>>(); + +export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { agentManager, agentStorage, childAgentId, callerAgentId, requireParentOwnership = false, + waitForTurnStart = false, logger, } = params; let hasSeenRunning = false; + let hasSeenTurn = false; let stopped = false; const notifiedPermissionRequestIds = new Set(); let unsubscribe: (() => void) | null = null; let notificationQueue = Promise.resolve(); + const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); + armedFinishNotifications.set(agentManager, armedByManager); + const armedKey = JSON.stringify([childAgentId, callerAgentId]); + armedByManager.get(armedKey)?.(); + armedByManager.set(armedKey, stop); + function stop(): void { if (stopped) return; stopped = true; unsubscribe?.(); + if (armedByManager.get(armedKey) === stop) { + armedByManager.delete(armedKey); + } } async function notify( @@ -430,7 +530,12 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): agentManager, agentStorage, agentId: callerAgentId, - prompt: formatSystemNotificationPrompt(body), + prompt: body, + source: { + kind: "agent-notification", + agentId: childAgentId, + event: finishNotificationEvents[reason], + }, activeTurnBehavior: "steer", unarchive: false, logger, @@ -450,6 +555,35 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): }); } + function observeState(agent: ManagedAgent): void { + for (const requestId of notifiedPermissionRequestIds) { + if (!agent.pendingPermissions.has(requestId)) { + notifiedPermissionRequestIds.delete(requestId); + } + } + if (agent.lifecycle === "running") { + if (waitForTurnStart && !hasSeenTurn) return; + hasSeenTurn = true; + if (agent.pendingPermissions.size === 0) { + hasSeenRunning = true; + } + return; + } + // Prior terminal state is not this task's outcome. A failure before turn start + // is reported by the dispatch acknowledgement instead of a finish callback. + if (agent.lifecycle === "error" && hasSeenTurn) { + notifySafely("errored"); + return; + } + // Idle is published before the terminal stream event for both completion + // and cancellation. Let that event identify the outcome instead. + if (agent.lifecycle === "closed") { + notifySafely("was closed"); + return; + } + return; + } + unsubscribe = agentManager.subscribe( (event) => { if (stopped) { @@ -457,29 +591,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): } if (event.type === "agent_state") { - for (const requestId of notifiedPermissionRequestIds) { - if (!event.agent.pendingPermissions.has(requestId)) { - notifiedPermissionRequestIds.delete(requestId); - } - } - if (event.agent.lifecycle === "running") { - if (event.agent.pendingPermissions.size === 0) { - hasSeenRunning = true; - } - return; - } - if (event.agent.lifecycle === "error") { - notifySafely("errored"); - return; - } - if (event.agent.lifecycle === "idle" && hasSeenRunning) { - notifySafely("finished"); - return; - } - if (event.agent.lifecycle === "closed") { - notifySafely("was closed"); - return; - } + observeState(event.agent); return; } @@ -487,6 +599,24 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } + if (event.event.type === "turn_started") { + hasSeenTurn = true; + hasSeenRunning = true; + return; + } + + if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") { + // Replacing a turn keeps the child running through the displaced turn's + // cancellation. The task settles only when the child becomes idle. + if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return; + if (event.event.type === "turn_completed" && hasSeenRunning) { + notifySafely("finished"); + } else if (event.event.type === "turn_canceled" && hasSeenTurn) { + notifySafely("was canceled"); + } + return; + } + if (event.event.type === "permission_requested") { // A permission pause is an intermediate checkpoint. Forget the run // observed before it so an idle state during follow-up startup cannot @@ -521,11 +651,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const childSnapshot = agentManager.getAgent(childAgentId); if (!childSnapshot || childSnapshot.lifecycle === "closed") { stop(); - return; + return stop; } - if (childSnapshot.lifecycle === "running") { + if (childSnapshot.lifecycle === "running" && !waitForTurnStart) { + hasSeenTurn = true; hasSeenRunning = true; - } else if (childSnapshot.lifecycle === "error") { - notifySafely("errored"); } + return stop; } diff --git a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts index ec9dbfdb10..a92e547edc 100644 --- a/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.real.e2e.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "vitest"; import { createServer } from "http"; -import { mkdtempSync, rmSync } from "fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import { randomUUID } from "crypto"; @@ -250,4 +250,39 @@ describe("getStructuredAgentResponse (e2e)", () => { expect(result.message.trim().toLowerCase()).toBe("hello"); }, 180000); + + test("does not run the project's Claude hooks for an internal Claude agent", async (context) => { + if (!canRunClaude) { + context.skip(); + } + const hookMarker = path.join(cwd, "stop-hook-ran"); + mkdirSync(path.join(cwd, ".claude")); + writeFileSync( + path.join(cwd, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: "command", command: `touch '${hookMarker}'` }] }], + }, + }), + ); + + const result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "claude", + model: CLAUDE_TEST_MODEL, + cwd, + title: "Branch name generator", + internal: true, + }, + prompt: + 'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}', + schema: z.object({ message: z.string() }), + maxRetries: 2, + persistSession: false, + }); + + expect(result.message.trim().toLowerCase()).toBe("hello"); + expect(existsSync(hookMarker)).toBe(false); + }, 180000); }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c916228020..34d36c9e16 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,4 +1,7 @@ import type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, AgentProviderNotice, AgentTaskItem, JsonValue, @@ -8,7 +11,13 @@ import type { import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -export type { AgentProviderNotice, AgentTaskItem }; +export type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, + AgentProviderNotice, + AgentTaskItem, +}; export type AgentProvider = string; @@ -125,6 +134,7 @@ export interface ProviderSnapshotEntry { fetchedAt?: string; label?: string; description?: string; + iconSvg?: string; defaultModeId?: string | null; } @@ -155,29 +165,6 @@ export interface AgentCreateConfigUnattendedInput { availableModes: AgentMode[]; } -export interface AgentFeatureToggle { - type: "toggle"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: boolean; -} - -export interface AgentFeatureSelect { - type: "select"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: string | null; - options: AgentSelectOption[]; -} - -export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; - export interface AgentCapabilityFlags { [capability: string]: boolean | undefined; supportsStreaming: boolean; @@ -407,6 +394,11 @@ export type AgentTimelineItem = | ToolCallTimelineItem | { type: "todo"; items: AgentTaskItem[] } | { type: "error"; message: string } + | { + type: "notification"; + level: "info" | "warning" | "error"; + message: string; + } | CompactionTimelineItem | PluginTimelineItem; @@ -543,6 +535,7 @@ export interface AgentSlashCommand { } export interface ListImportableSessionsOptions { + providerOptions?: ProviderOptions; limit?: number; /** Optional case-insensitive descriptor search text. */ query?: string; @@ -635,12 +628,22 @@ export interface AgentCreateSessionOptions { * Defaults to true. Providers that cannot honor false should no-op. */ persistSession?: boolean; + /** + * Model ids added by provider configuration (`models` / `additionalModels`). + * Providers that validate against runtime-advertised models must accept these. + */ + configuredModelIds?: readonly string[]; } +/** What a resumed session is for: driving the agent, or reading what it already did. */ +export type AgentResumePurpose = "interactive" | "history"; + /** Runtime-only intent for a persisted-session resume. Never persist this option. */ export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ - purpose?: "interactive" | "history"; + purpose?: AgentResumePurpose; + /** See AgentCreateSessionOptions.configuredModelIds. */ + configuredModelIds?: readonly string[]; } /** @@ -651,11 +654,22 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } +export interface AgentUsageSession { + provider: string; + model?: string; + env: Record; + sessionKey: string; +} + export interface AgentSession { + usageSession?(): AgentUsageSession | null; readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; readonly features?: AgentFeature[]; + /** New provider-owned rows to commit on registration. streamHistory must also + * replay them at their original timestamps; restored sessions omit old rows. */ + readonly initialTimeline?: ImportedTimelineEntry[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; @@ -699,7 +713,7 @@ export interface AgentSession { } | null; } -export type FetchCatalogOptions = +export type FetchCatalogOptions = { providerOptions?: ProviderOptions } & ( | { scope: "global"; force: boolean; @@ -708,7 +722,8 @@ export type FetchCatalogOptions = scope: "workspace"; cwd: string; force: boolean; - }; + } +); export interface ProviderRefreshContext { readonly signal: AbortSignal; @@ -742,6 +757,10 @@ export interface AgentClient { launchContext?: AgentLaunchContext, options?: AgentResumeSessionOptions, ): Promise; + /** Equal keys share availability and catalogue discovery within this configured client. + * Include the execution environment and effective configuration; omit to use target identity. + * force must not affect identity. Resolve before every cache lookup. */ + getCatalogCacheKey?(options: FetchCatalogOptions): Promise; /** * Discover models and modes together. Implementations may use one upstream * process, separate upstream calls, static modes, or private helpers; callers @@ -769,10 +788,10 @@ export interface AgentClient { context: ImportProviderSessionContext, ): Promise; /** - * Check if this provider is available (CLI binary is installed). + * Check availability in the catalogue target when supplied (CLI binary is installed). * Returns true if available, false otherwise. */ - isAvailable(signal?: AbortSignal): Promise; + isAvailable(signal?: AbortSignal, options?: FetchCatalogOptions): Promise; getDiagnostic?(): Promise<{ diagnostic: string }>; /** * Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close(). diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index b41bc75cfc..be94a31d49 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -16,7 +16,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z model: z.string().nullable().optional(), thinkingOptionId: z.string().nullable().optional(), featureValues: z.record(z.string(), z.unknown()).nullable().optional(), - providerOptions: z.record(z.string(), z.json()).nullable().optional(), + providerOptions: z.record(z.string(), z.unknown()).nullable().optional(), toolPolicy: z .object({ preapproved: z.array( diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index c48a5ec8e4..1cdf4bf375 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -723,6 +723,57 @@ describe("AgentStreamCoalescer", () => { ]); }); + test("keeps a known tool call detail when a later update in the window has none", () => { + const { coalescer, flushes } = createHarness(); + primeLeadingEdge(coalescer, flushes); + + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }), + ); + coalescer.handle( + "agent-1", + timeline({ + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { description: "List files" }, output: null }, + }), + ); + + expect(flushes.map((flush) => flush.item)).toEqual([ + { + type: "tool_call", + callId: "agent-call", + name: "Agent", + status: "completed", + error: null, + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "List files", + log: "", + actions: [], + }, + }, + ]); + }); + test("preserves mixed text and tool call arrival order within a flush", async () => { const { coalescer, flushes } = createHarness(); primeLeadingEdge(coalescer, flushes); diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index ca52a5479e..c11d968228 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,4 +1,5 @@ import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js"; +import { mergeToolCallItems } from "./timeline-projection.js"; export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60; @@ -183,9 +184,16 @@ export class AgentStreamCoalescer { } const existingIndex = buffer.toolCallEntryIndexes.get(event.item.callId); + const existing = existingIndex !== undefined ? buffer.entries[existingIndex] : undefined; + // Merge rather than replace, so an update that carries less (an unknown detail after a + // labeled one) cannot erase what the timeline would have kept had both been flushed. + let item = event.item; + if (existing?.kind === "tool_call") { + item = mergeToolCallItems(existing.item, event.item); + } const entry: PendingToolCallEntry = { kind: "tool_call", - item: event.item, + item, provider: event.provider, ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), }; diff --git a/packages/server/src/server/agent/agent-timeline-content.test.ts b/packages/server/src/server/agent/agent-timeline-content.test.ts index f3b94e248e..c40b712fad 100644 --- a/packages/server/src/server/agent/agent-timeline-content.test.ts +++ b/packages/server/src/server/agent/agent-timeline-content.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; describe("agent timeline content", () => { - test("limits terminal input to the tool-call content budget", () => { + test("marks truncated plain-text tool content within the content budget", () => { const oversizedInput = "x".repeat(64 * 1024 + 1); const item = limitAgentTimelineItemContent({ @@ -19,6 +19,7 @@ describe("agent timeline content", () => { }, }); + expect(limitAgentTimelineItemContent(item)).toEqual(item); expect(item).toEqual({ type: "tool_call", callId: "terminal-session-4242", @@ -27,7 +28,7 @@ describe("agent timeline content", () => { error: null, detail: { type: "plain_text", - text: "x".repeat(64 * 1024), + text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]", icon: "square_terminal", }, }); diff --git a/packages/server/src/server/agent/agent-timeline-content.ts b/packages/server/src/server/agent/agent-timeline-content.ts index 8cf92381fa..d9bb08c0bc 100644 --- a/packages/server/src/server/agent/agent-timeline-content.ts +++ b/packages/server/src/server/agent/agent-timeline-content.ts @@ -42,11 +42,12 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem { ) { return item; } + const notice = "\n\n[Content truncated]"; return { ...item, detail: { ...item.detail, - text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH), + text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice, }, }; } diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index d73f52299c..639b24a904 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -1,3 +1,4 @@ +import type { ProjectedTimelineRow } from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; export interface AgentTimelineRow { @@ -19,7 +20,7 @@ export interface AgentTimelineFetchOptions { direction?: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; /** - * Number of canonical rows to return. + * Number of projected items to return. * - undefined: store default * - 0: all rows in the selected window */ @@ -41,7 +42,9 @@ export interface AgentTimelineFetchResult { window: AgentTimelineWindow; hasOlder: boolean; hasNewer: boolean; - rows: AgentTimelineRow[]; + startSeq: number | null; + endSeq: number | null; + rows: ProjectedTimelineRow[]; } export interface AgentTimelineStore { diff --git a/packages/server/src/server/agent/agent-timeline-store.test.ts b/packages/server/src/server/agent/agent-timeline-store.test.ts index 2f801e0271..895aec2684 100644 --- a/packages/server/src/server/agent/agent-timeline-store.test.ts +++ b/packages/server/src/server/agent/agent-timeline-store.test.ts @@ -11,17 +11,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -32,7 +32,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 2, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "before", reset: false, @@ -45,12 +45,12 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -65,17 +65,17 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 5, timestamp: "2026-01-01T00:00:00.000Z", - item: { type: "assistant_message", text: "five" }, + item: { type: "assistant_message", text: "five", messageId: "five" }, }, { seq: 6, timestamp: "2026-01-01T00:00:01.000Z", - item: { type: "assistant_message", text: "six" }, + item: { type: "assistant_message", text: "six", messageId: "six" }, }, { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); @@ -86,7 +86,7 @@ describe("InMemoryAgentTimelineStore", () => { limit: 1, }); - expect(result).toEqual({ + expect(result).toMatchObject({ epoch: "epoch-1", direction: "after", reset: true, @@ -99,9 +99,115 @@ describe("InMemoryAgentTimelineStore", () => { { seq: 7, timestamp: "2026-01-01T00:00:02.000Z", - item: { type: "assistant_message", text: "seven" }, + item: { type: "assistant_message", text: "seven", messageId: "seven" }, }, ], }); }); }); + +describe("projected timeline retention", () => { + it("retains one full tool state while streaming every source update", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + for (let seq = 1; seq <= 2000; seq++) { + const item = { + type: "tool_call" as const, + callId: "child", + name: "task", + status: "running" as const, + error: null, + detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) }, + }; + expect(store.append("agent", item)).toMatchObject({ seq, item }); + } + const result = store.fetch("agent", { limit: 0 }); + expect(result.rows).toHaveLength(1); + expect(JSON.stringify(result.rows).length).toBeLessThan(260_000); + expect(result.window.maxSeq).toBe(2000); + }); + + it("catches up a mid-message cursor with the complete projected message", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent", { epoch: "e" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "A" }); + store.append("agent", { type: "assistant_message", messageId: "m", text: "B" }); + expect( + store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows, + ).toMatchObject([ + { + item: { text: "AB" }, + seqStart: 1, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }, + ]); + }); +}); + +describe("projected sequence ownership", () => { + it("preserves fetched coverage when another source chunk arrives", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", text: "A" }); + const before = store.fetch("a"); + store.append("a", { type: "assistant_message", text: "B" }); + expect(before.rows[0]).toMatchObject({ + item: { text: "A" }, + seqEnd: 1, + sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }], + }); + expect(store.fetch("a").rows[0]).toMatchObject({ + item: { text: "AB" }, + seqEnd: 2, + sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], + }); + }); + it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = { + type: "tool_call" as const, + callId: "t", + name: "shell", + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }; + store.append("a", { ...tool, status: "running" }); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", { ...tool, status: "completed" }); + store.initialize("b", { rows: store.getRows("a") }); + expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4); + expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]); + }); + it("returns the last projected assistant message without joining different messages", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + store.append("a", { type: "assistant_message", messageId: "first", text: "First" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" }); + store.append("a", { type: "assistant_message", messageId: "second", text: "ond" }); + expect(store.getLastAssistantMessage("a")).toBe("Second"); + }); +}); + +it("includes transitive tool updates in a contiguous projected tail", () => { + const store = new InMemoryAgentTimelineStore(); + store.initialize("a"); + const tool = (callId: string, status: "running" | "completed") => ({ + type: "tool_call" as const, + callId, + name: "shell", + status, + error: null, + detail: { type: "plain_text" as const, label: "work" }, + }); + store.append("a", tool("first", "running")); + store.append("a", tool("second", "running")); + store.append("a", { type: "user_message", text: "Continue" }); + store.append("a", tool("first", "completed")); + store.append("a", { type: "assistant_message", text: "Answer" }); + store.append("a", tool("second", "completed")); + const tail = store.fetch("a", { limit: 1 }); + expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]); + expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false }); +}); diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index e57080711a..6a37e11ef0 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -1,4 +1,9 @@ import { randomUUID } from "node:crypto"; +import { + TimelineProjection, + selectProjectedTimelinePage, + type ProjectedTimelineRow, +} from "./timeline-projection.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineFetchOptions, @@ -16,125 +21,15 @@ export interface SeedAgentTimelineOptions { interface AgentTimelineState { epoch: string; - rows: AgentTimelineRow[]; + projection: TimelineProjection; + minSeq: number; nextSeq: number; } - const DEFAULT_TIMELINE_FETCH_LIMIT = 200; - -function cloneRow(row: AgentTimelineRow): AgentTimelineRow { +function cloneRow(row: T): T { return { ...row }; } -interface FetchContext { - state: AgentTimelineState; - direction: NonNullable; - limit: number; - selectAll: boolean; - cursor: AgentTimelineFetchOptions["cursor"]; - minSeq: number; - maxSeq: number; - window: { minSeq: number; maxSeq: number; nextSeq: number }; -} - -function fetchTail(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const selected = - selectAll || limit >= state.rows.length - ? state.rows - : state.rows.slice(state.rows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: false, - rows: selected.map(cloneRow), - }; -} - -function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx; - const baseSeq = cursor?.seq ?? 0; - const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); - if (startIdx < 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: baseSeq >= minSeq, - hasNewer: false, - rows: [], - }; - } - - const selected = selectAll - ? state.rows.slice(startIdx) - : state.rows.slice(startIdx, startIdx + limit); - const lastSelected = selected[selected.length - 1]; - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected[0].seq > minSeq, - hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq, - rows: selected.map(cloneRow), - }; -} - -function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx; - const beforeSeq = cursor?.seq ?? state.nextSeq; - const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); - const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); - const selected = - selectAll || limit >= boundedRows.length - ? boundedRows - : boundedRows.slice(boundedRows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0].seq > minSeq, - hasNewer: endExclusive >= 0, - rows: selected.map(cloneRow), - }; -} - -function fetchReset( - ctx: FetchContext, - flags: { staleCursor: boolean; gap: boolean }, -): AgentTimelineFetchResult { - const { state, direction, limit, selectAll, minSeq, window } = ctx; - const rows = - selectAll || limit >= state.rows.length - ? state.rows.map(cloneRow) - : state.rows.slice(state.rows.length - limit).map(cloneRow); - return { - epoch: state.epoch, - direction, - reset: true, - staleCursor: flags.staleCursor, - gap: flags.gap, - window, - hasOlder: rows.length > 0 && rows[0].seq > minSeq, - hasNewer: false, - rows, - }; -} - export class InMemoryAgentTimelineStore { private readonly states = new Map(); @@ -147,10 +42,13 @@ export class InMemoryAgentTimelineStore { const rows = options?.rows?.length ? options.rows.map(cloneRow) : this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp); - const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1].seq + 1 : 1); + const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1); + const projection = new TimelineProjection(); + for (const row of rows) projection.append(row); this.states.set(agentId, { epoch: options?.epoch ?? randomUUID(), - rows, + projection, + minSeq: projection.getRows()[0]?.seqStart ?? 0, nextSeq, }); } @@ -160,19 +58,23 @@ export class InMemoryAgentTimelineStore { } getItems(agentId: string): AgentTimelineItem[] { - return this.requireState(agentId).rows.map((row) => row.item); + return this.requireState(agentId) + .projection.getRows() + .map((row) => row.item); } - getRows(agentId: string): AgentTimelineRow[] { - return this.requireState(agentId).rows.map(cloneRow); + getRows(agentId: string): ProjectedTimelineRow[] { + return this.requireState(agentId).projection.getRows().map(cloneRow); } getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null { - const row = this.requireState(agentId).rows.find( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, - ); + const row = this.requireState(agentId) + .projection.getRows() + .find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); return row ? cloneRow(row) : null; } @@ -181,19 +83,10 @@ export class InMemoryAgentTimelineStore { clientMessageId: string, providerMessageId: string, ): AgentTimelineRow | null { - const state = this.requireState(agentId); - const index = state.rows.findIndex( - (candidate) => - candidate.item.type === "user_message" && - candidate.item.clientMessageId === clientMessageId, + return this.requireState(agentId).projection.enrichSubmittedUserMessage( + clientMessageId, + providerMessageId, ); - const row = state.rows[index]; - if (!row || row.item.type !== "user_message") { - return null; - } - const enriched: AgentTimelineRow = { ...row, providerMessageId }; - state.rows[index] = enriched; - return cloneRow(enriched); } getEpoch(agentId: string): string { @@ -203,62 +96,37 @@ export class InMemoryAgentTimelineStore { fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult { const state = this.requireState(agentId); const direction = options?.direction ?? "tail"; - const requestedLimit = options?.limit; - const limit = - requestedLimit === undefined - ? DEFAULT_TIMELINE_FETCH_LIMIT - : Math.max(0, Math.floor(requestedLimit)); const cursor = options?.cursor; - const minSeq = state.rows.length ? state.rows[0].seq : 0; - const maxSeq = state.rows.length ? state.rows[state.rows.length - 1].seq : 0; - const selectAll = limit === 0; - - const window = { - minSeq, - maxSeq, - nextSeq: state.nextSeq, - }; - - const ctx: FetchContext = { - state, + const rows = state.projection.getRows(); + const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq }; + const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch; + const gap = + !staleCursor && + direction === "after" && + cursor !== undefined && + rows.length > 0 && + cursor.seq < state.minSeq - 1; + const reset = staleCursor || gap; + const page = selectProjectedTimelinePage({ + rows, + bounds: window, + direction: reset ? "tail" : direction, + cursorSeq: cursor?.seq, + limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT, + }); + return { + epoch: state.epoch, direction, - limit, - selectAll, - cursor, - minSeq, - maxSeq, + reset, + staleCursor, + gap, window, + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + startSeq: page.startSeq, + endSeq: page.endSeq, + rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)), }; - - if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) { - return fetchReset(ctx, { staleCursor: true, gap: false }); - } - - if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { - return fetchReset(ctx, { staleCursor: false, gap: true }); - } - - if (state.rows.length === 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: false, - hasNewer: false, - rows: [], - }; - } - - if (direction === "tail") { - return fetchTail(ctx); - } - if (direction === "after") { - return fetchAfter(ctx); - } - return fetchBefore(ctx); } append( @@ -275,34 +143,21 @@ export class InMemoryAgentTimelineStore { ...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}), }; state.nextSeq += 1; - state.rows.push(row); + if (state.minSeq === 0) state.minSeq = row.seq; + state.projection.append(row); return cloneRow(row); } getLastItem(agentId: string): AgentTimelineItem | null { const state = this.requireState(agentId); - return state.rows[state.rows.length - 1]?.item ?? null; + return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null; } getLastAssistantMessage(agentId: string): string | null { - const rows = this.requireState(agentId).rows; - const chunks: string[] = []; - for (let i = rows.length - 1; i >= 0; i -= 1) { - const item = rows[i].item; - if (item.type !== "assistant_message") { - if (chunks.length > 0) { - break; - } - continue; - } - chunks.push(item.text); - } - - if (chunks.length === 0) { - return null; - } - - return chunks.toReversed().join(""); + const row = this.requireState(agentId) + .projection.getRows() + .findLast((candidate) => candidate.item.type === "assistant_message"); + return row?.item.type === "assistant_message" ? row.item.text : null; } private requireState(agentId: string): AgentTimelineState { diff --git a/packages/server/src/server/agent/chat-search/index.test.ts b/packages/server/src/server/agent/chat-search/index.test.ts new file mode 100644 index 0000000000..41054d167c --- /dev/null +++ b/packages/server/src/server/agent/chat-search/index.test.ts @@ -0,0 +1,88 @@ +import { expect, it } from "vitest"; +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { searchTimeline } from "./index.js"; + +function rows(items: AgentTimelineItem[]) { + return items.map((item, index) => ({ item, seq: index + 1, timestamp: "2026-09-12T00:00:00Z" })); +} + +it("returns source locations for formatted assistant text and literal user text", async () => { + const result = await searchTimeline({ + rows: rows([ + { type: "user_message", text: "hello **world**" }, + { type: "assistant_message", text: "İ😀hello **world** and hello *world*" }, + { + type: "assistant_message", + text: "[hello & world](https://example.com)", + messageId: "separate", + }, + ]), + query: "hello world", + }); + expect(result).toEqual({ + locations: [{ seq: 2, role: "assistant", count: 2 }], + nextCursor: null, + }); +}); + +it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => { + const result = await searchTimeline({ + rows: rows([ + { type: "assistant_message", text: "```ts\nconst value = 'a.b';\n```" }, + { type: "reasoning", text: "a.b" }, + { type: "todo", items: [{ text: "a.b", completed: false }] }, + { type: "user_message", text: "axb" }, + { type: "user_message", text: "a.b" }, + ]), + query: "a.b", + }); + expect(result.locations).toEqual([ + { seq: 1, role: "assistant", count: 1 }, + { seq: 5, role: "user", count: 1 }, + ]); +}); + +it("bounds results and resumes without losing locations", async () => { + const source = rows( + Array.from({ length: 202 }, () => ({ type: "user_message", text: "target" })), + ); + const first = await searchTimeline({ rows: source, query: "target" }); + expect(first.locations).toHaveLength(200); + expect(first.nextCursor).toBe(200); + const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! }); + expect(second).toEqual({ + locations: [ + { seq: 201, role: "user", count: 1 }, + { seq: 202, role: "user", count: 1 }, + ], + nextCursor: null, + }); +}); + +it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => { + const source = rows([{ type: "assistant_message", text: "hello & **world**\nnext line" }]); + expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 1 }, + ]); + expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 1 }, + ]); +}); + +it("counts occurrences per rendered block rather than across block boundaries", async () => { + const source = rows([ + { type: "assistant_message", text: "target one\n\ntarget two\n\n- target three\n- end target" }, + { type: "user_message", text: "TARGET target" }, + ]); + expect((await searchTimeline({ rows: source, query: "target" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 4 }, + { seq: 2, role: "user", count: 2 }, + ]); + expect((await searchTimeline({ rows: source, query: "end target" })).locations).toEqual([ + { seq: 1, role: "assistant", count: 1 }, + ]); + expect((await searchTimeline({ rows: source, query: "two target" })).locations).toEqual([]); + // Rendered text never runs from the end of one block into the start of the next, so + // neither does a match, however contiguous the Markdown source looks. + expect((await searchTimeline({ rows: source, query: "one target" })).locations).toEqual([]); +}); diff --git a/packages/server/src/server/agent/chat-search/index.ts b/packages/server/src/server/agent/chat-search/index.ts new file mode 100644 index 0000000000..70ada71b57 --- /dev/null +++ b/packages/server/src/server/agent/chat-search/index.ts @@ -0,0 +1,87 @@ +import MarkdownIt from "markdown-it"; +import { setImmediate } from "node:timers/promises"; +import type { AgentTimelineRow } from "../agent-timeline-store-types.js"; +import { projectTimelineRows } from "../timeline-projection.js"; + +const markdown = new MarkdownIt({ html: false }); +const PAGE_SIZE = 200; +interface SearchLocation { + seq: number; + role: "user" | "assistant"; + count: number; +} +interface SearchInput { + rows: readonly AgentTimelineRow[]; + query: string; + cursor?: number; +} + +// Discovery is deliberately approximate. Only the client can verify displayed text, so +// `count` estimates rendered occurrences and the client replaces it with the rendered +// count once the message is on screen. Blocks are counted separately, and the Markdown +// source is never searched whole, because a match the client cannot highlight — across +// a block boundary, or on syntax the reader never sees — still inflates the total until +// navigation reaches that message and drops it. +function searchableBlocks(text: string): string[] { + return markdown + .parse(text, {}) + .map((token) => { + if (token.type === "inline") { + return (token.children ?? []) + .map((child) => { + if (child.type === "text" || child.type === "code_inline") return child.content; + if (child.type === "softbreak" || child.type === "hardbreak") return "\n"; + return ""; + }) + .join(""); + } + if (token.type === "fence" || token.type === "code_block") return token.content; + return ""; + }) + .filter(Boolean); +} + +function searchPattern(query: string): RegExp { + const source = query + .trim() + .split(/\s+/) + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("\\s+"); + return new RegExp(source, "giu"); +} + +function countMatches(text: string, pattern: RegExp): number { + let count = 0; + for (const _ of text.matchAll(pattern)) count += 1; + return count; +} + +export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) { + const locations: SearchLocation[] = []; + if (!query.trim()) return { locations, nextCursor: null }; + const pattern = searchPattern(query); + let lastYield = performance.now(); + for (const entry of projectTimelineRows({ rows, mode: "projected" })) { + if (entry.seqEnd <= cursor) continue; + const item = entry.item; + if (item.type !== "user_message" && item.type !== "assistant_message") continue; + const raw = item.text.replace(/\r/g, ""); + const blocks = item.type === "assistant_message" ? searchableBlocks(raw) : [raw]; + const count = blocks.reduce((sum, block) => sum + countMatches(block, pattern), 0); + if (count) { + if (locations.length === PAGE_SIZE) { + return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq }; + } + locations.push({ + seq: entry.seqEnd, + role: item.type === "user_message" ? "user" : "assistant", + count, + }); + } + if (performance.now() - lastYield >= 8) { + await setImmediate(); + lastYield = performance.now(); + } + } + return { locations, nextCursor: null }; +} diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 53a2177272..554bb9869e 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -7,13 +7,11 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, + CreatePaseoWorktreeSetupContinuationInput, + NormalizedGitOptions, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; -import type { - CreateAgentWorktreeTarget, - FirstAgentContext, - SessionOutboundMessage, -} from "../messages.js"; +import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js"; import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; @@ -59,18 +57,34 @@ export class CreateAgentLifecycleDispatch { async createWorktreeForRequest(input: { cwd: string; - target: CreateAgentWorktreeTarget | undefined; + target: NormalizedGitOptions | null; firstAgentContext: FirstAgentContext; - hasLegacyGitOptions: boolean; + setupContinuation?: CreatePaseoWorktreeSetupContinuationInput; + background?: boolean; + callerWorkspaceId?: string; }): Promise { - if (input.target && input.hasLegacyGitOptions) { - throw new Error("create_agent_request worktree cannot be combined with git options"); - } - if (!input.target) { - return null; - } - - return this.createWorktreeForTarget(input.cwd, input.target, input.firstAgentContext); + const target = input.target; + if (!target?.createWorktree) return null; + return this.dependencies.createPaseoWorktreeWorkflow( + { + cwd: input.cwd, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, + firstAgentContext: input.firstAgentContext, + worktreeSlug: target.worktreeSlug, + refName: target.refName, + action: target.action, + checkoutSource: target.checkoutSource, + githubPrNumber: target.githubPrNumber, + runSetup: false, + paseoHome: this.dependencies.paseoHome, + worktreesRoot: this.dependencies.worktreesRoot, + }, + { + ...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}), + setupContinuation: input.setupContinuation, + }, + ); } registerAutoArchiveIfRequested(input: { @@ -111,47 +125,6 @@ export class CreateAgentLifecycleDispatch { }); } - private async createWorktreeForTarget( - cwd: string, - target: CreateAgentWorktreeTarget, - firstAgentContext: FirstAgentContext, - ): Promise { - const baseInput = { - cwd, - firstAgentContext, - runSetup: false, - paseoHome: this.dependencies.paseoHome, - worktreesRoot: this.dependencies.worktreesRoot, - } as const; - - switch (target.mode) { - case "branch-off": - return this.dependencies.createPaseoWorktreeWorkflow( - { - ...baseInput, - worktreeSlug: target.newBranch, - action: "branch-off", - ...(target.base ? { refName: target.base } : {}), - }, - target.base ? { resolveDefaultBranch: async () => target.base! } : undefined, - ); - case "checkout-branch": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - refName: target.branch, - }); - case "checkout-pr": - return this.dependencies.createPaseoWorktreeWorkflow({ - ...baseInput, - action: "checkout", - githubPrNumber: target.prNumber, - }); - default: - throw new Error("Unsupported create_agent_request worktree target"); - } - } - private registerAutoArchiveOnTerminalState( agentId: string, target: AutoArchiveTarget, diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index e8547d27ed..f12309da2a 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -11,6 +11,7 @@ import { AgentStorage } from "../agent-storage.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createAgentCommand } from "./create.js"; import type { ManagedAgent } from "../agent-manager.js"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; const logger = createTestLogger(); @@ -211,7 +212,6 @@ test("mcp create accepts provider-only internal input and leaves model undefined workspaceId: "ws-create-test", title: "provider default", initialPrompt: "hello", - background: true, notifyOnFinish: false, }); @@ -329,7 +329,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async provider: "codex/gpt-5.4", title: "child", initialPrompt: "do the thing", - background: true, notifyOnFinish: false, callerAgentId: parent.id, worktree: { worktreeName: "feature", baseBranch: "main" }, @@ -378,7 +377,6 @@ test("mcp create exposes the created worktree before dispatching the initial pro cwd: workdir, title: "worktree callback", initialPrompt: "Say done.", - background: true, notifyOnFinish: false, worktree: { worktreeName: "feature", baseBranch: "main" }, onCreated: ({ agentId, createdWorktree: callbackWorktree }) => { @@ -471,3 +469,47 @@ test("session create keeps an explicit title after the initial prompt settles", await removeRealAgentManagerWorkdir({ agentManager, storage, workdir }); } }); + +test("session create with an internal config skips provider session persistence", async () => { + const snapshot = { + id: "agent-1", + provider: "codex", + cwd: "/tmp/paseo-create-test", + runtimeInfo: null, + } as ManagedAgent; + const createAgent = vi.fn(async () => snapshot); + const dependencies: Parameters[0] = { + agentManager: { + createAgent, + } as unknown as Parameters[0]["agentManager"], + agentStorage: {} as Parameters[0]["agentStorage"], + logger: createTestLogger(), + providerSnapshotManager: createProviderSnapshotManagerStub().manager, + }; + const input = { + kind: "session" as const, + workspaceId: "ws-create-test", + labels: {}, + provisionalTitle: null, + firstAgentContext: { attachments: [] }, + buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }), + }; + + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test", internal: true }, + }); + await createAgentCommand(dependencies, { + ...input, + config: { provider: "codex", cwd: "/tmp/paseo-create-test" }, + }); + + expect(createAgent).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ provider: "codex", internal: true }), + undefined, + expect.objectContaining({ persistSession: false }), + ); + expect(createAgent.mock.calls[1]?.[0]).not.toHaveProperty("internal"); + expect(createAgent.mock.calls[1]?.[2]).not.toHaveProperty("persistSession"); +}); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index b31a858f34..05f98285e1 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -1,3 +1,4 @@ +import type { AgentPromptSource } from "../agent-messages/index.js"; import type { Logger } from "pino"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; @@ -51,14 +52,18 @@ export interface CreateAgentCommandDependencies { export type EnsureWorkspaceForCreate = ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise; export interface CreateAgentFromSessionInput { kind: "session"; + onAgentReady?: (agent: ManagedAgent) => Promise; + agentId?: string; config: AgentSessionConfig; workspaceId: string; worktreeName?: string; initialPrompt?: string; + source?: AgentPromptSource; clientMessageId?: string; outputSchema?: Record; images?: Array<{ data: string; mimeType: string }>; @@ -90,7 +95,6 @@ export interface CreateAgentFromMcpInput { mode?: string; unattended?: boolean; promptFailure?: CreateAgentPromptFailureMode; - background: boolean; notifyOnFinish: boolean; internal?: boolean; detached?: boolean; @@ -123,7 +127,6 @@ export type CreateAgentPromptFailureMode = "throw" | "log" | "return-error"; export interface CreateAgentCommandResult { snapshot: ManagedAgent; liveSnapshot: ManagedAgent; - background: boolean; initialPromptStarted: boolean; initialPromptError: unknown | null; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -162,9 +165,9 @@ interface ResolvedCreateAgent { config: AgentSessionConfig; createOptions: CreateAgentOptions; prompt?: AgentPromptInput; + source?: AgentPromptSource; runOptions?: AgentRunOptions; setupContinuation?: AgentWorktreeSetupContinuation; - background: boolean; promptFailure: CreateAgentPromptFailureMode; promptLogger?: Logger; createdWorktree?: CreatePaseoWorktreeWorkflowResult; @@ -181,13 +184,11 @@ export async function createAgentCommand( const snapshot = await dependencies.agentManager.createAgent( resolved.config, - undefined, + input.kind === "session" ? input.agentId : undefined, resolved.createOptions, ); - resolved.setupContinuation?.startAfterAgentCreate({ - agentId: snapshot.id, - }); + if (input.kind === "session") await input.onAgentReady?.(snapshot); let liveSnapshot = snapshot; let initialPromptStarted = false; @@ -195,28 +196,37 @@ export async function createAgentCommand( if (input.kind === "mcp") { input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null }); } + const stopNotification = + input.kind === "mcp" && + input.notifyOnFinish && + input.callerAgentId && + (resolved.prompt !== undefined || resolved.setupContinuation !== undefined) + ? setupFinishNotification({ + agentManager: dependencies.agentManager, + agentStorage: dependencies.agentStorage, + childAgentId: snapshot.id, + callerAgentId: input.callerAgentId, + requireParentOwnership: true, + logger: dependencies.logger, + }) + : undefined; + + resolved.setupContinuation?.startAfterAgentCreate({ + agentId: snapshot.id, + }); if (resolved.prompt !== undefined) { - const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot); + const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => { + stopNotification?.(); + throw error; + }); initialPromptStarted = sendResult.started; liveSnapshot = sendResult.liveSnapshot; initialPromptError = sendResult.error ?? null; } - if (input.kind === "mcp" && input.notifyOnFinish && input.callerAgentId && initialPromptStarted) { - setupFinishNotification({ - agentManager: dependencies.agentManager, - agentStorage: dependencies.agentStorage, - childAgentId: snapshot.id, - callerAgentId: input.callerAgentId, - requireParentOwnership: true, - logger: dependencies.logger, - }); - } - return { snapshot, liveSnapshot, - background: resolved.background, initialPromptStarted, initialPromptError, ...(resolved.createdWorktree ? { createdWorktree: resolved.createdWorktree } : {}), @@ -283,16 +293,20 @@ async function resolveSessionCreateAgent( initialPrompt: trimmedPrompt, env: input.env, initialTitle: input.provisionalTitle, + // Paseo never persists an internal agent, so nothing could resume its + // provider session either. Same as the daemon's own ephemeral helpers. + ...(sessionConfig.internal ? { persistSession: false } : {}), // A legacy git/worktreeName worktree creates a fresh workspace, so the // agent belongs to that workspace, not the source one. createdWorkspaceId // is the freshly created worktree's workspace. workspaceId: requireResolvedWorkspaceId(workspaceId), }, prompt: hasPromptContent ? prompt : undefined, + source: input.source, runOptions, setupContinuation, - background: true, - promptFailure: "throw", + // Registration commits creation. First-turn failures belong to the created agent. + promptFailure: "return-error", promptLogger: dependencies.logger.child({ clientMessageId: resolveClientMessageId(input.clientMessageId), }), @@ -315,6 +329,7 @@ async function resolveMcpCreateAgent( cwd, worktree: input.worktree, initialPrompt: input.initialPrompt ?? "", + callerWorkspaceId: parentAgent?.workspaceId, }); if (createdWorktree) input.onWorktreeCreated?.(createdWorktree); @@ -329,7 +344,12 @@ async function resolveMcpCreateAgent( resolveWorkspace: async (workspaceId) => ({ workspaceId, cwd: resolvedCwd }), createWorkspace: async () => ({ workspaceId: requireResolvedWorkspaceId( - await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt ?? ""), + await ensureWorkspaceForMcpCreate( + dependencies, + resolvedCwd, + input.initialPrompt ?? "", + parentAgent?.workspaceId, + ), ), cwd: resolvedCwd, }), @@ -360,9 +380,11 @@ async function resolveMcpCreateAgent( env: input.env, }, prompt: trimmedPrompt ? trimmedPrompt : undefined, + source: input.callerAgentId + ? { kind: "agent-message", agentId: input.callerAgentId } + : undefined, setupContinuation, createdWorktree, - background: input.background, promptFailure: input.promptFailure ?? "log", }; } @@ -437,11 +459,16 @@ async function ensureWorkspaceForMcpCreate( dependencies: CreateAgentCommandDependencies, cwd: string, initialPrompt: string, + callerWorkspaceId?: string, ): Promise { if (!dependencies.ensureWorkspaceForCreate) { return undefined; } - return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }); + return dependencies.ensureWorkspaceForCreate( + cwd, + { prompt: initialPrompt }, + { callerWorkspaceId }, + ); } async function sendInitialPrompt( @@ -455,10 +482,12 @@ async function sendInitialPrompt( return { started: false, liveSnapshot: snapshot }; } const liveSnapshot = await startCreatedAgentInitialPrompt({ + agentStorage: dependencies.agentStorage, agentManager: dependencies.agentManager, agentId: snapshot.id, snapshot, prompt, + source: resolved.source, runOptions: resolved.runOptions, logger: resolved.promptLogger ?? dependencies.logger, }); @@ -468,7 +497,11 @@ async function sendInitialPrompt( throw error; } if (resolved.promptFailure === "return-error") { - return { started: false, liveSnapshot: snapshot, error }; + return { + started: false, + liveSnapshot: dependencies.agentManager.getAgent(snapshot.id) ?? snapshot, + error, + }; } dependencies.logger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); return { started: false, liveSnapshot: snapshot }; @@ -506,6 +539,7 @@ async function resolveMcpCwd(params: { dependencies: CreateAgentCommandDependencies; cwd: string; initialPrompt: string; + callerWorkspaceId?: string; worktree: CreateAgentFromMcpInput["worktree"]; }): Promise<{ resolvedCwd: string; @@ -536,6 +570,7 @@ async function resolveMcpCwd(params: { const createdWorktree = await createMcpWorktree({ input: { cwd: params.cwd, + callerWorkspaceId: params.callerWorkspaceId, worktreeSlug: worktree.worktreeName, branchName: worktree.branchName, refName: worktree.refName, diff --git a/packages/server/src/server/agent/directory.ts b/packages/server/src/server/agent/directory.ts new file mode 100644 index 0000000000..2f51d4a4c1 --- /dev/null +++ b/packages/server/src/server/agent/directory.ts @@ -0,0 +1,65 @@ +import type { AgentSnapshotPayload } from "../messages.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; + +/** Public agent discovery owns workspace visibility for live and durable records. */ +export class AgentDirectory { + constructor( + private readonly deps: { + manager: AgentManager; + storage: AgentStorage; + workspaces: Pick; + projectLive(agent: ManagedAgent): Promise; + projectStored(record: StoredAgentRecord): AgentSnapshotPayload; + isProviderVisible(provider: string): boolean; + isStoredProviderAvailable(record: StoredAgentRecord): boolean; + }, + ) {} + + async includes( + agent: Pick, + includeBackground = false, + ): Promise { + if (agent.internal) return false; + if (includeBackground || !agent.workspaceId) return true; + return !(await this.deps.workspaces.get(agent.workspaceId))?.background; + } + + async list(filter?: { + labels?: Record; + includeArchived?: boolean; + includeBackground?: boolean; + includeUnavailablePersisted?: boolean; + }): Promise { + const live = this.deps.manager.listAgents(); + const liveIds = new Set(live.map((agent) => agent.id)); + const labels = Object.entries(filter?.labels ?? {}); + const backgroundIds = new Set( + filter?.includeBackground + ? [] + : (await this.deps.workspaces.list()) + .filter((workspace) => workspace.background) + .map((workspace) => workspace.workspaceId), + ); + const matches = (agent: { + archivedAt?: string | null; + workspaceId?: string; + labels?: Record; + }) => + (filter?.includeArchived || !agent.archivedAt) && + (filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) && + labels.every(([key, value]) => agent.labels?.[key] === value); + const stored = (await this.deps.storage.list()) + .filter((record) => !record.internal && !liveIds.has(record.id) && matches(record)) + .filter( + (record) => + filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record), + ) + .map((record) => this.deps.projectStored(record)); + const active = await Promise.all( + live.filter(matches).map((agent) => this.deps.projectLive(agent)), + ); + return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider)); + } +} diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index 6e5714d489..763e676f43 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -219,6 +219,25 @@ describe("agent lifecycle commands", () => { expect(manager.archivedAgentIds).toEqual(["agent-1"]); }); + test("archives a live internal agent without touching storage", async () => { + const storage = new FakeLifecycleAgentStorage(); + const manager = new FakeLifecycleAgentManager(storage); + manager.liveAgents.set("agent-1", { ...managedAgent("agent-1", "idle"), internal: true }); + + const result = await archiveAgentCommand( + { agentManager: manager, agentStorage: storage, logger }, + "agent-1", + ); + + expect(result).toEqual({ + agentId: "agent-1", + archivedAt: "2026-05-10T10:00:00.000Z", + record: null, + }); + expect(manager.archivedAgentIds).toEqual(["agent-1"]); + expect(storage.upserts).toEqual([]); + }); + test("archives a live agent when its graceful cancellation is rejected", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index cb172ce620..431cff0fe6 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -8,7 +8,7 @@ import { import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentProviderNotice } from "./agent-sdk-types.js"; -export type LifecycleAgentSnapshot = Pick; +export type LifecycleAgentSnapshot = Pick; export interface LifecycleAgentManager { getAgent(agentId: string): LifecycleAgentSnapshot | null; @@ -112,7 +112,8 @@ export async function cancelAgentRunCommand( export interface ArchiveAgentResult { agentId: string; archivedAt: string; - record: StoredAgentRecord; + /** Null for an internal agent, which is closed and leaves no record behind. */ + record: StoredAgentRecord | null; } export async function archiveAgentCommand( @@ -124,7 +125,10 @@ export async function archiveAgentCommand( if (liveAgent) { await requestAgentRunCancellation(dependencies, agentId); await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined); - await dependencies.agentManager.archiveAgent(agentId); + const archived = await dependencies.agentManager.archiveAgent(agentId); + if (liveAgent.internal) { + return { agentId, archivedAt: archived.archivedAt, record: null }; + } record = await dependencies.agentStorage.get(agentId); } else { record = await archiveStoredAgent(dependencies, agentId); diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 50867f10d9..bfb6be7964 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -12,6 +12,8 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { parseAgentMessage } from "./agent-messages/index.js"; interface StructuredContent { [key: string]: unknown; @@ -215,7 +217,6 @@ async function createTopLevelAgent(args?: Partial): Promise { - if (!params.worktreePath && !params.worktreeSlug) { - return; - } - try { - await topLevelClient.callTool({ - name: "archive_worktree", - args: { - cwd: params.cwd, - ...(params.worktreePath ? { worktreePath: params.worktreePath } : {}), - ...(params.worktreeSlug ? { worktreeSlug: params.worktreeSlug } : {}), - }, - }); - } catch { - // ignore cleanup errors - } -} - beforeAll(async () => { tempRoot = await mkdtemp(path.join(os.tmpdir(), "mcp-parity-e2e-")); parentAgentCwd = await makeCwd("parent-agent-cwd"); @@ -307,7 +286,6 @@ beforeAll(async () => { provider: "claude/claude-test-model", initialPrompt: "say done and stop", settings: { modeId: "bypassPermissions" }, - background: true, }); parentAgentId = str(parentPayload.agentId); @@ -335,6 +313,167 @@ afterAll(async () => { }); describe("Suite A: Core Fixes", () => { + test.each([ + ["Stop", "cancelAgent"], + ["Reload", "refreshAgent"], + ] as const)( + "%s tells the parent that a watched child was canceled", + async (_action, method) => { + let releaseChild!: () => void; + let childReachedGate!: () => void; + const heldTurn = new Promise((resolve) => { + releaseChild = resolve; + }); + const childAtGate = new Promise((resolve) => { + childReachedGate = resolve; + }); + const parentPrompts: string[] = []; + const daemon = await createTestPaseoDaemon({ + agentClients: createTestAgentClients({ + onStartTurn(prompt, config) { + if (config.title === "Watching parent" && typeof prompt === "string") + parentPrompts.push(prompt); + }, + async beforeTurnComplete(prompt) { + if ( + typeof prompt === "string" && + parseAgentMessage(prompt).text === "Hold this child turn" + ) { + childReachedGate(); + await heldTurn; + } + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + let mcp: McpClient | undefined; + let unsubscribe: (() => void) | undefined; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const parent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Watching parent", + modeId: "bypassPermissions", + }); + mcp = await createMcpClient( + buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }), + ); + const created = await callToolStructured(mcp, "create_agent", { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Watched child", + provider: "codex/gpt-5.4", + initialPrompt: "Hold this child turn", + notifyOnFinish: true, + settings: { modeId: "full-access" }, + }); + const childId = str(created.agentId); + await childAtGate; + expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running"); + const terminalEvents: string[] = []; + unsubscribe = daemon.daemon.agentManager.subscribe( + (event) => { + if ( + event.type === "agent_stream" && + event.agentId === childId && + (event.event.type === "turn_completed" || event.event.type === "turn_canceled") + ) + terminalEvents.push(event.event.type); + }, + { agentId: childId, replayState: false }, + ); + + await client[method](childId); + await waitFor({ + timeoutMs: 10000, + label: "parent cancellation notification", + check: () => (parentPrompts.length === 1 ? true : null), + }); + const notification = parseAgentMessage(parentPrompts[0]); + expect(terminalEvents).toEqual(["turn_canceled"]); + expect(notification.source).toEqual({ + kind: "agent-notification", + agentId: childId, + title: "Watched child", + event: "errored", + }); + expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`); + expect(notification.text).not.toContain("finished."); + await waitFor({ + timeoutMs: 10000, + label: "parent notification turn drained", + check: () => + daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null, + }); + expect(parentPrompts).toHaveLength(1); + } finally { + releaseChild(); + unsubscribe?.(); + await mcp?.close(); + await client.close(); + await daemon.close(); + } + }, + 30000, + ); + + test.each([ + { outcome: "finished", prompt: "say done and stop", status: "idle" }, + { outcome: "errored", prompt: "Emit a turn failure", status: "error" }, + ])( + "a watched child still reports $outcome after send_agent_prompt", + async ({ outcome, prompt, status }) => { + const childId = await createChildAgent({ + title: "Follow-up child", + settings: { modeId: "bypassPermissions" }, + }); + try { + await waitFor({ + timeoutMs: 10000, + label: "initial child turn completed", + check: () => + daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null, + }); + await callToolStructured(agentScopedClient, "send_agent_prompt", { + agentId: childId, + prompt, + notifyOnFinish: true, + }); + const notifications = await waitFor({ + timeoutMs: 10000, + label: `${outcome} notification`, + check: () => { + const items = daemonHandle.daemon.agentManager + .getTimeline(parentAgentId) + .filter( + (item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId, + ); + return items.length > 0 ? items : null; + }, + }); + expect(notifications).toEqual([ + expect.objectContaining({ + type: "tool_call", + agentMessage: { + event: outcome, + sender: { id: childId, title: "Follow-up child" }, + text: expect.stringContaining(`(Follow-up child) ${outcome}.`), + }, + }), + ]); + expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status); + } finally { + await archiveAgentIfPresent(childId); + } + }, + 30000, + ); + test("AGENT_WAIT_TIMEOUT_MS is 30000", () => { expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); }); @@ -845,117 +984,107 @@ describe("Suite D: Provider Tools", () => { }); }); -describe("Suite E: Worktree Tools", () => { - test("list_worktrees on empty repo", async () => { - const payload = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, +describe("Suite E: Workspace Tools", () => { + test("background worktree creation, discovery and archive use ordinary workspace ownership", async () => { + const created = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "worktree", + path: worktreeRepoCwd, + baseBranch: "main", + branchName: `parity-background-${Date.now()}`, + background: true, }); - expect(payload.worktrees).toEqual([]); - }); - - test("create_worktree and list_worktrees", async () => { - let worktreePath: string | null = null; - const branchName = `parity-create-${Date.now()}`; + const workspaceId = str(created.workspaceId); try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + expect(created.background).toBe(true); + const ordinary = await callToolStructured(topLevelClient, "list_workspaces", {}); + expect( + recordArr(ordinary.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); + const inclusive = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, }); - worktreePath = str(created.worktreePath); - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - path: worktreePath, - branchName, - }), - ]), + expect(recordArr(inclusive.workspaces)).toContainEqual( + expect.objectContaining({ workspaceId, background: true }), ); + const child = await callToolStructured(topLevelClient, "create_agent", { + workspaceId, + title: "Background worker", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", + }); + const agentId = str(child.agentId); + const workerClient = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${agentId}`, + ); + let terminalId: string; + try { + const terminal = await callToolStructured(workerClient, "create_terminal", { + name: "Background terminal", + }); + terminalId = str(terminal.id); + expect(terminal.cwd).toBe(created.cwd); + } finally { + await workerClient.close(); + } + const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId }); + expect((status.snapshot as StructuredContent).workspaceId).toBe(workspaceId); + const archived = await callToolStructured(topLevelClient, "archive_workspace", { + workspaceId, + }); + expect(archived.archivedAgentIds).toContain(agentId); + const terminals = await callToolStructured(topLevelClient, "list_terminals", { all: true }); + expect(recordArr(terminals.terminals).map((terminal) => terminal.id)).not.toContain( + terminalId, + ); + const remaining = await callToolStructured(topLevelClient, "list_workspaces", { + includeBackground: true, + }); + expect( + recordArr(remaining.workspaces).map((workspace) => workspace.workspaceId), + ).not.toContain(workspaceId); } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); - test("archive_worktree removes worktree", async () => { - let worktreePath: string | null = null; - const branchName = `parity-archive-${Date.now()}`; + test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { + const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { + isolation: "local", + path: worktreeRepoCwd, + background: true, + }); + const parentWorkspaceId = str(parentWorkspace.workspaceId); + let scoped: McpClient | null = null; + const ownedWorkspaces: string[] = [parentWorkspaceId]; try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, + const parent = await callToolStructured(topLevelClient, "create_agent", { + workspaceId: parentWorkspaceId, + title: "Background parent", + provider: "claude/claude-test-model", + initialPrompt: "Say done.", }); - worktreePath = str(created.worktreePath); - - await callToolStructured(topLevelClient, "archive_worktree", { - cwd: worktreeRepoCwd, - worktreePath, - }); - worktreePath = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, - }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.some((worktree) => worktree.path === created.worktreePath)).toBe(false); - } finally { - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); - } - }); - - test("archive_worktree succeeds when caller cwd is inside the archived worktree", async () => { - let worktreePath: string | null = null; - let worktreeAgentId: string | null = null; - let worktreeScopedClient: McpClient | null = null; - const branchName = `parity-archive-self-cwd-${Date.now()}`; - - try { - const created = await callToolStructured(topLevelClient, "create_worktree", { - cwd: worktreeRepoCwd, - target: { - kind: "branch-off", - worktreeSlug: branchName, - baseBranch: "main", - }, - }); - worktreePath = str(created.worktreePath); - worktreeAgentId = await createTopLevelAgent({ - cwd: worktreePath, - title: "Worktree scoped parity agent", - }); - worktreeScopedClient = await createMcpClient( - `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${encodeURIComponent( - worktreeAgentId, - )}`, + scoped = await createMcpClient( + `http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${str(parent.agentId)}`, ); - - const archived = await callToolStructured(worktreeScopedClient, "archive_worktree", { - worktreePath, + const inherited = await callToolStructured(scoped, "create_workspace", { + isolation: "local", }); - expect(archived).toEqual({ success: true }); - worktreePath = null; - worktreeAgentId = null; - - const listed = await callToolStructured(topLevelClient, "list_worktrees", { - cwd: worktreeRepoCwd, + ownedWorkspaces.push(str(inherited.workspaceId)); + expect(inherited.background).toBe(true); + const visible = await callToolStructured(scoped, "create_workspace", { + isolation: "local", + background: false, }); - const worktrees = recordArr(listed.worktrees); - expect(worktrees.map((worktree) => worktree.path)).not.toContain(created.worktreePath); + ownedWorkspaces.push(str(visible.workspaceId)); + expect(visible.background).toBe(false); } finally { - await worktreeScopedClient?.close(); - await archiveAgentIfPresent(worktreeAgentId); - await archiveWorktreeIfPresent({ cwd: worktreeRepoCwd, worktreePath }); + await scoped?.close(); + for (const workspaceId of ownedWorkspaces.toReversed()) + await topLevelClient + .callTool({ name: "archive_workspace", args: { workspaceId } }) + .catch(() => undefined); } }); }); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 349eb15c53..f1311d31e2 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,19 +1,30 @@ +import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it, vi } from "vitest"; -import { realpathSync, rmSync } from "node:fs"; +import { realpathSync } from "node:fs"; import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { join, resolve as resolvePath } from "node:path"; import { tmpdir } from "node:os"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { createAgentMcpServer } from "./mcp-server.js"; +import { createAgentMcpServer as createAgentMcpServerWithDependencies } from "./mcp-server.js"; import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; -import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js"; +import type { + AgentClient, + AgentMode, + AgentPromptInput, + AgentProvider, + AgentRunResult, + AgentSession, + AgentStreamEvent, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js"; import { @@ -182,6 +193,16 @@ async function removeTempDir(path: string): Promise { await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } +async function removeAgentStateDir( + agentManager: AgentManager, + storage: AgentStorage, + path: string, +): Promise { + await agentManager.flush(); + await storage.flush(); + await removeTempDir(path); +} + type AgentManagerSpies = ReturnType; type AgentStorageSpies = ReturnType; @@ -737,6 +758,7 @@ function createPaseoWorktreeForMcpTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); const workspaceAutoName = new WorkspaceAutoName({ @@ -785,7 +807,6 @@ function createPaseoWorktreeForMcpTest(options: { emit: () => {}, sessionLogger: createTestLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -812,6 +833,14 @@ function createPaseoWorktreeForMcpTest(options: { }; } +// Typed in-memory workspace port for catalogue tests without workspace setup. +function createAgentMcpServer(options: Parameters[0]) { + return createAgentMcpServerWithDependencies({ + workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} }, + ...options, + }); +} + describe("browser MCP tools", () => { const logger = createTestLogger(); @@ -971,8 +1000,85 @@ describe("browser MCP tools", () => { logger, }); + const client = await connectInMemoryMcpClient(server); + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).not.toContain("browser_snapshot"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "list_agents"])); + } finally { + await client.close(); + await server.close(); + } + }); + + it("applies provider policy after the browser-tools host gate", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["browser_list_tabs"] }, + logger, + }); + expect(lookupTool(server, "browser_list_tabs")).toBeUndefined(); - expect(lookupTool(server, "browser_snapshot")).toBeUndefined(); + expect(lookupTool(server, "browser_snapshot")).toBeDefined(); + }); + + it("filters policy-disabled tools from MCP listing and calls", async () => { + const agentManager = new BoundaryAgentManagerFake(); + const agentStorage = new BoundaryAgentStorageFake(); + const broker = new FakeBrowserToolsBroker({ + requestId: "req-browser-policy-call", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }); + const server = await createAgentMcpServer({ + agentManager: agentManager as AgentManager, + agentStorage: agentStorage as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: broker as BrowserToolsBroker, + callerAgentId: "agent-1", + paseoToolPolicy: { disabledTools: ["list_agents", "browser_list_tabs"] }, + logger, + }); + const client = await connectInMemoryMcpClient(server); + + try { + const listedTools = await client.listTools(); + const toolNames = listedTools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain("list_agents"); + expect(toolNames).not.toContain("browser_list_tabs"); + expect(toolNames).toEqual(expect.arrayContaining(["create_agent", "browser_snapshot"])); + await expect(client.callTool({ name: "list_agents", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool list_agents not found" }], + isError: true, + }); + await expect(client.callTool({ name: "browser_list_tabs", arguments: {} })).resolves.toEqual({ + content: [{ type: "text", text: "MCP error -32602: Tool browser_list_tabs not found" }], + isError: true, + }); + expect(broker.calls).toEqual([]); + } finally { + await client.close(); + await server.close(); + } }); it("wires browser tools through the browser tools broker", async () => { @@ -1229,10 +1335,13 @@ describe("create_agent MCP tool", () => { title: "Top-level agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); - expect(ensureWorkspace).toHaveBeenCalledWith(existingCwd, { prompt: "Do work" }); + expect(ensureWorkspace).toHaveBeenCalledWith( + existingCwd, + { prompt: "Do work" }, + { callerWorkspaceId: undefined }, + ); expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect.objectContaining({ cwd: existingCwd }), undefined, @@ -1240,6 +1349,32 @@ describe("create_agent MCP tool", () => { ); }); + it("rejects public internal helpers and the old top-level execution background option", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const agentManager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + try { + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "create_agent"); + const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" }; + await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/); + await expect(tool.handler({ ...input, background: true })).rejects.toThrow( + /Unrecognized key/, + ); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("rejects partial explicit workspace shape", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -1297,6 +1432,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, provider: "codex", currentModeId: "full-access", @@ -1347,7 +1483,6 @@ describe("create_agent MCP tool", () => { title: "Existing workspace", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent.workspaceId).toBe("wks_existing"); @@ -1384,7 +1519,7 @@ describe("create_agent MCP tool", () => { title: "Feature test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, + settings: { features: { fast_mode: true } }, }; @@ -1437,7 +1572,6 @@ describe("create_agent MCP tool", () => { title: "Mode test", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(response.structuredContent).toEqual( @@ -1763,7 +1897,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Do work", - background: true, }); expect(broadcasts).toHaveLength(1); @@ -1851,7 +1984,6 @@ describe("create_agent MCP tool", () => { title: "Worktree agent", provider: "codex/gpt-5.4", initialPrompt: "Fix workspace creation naming", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -1932,7 +2064,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Build a workspace auto title flow", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow"); @@ -2034,7 +2165,6 @@ describe("create_agent MCP tool", () => { title: "Agent title", provider: "codex/gpt-5.4", initialPrompt: "Keep the manually renamed workspace title", - background: true, }); const renameWorkspaceTool = registeredTool(server, "rename_workspace"); await renameWorkspaceTool.handler({ @@ -2124,7 +2254,6 @@ describe("create_agent MCP tool", () => { title: "Explicit Agent Title", provider: "codex/gpt-5.4", initialPrompt: "Generate the workspace title anyway", - background: true, }); const workspaceId = z.string().parse(createdWorkspaceIds[0]); await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title"); @@ -2231,7 +2360,6 @@ describe("create_agent MCP tool", () => { title: "Directory agent", provider: "codex/gpt-5.4", initialPrompt: "Name a directory workspace from the prompt", - background: true, }); await waitForWorkspaceTitle( workspaceRecords, @@ -2338,7 +2466,6 @@ describe("create_agent MCP tool", () => { title: "Checkout agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this checkout from the prompt", - background: true, }); const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd); @@ -2359,7 +2486,7 @@ describe("create_agent MCP tool", () => { }); expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({ version: 1, - baseRefName: "existing-feature", + baseRefName: "main", }); expect(generateCalls).toBe(1); expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); @@ -2445,7 +2572,6 @@ describe("create_agent MCP tool", () => { title: "PR agent", provider: "codex/gpt-5.4", initialPrompt: "Rename this PR branch from prompt", - background: true, }); expect(createPaseoWorktree).toHaveBeenCalledWith( @@ -2957,6 +3083,47 @@ describe("create_agent MCP tool", () => { ]); }); + it("creates an background local workspace and leaves it out of list_workspaces", async () => { + const { agentManager, agentStorage } = createTestDeps(); + const createCalls: Array<{ cwd: string; context: { background?: boolean } | undefined }> = []; + const backgroundWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "ws-background", + projectId: "project-1", + cwd: process.cwd(), + kind: "directory", + displayName: "helper", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + background: true, + }); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: vi.fn(async () => backgroundWorkspace), + list: vi.fn(async () => [backgroundWorkspace]), + upsert: vi.fn(async () => undefined), + }, + createDirectoryWorkspace: async (cwd, _title, _projectId, context) => { + createCalls.push({ cwd, context }); + return backgroundWorkspace; + }, + logger, + }); + + const created = await invokeToolWithParsedInput(registeredTool(server, "create_workspace"), { + isolation: "local", + path: process.cwd(), + background: true, + }); + expect(created.structuredContent.workspaceId).toBe("ws-background"); + expect(createCalls).toEqual([{ cwd: process.cwd(), context: { background: true } }]); + + const listed = await registeredTool(server, "list_workspaces").handler({}); + expect(listed.structuredContent.workspaces).toEqual([]); + }); + it("accepts custom provider IDs in create_agent input validation", async () => { const { agentManager, agentStorage } = createTestDeps(); const server = await createAgentMcpServer({ @@ -2985,6 +3152,7 @@ describe("create_agent MCP tool", () => { await mkdir(subdir, { recursive: true }); spies.agentManager.getAgent.mockReturnValue({ id: "voice-agent", + config: { title: "Voice" }, cwd: baseDir, workspaceId: "wks_voice", provider: "codex", @@ -3039,6 +3207,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3085,6 +3254,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3130,6 +3300,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3182,6 +3353,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3332,7 +3504,7 @@ describe("create_agent MCP tool", () => { expect(storedChild?.workspaceId).toBe("wks_parent"); expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id); } finally { - rmSync(workdir, { recursive: true, force: true }); + await removeAgentStateDir(agentManager, storage, workdir); } }); @@ -3496,6 +3668,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3548,6 +3721,7 @@ describe("create_agent MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "claude", @@ -3586,6 +3760,193 @@ describe("create_agent MCP tool", () => { }); }); +const HELD_TURN_CAPABILITIES = { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsSessionListing: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +} as const; + +/** + * Provider session that records every prompt it receives. With `holdTurns`, a started + * turn stays running until `finishTurn()` so a caller's bounded wait can run out first. + */ +class HeldTurnAgentSession implements AgentSession { + readonly capabilities = HELD_TURN_CAPABILITIES; + readonly id = randomUUID(); + readonly prompts: string[] = []; + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private activeTurnId: string | null = null; + private turnStartGate: Promise | null = null; + private nextStartError: Error | null = null; + + constructor( + readonly provider: AgentProvider, + private readonly holdTurns: boolean, + ) {} + + /** Hold the next turn's acknowledgment, as a provider awaiting its turn-start request does. */ + holdTurnStart(): () => void { + let release!: () => void; + this.turnStartGate = new Promise((resolve) => { + release = resolve; + }); + return release; + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + rejectNextTurnStart(error: Error): void { + this.nextStartError = error; + } + + tryHandleOutOfBand(prompt: AgentPromptInput) { + const text = typeof prompt === "string" ? (parseAgentMessage(prompt)?.text ?? prompt) : null; + return text === "/side-command" ? { run: async () => {} } : null; + } + + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); + await this.turnStartGate; + if (this.nextStartError) { + const error = this.nextStartError; + this.nextStartError = null; + throw error; + } + const turnId = randomUUID(); + this.activeTurnId = turnId; + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "user_message", + text: typeof prompt === "string" ? prompt : JSON.stringify(prompt), + messageId: randomUUID(), + }, + }); + setTimeout(() => { + if (this.activeTurnId !== turnId) return; + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + if (!this.holdTurns && this.activeTurnId === turnId) { + this.finishTurn(); + } + }, 0); + return { turnId }; + } + + finishTurn(finalText?: string): void { + const turnId = this.activeTurnId; + if (!turnId) { + throw new Error("No held turn to finish"); + } + this.activeTurnId = null; + if (finalText) + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: finalText }, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + } + + failTurn(error: string): void { + const turnId = this.activeTurnId; + if (!turnId) throw new Error("No held turn to fail"); + this.activeTurnId = null; + this.pushEvent({ type: "turn_failed", provider: this.provider, error, turnId }); + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + private pushEvent(event: AgentStreamEvent): void { + for (const callback of this.subscribers) { + callback(event); + } + } + + async *streamHistory(): AsyncGenerator {} + + async getRuntimeInfo() { + return { provider: this.provider, sessionId: this.id, model: null, modeId: null }; + } + + async getAvailableModes() { + return []; + } + + async getCurrentMode() { + return null; + } + + async setMode(): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence() { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise { + const turnId = this.activeTurnId; + if (!turnId) { + return; + } + this.activeTurnId = null; + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + reason: "interrupted", + turnId, + }); + } + + async close(): Promise {} +} + +class HeldTurnAgentClient implements AgentClient { + readonly capabilities = HELD_TURN_CAPABILITIES; + readonly sessions: HeldTurnAgentSession[] = []; + + constructor( + readonly provider: AgentProvider, + private readonly holdTurns: boolean, + ) {} + + async isAvailable(): Promise { + return true; + } + + async createSession(): Promise { + const session = new HeldTurnAgentSession(this.provider, this.holdTurns); + this.sessions.push(session); + return session; + } + + async fetchCatalog() { + return { models: [], modes: [] }; + } + + async resumeSession(): Promise { + return await this.createSession(); + } +} + describe("send_agent_prompt MCP tool", () => { const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -3594,6 +3955,7 @@ describe("send_agent_prompt MCP tool", () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3631,7 +3993,6 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected caller send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: true, notifyOnFinish: true, }); @@ -3644,7 +4005,7 @@ describe("send_agent_prompt MCP tool", () => { ); }); - it("keeps top-level prompts blocking by default", async () => { + it("acknowledges top-level prompts without a completion wait", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); spies.agentManager.getAgent.mockReturnValue({ id: "child-agent", @@ -3672,23 +4033,20 @@ describe("send_agent_prompt MCP tool", () => { throw new Error("Expected top-level send_agent_prompt input to parse"); } expect(parsed.data).toMatchObject({ - background: false, notifyOnFinish: false, }); await tool.handler(parsed.data as Record); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); }); - it("does not arm a finish notification for blocking agent-scoped prompts", async () => { + it("allows agent-scoped callers to opt out of finish notifications", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); const parentAgent = { id: "parent-agent", + config: {}, cwd: existingCwd, workspaceId: "wks_parent", provider: "codex", @@ -3720,14 +4078,424 @@ describe("send_agent_prompt MCP tool", () => { await invokeToolWithParsedInput(tool, { agentId: "child-agent", prompt: "Follow up", - background: false, + notifyOnFinish: false, }); expect(spies.agentManager.subscribe).not.toHaveBeenCalled(); - expect(spies.agentManager.waitForAgentEvent).toHaveBeenCalledWith( - "child-agent", - expect.objectContaining({ waitForActive: true }), - ); + expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); + }); + + it.each([ + { immediateFinish: false, failure: false }, + { immediateFinish: true, failure: false }, + { immediateFinish: false, failure: true }, + ])( + "notifies the retry outcome rather than the prior failed turn (immediate: $immediateFinish, failure: $failure)", + async ({ immediateFinish, failure }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-retry-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd, title: "Retry child" }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Old task", + notifyOnFinish: false, + }); + childClient.sessions[0]!.failTurn("Old task failed"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("error")); + const unsubscribe = agentManager.subscribe( + (event) => { + if ( + immediateFinish && + event.type === "agent_state" && + event.agent.lifecycle === "running" + ) { + childClient.sessions[0]!.finishTurn("Retry done"); + } + }, + { agentId: child.id, replayState: false }, + ); + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Retry now", + }); + unsubscribe(); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + if (!immediateFinish) { + expect(response.structuredContent.status).toBe("running"); + if (failure) childClient.sessions[0]!.failTurn("Retry failed"); + else childClient.sessions[0]!.finishTurn("Retry done"); + } + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain( + `Agent ${child.id} (Retry child) ${failure ? "errored" : "finished"}.`, + ); + expect(parentClient.sessions[0]!.prompts[0]).not.toContain("Old task failed"); + }); + await vi.waitFor(() => expect(agentManager.getAgent(parent.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it.each(["out_of_band", "startup_failure"] as const)( + "preserves %s dispatch notification semantics", + async (scenario) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Ongoing task", + notifyOnFinish: false, + }); + if (scenario === "out_of_band") { + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "/side-command", + }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "out_of_band", + status: "running", + }); + expect(childClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(childClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-message", agentId: parent.id }, + text: "Ongoing task", + }); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + childClient.sessions[0]!.finishTurn("Ongoing task done"); + await vi.waitFor(() => { + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts[0]).toContain("finished."); + }); + } else { + childClient.sessions[0]!.rejectNextTurnStart(new Error("Requested turn could not start")); + await expect( + invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "Replacement task" }), + ).rejects.toThrow("Requested turn could not start"); + // A later real turn must not inherit a subscription from the rejected dispatch. + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Recovery", + notifyOnFinish: false, + }); + childClient.sessions[0]!.finishTurn("Recovered"); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + } + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }, + ); + + it("acknowledges a pending agent-scoped turn and notifies its completion", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + + const pending = invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Follow up", + }); + const response = await pending; + expect(response.structuredContent).toMatchObject({ + status: "running", + guidance: + "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", + }); + expect(response.structuredContent).not.toHaveProperty("lastMessage"); + + childClient.sessions[0]!.finishTurn(); + + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(child.id); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + vi.useRealTimers(); + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + it("notifies the caller once when it prompts a created child that is still running", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-running-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + + const created = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), { + relationship: { kind: "subagent" }, + workspace: { kind: "current" }, + title: "Busy Child", + provider: "codex/gpt-5.4", + initialPrompt: "Run a long command", + }); + const childId = z.object({ agentId: z.string() }).parse(created.structuredContent).agentId; + await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running")); + + const sent = await invokeToolWithParsedInput(registeredTool(server, "send_agent_prompt"), { + agentId: childId, + prompt: "Stop and reply instead", + }); + expect(sent.structuredContent).toMatchObject({ + success: true, + status: "running", + }); + const childSession = childClient.sessions[0]!; + await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2)); + + expect(parentClient.sessions[0]!.prompts).toHaveLength(0); + childSession.finishTurn(); + + function finishNotifications() { + return (parentClient.sessions[0]?.prompts ?? []).filter((prompt) => + prompt.includes(`Agent ${childId} (Busy Child) finished.`), + ); + } + await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0)); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(finishNotifications()).toHaveLength(1); + expect(parentClient.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: childId, event: "finished" }, + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + + it("reports a background prompt's accepted turn as running", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-background-send-status-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + const childSession = childClient.sessions[0]!; + const acknowledgeTurnStart = childSession.holdTurnStart(); + + const pending = invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Follow up", + }); + await vi.waitFor(() => expect(childSession.prompts).toHaveLength(1)); + expect(parseAgentMessage(childSession.prompts[0])).toEqual({ + id: expect.any(String), + source: { kind: "agent-message", agentId: parent.id }, + text: "Follow up", + }); + acknowledgeTurnStart(); + const response = await pending; + + expect(response.structuredContent).toMatchObject({ success: true, status: "running" }); + expect(agentManager.getAgent(child.id)?.lifecycle).toBe("running"); + + childSession.finishTurn(); + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(child.id); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } + }); + + it("accepts a background turn completed as the manager publishes its start", async () => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-fast-background-send-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const parentClient = new HeldTurnAgentClient("claude", false); + const childClient = new HeldTurnAgentClient("codex", true); + const agentManager = new AgentManager({ + clients: { claude: parentClient, codex: childClient }, + registry: storage, + logger, + }); + + try { + const parent = await agentManager.createAgent( + { provider: "claude", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await agentManager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + let finished = false; + const unsubscribe = agentManager.subscribe( + (event) => { + if ( + !finished && + event.type === "agent_state" && + event.agent.id === child.id && + event.agent.lifecycle === "running" + ) { + finished = true; + childClient.sessions[0]!.finishTurn(); + } + }, + { agentId: child.id, replayState: false }, + ); + + const response = await invokeToolWithParsedInput( + registeredTool(server, "send_agent_prompt"), + { + agentId: child.id, + prompt: "Fast reply", + }, + ); + unsubscribe(); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "turn_started", + }); + await vi.waitFor(() => expect(agentManager.getAgent(child.id)?.lifecycle).toBe("idle")); + await vi.waitFor(() => { + const parentPrompts = parentClient.sessions[0]!.prompts; + expect(parentPrompts).toHaveLength(1); + expect(parentPrompts[0]).toContain(child.id); + expect(parentPrompts[0]).toContain("finished"); + }); + } finally { + await removeAgentStateDir(agentManager, storage, workdir); + } }); }); diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 60a1273859..4622d7bba4 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -86,6 +86,11 @@ export function resolveRequiredProviderModel( }; } +export interface AgentWaitWithTimeoutResult extends WaitForAgentResult { + /** The wait gave up while the agent was still working. */ + timedOut: boolean; +} + /** * Wraps agentManager.waitForAgentEvent with a self-imposed timeout. * Returns a friendly message when timeout occurs, rather than letting @@ -98,7 +103,7 @@ export async function waitForAgentWithTimeout( signal?: AbortSignal; waitForActive?: boolean; }, -): Promise { +): Promise { const timeoutController = new AbortController(); const combinedController = new AbortController(); @@ -133,7 +138,7 @@ export async function waitForAgentWithTimeout( signal: combinedController.signal, waitForActive: options?.waitForActive, }); - return result; + return { ...result, timedOut: false }; } catch (error) { if (error instanceof Error && error.message === "wait timeout") { const snapshot = agentManager.getAgent(agentId); @@ -150,6 +155,7 @@ export async function waitForAgentWithTimeout( status: snapshot?.lifecycle ?? "idle", permission: null, lastMessage: message, + timedOut: true, }; } throw error; diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts index 4a8c30b1bb..53270c1a78 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.test.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.test.ts @@ -12,10 +12,12 @@ import type { AgentMode, AgentModelDefinition, FetchCatalogOptions, - ProviderSnapshotEntry, } from "./agent-sdk-types.js"; import { attachMutableProviderConfigOwner } from "./mutable-provider-config-owner.js"; -import { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; +import { + type ProviderSnapshotTransition, + ProviderSnapshotManager, +} from "./provider-snapshot-manager.js"; const tempDirs: string[] = []; const CONTROLLED_PROVIDERS = { @@ -61,15 +63,15 @@ function catalog(modelId: string): Catalog { }; } -function recordCodexEvent(events: string[]): (entries: ProviderSnapshotEntry[]) => void { - return (entries) => { - const codex = entries.find((entry) => entry.provider === "codex"); +function recordCodexEvent(events: string[]): (transition: ProviderSnapshotTransition) => void { + return ({ current }) => { + const codex = current.records.find(({ entry }) => entry.provider === "codex")?.entry; events.push(`${codex?.status}:${codex?.models?.[0]?.id ?? "none"}`); }; } function getCodexSnapshot(manager: ProviderSnapshotManager, cwd: string) { - return manager.getSnapshot(cwd).find((entry) => entry.provider === "codex"); + return manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; } function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { @@ -77,7 +79,7 @@ function mutableConfig(persisted: PersistedConfig): MutableDaemonConfig { relay: { enabled: false }, mcp: { enabled: true, injectIntoAgents: false }, browserTools: { enabled: false }, - providers: persisted.agents?.providers ?? CONTROLLED_PROVIDERS, + providers: CONTROLLED_PROVIDERS, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, enableTerminalAgentHooks: false, @@ -108,7 +110,7 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-owner"); manager.getSnapshot(cwd); const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let agentManagerState = manager.getAgentManagerProviderState(); const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -158,7 +160,6 @@ describe("mutable provider config owner", () => { git: { maxProcessesPerSecond: 64, maxProcessConcurrency: 8 }, }, app: { baseUrl: "https://before.example.test" }, - agents: { providers: { codex: { enabled: true } } }, }; const configPath = path.join(paseoHome, "config.json"); writeFileSync(configPath, `${JSON.stringify(initial, null, 2)}\n`, "utf-8"); @@ -177,7 +178,7 @@ describe("mutable provider config owner", () => { }); manager.getSnapshot("/tmp/provider-config-owner"); const events: string[] = []; - manager.on("change", (_entries, cwd) => events.push(cwd)); + manager.on("change", ({ current }) => events.push(current.cwd)); let registryUpdates = 0; const unsubscribe = attachMutableProviderConfigOwner({ store, @@ -242,6 +243,7 @@ describe("mutable provider config owner", () => { try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } }); expect(events).toEqual(["loading:none"]); @@ -282,23 +284,42 @@ describe("mutable provider config owner", () => { const cwd = path.resolve("/tmp/provider-config-rollback"); const events: string[] = []; manager.on("change", recordCodexEvent(events)); + const previousState = manager.getAgentManagerProviderState(); + let agentManagerState = previousState; const unsubscribe = attachMutableProviderConfigOwner({ store, providerSnapshotManager: manager, - updateProviderRegistry: () => undefined, + updateProviderRegistry: (state) => { + agentManagerState = state; + }, }); + const observedDuringApply: Array<{ + label: string; + snapshot: ReturnType; + }> = []; const unsubscribeFailure = store.onApply(() => { + observedDuringApply.push({ + label: manager.getProviderLabel("codex"), + snapshot: manager.getSnapshot(cwd), + }); throw new Error("later owner failed"); }); try { const originalRead = manager.getProvider({ cwd, provider: "codex", wait: true }); await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1)); + expect(events).toEqual([]); + const before = manager.getSnapshot(cwd); expect(() => store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }), ).toThrow("later owner failed"); expect(events).toEqual([]); + expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]); + expect(observedDuringApply[0]!.snapshot).toBe(before); + expect(manager.getSnapshot(cwd)).toBe(before); + expect(agentManagerState).toEqual(previousState); + expect(agentManagerState.clients.codex).toBe(previousState.clients.codex); catalogResolvers[0]?.(catalog("original-model")); await expect(originalRead).resolves.toMatchObject({ @@ -309,8 +330,7 @@ describe("mutable provider config owner", () => { status: "ready", models: [{ id: "original-model" }], }); - expect(events.length).toBeGreaterThan(0); - expect(new Set(events)).toEqual(new Set(["ready:original-model"])); + expect(events).toEqual(["ready:original-model"]); expect(catalogResolvers).toHaveLength(1); } finally { unsubscribeFailure(); diff --git a/packages/server/src/server/agent/mutable-provider-config-owner.ts b/packages/server/src/server/agent/mutable-provider-config-owner.ts index 950a70e3c7..8ae589591c 100644 --- a/packages/server/src/server/agent/mutable-provider-config-owner.ts +++ b/packages/server/src/server/agent/mutable-provider-config-owner.ts @@ -11,35 +11,37 @@ export function attachMutableProviderConfigOwner(options: { providerSnapshotManager: ProviderSnapshotManager; updateProviderRegistry: (state: AgentManagerProviderState) => void; }): () => void { - let publishPendingProviderChange: (() => void) | null = null; + let commitPendingProviderChange: (() => void) | null = null; const unsubscribeApply = options.store.onApply((config, previous, details) => { if (equal(config.providers, previous.providers)) return () => undefined; const previousAgentManagerState = options.providerSnapshotManager.getAgentManagerProviderState(); - const staged = options.providerSnapshotManager.stageMutableProviderConfig(config.providers, { - removeProviders: details.removedProviders, - replace: true, - }); + const prepared = options.providerSnapshotManager.prepareMutableProviderConfig( + config.providers, + { + removeProviders: details.removedProviders, + replace: true, + }, + ); try { - options.updateProviderRegistry(staged.agentManagerState); + options.updateProviderRegistry(prepared.agentManagerState); } catch (error) { - staged.rollback(); + options.updateProviderRegistry(previousAgentManagerState); throw error; } - publishPendingProviderChange = staged.publish; + commitPendingProviderChange = prepared.commit; return () => { - publishPendingProviderChange = null; - staged.rollback(); + commitPendingProviderChange = null; options.updateProviderRegistry(previousAgentManagerState); }; }); const unsubscribeChange = options.store.onChange(() => { - const publish = publishPendingProviderChange; - publishPendingProviderChange = null; - publish?.(); + const commit = commitPendingProviderChange; + commitPendingProviderChange = null; + commit?.(); }); return () => { diff --git a/packages/server/src/server/agent/paseo-tool-policy.test.ts b/packages/server/src/server/agent/paseo-tool-policy.test.ts new file mode 100644 index 0000000000..d5bc6c648b --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +import { isPaseoToolEnabled, resolvePaseoToolPolicy } from "./paseo-tool-policy.js"; + +describe("Paseo tool policy", () => { + test("defaults to all Paseo tools and resolves only the exact provider ID", () => { + const customPolicy = { + enabled: true, + disabledTools: ["list_agents"], + } satisfies ProviderPaseoToolsPolicy; + + expect( + resolvePaseoToolPolicy("custom-claude", { + claude: { paseoTools: { enabled: false } }, + "custom-claude": { paseoTools: customPolicy }, + }), + ).toBe(customPolicy); + expect(resolvePaseoToolPolicy("other-custom", { claude: { paseoTools: customPolicy } })).toBe( + undefined, + ); + expect(isPaseoToolEnabled(undefined, "list_agents")).toBe(true); + }); + + test("applies the provider gate and sparse disabled tools without filtering speak", () => { + expect(isPaseoToolEnabled({ enabled: false }, "list_agents")).toBe(false); + expect(isPaseoToolEnabled({ enabled: false }, "speak")).toBe(true); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "list_agents"), + ).toBe(false); + expect( + isPaseoToolEnabled({ enabled: true, disabledTools: ["list_agents"] }, "create_agent"), + ).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/paseo-tool-policy.ts b/packages/server/src/server/agent/paseo-tool-policy.ts new file mode 100644 index 0000000000..1089fb76e8 --- /dev/null +++ b/packages/server/src/server/agent/paseo-tool-policy.ts @@ -0,0 +1,29 @@ +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; + +interface ProviderPaseoToolSettings { + paseoTools?: ProviderPaseoToolsPolicy; +} + +export function resolvePaseoToolPolicy( + providerId: string, + providerSettings: Readonly> | undefined, +): ProviderPaseoToolsPolicy | undefined { + return providerSettings?.[providerId]?.paseoTools; +} + +export function isPaseoToolEnabled( + policy: ProviderPaseoToolsPolicy | undefined, + toolName: string, +): boolean { + if (toolName === "speak") { + return true; + } + if (!isPaseoToolPolicyEnabled(policy)) { + return false; + } + return !policy?.disabledTools?.includes(toolName); +} + +export function isPaseoToolPolicyEnabled(policy: ProviderPaseoToolsPolicy | undefined): boolean { + return policy?.enabled !== false; +} diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts new file mode 100644 index 0000000000..8b894a7765 --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -0,0 +1,1123 @@ +import { relative } from "node:path"; +import type { + ProviderConnection, + ProviderLaunch, + ProviderEvent, + ProviderInput, + ProviderRegistration, +} from "@getpaseo/plugin/server/provider"; +import { setImmediate as nextTurn } from "node:timers/promises"; +import { describe, expect, test } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js"; +import { toStoredAgentRecord } from "./agent-projections.js"; +import { AgentManager } from "./agent-manager.js"; +import { buildProviderRegistry } from "./provider-registry.js"; +import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; +import { + isStaleProviderSessionError, + StaleProviderSessionError, +} from "./stale-provider-session-error.js"; + +const CAPABILITIES = [ + "prompt.message", + "session.configure", + "session.persistence", + "session.subsession", + "permission", +] as const; + +interface ProviderHarnessOptions { + capabilities?: ProviderConnection["capabilities"]; + completeTurn?: boolean; + openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void; + handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; +} + +function createProviderHarness(options: ProviderHarnessOptions = {}) { + let listener: ((event: ProviderEvent) => void) | null = null; + let closeCount = 0; + let resolveClosed!: () => void; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const inputs: ProviderInput[] = []; + const emit = (event: ProviderEvent) => listener?.(event); + const capabilities = options.capabilities ?? CAPABILITIES; + + const connection: ProviderConnection = { + version: 1, + capabilities, + async send(input) { + inputs.push(input); + if (await options.handleInput?.(input, emit)) return; + if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultModel: "plugin-model", + defaultMode: "build", + defaultThinkingOption: "deep", + }, + }); + return; + } + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "select", + id: "voice", + label: "Voice", + value: "direct", + options: [{ label: "Direct", value: "direct" }], + }, + ], + }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + title: "Plugin child", + cwd: input.config.cwd, + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { type: "assistant_message", id: "child-message", text: "Child result" }, + }); + emit({ + type: "session.turn", + sessionId: "child-1", + turnId: "child-turn", + state: "completed", + }); + emit({ type: "session.ready", sessionId: "child-1" }); + options.openChildren?.(input.sessionId, emit); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type === "session.prompt") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "started", + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hel" }, + }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer", text: "Hello" }, + }); + emit({ + type: "session.permission", + sessionId: input.sessionId, + request: { id: "permission-1", name: "write", kind: "tool" }, + }); + if (options.completeTurn !== false) { + emit({ + type: "session.turn", + sessionId: input.sessionId, + turnId: "turn-1", + state: "completed", + }); + } + return; + } + if (input.type === "session.permission") { + emit({ + type: "session.permission_resolved", + sessionId: input.sessionId, + permissionId: input.permissionId, + }); + return; + } + if (input.type === "session.configure") { + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: input.changes.model ?? "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [], + }, + }); + emit({ type: "request.completed", requestId: input.requestId }); + return; + } + if (input.type === "session.close") { + emit({ type: "session.closed", sessionId: input.sessionId }); + } + if ("requestId" in input) { + emit({ type: "request.completed", requestId: input.requestId }); + } + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() { + closeCount += 1; + resolveClosed(); + }, + }; + + const registration: ProviderRegistration = { + id: "plugin-direct", + label: "Plugin direct", + async connect() { + return connection; + }, + }; + + return { + registration, + emit, + inputs, + closeCount: () => closeCount, + waitForClose: () => closed, + }; +} + +function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) { + return events.filter((event) => event.type === type); +} + +function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) { + for (const [sessionId, parentSessionId] of [ + ["a", rootSessionId], + ["a.b", "a"], + ["a.b.c", "a.b"], + ["sibling", rootSessionId], + ]) { + emit({ + type: "session.opened", + sessionId, + parentSessionId, + toolCallId: `${sessionId}-task`, + capabilities: [], + restoration: "parent", + cwd: "/workspace", + }); + emit({ + type: "timeline.item", + sessionId, + item: { type: "assistant_message", id: `${sessionId}-message`, text: sessionId }, + }); + emit({ + type: "session.turn", + sessionId, + turnId: `${sessionId}-turn`, + state: "completed", + }); + } +} + +function expectNestedChildren(events: AgentStreamEvent[]) { + for (const [id, parentSubagentId] of [ + ["a", null], + ["a.b", "a"], + ["a.b.c", "a.b"], + ["sibling", null], + ]) { + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "upsert", + id, + parentSubagentId, + toolCallId: `${id}-task`, + status: "running", + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id, + item: expect.objectContaining({ text: id }), + }), + }), + ); + } +} + +describe("PluginAgentClientRegistry", () => { + test("stores only agent options while the plugin receives merged defaults", async () => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { options: { nested: { base: true, replace: "config" } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) }, + providerDefinitions: { "plugin-direct": registry["plugin-direct"] }, + }); + const own = { nested: { replace: "agent" } }; + const agent = await manager.createAgent( + { provider: "plugin-direct", cwd: "/tmp", providerOptions: own }, + undefined, + { workspaceId: undefined }, + ); + expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own); + expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({ + config: { providerOptions: { nested: { base: true, replace: "agent" } } }, + }); + await agent.session?.close(); + }); + + test.each([ + { options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, + { + params: { ignored: true }, + options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" }, + }, + ])( + "merges configured options into plugin session.open, preserving caller input: %j", + async (override) => { + const logger = createTestLogger(); + const harness = createProviderHarness(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const registry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { "plugin-direct": ProviderOverrideSchema.parse(override) }, + }); + const client = registry["plugin-direct"].createClient(logger); + const defaultsSession = await client.createSession({ + provider: "plugin-direct", + cwd: "/tmp", + }); + const defaultsOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(defaultsOpen.config.providerOptions).toEqual({ + nested: { base: true, replace: "config" }, + list: [1, 2], + scalar: "config", + }); + await defaultsSession.close(); + const config = { + provider: "plugin-direct", + cwd: "/tmp", + providerOptions: { nested: { replace: "agent" }, list: [3], scalar: null }, + }; + const session = await client.createSession(config); + const open = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(open.config.providerOptions).toEqual({ + nested: { base: true, replace: "agent" }, + list: [3], + scalar: null, + }); + expect(config.providerOptions).toEqual({ + nested: { replace: "agent" }, + list: [3], + scalar: null, + }); + const handle = session.describePersistence()!; + await session.close(); + const updatedRegistry = buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides: { + "plugin-direct": { + options: { + nested: { base: false, replace: "new-config" }, + list: [4], + scalar: "new-config", + }, + }, + }, + }); + const resumed = await updatedRegistry["plugin-direct"] + .createClient(logger) + .resumeSession(handle, config); + const resumedOpen = harness.inputs.findLast((input) => input.type === "session.open")!; + expect(resumedOpen.config.providerOptions).toEqual({ + nested: { base: false, replace: "agent" }, + list: [3], + scalar: null, + }); + await resumed.close(); + }, + ); + + test.each([false, true])( + "contains a failed session open while send is pending: %s", + async (pendingSend) => { + let listener: ((event: ProviderEvent) => void) | undefined; + const unhandled: unknown[] = []; + const observeUnhandled = (reason: unknown) => unhandled.push(reason); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([ + { + id: "failing-provider", + label: "Failing provider", + async connect() { + return { + version: 1, + capabilities: ["session.persistence"], + async send(input) { + if (input.type !== "session.open") return; + listener?.({ + type: "request.failed", + requestId: input.requestId, + error: { message: "OMP persistent session registration is in progress" }, + }); + // Keep acceptance pending across a Node event-loop turn, as IPC can do. + if (pendingSend) await nextTurn(); + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }, + ]); + process.on("unhandledRejection", observeUnhandled); + try { + await expect( + registry.clients()["failing-provider"]!.createSession({ + provider: "failing-provider", + cwd: "/workspace", + }), + ).rejects.toThrow("OMP persistent session registration is in progress"); + await nextTurn(); + expect(unhandled).toEqual([]); + } finally { + await registry.shutdown(); + process.off("unhandledRejection", observeUnhandled); + } + }, + ); + + test("preserves nested provider child ownership during opening", async () => { + const harness = createProviderHarness({ openChildren: openNestedChildren }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + try { + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + expectNestedChildren(events); + } finally { + await session.close(); + registry.replace([]); + } + }); + + test("preserves nested provider child ownership during live events", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + try { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + const open = harness.inputs.find((input) => input.type === "session.open")!; + openNestedChildren(open.sessionId, harness.emit); + expectNestedChildren(events); + } finally { + await session.close(); + registry.replace([]); + } + }); + + test("gates persistence operations on negotiated provider capabilities", async () => { + const harness = createProviderHarness({ capabilities: ["session.persistence"] }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + + await expect(client.isAvailable()).resolves.toBe(true); + expect(client.capabilities).toMatchObject({ + supportsSessionPersistence: true, + supportsSessionListing: false, + }); + await expect(client.listImportableSessions?.()).resolves.toEqual([]); + + const persistence = { + provider: harness.registration.id, + sessionId: 'plugin:{"version":1,"data":{"token":"root"}}', + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }; + await expect(client.archiveNativeSession?.(persistence)).resolves.toBeUndefined(); + await expect(client.unarchiveNativeSession?.(persistence)).resolves.toBeUndefined(); + expect(harness.inputs).toEqual([]); + await registry.shutdown(); + }); + + test("terminalizes an active turn exactly once when its plugin provider is removed", async () => { + const harness = createProviderHarness({ completeTurn: false }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const run = session.run("hello", { clientMessageId: "active-message" }); + void run.catch(() => undefined); + await expect.poll(() => eventsOfType(events, "turn_started")).toHaveLength(1); + + registry.replace([]); + + await expect + .poll(() => eventsOfType(events, "turn_failed")) + .toEqual([ + expect.objectContaining({ + type: "turn_failed", + provider: harness.registration.id, + turnId: "turn-1", + error: "Provider connection closed", + }), + ]); + await expect(run).rejects.toThrow("Provider connection closed"); + await expect.poll(harness.closeCount).toBe(1); + + registry.replace([]); + expect(eventsOfType(events, "turn_failed")).toHaveLength(1); + }); + + test("leaves a completed session without a failure when its plugin provider is removed", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.run("hello", { clientMessageId: "completed-message" }); + expect(eventsOfType(events, "turn_completed")).toHaveLength(1); + + registry.replace([]); + await harness.waitForClose(); + + expect(eventsOfType(events, "turn_failed")).toEqual([]); + await expect( + session.startTurn("after reload", { clientMessageId: "after-reload" }), + ).rejects.toBeInstanceOf(StaleProviderSessionError); + }); + + test("fails an accepted turn that has not started when its plugin provider is removed", async () => { + const harness = createProviderHarness({ + handleInput: async (input, emit) => { + if (input.type !== "session.prompt") return false; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "accepted-turn" }, + }); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await expect( + session.startTurn("hello", { clientMessageId: "accepted-message" }), + ).resolves.toEqual({ turnId: "accepted-turn" }); + + registry.replace([]); + await harness.waitForClose(); + + expect(eventsOfType(events, "turn_failed")).toEqual([ + expect.objectContaining({ error: "Provider connection closed" }), + ]); + }); + + test("closes a stale session after its plugin provider is replaced", async () => { + const old = createProviderHarness(); + const next = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + + try { + registry.replace([old.registration]); + + const stale = await registry.clients()[old.registration.id]!.createSession({ + provider: old.registration.id, + cwd: "/workspace", + }); + const persistence = stale.describePersistence(); + expect(persistence).not.toBeNull(); + + registry.replace([next.registration]); + await old.waitForClose(); + expect(old.closeCount()).toBe(1); + + await expect(stale.close()).resolves.toBeUndefined(); + + const replacement = registry.clients()[next.registration.id]; + expect(replacement).toBeDefined(); + const resumed = await replacement!.resumeSession(persistence!, { + cwd: "/workspace", + }); + + await expect( + resumed.startTurn("after reload", { clientMessageId: "after-reload" }), + ).resolves.toEqual({ turnId: "turn-1" }); + + expect(next.inputs).toContainEqual( + expect.objectContaining({ + type: "session.open", + history: "replay", + persistence: { + version: 1, + data: { token: "root" }, + }, + }), + ); + + await resumed.close(); + } finally { + await registry.shutdown(); + } + }); + + test("prompting a stale session raises StaleProviderSessionError", async () => { + const old = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + + try { + registry.replace([old.registration]); + const stale = await registry.clients()[old.registration.id]!.createSession({ + provider: old.registration.id, + cwd: "/workspace", + }); + + registry.replace([]); + await old.waitForClose(); + expect(old.closeCount()).toBe(1); + + const failure = await stale + .startTurn("after reload", { clientMessageId: "after-reload" }) + .then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(StaleProviderSessionError); + expect(isStaleProviderSessionError(failure)).toBe(true); + expect(isStaleProviderSessionError(new Error("Provider connection is closed"))).toBe(false); + expect(isStaleProviderSessionError(new Error("boom"))).toBe(false); + } finally { + await registry.shutdown(); + } + }); + + test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]; + expect(client).toBeDefined(); + + await expect(client!.fetchCatalog({ scope: "global", force: false })).resolves.toMatchObject({ + models: [ + { + provider: "plugin-direct", + id: "plugin-model", + isDefault: true, + thinkingOptions: [{ id: "deep", label: "Deep" }], + defaultThinkingOptionId: "deep", + }, + ], + modes: [{ id: "build" }], + defaultModeId: "build", + }); + + const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" }); + expect(session.features).toEqual([ + expect.objectContaining({ + type: "select", + id: "voice", + options: [{ id: "direct", label: "Direct", value: "direct" }], + }), + ]); + expect(session.describePersistence()).toMatchObject({ + provider: "plugin-direct", + metadata: { pluginProviderPersistence: { version: 1, data: { token: "root" } } }, + }); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline", id: "child-1" }), + }), + ); + expect(history).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "upsert", id: "child-1", status: "completed" }), + }), + ); + + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "client-message" }), + ).resolves.toEqual({ turnId: "turn-1" }); + expect( + events + .filter((event) => event.type === "timeline") + .map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "assistant_message", text: "Hel", messageId: "answer" }, + { type: "assistant_message", text: "lo", messageId: "answer" }, + ]); + expect(session.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "permission-1", provider: "plugin-direct" }), + ]); + + await session.respondToPermission("permission-1", { behavior: "allow" }); + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-1", + resolution: { behavior: "allow" }, + }), + ); + await session.setModel?.("plugin-model"); + expect(await session.getRuntimeInfo()).toMatchObject({ + model: "plugin-model", + modeId: "build", + }); + + unsubscribe(); + await session.close(); + registry.replace([]); + await expect.poll(harness.closeCount).toBe(1); + expect(harness.inputs.map((input) => input.type)).toContain("session.close"); + }); +}); + +type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt"; + +async function requestFromClient(client: AgentClient, kind: RequestKind): Promise { + if (kind === "catalog") return client.fetchCatalog({ scope: "global" }); + const session = await client.createSession({ provider: client.provider, cwd: "/workspace" }); + if (kind === "session.open") return session; + if (kind === "session.configure") return session.setMode("build"); + return session.startTurn("hello", { clientMessageId: "pending-message" }); +} + +async function withObservedProvider( + options: ProviderHarnessOptions, + run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise, +): Promise { + const harness = createProviderHarness(options); + const registry = new PluginAgentClientRegistry(createTestLogger()); + const unhandled: unknown[] = []; + const observe = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", observe); + registry.replace([harness.registration]); + try { + await run(registry.clients()[harness.registration.id]!, registry); + await registry.shutdown(); + await nextTurn(); + expect(unhandled).toEqual([]); + } finally { + await registry.shutdown(); + process.off("unhandledRejection", observe); + } +} + +test("commits notices emitted during session open as the initial timeline with stable history timestamps", async () => { + await withObservedProvider( + { + openChildren(sessionId, emit) { + emit({ + type: "session.notice", + sessionId, + notice: { + id: "startup", + severity: "warning", + title: "Full access", + description: "Tools run without asking.", + }, + }); + emit({ + type: "session.notice", + sessionId, + notice: { id: "dismissed", severity: "info", title: "Hidden", dismissed: true }, + }); + }, + }, + async (client) => { + const session = await client.createSession({ provider: client.provider, cwd: "/workspace" }); + try { + expect(session.initialTimeline).toEqual([ + { + timestamp: expect.any(String), + item: { + type: "notification", + level: "warning", + message: "Full access\nTools run without asking.", + }, + }, + ]); + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.filter( + (event) => event.type === "timeline" && event.item.type === "notification", + ), + ).toEqual([ + { type: "timeline", provider: client.provider, ...session.initialTimeline![0] }, + ]); + } finally { + await session.close(); + } + }, + ); +}); + +describe("pending provider responses", () => { + test.each(["session.open", "catalog", "session.configure", "session.prompt"])( + "preserves the send error for %s", + async (kind) => { + const failure = new Error("Provider send failed"); + await withObservedProvider( + { + async handleInput(input) { + if (input.type === kind) throw failure; + return false; + }, + }, + async (client) => { + await expect(requestFromClient(client, kind)).rejects.toBe(failure); + }, + ); + }, + ); + + test.each([ + [ + "catalog", + expect.objectContaining({ + models: [expect.objectContaining({ id: "plugin-model", isDefault: true })], + defaultModeId: "build", + }), + ], + ["session.configure", undefined], + ] as const)( + "contains an early request.failed for %s and permits a successful retry", + async (kind, result) => { + let failed = false; + await withObservedProvider( + { + async handleInput(input, emit) { + if (input.type !== kind || !("requestId" in input) || failed) return false; + failed = true; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "Provider request failed", code: "busy", diagnostic: "retry" }, + }); + await nextTurn(); + return true; + }, + }, + async (client) => { + await expect(requestFromClient(client, kind)).rejects.toMatchObject({ + message: "Provider request failed", + code: "busy", + diagnostic: "retry", + }); + await expect(requestFromClient(client, kind)).resolves.toEqual(result); + }, + ); + }, + ); + + test.each([ + ["session.open", "Provider session closed"], + ["session.configure", "Provider session closed"], + ["session.prompt", StaleProviderSessionError], + ] as const)("contains session closure while accepting %s", async (kind, error) => { + await withObservedProvider( + { + async handleInput(input, emit) { + if (input.type !== kind || !("sessionId" in input)) return false; + emit({ + type: "session.closed", + sessionId: input.sessionId, + error: { message: "Provider session closed" }, + }); + await nextTurn(); + return true; + }, + }, + async (client) => { + await expect(requestFromClient(client, kind)).rejects.toThrow(error); + }, + ); + }); + + test.each([ + ["session.open", "Provider connection closed"], + ["catalog", "Provider closed"], + ["session.configure", "Provider closed"], + ["session.prompt", StaleProviderSessionError], + ] as const)("contains connection shutdown while accepting %s", async (kind, error) => { + let disconnect!: () => Promise; + await withObservedProvider( + { + async handleInput(input) { + if (input.type !== kind) return false; + await disconnect(); + await nextTurn(); + return true; + }, + }, + async (client, registry) => { + disconnect = () => registry.shutdown(); + await expect(requestFromClient(client, kind)).rejects.toThrow(error); + }, + ); + }); + + test.each([true, false])( + "preserves cancellation with acceptance pending: %s", + async (pending) => { + const controller = new AbortController(); + const reason = new Error("Catalog refresh cancelled"); + await withObservedProvider( + { + async handleInput(input) { + if (input.type !== "catalog") return false; + if (pending) { + controller.abort(reason); + await nextTurn(); + } else { + setImmediate(() => controller.abort(reason)); + } + return true; + }, + }, + async (client) => { + await expect( + client.fetchCatalog({ scope: "global" }, { signal: controller.signal }), + ).rejects.toBe(reason); + }, + ); + }, + ); +}); + +describe("plugin provider launch and status", () => { + test("resolves the executable against the provider's PATH override", async () => { + const harness = createProviderHarness(); + const registration: ProviderRegistration = { ...harness.registration, command: ["node"] }; + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([registration]); + try { + const definition = registry.definitions()[registration.id]!; + const client = definition.createClient(logger, { env: { PATH: "/paseo-nonexistent-bin" } }); + expect(await client.isAvailable()).toBe(false); + expect(await client.getDiagnostic!()).toEqual({ diagnostic: "node not found on PATH" }); + } finally { + await registry.shutdown(); + } + }); + + test("strips parent-session and daemon-control variables from session env overlays", async () => { + const harness = createProviderHarness(); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + try { + const client = registry.clients()[harness.registration.id]!; + await client.createSession( + { provider: harness.registration.id, cwd: "/workspace" }, + { + env: { + SESSION_TOKEN: "session", + PASEO_AGENT_ID: "agent", + CLAUDECODE: "parent", + PASEO_NODE_ENV: "development", + }, + }, + ); + const input = harness.inputs.find((value) => value.type === "session.open"); + expect(input).toMatchObject({ + config: { env: { SESSION_TOKEN: "session", PASEO_AGENT_ID: "agent" } }, + }); + if (input?.type !== "session.open") throw new Error("Expected session.open"); + expect(input.config.env).not.toHaveProperty("CLAUDECODE"); + expect(input.config.env).not.toHaveProperty("PASEO_NODE_ENV"); + } finally { + await registry.shutdown(); + } + }); + + test("resolves relative executable paths before sending launch data", async () => { + const harness = createProviderHarness(); + const launches: Array = []; + const registration: ProviderRegistration = { + ...harness.registration, + command: [relative(process.cwd(), process.execPath)], + async connect(request) { + launches.push(request.launch); + return harness.registration.connect(request); + }, + }; + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([registration]); + try { + await registry.clients()[registration.id]!.fetchCatalog({ scope: "global" }); + expect(launches).toMatchObject([{ command: process.execPath }]); + } finally { + await registry.shutdown(); + } + }); + + test("a missing executable is unavailable with a diagnostic without connecting", async () => { + const harness = createProviderHarness(); + const registration: ProviderRegistration = { + ...harness.registration, + command: ["paseo-nonexistent-provider-executable"], + }; + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([registration]); + try { + const client = registry.clients()[registration.id]!; + expect(await client.isAvailable()).toBe(false); + expect(await client.getDiagnostic!()).toEqual({ + diagnostic: "paseo-nonexistent-provider-executable not found on PATH", + }); + expect(harness.inputs).toEqual([]); + } finally { + await registry.shutdown(); + } + }); + + test.each([ + { mode: "default" as const }, + { mode: "append" as const, args: ["extra"] }, + { mode: "replace" as const, argv: [process.execPath, "replacement"] }, + ])( + "resolves $mode launch and supplies identical data to status, catalogue identity, and connect", + async (command) => { + const harness = createProviderHarness(); + const launches: Array = []; + const registration: ProviderRegistration = { + ...harness.registration, + command: [process.execPath, "default"], + async status(request) { + launches.push(request.launch); + return { available: true }; + }, + async getCatalogCacheKey(options) { + launches.push(options.launch); + return "shared"; + }, + async connect(request) { + launches.push(request.launch); + return harness.registration.connect(request); + }, + }; + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([registration]); + try { + const client = registry.definitions()[registration.id]!.createClient(logger, { + command, + env: { PLUGIN_SETTING: "custom", CLAUDECODE: "parent" }, + }); + expect(await client.isAvailable()).toBe(true); + expect(await client.getCatalogCacheKey!({ scope: "global" })).toBe("shared"); + await client.fetchCatalog({ scope: "global" }); + let expectedArgs = ["default"]; + if (command.mode === "replace") expectedArgs = ["replacement"]; + if (command.mode === "append") expectedArgs = ["default", "extra"]; + expect(launches).toHaveLength(3); + for (const launch of launches) { + expect(launch).toMatchObject({ + command: process.execPath, + args: expectedArgs, + env: { PLUGIN_SETTING: "custom" }, + }); + expect(launch!.env).not.toHaveProperty("CLAUDECODE"); + expect(launch!.env).not.toHaveProperty("PASEO_NODE_ENV"); + } + expect(launches[0]).toEqual(launches[1]); + expect(launches[1]).toEqual(launches[2]); + } finally { + await registry.shutdown(); + } + }, + ); +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts new file mode 100644 index 0000000000..58bddd07b6 --- /dev/null +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -0,0 +1,1767 @@ +import { randomUUID } from "node:crypto"; +import { resolve as resolvePath } from "node:path"; +import type { Logger } from "pino"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { z } from "zod"; +import { + PROVIDER_CAPABILITIES, + PROVIDER_PROTOCOL_VERSION, + ProviderEventSchema, + ProviderInputSchema, + ProviderStatusSchema, + type ProviderStatus, + type ProviderLaunch, + requireProviderCapabilities, + type ProviderCapability, + type ProviderConfigChanges, + type ProviderConfigState, + type ProviderConnection, + type ProviderError, + type ProviderEvent, + type ProviderInput, + type ProviderPersistence, + type ProviderPrompt, + type ProviderRegistration, + type ProviderSessionConfig, + type ProviderContent, + type ProviderTimelineItem, +} from "@getpaseo/plugin/server/provider"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentCreateSessionOptions, + AgentFeature, + AgentLaunchContext, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentSlashCommand, + AgentStreamEvent, + AgentTimelineItem, + FetchCatalogOptions, + ImportableProviderSession, + ImportedProviderSession, + ImportProviderSessionContext, + ImportProviderSessionInput, + ListImportableSessionsOptions, + ProviderCatalog, + ProviderRefreshContext, + SteerActiveTurnOptions, + SteerResult, +} from "./agent-sdk-types.js"; +import { + resolveProviderLaunch, + checkProviderLaunchAvailable, + createProviderEnv, + type ProviderRuntimeSettings, +} from "./provider-launch-config.js"; +import type { RegisteredProviderDefinition } from "./provider-registry.js"; +import { runProviderTurn } from "./providers/provider-runner.js"; +import { StaleProviderSessionError } from "./stale-provider-session-error.js"; + +interface Deferred { + promise: Promise; + resolve(value: Value): void; + reject(error: Error): void; +} + +interface PendingRequest { + kind: ProviderInput["type"]; + sessionId?: string; + resolve(event: ProviderEvent): void; + reject(error: Error): void; +} + +interface PendingOpenDescendantState { + readonly providerSessionIds: Set; + readonly events: ProviderEvent[]; +} + +interface OpenProviderSessionInput { + sessionId: string; + config: ProviderSessionConfig; + persistence?: ProviderPersistence; + history: "replay" | "skip"; +} + +function deferred(): Deferred { + let resolve!: (value: Value) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + // Provider events can reject before send() settles and the caller awaits this promise. + // Observe that interval without replacing the rejecting promise returned to the caller. + void promise.catch(() => undefined); + return { promise, resolve, reject }; +} + +function providerError(error: ProviderError): Error { + return Object.assign(new Error(error.message), { + code: error.code, + diagnostic: error.diagnostic, + }); +} + +function isProviderRequestReply( + event: ProviderEvent, +): event is Extract { + return ( + event.type === "request.completed" || event.type === "catalog" || event.type === "sessions" + ); +} + +class ProviderRuntime { + private connection: ProviderConnection | null = null; + private connecting: Promise | null = null; + private closed = false; + private generation = 0; + private closePromise: Promise | null = null; + private unsubscribe: (() => void) | null = null; + private readonly sessions = new Map(); + private readonly providerSessions = new Map(); + private readonly sessionListeners = new Set< + ( + session: ProviderRuntimeSession, + event: Extract, + ) => void + >(); + private readonly requests = new Map(); + private readonly sessionRequests = new Map(); + private readonly pendingOpenDescendants = new Map(); + private readonly pendingOpenDescendantsByRoot = new Map< + ProviderRuntimeSession, + PendingOpenDescendantState + >(); + + constructor( + private readonly registration: ProviderRegistration, + private readonly runtimeSettings?: ProviderRuntimeSettings, + ) {} + + private async resolveLaunch(): Promise { + const command = this.registration.command; + if (!command) return undefined; + const launch = await resolveProviderLaunch({ + commandConfig: this.runtimeSettings?.command, + defaultBinary: command[0], + }); + if (launch.source !== "override") launch.args.unshift(...command.slice(1)); + const env = createProviderEnv({ runtimeSettings: this.runtimeSettings }); + launch.env = env; + const availability = await checkProviderLaunchAvailable(launch); + if (!availability.resolvedPath) throw new Error(`${launch.command} not found on PATH`); + return { command: resolvePath(availability.resolvedPath), args: launch.args, env }; + } + + async status(): Promise { + try { + const launch = await this.resolveLaunch(); + if (this.registration.status) + return ProviderStatusSchema.parse(await this.registration.status({ launch })); + if (!this.registration.command) await this.getConnection(); + return { available: true }; + } catch (error) { + return { + available: false, + diagnostic: error instanceof Error ? error.message : String(error), + }; + } + } + + async catalogCacheKey(options: FetchCatalogOptions): Promise { + const callback = this.registration.getCatalogCacheKey; + if (!callback) return undefined; + return callback({ ...options, launch: await this.resolveLaunch() }); + } + + get negotiatedCapabilities(): readonly string[] { + return this.connection?.capabilities ?? []; + } + + get isClosed(): boolean { + return this.closed; + } + + async isAvailable(): Promise { + return (await this.status()).available; + } + + async catalog( + cwd?: string, + signal?: AbortSignal, + ): Promise["catalog"]> { + const event = await this.complete({ type: "catalog", requestId: randomUUID(), cwd }, signal); + if (event.type !== "catalog") throw new Error("Provider returned an invalid catalog response"); + return event.catalog; + } + + async listSessions( + input: { + query?: string; + cwd?: string; + limit?: number; + } = {}, + ): Promise["sessions"]> { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.list")) return []; + const event = await this.complete({ type: "sessions", requestId: randomUUID(), ...input }); + if (event.type !== "sessions") + throw new Error("Provider returned an invalid sessions response"); + return event.sessions; + } + + onSessionOpened( + listener: ( + session: ProviderRuntimeSession, + event: Extract, + ) => void, + ): () => void { + this.sessionListeners.add(listener); + return () => this.sessionListeners.delete(listener); + } + + async openSession(input: OpenProviderSessionInput): Promise { + if (this.sessions.has(input.sessionId)) { + throw new Error(`Provider session already exists: ${input.sessionId}`); + } + const connection = await this.getConnection(); + requireProviderCapabilities(connection.capabilities, { + type: "session.open", + requestId: "capability-check", + ...input, + }); + const session = new ProviderRuntimeSession( + this, + connection, + input.sessionId, + input.sessionId, + "core", + ); + this.sessions.set(input.sessionId, session); + this.providerSessions.set(input.sessionId, session); + const requestId = randomUUID(); + const ready = session.beginOpen(requestId); + this.sessionRequests.set(requestId, input.sessionId); + try { + await connection.send({ + type: "session.open", + requestId, + sessionId: input.sessionId, + config: input.config, + persistence: input.persistence, + history: input.history, + }); + await ready; + return session; + } catch (error) { + this.sessionRequests.delete(requestId); + this.discardPendingOpenDescendants(session); + this.sessions.delete(input.sessionId); + this.providerSessions.delete(input.sessionId); + throw error; + } + } + + async archive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.archive")) return; + await this.complete({ type: "session.archive", requestId: randomUUID(), persistence }); + } + + async unarchive(persistence: ProviderPersistence): Promise { + const connection = await this.getConnection(); + if (!connection.capabilities.includes("session.unarchive")) return; + await this.complete({ type: "session.unarchive", requestId: randomUUID(), persistence }); + } + + close(): Promise { + if (this.closePromise) return this.closePromise; + this.closed = true; + this.generation += 1; + const connection = this.connection; + const connecting = connection ? null : this.connecting; + this.connection = null; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const session of this.sessions.values()) session.connectionClosed(); + this.sessions.clear(); + this.providerSessions.clear(); + this.pendingOpenDescendants.clear(); + this.pendingOpenDescendantsByRoot.clear(); + this.sessionListeners.clear(); + for (const request of this.requests.values()) request.reject(new Error("Provider closed")); + this.requests.clear(); + this.closePromise = Promise.all([ + connection?.close(), + connecting?.then( + () => undefined, + () => undefined, + ), + ]).then(() => undefined); + return this.closePromise; + } + + async complete( + input: ProviderInput & { requestId: string }, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + const connection = await this.getConnection(); + signal?.throwIfAborted(); + const pending = deferred(); + this.requests.set(input.requestId, { + kind: input.type, + sessionId: + "sessionId" in input + ? (this.providerSessions.get(input.sessionId)?.id ?? input.sessionId) + : undefined, + resolve: pending.resolve, + reject: pending.reject, + }); + const abort = () => { + if (!this.requests.delete(input.requestId)) return; + pending.reject( + signal?.reason instanceof Error ? signal.reason : new Error("Provider request aborted"), + ); + }; + signal?.addEventListener("abort", abort, { once: true }); + try { + await connection.send(input); + return await pending.promise; + } catch (error) { + this.requests.delete(input.requestId); + throw error; + } finally { + signal?.removeEventListener("abort", abort); + } + } + + removeSession(sessionId: string, providerSessionId: string): void { + this.sessions.delete(sessionId); + this.providerSessions.delete(providerSessionId); + } + + private getConnection(): Promise { + if (this.closed) return Promise.reject(new Error("Provider runtime is closed")); + if (this.connection) return Promise.resolve(this.connection); + if (this.connecting) return this.connecting; + const generation = this.generation; + const connecting = this.establishConnection(generation); + this.connecting = connecting; + connecting.then( + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + () => { + if (this.connecting === connecting) this.connecting = null; + return undefined; + }, + ); + return connecting; + } + + private async establishConnection(generation: number): Promise { + const rawConnection = await this.registration.connect({ + launch: await this.resolveLaunch(), + versions: [PROVIDER_PROTOCOL_VERSION], + capabilities: PROVIDER_CAPABILITIES, + }); + if (this.closed || generation !== this.generation) { + await rawConnection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + try { + this.validateConnection(rawConnection); + } catch (error) { + await rawConnection.close().catch(() => undefined); + throw error; + } + const connection = normalizeConnection(rawConnection); + const unsubscribe = connection.onEvent((event) => this.accept(event)); + if (this.closed || generation !== this.generation) { + unsubscribe(); + await connection.close().catch(() => undefined); + throw new Error("Provider runtime is closed"); + } + this.unsubscribe = unsubscribe; + this.connection = connection; + return connection; + } + + private validateConnection(connection: ProviderConnection): void { + if (connection.version !== PROVIDER_PROTOCOL_VERSION) { + throw new Error(`Provider selected unsupported version ${connection.version}`); + } + } + + private accept(event: ProviderEvent): void { + if (event.type === "request.failed") { + this.failRequest(event); + return; + } + if (isProviderRequestReply(event)) { + this.finishRequest(event); + return; + } + this.finishSessionReadyRequest(event); + if (this.holdPendingOpenDescendant(event)) return; + if (this.acceptProviderChild(event)) return; + if ("sessionId" in event) { + const session = this.providerSessions.get(event.sessionId); + if (!session) return; + event = { + ...event, + sessionId: session.id, + ...(event.type === "session.opened" && event.parentSessionId + ? { parentSessionId: this.providerSessions.get(event.parentSessionId)?.id } + : {}), + } as ProviderEvent; + } + if (event.type === "session.runtime_failed") { + this.failSessionRequests(event.sessionId, providerError(event.error)); + } else if (event.type === "session.closed") { + this.settleClosedSessionRequests( + event.sessionId, + new Error(event.error?.message ?? `Provider session ${event.sessionId} closed`), + event, + ); + } + if ("sessionId" in event) { + const session = this.sessions.get(event.sessionId); + session?.accept(event); + if (event.type === "session.closed" && session) { + this.removeSession(session.id, session.providerId); + } + } + } + + private finishSessionReadyRequest(event: ProviderEvent): void { + if (event.type !== "session.ready" || !event.requestId) return; + this.sessionRequests.delete(event.requestId); + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } + + private acceptProviderChild(event: ProviderEvent): boolean { + if (event.type !== "session.opened" || this.providerSessions.has(event.sessionId)) return false; + const parent = event.parentSessionId + ? this.providerSessions.get(event.parentSessionId) + : undefined; + if (!parent) return true; + const sessionId = randomUUID(); + const session = new ProviderRuntimeSession( + this, + this.connection!, + sessionId, + event.sessionId, + event.restoration, + ); + this.sessions.set(sessionId, session); + this.providerSessions.set(event.sessionId, session); + const mappedEvent = { ...event, sessionId, parentSessionId: parent.id }; + session.accept(mappedEvent); + for (const listener of this.sessionListeners) listener(session, mappedEvent); + return true; + } + + private holdPendingOpenDescendant(event: ProviderEvent): boolean { + if (!("sessionId" in event)) return false; + let state = this.pendingOpenDescendants.get(event.sessionId); + if (event.type === "session.opened" && event.parentSessionId) { + const parent = this.providerSessions.get(event.parentSessionId); + state = + this.pendingOpenDescendants.get(event.parentSessionId) ?? + (parent?.hasPendingOpen() ? this.pendingOpenDescendantStateFor(parent) : undefined); + if (state) { + state.providerSessionIds.add(event.sessionId); + this.pendingOpenDescendants.set(event.sessionId, state); + } + } + if (!state) return false; + state.events.push(event); + return true; + } + + private pendingOpenDescendantStateFor(root: ProviderRuntimeSession): PendingOpenDescendantState { + const existing = this.pendingOpenDescendantsByRoot.get(root); + if (existing) return existing; + const state: PendingOpenDescendantState = { providerSessionIds: new Set(), events: [] }; + this.pendingOpenDescendantsByRoot.set(root, state); + return state; + } + + releasePendingOpenDescendants(root: ProviderRuntimeSession): void { + const state = this.takePendingOpenDescendants(root); + if (!state) return; + for (const event of state.events) this.accept(event); + } + + discardPendingOpenDescendants(root: ProviderRuntimeSession): void { + this.takePendingOpenDescendants(root); + } + + private takePendingOpenDescendants( + root: ProviderRuntimeSession, + ): PendingOpenDescendantState | undefined { + const state = this.pendingOpenDescendantsByRoot.get(root); + if (!state) return undefined; + this.pendingOpenDescendantsByRoot.delete(root); + for (const providerSessionId of state.providerSessionIds) { + if (this.pendingOpenDescendants.get(providerSessionId) === state) { + this.pendingOpenDescendants.delete(providerSessionId); + } + } + return state; + } + + private failSessionRequests(sessionId: string, error: Error): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + request.reject(error); + } + for (const [requestId, pendingSessionId] of this.sessionRequests) { + if (pendingSessionId === sessionId) this.sessionRequests.delete(requestId); + } + } + + private settleClosedSessionRequests( + sessionId: string, + error: Error, + event: Extract, + ): void { + for (const [requestId, request] of this.requests) { + if (request.sessionId !== sessionId) continue; + this.requests.delete(requestId); + if (request.kind === "session.close") request.resolve(event); + else request.reject(error); + } + } + + private failRequest(event: Extract): void { + const request = this.requests.get(event.requestId); + if (request) { + this.requests.delete(event.requestId); + request.reject(providerError(event.error)); + } + if (request?.sessionId) this.sessions.get(request.sessionId)?.requestFailed(event); + const sessionId = this.sessionRequests.get(event.requestId); + if (!sessionId) return; + this.sessionRequests.delete(event.requestId); + this.sessions.get(sessionId)?.requestFailed(event); + } + + private finishRequest( + event: Extract, + ): void { + const request = this.requests.get(event.requestId); + if (!request) return; + this.requests.delete(event.requestId); + request.resolve(event); + } +} + +class ProviderRuntimeSession { + readonly history: ProviderEvent[] = []; + private readonly listeners = new Set<(event: ProviderEvent) => void>(); + private capabilities: readonly string[] = []; + private openRequest: { requestId: string; deferred: Deferred } | null = null; + private readonly prompts = new Map< + string, + Deferred> + >(); + private readonly activeTurnIds = new Set(); + private terminal = false; + config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] }; + commands: Array<{ name: string; description: string; argumentHint?: string }> = []; + persistence: ProviderPersistence | null = null; + + constructor( + private readonly runtime: ProviderRuntime, + private readonly connection: ProviderConnection, + readonly id: string, + private readonly providerSessionId: string, + readonly restoration: "core" | "parent", + ) {} + + get providerId(): string { + return this.providerSessionId; + } + + get negotiatedCapabilities(): readonly string[] { + return this.capabilities; + } + + onEvent(listener: (event: ProviderEvent) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async prompt( + prompt: ProviderPrompt, + ): Promise["result"]> { + requireProviderCapabilities(this.capabilities, { + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + if (this.terminal) throw new StaleProviderSessionError(this.id); + const pending = deferred>(); + this.prompts.set(prompt.clientMessageId, pending); + try { + await this.connection.send({ + type: "session.prompt", + sessionId: this.providerSessionId, + prompt, + }); + return (await pending.promise).result; + } catch (error) { + if (this.terminal || this.runtime.isClosed) throw new StaleProviderSessionError(this.id); + throw error; + } finally { + this.prompts.delete(prompt.clientMessageId); + } + } + + async configure(changes: ProviderConfigChanges): Promise { + const input: Extract = { + type: "session.configure", + requestId: randomUUID(), + sessionId: this.providerSessionId, + changes, + }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async respondToPermission( + permissionId: string, + response: Extract["response"], + ): Promise { + const input: Extract = { + type: "session.permission", + sessionId: this.providerSessionId, + permissionId, + response, + }; + requireProviderCapabilities(this.capabilities, input); + await this.connection.send(input); + } + + async revert(token: ProviderInput & { type: "session.revert" }): Promise { + const input = { ...token, sessionId: this.providerSessionId }; + requireProviderCapabilities(this.capabilities, input); + await this.runtime.complete(input); + } + + async interrupt(): Promise { + await this.runtime.complete({ + type: "session.interrupt", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } + + async close(): Promise { + if (this.restoration === "parent") { + this.runtime.removeSession(this.id, this.providerSessionId); + return; + } + try { + await this.runtime.complete({ + type: "session.close", + requestId: randomUUID(), + sessionId: this.providerSessionId, + }); + } catch (error) { + if (!this.runtime.isClosed) throw error; + } finally { + this.runtime.removeSession(this.id, this.providerSessionId); + } + } + + beginOpen(requestId: string): Promise { + const pending = deferred(); + this.openRequest = { requestId, deferred: pending }; + return pending.promise; + } + + requestFailed(event: Extract): void { + if (this.openRequest?.requestId === event.requestId) { + this.openRequest.deferred.reject(providerError(event.error)); + this.openRequest = null; + this.runtime.discardPendingOpenDescendants(this); + } + } + + accept(event: ProviderEvent): void { + if (event.type === "session.opened") { + event = { ...event, capabilities: this.normalizeSessionCapabilities(event.capabilities) }; + } + if (event.type === "session.opened") { + this.capabilities = [...event.capabilities]; + this.persistence = this.restoration === "core" ? (event.persistence ?? null) : null; + return; + } + const openRequest = this.openRequest; + if ( + event.type === "session.ready" && + openRequest && + openRequest.requestId === event.requestId + ) { + this.openRequest = null; + this.runtime.releasePendingOpenDescendants(this); + openRequest.deferred.resolve(); + return; + } + if (event.type === "session.prompt_result") { + if (event.result.type === "turn") this.activeTurnIds.add(event.result.turnId); + this.prompts.get(event.clientMessageId)?.resolve(event); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + if (this.terminal) return; + this.terminal = true; + this.publish(event); + this.rejectPending(new Error(event.error?.message ?? `Provider session ${this.id} closed`)); + return; + } + this.publish(event); + } + + hasPendingOpen(): boolean { + return this.openRequest !== null; + } + + connectionClosed(error = new Error("Provider connection closed")): void { + // Closing fails only an interrupted turn. An idle session goes stale, and its next prompt + // reopens it from persistence. + if (!this.terminal && this.activeTurnIds.size > 0) { + this.publish({ + type: "session.runtime_failed", + sessionId: this.id, + error: { message: error.message }, + }); + } + this.terminal = true; + this.rejectPending(error); + } + + private rejectPending(error: Error): void { + const openRequest = this.openRequest; + this.openRequest = null; + openRequest?.deferred.reject(error); + this.runtime.discardPendingOpenDescendants(this); + for (const prompt of this.prompts.values()) prompt.reject(error); + this.prompts.clear(); + } + + private normalizeSessionCapabilities(capabilities: readonly string[]): ProviderCapability[] { + const known = PROVIDER_CAPABILITIES.filter((capability) => capabilities.includes(capability)); + for (const capability of known) { + if (!this.connection.capabilities.includes(capability)) { + throw new Error(`Provider session selected unoffered capability ${capability}`); + } + } + return known; + } + + private publish(event: ProviderEvent): void { + if (event.type === "session.turn") { + if (event.state === "started") this.activeTurnIds.add(event.turnId); + else this.activeTurnIds.delete(event.turnId); + } + if (event.type === "session.config") this.config = event.config; + if (event.type === "session.commands") this.commands = [...event.commands]; + if (event.type === "session.persistence" && this.restoration === "core") { + this.persistence = event.persistence; + } + this.history.push(event); + for (const listener of this.listeners) listener(event); + } +} + +function normalizeConnection(connection: ProviderConnection): ProviderConnection { + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + connection.capabilities.includes(capability), + ); + return { + version: connection.version, + capabilities, + send: (input) => connection.send(ProviderInputSchema.parse(input)), + onEvent: (listener) => + connection.onEvent((event) => listener(ProviderEventSchema.parse(event))), + close: () => connection.close(), + }; +} + +interface AdaptedPluginProvider { + registration: ProviderRegistration; + client: PluginAgentClient; + definition: RegisteredProviderDefinition; + clients: Set; +} + +/** Owns the complete plugin-provider adaptation behind the existing core provider boundary. */ +export class PluginAgentClientRegistry { + private readonly providers = new Map(); + + constructor(private readonly logger: Logger) {} + + replace(registrations: readonly ProviderRegistration[]): void { + const incoming = new Map(); + for (const registration of registrations) { + if (incoming.has(registration.id)) { + throw new Error(`Duplicate plugin provider ID: ${registration.id}`); + } + incoming.set(registration.id, registration); + } + + for (const [id, adapted] of this.providers) { + if (incoming.get(id) === adapted.registration) continue; + this.providers.delete(id); + void Promise.all([...adapted.clients].map((client) => client.shutdown())).catch((error) => { + this.logger.warn({ err: error, provider: id }, "Failed to stop plugin provider"); + }); + } + + for (const [id, registration] of incoming) { + if (this.providers.has(id)) continue; + const client = new PluginAgentClient(registration); + const clients = new Set([client]); + const definition = createPluginProviderDefinition( + registration, + (_logger, runtimeSettings) => { + const configuredClient = new PluginAgentClient(registration, runtimeSettings); + clients.add(configuredClient); + return configuredClient; + }, + ); + this.providers.set(id, { registration, client, definition, clients }); + } + } + + definitions(): Record { + return Object.fromEntries( + [...this.providers].map(([id, provider]) => [id, provider.definition]), + ); + } + + clients(): Record { + return Object.fromEntries([...this.providers].map(([id, provider]) => [id, provider.client])); + } + + has(provider: string): boolean { + return this.providers.has(provider); + } + + async shutdown(): Promise { + const providers = [...this.providers.values()]; + this.providers.clear(); + await Promise.all( + providers.flatMap(({ clients }) => [...clients].map((client) => client.shutdown())), + ); + } +} + +function createPluginProviderDefinition( + registration: ProviderRegistration, + createClient: RegisteredProviderDefinition["createClient"], +): RegisteredProviderDefinition { + return { + id: registration.id, + label: registration.label, + description: registration.description ?? `Plugin provider ${registration.label}`, + iconSvg: registration.icon, + defaultModeId: null, + modes: [], + supportsExactMcpPreapproval: true, + createClient, + }; +} + +interface PendingChild { + session: ProviderRuntimeSession; + opened: Extract; +} + +class PluginAgentClient implements AgentClient { + readonly provider: string; + private readonly runtime: ProviderRuntime; + private readonly rootsBySession = new Map(); + private readonly pendingChildren: PendingChild[] = []; + + readonly getCatalogCacheKey?: AgentClient["getCatalogCacheKey"]; + + constructor(registration: ProviderRegistration, runtimeSettings?: ProviderRuntimeSettings) { + this.provider = registration.id; + this.runtime = new ProviderRuntime(registration, runtimeSettings); + if (registration.getCatalogCacheKey) + this.getCatalogCacheKey = (options) => this.runtime.catalogCacheKey(options); + this.runtime.onSessionOpened((session, opened) => this.acceptChild(session, opened)); + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.runtime.negotiatedCapabilities); + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + return await this.openSession({ + config, + launchContext, + history: "skip", + persist: options?.persistSession !== false, + }); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + if (!overrides?.cwd) { + throw new Error(`Plugin provider '${this.provider}' requires cwd to resume a session`); + } + return await this.openSession({ + config: { ...overrides, provider: this.provider, cwd: overrides.cwd }, + launchContext, + persistence: decodePersistence(handle), + history: "replay", + persist: true, + }); + } + + async fetchCatalog( + options: FetchCatalogOptions, + context?: ProviderRefreshContext, + ): Promise { + const catalog = await this.runtime.catalog( + options.scope === "workspace" ? options.cwd : undefined, + context?.signal, + ); + return { + models: catalog.models.map((model) => + mapModel( + this.provider, + model, + catalog.defaultModel, + catalog.thinkingOptions, + catalog.defaultThinkingOption, + ), + ), + modes: catalog.modes.map((mode) => ({ ...mode })), + defaultModeId: catalog.defaultMode, + }; + } + + async isAvailable(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + return await this.runtime.isAvailable(); + } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + const status = await this.runtime.status(); + return { diagnostic: status.diagnostic ?? "Provider is available" }; + } + + async listImportableSessions( + options: ListImportableSessionsOptions = {}, + ): Promise { + const sessions = await this.runtime.listSessions({ + query: options.query, + cwd: options.cwd, + limit: options.limit, + }); + return sessions.map((session) => ({ + providerHandleId: encodePersistence(session.persistence), + cwd: session.cwd, + title: session.title ?? null, + firstPromptPreview: null, + lastPromptPreview: session.description ?? null, + lastActivityAt: parseProviderDate(session.updatedAt), + })); + } + + async importSession( + input: ImportProviderSessionInput, + context: ImportProviderSessionContext, + ): Promise { + const session = await this.openSession({ + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + launchContext: context.launchContext, + persistence: decodePersistenceId(input.providerHandleId), + history: "replay", + persist: true, + }); + const persistence = session.describePersistence(); + if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`); + return { + session, + config: { ...context.config, provider: this.provider, cwd: input.cwd }, + persistence, + timeline: session.timelineHistory(), + providerSubagentEvents: session.subagentHistory(), + }; + } + + async archiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.archive(decodePersistence(handle)); + } + + async unarchiveNativeSession(handle: AgentPersistenceHandle): Promise { + await this.runtime.unarchive(decodePersistence(handle)); + } + + async shutdown(): Promise { + await this.runtime.close(); + } + + private async openSession(input: { + config: AgentSessionConfig; + launchContext?: AgentLaunchContext; + persistence?: ProviderPersistence; + history: "replay" | "skip"; + persist: boolean; + }): Promise { + const sessionId = randomUUID(); + const bridge = await this.runtime.openSession({ + sessionId, + config: mapSessionConfig(input.config, input.launchContext, input.persist), + persistence: input.persistence, + history: input.history, + }); + const session = new PluginAgentSession(this.provider, bridge, () => { + this.rootsBySession.delete(bridge.id); + }); + this.rootsBySession.set(bridge.id, session); + this.attachPendingChildren(); + return session; + } + + private acceptChild( + session: ProviderRuntimeSession, + opened: Extract, + ): void { + const root = opened.parentSessionId + ? this.rootsBySession.get(opened.parentSessionId) + : undefined; + if (!root) { + this.pendingChildren.push({ session, opened }); + return; + } + root.attachChild(session, opened); + this.rootsBySession.set(session.id, root); + this.attachPendingChildren(); + } + + private attachPendingChildren(): void { + let attached = true; + while (attached) { + attached = false; + for (let index = this.pendingChildren.length - 1; index >= 0; index -= 1) { + const pending = this.pendingChildren[index]; + const root = pending.opened.parentSessionId + ? this.rootsBySession.get(pending.opened.parentSessionId) + : undefined; + if (!root) continue; + this.pendingChildren.splice(index, 1); + root.attachChild(pending.session, pending.opened); + this.rootsBySession.set(pending.session.id, root); + attached = true; + } + } + } +} + +class PluginAgentSession implements AgentSession { + readonly initialTimeline: ImportedProviderSession["timeline"]; + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private readonly pendingPermissions = new Map(); + private readonly permissionResponses = new Map(); + private readonly revertTokens = new Map(); + private readonly timelineSnapshots = new Map(); + private readonly subagentIdsBySession = new Map(); + private readonly childUnsubscribes = new Map void>(); + private readonly childSnapshots = new Map>(); + private unsubscribe: (() => void) | null = null; + private currentTurnId: string | null = null; + private closed = false; + + constructor( + readonly provider: string, + private readonly bridge: ProviderRuntimeSession, + private readonly onClose: () => void, + ) { + this.subagentIdsBySession.set(bridge.id, null); + for (const event of bridge.history) this.accept(event, false); + // Opening notices arrive before the manager subscribes to the session. + // Commit them on registration and replay the same timestamps from history. + this.initialTimeline = this.timelineHistory().filter( + (entry) => entry.item.type === "notification", + ); + this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); + } + + get id(): string { + return this.describePersistence()?.sessionId ?? this.bridge.id; + } + + get capabilities(): AgentCapabilityFlags { + return agentCapabilities(this.bridge.negotiatedCapabilities); + } + + get features(): AgentFeature[] { + return this.bridge.config.settings.map((setting) => + setting.type === "toggle" + ? { ...setting } + : { + ...setting, + options: setting.options.map((option) => ({ ...option, id: option.value })), + }, + ); + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + return await runProviderTurn({ + prompt, + runOptions: options, + startTurn: (nextPrompt, nextOptions) => this.startTurn(nextPrompt, nextOptions), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.id, + }); + } + + async startTurn( + prompt: AgentPromptInput, + options: AgentRunOptions = {}, + ): Promise<{ turnId: string }> { + const clientMessageId = options.clientMessageId ?? randomUUID(); + const result = await this.bridge.prompt({ + clientMessageId, + delivery: "auto", + input: mapPromptInput(prompt, this.bridge.commands), + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + }); + if (result.type === "failed") throw providerError(result.error); + if (result.type === "turn" || result.type === "steer") return { turnId: result.turnId }; + + const turnId = `command-${clientMessageId}`; + this.publish({ type: "turn_started", provider: this.provider, turnId }); + this.publish({ type: "turn_completed", provider: this.provider, turnId }); + return { turnId }; + } + + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + const result = await this.bridge.prompt({ + clientMessageId: options.clientMessageId ?? randomUUID(), + delivery: "steer", + input: { type: "message", content: mapPromptContent(prompt) }, + ...(options.outputSchema === undefined + ? {} + : { outputSchema: toJsonValue(options.outputSchema, "output schema") }), + clearPendingPermissions: options.clearPendingPermissions, + }); + return result.type === "steer" && result.turnId === options.expectedTurnId + ? { status: "accepted" } + : { status: "unavailable" }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + for (const event of this.history) yield event; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.bridge.config.model ?? null, + modeId: this.bridge.config.mode ?? null, + thinkingOptionId: this.bridge.config.thinkingOption ?? null, + }; + } + + async getAvailableModes(): Promise { + return this.bridge.config.modes.map((mode) => ({ ...mode })); + } + + async getCurrentMode(): Promise { + return this.bridge.config.mode ?? null; + } + + async setMode(modeId: string): Promise { + await this.bridge.configure({ mode: modeId }); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return [...this.pendingPermissions.values()]; + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + this.permissionResponses.set(requestId, response); + try { + await this.bridge.respondToPermission( + requestId, + toJsonValue(response, "permission response") as Extract< + ProviderInput, + { type: "session.permission" } + >["response"], + ); + } catch (error) { + this.permissionResponses.delete(requestId); + throw error; + } + } + + describePersistence(): AgentPersistenceHandle | null { + return this.bridge.persistence + ? persistenceHandle(this.provider, this.bridge.persistence) + : null; + } + + async interrupt(): Promise { + await this.bridge.interrupt(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe?.(); + this.unsubscribe = null; + for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe(); + this.childUnsubscribes.clear(); + this.subagentIdsBySession.clear(); + this.listeners.clear(); + this.onClose(); + await this.bridge.close(); + } + + async listCommands(): Promise { + return this.bridge.commands.map((command) => ({ + ...command, + argumentHint: command.argumentHint ?? "", + kind: "command", + })); + } + + async setModel(modelId: string | null): Promise { + await this.bridge.configure({ model: modelId }); + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + await this.bridge.configure({ thinkingOption: thinkingOptionId }); + } + + async setFeature(featureId: string, value: unknown): Promise { + await this.bridge.configure({ + settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, + }); + } + + async revertConversation(input: { messageId: string }): Promise { + await this.revert(input.messageId, "conversation"); + } + + async revertFiles(input: { messageId: string }): Promise { + await this.revert(input.messageId, "files"); + } + + async revertBoth(input: { messageId: string }): Promise { + await this.revert(input.messageId, "both"); + } + + timelineHistory(): ImportedProviderSession["timeline"] { + return this.history.flatMap((event) => + event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [], + ); + } + + subagentHistory(): NonNullable { + return this.history.filter( + (event): event is Extract => + event.type === "provider_subagent", + ); + } + + attachChild( + child: ProviderRuntimeSession, + opened: Extract, + ): void { + const parentSubagentId = opened.parentSessionId + ? this.subagentIdsBySession.get(opened.parentSessionId) + : undefined; + if (parentSubagentId === undefined) { + throw new Error(`Missing plugin child parent ${opened.parentSessionId}`); + } + const childId = child.providerId; + this.subagentIdsBySession.set(child.id, childId); + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + parentSubagentId, + toolCallId: opened.toolCallId ?? null, + title: opened.title ?? null, + description: opened.description ?? null, + status: "running", + cwd: opened.cwd, + }, + }); + const snapshots = new Map(); + this.childSnapshots.set(childId, snapshots); + for (const event of child.history) this.acceptChildEvent(childId, event, snapshots); + this.childUnsubscribes.set( + child.id, + child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)), + ); + } + + private accept(event: ProviderEvent, live: boolean): void { + const translated = this.translate(event); + for (const next of translated) { + this.history.push(next); + if (live) this.emit(next); + } + } + + private translate(event: ProviderEvent): AgentStreamEvent[] { + switch (event.type) { + case "session.turn": + return [this.translateTurn(event)]; + case "session.usage": + return [ + { + type: "usage_updated", + provider: this.provider, + usage: { ...event.usage }, + turnId: event.turnId, + }, + ]; + case "timeline.item": + return this.translateTimeline(event); + case "session.permission": + return [this.translatePermission(event)]; + case "session.permission_resolved": + return [this.translatePermissionResolution(event)]; + case "session.config": + return this.translateConfig(event); + case "session.notice": + return this.translateNotice(event); + case "session.runtime_failed": + return [this.translateFailure(event.error)]; + case "session.closed": + return event.error ? [this.translateFailure(event.error)] : []; + default: + return []; + } + } + + private translateTurn(event: Extract): AgentStreamEvent { + if (event.state === "started") { + this.currentTurnId = event.turnId; + return { type: "turn_started", provider: this.provider, turnId: event.turnId }; + } + if (this.currentTurnId === event.turnId) this.currentTurnId = null; + if (event.state === "completed") { + return { type: "turn_completed", provider: this.provider, turnId: event.turnId }; + } + if (event.state === "canceled") { + return { + type: "turn_canceled", + provider: this.provider, + turnId: event.turnId, + reason: event.error?.message ?? "Canceled by provider", + }; + } + return { + type: "turn_failed", + provider: this.provider, + turnId: event.turnId, + error: event.error?.message ?? "Provider turn failed", + code: event.error?.code, + diagnostic: event.error?.diagnostic, + }; + } + + private translateTimeline( + event: Extract, + ): AgentStreamEvent[] { + const item = mapTimelineItem(event.item, this.timelineSnapshots, this.revertTokens); + return item + ? [ + { + type: "timeline", + provider: this.provider, + item, + turnId: this.currentTurnId ?? undefined, + timestamp: event.timestamp, + }, + ] + : []; + } + + private translatePermission( + event: Extract, + ): AgentStreamEvent { + const request: AgentPermissionRequest = { ...event.request, provider: this.provider }; + this.pendingPermissions.set(request.id, request); + return { + type: "permission_requested", + provider: this.provider, + request, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translatePermissionResolution( + event: Extract, + ): AgentStreamEvent { + this.pendingPermissions.delete(event.permissionId); + const resolution = this.permissionResponses.get(event.permissionId) ?? { + behavior: "deny" as const, + message: "Resolved by provider", + }; + this.permissionResponses.delete(event.permissionId); + return { + type: "permission_resolved", + provider: this.provider, + requestId: event.permissionId, + resolution, + turnId: this.currentTurnId ?? undefined, + }; + } + + private translateConfig( + event: Extract, + ): AgentStreamEvent[] { + return [ + { + type: "mode_changed", + provider: this.provider, + currentModeId: event.config.mode ?? null, + availableModes: event.config.modes.map((mode) => ({ ...mode })), + }, + { + type: "model_changed", + provider: this.provider, + runtimeInfo: { + provider: this.provider, + sessionId: this.id, + model: event.config.model ?? null, + modeId: event.config.mode ?? null, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + }, + { + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: event.config.thinkingOption ?? null, + }, + ]; + } + + private translateNotice( + event: Extract, + ): AgentStreamEvent[] { + if (event.notice.dismissed) return []; + return [ + { + type: "timeline", + provider: this.provider, + timestamp: new Date().toISOString(), + item: { + type: "notification", + level: event.notice.severity, + message: [event.notice.title, event.notice.description].filter(Boolean).join("\n"), + }, + turnId: this.currentTurnId ?? undefined, + }, + ]; + } + + private translateFailure(error: ProviderError): AgentStreamEvent { + return { + type: "turn_failed", + provider: this.provider, + turnId: this.currentTurnId ?? undefined, + error: error.message, + code: error.code, + diagnostic: error.diagnostic, + }; + } + + private acceptChildEvent( + childId: string, + event: ProviderEvent, + snapshots: Map, + ): void { + if (event.type === "timeline.item") { + const item = mapTimelineItem(event.item, snapshots); + if (item) { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "timeline", id: childId, item, timestamp: event.timestamp }, + }); + } + return; + } + if (event.type === "session.turn" && event.state !== "started") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { type: "upsert", id: childId, status: event.state }, + }); + return; + } + if (event.type === "session.runtime_failed" || event.type === "session.closed") { + this.publish({ + type: "provider_subagent", + provider: this.provider, + event: { + type: "upsert", + id: childId, + status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed", + }, + }); + } + } + + private async revert(messageId: string, scope: "conversation" | "files" | "both") { + const token = this.revertTokens.get(messageId); + if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`); + await this.bridge.revert({ + type: "session.revert", + requestId: randomUUID(), + sessionId: this.bridge.providerId, + token, + scope, + }); + } + + private publish(event: AgentStreamEvent): void { + this.history.push(event); + this.emit(event); + } + + private emit(event: AgentStreamEvent): void { + for (const listener of this.listeners) listener(event); + } +} + +function agentCapabilities(capabilities: readonly string[]): AgentCapabilityFlags { + const supports = (capability: ProviderCapability) => capabilities.includes(capability); + return { + supportsStreaming: true, + supportsSessionPersistence: supports("session.persistence"), + supportsSessionListing: supports("session.list"), + supportsDynamicModes: supports("session.configure"), + supportsMcpServers: true, + supportsNativePaseoTools: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: supports("session.revert.conversation"), + supportsRewindFiles: supports("session.revert.files"), + supportsRewindBoth: supports("session.revert.both"), + }; +} + +function mapModel( + provider: string, + model: Extract["catalog"]["models"][number], + defaultModel: string | undefined, + catalogThinkingOptions: Extract["catalog"]["thinkingOptions"], + catalogDefaultThinkingOption: string | undefined, +): AgentModelDefinition { + const thinkingOptions = model.thinkingOptions ?? catalogThinkingOptions; + return { + ...model, + provider, + isDefault: model.isDefault ?? model.id === defaultModel, + metadata: model.metadata ? { ...model.metadata } : undefined, + thinkingOptions: thinkingOptions?.map((option) => Object.assign({}, option)), + defaultThinkingOptionId: model.defaultThinkingOptionId ?? catalogDefaultThinkingOption, + }; +} + +function mapSessionConfig( + config: AgentSessionConfig, + launchContext: AgentLaunchContext | undefined, + persist: boolean, +): ProviderSessionConfig { + return { + cwd: config.cwd, + env: createProviderEnv({ baseEnv: {}, overlays: [launchContext?.env] }), + systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: { ...config.mcpServers }, + toolPolicy: config.toolPolicy + ? { preapproved: config.toolPolicy.preapproved.map((grant) => ({ ...grant })) } + : undefined, + model: config.model, + mode: config.modeId, + thinkingOption: config.thinkingOptionId, + settings: toJsonObject(config.featureValues ?? {}, "provider settings"), + providerOptions: config.providerOptions, + title: config.title ?? undefined, + persist, + }; +} + +function combineSystemPrompts( + systemPrompt: string | undefined, + daemonPrompt: string | undefined, +): string | undefined { + const parts = [systemPrompt, daemonPrompt].filter( + (value): value is string => typeof value === "string" && value.trim().length > 0, + ); + return parts.length > 0 ? parts.join("\n\n") : undefined; +} + +function mapPromptInput( + prompt: AgentPromptInput, + commands: readonly { name: string }[], +): ProviderPrompt["input"] { + if (typeof prompt === "string" && prompt.startsWith("/")) { + const separator = prompt.indexOf(" "); + const name = prompt.slice(1, separator === -1 ? undefined : separator); + if (commands.some((command) => command.name === name)) { + return { + type: "command", + name, + arguments: separator === -1 ? "" : prompt.slice(separator + 1), + }; + } + } + return { type: "message", content: mapPromptContent(prompt) }; +} + +function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] { + const content = typeof prompt === "string" ? [{ type: "text" as const, text: prompt }] : prompt; + return toJsonValue(content, "prompt content") as ProviderContent[]; +} + +function mapTimelineItem( + item: ProviderTimelineItem, + snapshots: Map, + revertTokens?: Map, +): AgentTimelineItem | null { + const previous = snapshots.get(item.id); + snapshots.set(item.id, item); + const { id, revertToken, ...withoutIdentity } = item; + if (revertToken !== undefined && revertTokens) { + revertTokens.set(id, revertToken); + if (item.type === "user_message" || item.type === "assistant_message") { + revertTokens.set(item.messageId ?? id, revertToken); + } + } + + if (item.type === "assistant_message" || item.type === "reasoning") { + const previousText = previous?.type === item.type ? previous.text : ""; + const text = item.text.startsWith(previousText) + ? item.text.slice(previousText.length) + : item.text; + if (text.length === 0) return null; + return item.type === "assistant_message" + ? { type: "assistant_message", text, messageId: item.messageId ?? id } + : { type: "reasoning", text }; + } + if (item.type === "user_message") { + if (previous?.type === "user_message" && previous.text === item.text) return null; + return { + type: "user_message", + text: item.text, + messageId: item.messageId ?? id, + clientMessageId: item.clientMessageId, + }; + } + if (item.type === "plugin") { + return { + type: "plugin", + id, + pluginId: item.pluginId, + kind: item.kind, + version: item.version, + data: item.data, + }; + } + return withoutIdentity as AgentTimelineItem; +} + +function persistenceHandle( + provider: string, + persistence: ProviderPersistence, +): AgentPersistenceHandle { + return { + provider, + sessionId: encodePersistence(persistence), + metadata: { pluginProviderPersistence: persistence }, + }; +} + +function encodePersistence(persistence: ProviderPersistence): string { + return `plugin:${JSON.stringify(persistence)}`; +} + +function decodePersistence(handle: AgentPersistenceHandle): ProviderPersistence { + const fromMetadata = handle.metadata?.pluginProviderPersistence; + if (fromMetadata !== undefined) { + return ProviderPersistenceSchema.parse(fromMetadata); + } + return decodePersistenceId(handle.sessionId); +} + +const ProviderPersistenceSchema: z.ZodType = z + .object({ version: z.number().int().nonnegative(), data: z.json() }) + .strict(); + +function decodePersistenceId(id: string): ProviderPersistence { + if (!id.startsWith("plugin:")) throw new Error("Invalid plugin provider persistence handle"); + return ProviderPersistenceSchema.parse(JSON.parse(id.slice("plugin:".length))); +} + +function parseProviderDate(value: string | undefined): Date { + const timestamp = value ? Date.parse(value) : Number.NaN; + return new Date(Number.isFinite(timestamp) ? timestamp : 0); +} + +function toJsonObject(value: unknown, label: string): Record { + const encoded = toJsonValue(value, label); + if (typeof encoded !== "object" || encoded === null || Array.isArray(encoded)) { + throw new Error(`${label} must be a JSON object`); + } + return encoded as Record; +} + +function toJsonValue(value: unknown, label: string): JsonValue { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`); + return JSON.parse(encoded) as JsonValue; +} diff --git a/packages/server/src/server/agent/projected-timeline-contract.test.ts b/packages/server/src/server/agent/projected-timeline-contract.test.ts new file mode 100644 index 0000000000..ee76fe50a7 --- /dev/null +++ b/packages/server/src/server/agent/projected-timeline-contract.test.ts @@ -0,0 +1,201 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import pino from "pino"; +import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { createDaemonTestContext } from "../test-utils/daemon-test-context.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { CodexAppServerAgentSession } from "./providers/codex-app-server-agent.js"; +import { createFakeCodexAppServer } from "./providers/codex/test-utils/fake-app-server.js"; +import type { AgentClient } from "./agent-sdk-types.js"; + +test("projects Codex child history and confines old-client degradation to the child transcript", async () => { + const cwd = await mkdtemp(join(tmpdir(), "paseo-projected-contract-")); + const app = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd }, + null, + pino({ level: "silent" }), + async () => app.child, + ); + await session.connect(); + const provider: AgentClient = { + provider: "codex", + capabilities: session.capabilities, + isAvailable: async () => true, + createSession: async () => session, + resumeSession: async () => session, + fetchCatalog: async () => ({ models: [], modes: [] }), + }; + const ctx = await createDaemonTestContext({ + pluginsEnabled: false, + relayEnabled: false, + agentClients: { codex: provider }, + }); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, + capabilities: { [CLIENT_CAPS.projectedSubagentTimeline]: false }, + }); + await legacy.connect(); + const messages = createMessageCollector(legacy); + const observation = legacy.observeEvents(["agent.provider_subagents.update"]); + const unsubscribe = observation.subscribe({ snapshot() {}, update() {} }); + await observation.ready; + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Projected contract", + }); + app.startsTurn({ threadId: "thread-1" }); + app.startsSubAgent({ callId: "spawn-child", threadId: "child-thread", agentPath: "child" }); + for (const delta of ["A", "B", "C"]) { + app.child.stdout.write( + JSON.stringify({ + method: "item/agentMessage/delta", + params: { threadId: "child-thread", itemId: "message-1", delta }, + }) + "\n", + ); + } + await expect + .poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length) + .toBe(1); + await expect + .poll( + () => + ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread") + .window.maxSeq, + ) + .toBe(3); + const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + limit: 1, + }); + expect(child.projection).toBe("projected"); + expect(child.rows).toMatchObject([{ seqStart: 1, seqEnd: 3, item: { text: "ABC" } }]); + const catchUp = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", { + direction: "after", + cursor: { epoch: child.epoch, seq: 1 }, + }); + expect(catchUp.rows).toMatchObject([{ item: { text: "ABC" } }]); + const oldChild = await legacy.fetchProviderSubagentTimeline(agent.id, "child-thread"); + expect(oldChild.rows).toMatchObject([ + { item: { type: "assistant_message", text: expect.stringContaining("upgrade") } }, + ]); + expect((await legacy.listProviderSubagents(agent.id)).subagents).toHaveLength(1); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "upsert", + ), + ).toBe(true); + expect( + messages.messages.some( + (message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline", + ), + ).toBe(false); + const root = await legacy.fetchAgentTimeline(agent.id, { projection: "canonical", limit: 0 }); + expect(root.error).toBeNull(); + expect(root.projection).toBe("projected"); + expect(root.entries).toHaveLength(1); + expect((await legacy.buildAgentForkContext(agent.id)).attachment.text).toContain( + "[Child] child", + ); + app.assertNoErrors(); + } finally { + unsubscribe(); + messages.unsubscribe(); + await legacy.close(); + await ctx.cleanup(); + await rm(cwd, { recursive: true, force: true }); + } +}, 30_000); + +test("resuming Codex restores child descriptors and complete messages without historical deltas", async () => { + const app = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { + id: threadId, + turns: [ + { + id: "turn", + items: + threadId === "root" + ? [ + { + type: "subAgentActivity", + id: "spawn", + kind: "started", + agentThreadId: "child", + agentPath: "explore", + }, + ] + : [{ type: "agentMessage", id: "message", text: "Complete child answer" }], + }, + ], + }, + }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "root" }, + pino({ level: "silent" }), + async () => app.child, + ); + try { + await session.connect(); + const children = []; + for await (const event of session.streamHistory()) { + if (event.type === "provider_subagent") children.push(event.event); + } + expect(children).toMatchObject([ + { type: "upsert", id: "child", status: "completed" }, + { + type: "timeline", + id: "child", + item: { type: "assistant_message", text: "Complete child answer" }, + }, + ]); + app.assertNoErrors(); + } finally { + await session.close(); + } +}); + +test("retains only the latest cumulative Pi tool progress payload", async () => { + const { parseToolArgs, parseToolResult, mapToolDetail } = + await import("./providers/pi/tool-call-mapper.js"); + const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js"); + const store = new InMemoryAgentTimelineStore(); + store.initialize("agent"); + const tool = parseToolArgs("custom_progress", {}); + const messages: string[] = []; + for (let index = 0; index < 20; index++) { + messages.push(`Update ${index}: ${"x".repeat(2048)}`); + const result = parseToolResult({ + content: [{ type: "text", text: "running" }], + details: { results: [{ messages: [...messages] }] }, + }); + store.append("agent", { + type: "tool_call", + callId: "same-call", + name: "custom_progress", + status: "running", + error: null, + detail: mapToolDetail(tool, result), + }); + } + const history = store.fetch("agent", { limit: 0 }); + expect(history.rows).toHaveLength(1); + expect(history.rows[0]).toMatchObject({ + seqStart: 1, + seqEnd: 20, + item: { detail: { output: { details: { results: [{ messages }] } } } }, + }); + expect(JSON.stringify(history.rows).length).toBeLessThan(50_000); +}); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index b75a361fad..29554fd2b2 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -3,7 +3,11 @@ import { executableExists, findExecutable, } from "../../executable-resolution/executable-resolution.js"; -import { createExternalProcessEnv, type ProcessEnvRecord } from "../paseo-env.js"; +import { + createExternalProcessEnv, + type ProcessEnvRecord, + type ExternalProcessEnv, +} from "../paseo-env.js"; export { AgentProviderRuntimeSettingsMapSchema, ProviderCommandSchema, @@ -39,6 +43,7 @@ export interface ResolvedProviderLaunch { command: string; args: string[]; source: ProviderLaunchSource; + env?: ProcessEnvRecord; } export interface ProviderLaunchAvailability { @@ -60,8 +65,8 @@ function normalizeLaunchDefault( return defaultBinary; } -async function resolveLaunchPath(command: string): Promise { - const found = await findExecutable(command); +async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promise { + const found = await findExecutable(command, { env }); if (found) { return found; } @@ -116,7 +121,7 @@ export async function checkProviderLaunchAvailable( const resolvedPath = defaultBinary && launch.source !== "override" ? await resolveDefaultLaunchPath(defaultBinary) - : await resolveLaunchPath(launch.command); + : await resolveLaunchPath(launch.command, launch.env); return { available: resolvedPath !== null, resolvedPath, @@ -242,7 +247,7 @@ export function createProviderEnvSpec(options: ProviderEnvOptions = {}): Provide }; } -export function createProviderEnv(options: ProviderEnvOptions = {}): NodeJS.ProcessEnv { +export function createProviderEnv(options: ProviderEnvOptions = {}): ExternalProcessEnv { const spec = createProviderEnvSpec(options); return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay); } diff --git a/packages/server/src/server/agent/provider-options.test.ts b/packages/server/src/server/agent/provider-options.test.ts index 911c6dcddd..f5b97d064a 100644 --- a/packages/server/src/server/agent/provider-options.test.ts +++ b/packages/server/src/server/agent/provider-options.test.ts @@ -1,7 +1,11 @@ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { z } from "zod"; import { createTestLogger } from "../../test-utils/test-logger.js"; +import { AgentManager } from "./agent-manager.js"; import { validateProviderOptions } from "./provider-options.js"; import { applyClaudeToolPolicy, ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; import { applyCodexToolPolicy, CodexProviderOptionsSchema } from "./providers/codex/options.js"; @@ -51,6 +55,7 @@ describe("provider-owned option schemas", () => { ClaudeProviderOptionsSchema.parse({ allowedTools: ["Read"], disallowedTools: ["Bash(rm *)"], + extraArgs: { chrome: null, model: "x" }, sandbox: { enabled: true, failIfUnavailable: true, @@ -63,7 +68,26 @@ describe("provider-owned option schemas", () => { }, settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } }, }), - ).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } }); + ).toMatchObject({ + extraArgs: { chrome: null, model: "x" }, + sandbox: { enabled: true, failIfUnavailable: true }, + }); + }); + + test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => { + expect(() => + validateProviderOptions("claude", ClaudeProviderOptionsSchema, { + extraArgs: { chrome: value }, + }), + ).toThrow("providerOptions.extraArgs.chrome"); + }); + + test("rejects a raw argument list for the SDK argument map", () => { + expect(() => + validateProviderOptions("claude", ClaudeProviderOptionsSchema, { + extraArgs: ["--chrome"], + }), + ).toThrow("providerOptions.extraArgs"); }); test("reports the exact invalid Claude option path", () => { @@ -179,3 +203,66 @@ describe("exact MCP preapproval mappings", () => { ]); }); }); + +describe("configured provider options", () => { + test.each(["options", "params"] as const)( + "Pi imports read sessionDir from config %s", + async (key) => { + const dir = mkdtempSync(join(tmpdir(), "provider-options-import-")); + try { + const file = join(dir, "native.jsonl"); + writeFileSync( + file, + JSON.stringify({ + type: "session", + version: 3, + id: "native", + cwd: "/tmp/options-project", + }) + "\n", + ); + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { pi: { [key]: { sessionDir: dir } } }, + }); + const sessions = await registry.pi.createClient(logger).listImportableSessions!({ + cwd: "/tmp/options-project", + }); + expect(sessions.map((session) => session.providerHandleId)).toEqual([file]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); + + test("a provider without option handling ignores the record", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + isDev: true, + providerOverrides: { mock: { options: { arbitrary: { base: true } } } }, + }); + const session = await registry.mock + .createClient(logger) + .createSession({ provider: "mock", cwd: "/tmp", providerOptions: { more: [1] } }); + expect(session.provider).toBe("mock"); + await session.close(); + }); + + test("config options reach Claude and provider validation reaches the create caller", async () => { + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { options: { sandbox: { network: { allowLocalBinding: "yes" } } } }, + }, + }); + const manager = new AgentManager({ + logger, + clients: { claude: registry.claude.createClient(logger) }, + providerDefinitions: { claude: registry.claude }, + }); + await expect( + manager.createAgent({ provider: "claude", cwd: "/tmp", model: "sonnet" }, undefined, { + workspaceId: undefined, + }), + ).rejects.toThrow("providerOptions.sandbox.network.allowLocalBinding"); + }); +}); diff --git a/packages/server/src/server/agent/provider-options.ts b/packages/server/src/server/agent/provider-options.ts index bf02410e22..8a7d0b16af 100644 --- a/packages/server/src/server/agent/provider-options.ts +++ b/packages/server/src/server/agent/provider-options.ts @@ -36,11 +36,11 @@ export class ToolPolicyUnsupportedError extends Error { } } -export function validateProviderOptions( +export function validateProviderOptions( provider: string, - schema: z.ZodType, + schema: z.ZodType, options: ProviderOptions | undefined, -): ProviderOptions | undefined { +): T | undefined { if (options === undefined) return undefined; const parsed = schema.safeParse(options); if (parsed.success) return parsed.data; @@ -75,3 +75,31 @@ function formatProviderOptionPath(path: Array): string { : `${formatted}[${JSON.stringify(segment)}]`; }, "providerOptions"); } + +/** Plain objects merge recursively; all other values replace the base value. */ +export function mergeProviderOptions( + base: ProviderOptions | undefined, + override: ProviderOptions | undefined, +): ProviderOptions | undefined { + if (base === undefined && override === undefined) return undefined; + const merged: ProviderOptions = { ...base }; + for (const [key, value] of Object.entries(override ?? {})) { + const previous = merged[key]; + Object.defineProperty(merged, key, { + value: + isPlainObject(previous) && isPlainObject(value) + ? mergeProviderOptions(previous, value) + : value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return merged; +} + +function isPlainObject(value: unknown): value is ProviderOptions { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/packages/server/src/server/agent/provider-registry-wrap.test.ts b/packages/server/src/server/agent/provider-registry-wrap.test.ts index ae1254efa7..04d123ead1 100644 --- a/packages/server/src/server/agent/provider-registry-wrap.test.ts +++ b/packages/server/src/server/agent/provider-registry-wrap.test.ts @@ -6,6 +6,9 @@ import type { AgentSession, AgentStreamEvent, AgentRuntimeInfo, + SteerActiveTurnOptions, + SteerResult, + ImportedTimelineEntry, } from "./agent-sdk-types.js"; import { wrapSessionProvider } from "./provider-registry.js"; @@ -18,6 +21,7 @@ type OptionalAgentSessionMethodName = { }[keyof AgentSession]; const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -56,8 +60,12 @@ const RUNTIME_INFO: AgentRuntimeInfo = { class FakeSession implements AgentSession { readonly provider = "claude"; - readonly id = "session-1"; - readonly capabilities = CAPABILITIES; + id = "session-1"; + capabilities = CAPABILITIES; + initialTimeline: ImportedTimelineEntry[] = [ + { item: { type: "assistant_message", id: "initial", text: "Provider setup" } }, + ]; + readonly steers: Array<{ prompt: AgentPromptInput; options: SteerActiveTurnOptions }> = []; readonly features = []; readonly recordedCalls: string[] = []; @@ -71,6 +79,15 @@ class FakeSession implements AgentSession { return { turnId: "turn-1" }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + this.recordedCalls.push("steerActiveTurn"); + this.steers.push({ prompt, options }); + return { status: "accepted" }; + } + subscribe(_callback: (event: AgentStreamEvent) => void) { this.recordedCalls.push("subscribe"); return () => {}; @@ -172,6 +189,7 @@ describe("wrapSessionProvider", () => { const session = new FakeSession(); const wrapped = wrapSessionProvider("custom-claude", session); + await wrapped.steerActiveTurn?.("follow-up", { expectedTurnId: "turn-1" }); await wrapped.listCommands?.(); await wrapped.setModel?.("sonnet"); await wrapped.setThinkingOption?.("high"); @@ -182,7 +200,11 @@ describe("wrapSessionProvider", () => { const handler = wrapped.tryHandleOutOfBand?.("/compact"); await handler?.run({ emit: () => {} }); + expect(session.steers).toEqual([ + { prompt: "follow-up", options: { expectedTurnId: "turn-1" } }, + ]); expect(session.recordedCalls).toEqual([ + "steerActiveTurn", "listCommands", "setModel", "setThinkingOption", @@ -194,4 +216,34 @@ describe("wrapSessionProvider", () => { "tryHandleOutOfBand.run", ]); }); + test("keeps provider-owned session values live", () => { + const session = new FakeSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + session.id = "session-2"; + session.capabilities = { ...CAPABILITIES, supportsMcpServers: false }; + expect(wrapped.id).toBe("session-2"); + expect(wrapped.capabilities).toEqual(session.capabilities); + expect(wrapped.initialTimeline).toEqual(session.initialTimeline); + }); + + test("propagates steering failure without interrupting or replacing the turn", async () => { + const error = new Error("Provider steer transport failed"); + class RejectingSession extends FakeSession { + override async steerActiveTurn(): Promise { + throw error; + } + } + const session = new RejectingSession(); + const wrapped = wrapSessionProvider("custom-claude", session); + await expect(wrapped.steerActiveTurn!("follow-up", { expectedTurnId: "turn-1" })).rejects.toBe( + error, + ); + expect(session.recordedCalls).toEqual([]); + }); + + test("leaves steering unavailable when the provider has no implementation", () => { + const session: AgentSession = new FakeSession(); + session.steerActiveTurn = undefined; + expect(wrapSessionProvider("custom-claude", session).steerActiveTurn).toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 656349e10b..9a2cb768f0 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -24,7 +24,7 @@ const CLAUDE_CUSTOM_THINKING_FIELDS = { const mockState = vi.hoisted(() => { interface ConstructorEntry { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; } @@ -36,17 +36,14 @@ const mockState = vi.hoisted(() => { cursor: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, trae: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, kimi: [] as Array<{ command: string[]; env?: Record; - providerParams?: unknown; }>, pi: [] as ConstructorEntry[], genericAcp: [] as Array<{ @@ -54,12 +51,12 @@ const mockState = vi.hoisted(() => { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }>, }, isCommandAvailable: vi.fn(async (_command: string) => false), runtimeModels: new Map(), cursorListFeaturesConfigs: [] as AgentSessionConfig[], + codexNativeArchiveCalls: [] as Array<{ state: "archive" | "restore"; handle: unknown }>, reset() { this.constructorArgs.claude = []; this.constructorArgs.codex = []; @@ -73,6 +70,7 @@ const mockState = vi.hoisted(() => { this.isCommandAvailable.mockImplementation(async (_command: string) => false); this.runtimeModels.clear(); this.cursorListFeaturesConfigs = []; + this.codexNativeArchiveCalls = []; }, }; }); @@ -171,6 +169,14 @@ vi.mock("./providers/codex-app-server-agent.js", () => ({ }; } + async archiveNativeSession(handle: unknown): Promise { + mockState.codexNativeArchiveCalls.push({ state: "archive", handle }); + } + + async unarchiveNativeSession(handle: unknown): Promise { + mockState.codexNativeArchiveCalls.push({ state: "restore", handle }); + } + async isAvailable(): Promise { const command: { mode?: string; argv?: string[] } | undefined = typeof this.runtimeSettings === "object" && this.runtimeSettings !== null @@ -251,13 +257,12 @@ vi.mock("./providers/pi/agent.js", () => ({ constructor(options: { runtimeSettings?: unknown; - providerParams?: unknown; + commandsRpcType?: unknown; }) { this.runtimeSettings = options.runtimeSettings; const entry: ConstructorEntry = { runtimeSettings: options.runtimeSettings, - providerParams: options.providerParams, }; if (options.commandsRpcType !== undefined) { entry.commandsRpcType = options.commandsRpcType; @@ -304,21 +309,7 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env?: Record; providerId?: string; label?: string; - providerParams?: unknown; }) { - const providerParams = - options.providerParams && - typeof options.providerParams === "object" && - !Array.isArray(options.providerParams) - ? (options.providerParams as Record) - : {}; - this.capabilities = { - ...this.capabilities, - supportsMcpServers: - typeof providerParams.supportsMcpServers === "boolean" - ? providerParams.supportsMcpServers - : this.capabilities.supportsMcpServers, - }; this.runtimeSettings = { command: { mode: "replace", @@ -331,7 +322,6 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, }); } @@ -369,11 +359,7 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -384,7 +370,6 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({ mockState.constructorArgs.cursor.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -435,11 +420,7 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -450,7 +431,6 @@ vi.mock("./providers/trae-acp-agent.js", () => ({ mockState.constructorArgs.trae.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -488,11 +468,7 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { - command: string[]; - env?: Record; - providerParams?: unknown; - }) { + constructor(options: { command: string[]; env?: Record }) { this.runtimeSettings = { command: { mode: "replace", @@ -503,7 +479,6 @@ vi.mock("./providers/kimi-acp-agent.js", () => ({ mockState.constructorArgs.kimi.push({ command: options.command, env: options.env, - providerParams: options.providerParams, }); } @@ -651,6 +626,22 @@ test("new provider extending claude appears in registry", () => { expect(registry.zai.createClient(logger).provider).toBe("zai"); }); +test("new provider extending codex archives and unarchives its native sessions", async () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { "my-codex": { extends: "codex", label: "My Codex" } }, + }); + const client = registry["my-codex"].createClient(logger); + const handle = { provider: "my-codex", sessionId: "thread-1", nativeHandle: "thread-1" }; + + await client.archiveNativeSession?.(handle); + await client.unarchiveNativeSession?.(handle); + + expect(mockState.codexNativeArchiveCalls).toEqual([ + { state: "archive", handle: { ...handle, provider: "codex" } }, + { state: "restore", handle: { ...handle, provider: "codex" } }, + ]); +}); + test("built-in OMP override keeps the real OMP adapter enabled and launchable", async () => { const omp = new FakeOmp(["custom-omp"]); const registry = buildProviderRegistry(logger, { @@ -702,7 +693,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, { command: ["my-agent", "--acp"], @@ -711,7 +701,6 @@ test("new provider extending acp uses GenericACPAgentClient", () => { }, providerId: "my-agent", label: "My Agent", - providerParams: undefined, }, ]); }); @@ -786,43 +775,20 @@ test("ordinary custom ACP providers remain fail-closed for exact MCP grants", () ).toThrow(/cannot preapprove exact MCP tools for unattended execution/u); }); -test("ACP provider params can disable MCP support", () => { +test("ACP provider configuration retains opaque options", () => { const registry = buildProviderRegistry(logger, { providerOverrides: { "no-mcp-acp": { extends: "acp", label: "No MCP ACP", command: ["no-mcp-acp", "serve"], - params: { - supportsMcpServers: false, - }, + options: { supportsMcpServers: false }, }, }, }); - - const client = registry["no-mcp-acp"].createClient(logger); - - expect(client.capabilities.supportsMcpServers).toBe(false); - expect(mockState.constructorArgs.genericAcp).toEqual([ - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - { - command: ["no-mcp-acp", "serve"], - env: undefined, - providerId: "no-mcp-acp", - label: "No MCP ACP", - providerParams: { - supportsMcpServers: false, - }, - }, - ]); + expect(registry["no-mcp-acp"].configuration?.providerOptions).toEqual({ + supportsMcpServers: false, + }); }); test("cursor provider extending acp uses CursorACPAgentClient", () => { @@ -846,14 +812,12 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => { env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, { command: ["cursor-agent", "acp"], env: { CURSOR_AGENT_LOG: "debug", }, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -910,12 +874,10 @@ test("traecli provider extending acp uses TraeACPAgentClient", () => { { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, { command: ["traecli", "acp", "serve"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); @@ -937,12 +899,10 @@ test("kimi provider extending acp uses KimiACPAgentClient", () => { { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, { command: ["kimi", "acp"], env: undefined, - providerParams: undefined, }, ]); expect(mockState.constructorArgs.genericAcp).toEqual([]); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index c65db34b2d..2698a3b2ac 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -1,6 +1,5 @@ import type { Logger } from "pino"; import type { ProviderOptions, ToolPolicy } from "@getpaseo/protocol/agent-types"; -import { z } from "zod"; import type { AgentClient, @@ -41,7 +40,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js"; import { GenericACPAgentClient } from "./providers/generic-acp-agent.js"; import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js"; import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js"; -import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; +import { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js"; import type { OpenCodeBridge } from "./providers/opencode/bridge.js"; import { OmpAgentClient } from "./providers/omp/agent.js"; import type { OmpRuntime } from "./providers/omp/runtime.js"; @@ -49,10 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; -import { ClaudeProviderOptionsSchema } from "./providers/claude/options.js"; -import { CodexProviderOptionsSchema } from "./providers/codex/options.js"; -import { OpenCodeProviderOptionsSchema } from "./providers/opencode/options.js"; -import { ToolPolicyUnsupportedError, validateProviderOptions } from "./provider-options.js"; +import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js"; import { AGENT_PROVIDER_DEFINITIONS, BUILTIN_PROVIDER_IDS, @@ -70,6 +66,9 @@ export type { AgentProviderDefinition }; export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition }; export interface ProviderDefinition extends AgentProviderDefinition { + /** Effective inputs after overrides and inheritance; plugin registrations are owned separately. */ + configuration: Omit | null; + iconSvg?: string; enabled: boolean; /** * The id of another *registered* provider this one extends (e.g. a Z.AI @@ -77,13 +76,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { * generic ACP providers (which only extend the literal "acp" sentinel). */ derivedFromProviderId: string | null; - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; - validateOptions: (options: ProviderOptions | undefined) => ProviderOptions | undefined; - applyOptions: ( - config: AgentSessionConfig, - options: ProviderOptions | undefined, - ) => AgentSessionConfig; applyToolPolicy: ( config: AgentSessionConfig, toolPolicy: ToolPolicy | undefined, @@ -102,7 +95,15 @@ export interface ProviderDefinition extends AgentProviderDefinition { ) => Promise; } +/** Provider metadata and an unconfigured factory; the registry owns override application. */ +export interface RegisteredProviderDefinition extends AgentProviderDefinition { + iconSvg?: string; + supportsExactMcpPreapproval: boolean; + createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; +} + export interface BuildProviderRegistryOptions { + pluginProviders?: Record; runtimeSettings?: AgentProviderRuntimeSettingsMap; providerOverrides?: Record; workspaceGitService?: Pick; @@ -117,7 +118,6 @@ interface ProviderClientFactoryOptions extends Pick< "workspaceGitService" | "managedProcesses" | "ompRuntime" > { openCodeBridge?: OpenCodeBridge; - providerParams?: unknown; customProvider?: { id: string; label: string; @@ -139,27 +139,23 @@ interface ResolvedProvider { profileModelsAreAdditive: boolean; enabled: boolean; derivedFromProviderId: string | null; - providerParams?: unknown; + providerOptions?: ProviderOptions; createBaseClient: (logger: Logger) => AgentClient; contract: ProviderContract; } interface ProviderContract { - optionsSchema: z.ZodType; supportsExactMcpPreapproval: boolean; applyToolPolicy?: (provider: string, toolPolicy: ToolPolicy) => ToolPolicy; } -const EmptyProviderOptionsSchema: z.ZodType = z.object({}).strict(); - const PROVIDER_CONTRACTS: Record = { - claude: { optionsSchema: ClaudeProviderOptionsSchema, supportsExactMcpPreapproval: true }, - codex: { optionsSchema: CodexProviderOptionsSchema, supportsExactMcpPreapproval: true }, - opencode: { optionsSchema: OpenCodeProviderOptionsSchema, supportsExactMcpPreapproval: true }, + claude: { supportsExactMcpPreapproval: true }, + codex: { supportsExactMcpPreapproval: true }, + opencode: { supportsExactMcpPreapproval: true }, }; const UNSUPPORTED_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: false, }; @@ -169,7 +165,6 @@ const HUB_E2E_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/u; // The cross-repository Hub harness owns this synthetic provider ID. It exercises the production // registry path without extending exact-preapproval support to user-defined ACP providers. const HUB_E2E_PROVIDER_CONTRACT: ProviderContract = { - optionsSchema: EmptyProviderOptionsSchema, supportsExactMcpPreapproval: true, applyToolPolicy: (provider, toolPolicy) => { for (const grant of toolPolicy.preapproved) { @@ -213,21 +208,19 @@ const PROVIDER_CLIENT_FACTORIES: Record = { env: runtimeSettings?.env, }), opencode: (logger, runtimeSettings, options) => - new OpenCodeAgentClient(logger, runtimeSettings, { + new OpenCodeRuntimeClient(logger, runtimeSettings, { managedProcesses: options?.managedProcesses, bridge: options?.openCodeBridge, }), - pi: (logger, runtimeSettings, options) => + pi: (logger, runtimeSettings) => new PiRpcAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, }), omp: (logger, runtimeSettings, options) => new OmpAgentClient({ logger, runtimeSettings, - providerParams: options?.providerParams, runtime: options?.ompRuntime, }), mock: (logger) => new MockLoadTestAgentClient(logger), @@ -438,16 +431,28 @@ function mergeModelAdditions( ); } +// Every session member must cross this boundary, including optional capabilities. +type ForwardedAgentSession = { [K in keyof Required]: AgentSession[K] }; + export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { return { provider, - id: inner.id, - capabilities: inner.capabilities, + get id() { + return inner.id; + }, + get capabilities() { + return inner.capabilities; + }, + get initialTimeline() { + return inner.initialTimeline; + }, get features() { return inner.features; }, + usageSession: inner.usageSession?.bind(inner), run: (prompt, options) => inner.run(prompt, options), startTurn: (prompt, options) => inner.startTurn(prompt, options), + steerActiveTurn: inner.steerActiveTurn?.bind(inner), subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))), async *streamHistory() { for await (const event of inner.streamHistory()) { @@ -471,7 +476,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession revertFiles: inner.revertFiles?.bind(inner), revertBoth: inner.revertBoth?.bind(inner), tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner), - }; + } satisfies ForwardedAgentSession; } function wrapClientProvider( @@ -480,23 +485,32 @@ function wrapClientProvider( profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], profileModelsAreAdditive: boolean, + providerOptions: ProviderOptions | undefined, ): AgentClient { const listImportableSessions = inner.listImportableSessions?.bind(inner); const importSession = inner.importSession?.bind(inner); const listFeatures = inner.listFeatures?.bind(inner); + const archiveNativeSession = inner.archiveNativeSession?.bind(inner); + const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); + const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); return { provider, - capabilities: inner.capabilities, - createSession: async (config, launchContext) => + get capabilities() { + return inner.capabilities; + }, + shutdown: inner.shutdown?.bind(inner), + createSession: async (config, launchContext, options) => wrapSessionProvider( provider, await inner.createSession( { ...config, provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), }, launchContext, + { ...options, configuredModelIds }, ), ), resumeSession: async (handle, overrides, launchContext, options) => @@ -507,18 +521,17 @@ function wrapClientProvider( ...handle, provider: inner.provider, }, - overrides - ? { - ...overrides, - provider: inner.provider, - } - : undefined, + { + ...overrides, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions), + }, launchContext, - options, + { ...options, configuredModelIds }, ), ), fetchCatalog: async (options, context) => { - const catalog = await inner.fetchCatalog(options, context); + const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { @@ -530,7 +543,11 @@ function wrapClientProvider( resolveDefaultModeId: inner.resolveDefaultModeId ? async ({ config, env, signal }: ResolveAgentDefaultModeInput) => await inner.resolveDefaultModeId?.({ - config: { ...config, provider: inner.provider }, + config: { + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }, env, signal, }) @@ -539,10 +556,15 @@ function wrapClientProvider( resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listFeatures: listFeatures - ? async (config) => await listFeatures({ ...config, provider: inner.provider }) + ? async (config) => + await listFeatures({ + ...config, + provider: inner.provider, + providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), + }) : undefined, listImportableSessions: listImportableSessions - ? async (options) => await listImportableSessions(options) + ? async (options) => await listImportableSessions({ ...options, providerOptions }) : undefined, importSession: importSession ? async (input, context) => { @@ -551,6 +573,10 @@ function wrapClientProvider( config: { ...context.config, provider: inner.provider, + providerOptions: mergeProviderOptions( + providerOptions, + context.config.providerOptions, + ), }, storedConfig: { ...context.storedConfig, @@ -567,12 +593,20 @@ function wrapClientProvider( config: { ...imported.config, provider, + providerOptions: context.storedConfig.providerOptions, }, persistence, }; } : undefined, - isAvailable: (signal) => inner.isAvailable(signal), + archiveNativeSession: archiveNativeSession + ? async (handle) => await archiveNativeSession({ ...handle, provider: inner.provider }) + : undefined, + unarchiveNativeSession: unarchiveNativeSession + ? async (handle) => await unarchiveNativeSession({ ...handle, provider: inner.provider }) + : undefined, + getCatalogCacheKey: inner.getCatalogCacheKey?.bind(inner), + isAvailable: (signal, options) => inner.isAvailable(signal, options), getDiagnostic: inner.getDiagnostic?.bind(inner), }; } @@ -582,7 +616,7 @@ function createRegistryEntry( provider: AgentProvider, resolved: ResolvedProvider, ): ProviderDefinition { - const modelClient = resolved.createBaseClient(logger); + const modelClient = createResolvedProviderClient(logger, provider, resolved); const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels); const additionalModels = resolveConfiguredModels( provider, @@ -607,15 +641,13 @@ function createRegistryEntry( const hasStaticModes = resolved.definition.modes.length > 0; + const { createBaseClient: _createBaseClient, contract: _contract, ...configuration } = resolved; return { ...resolved.definition, + configuration, enabled: resolved.enabled, derivedFromProviderId: resolved.derivedFromProviderId, - optionsSchema: resolved.contract.optionsSchema, supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval, - validateOptions: (options) => - validateProviderOptions(provider, resolved.contract.optionsSchema, options), - applyOptions: (config, options) => ({ ...config, providerOptions: options }), applyToolPolicy: (config, toolPolicy) => { if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) { throw new ToolPolicyUnsupportedError(provider); @@ -686,19 +718,41 @@ function createResolvedProviderClient( const inner = resolved.createBaseClient(logger); const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels); const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels); - const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0; - if (inner.provider === provider && !hasModelOverrides) { - return inner; - } return wrapClientProvider( provider, inner, profileModels, additionalModels, resolved.profileModelsAreAdditive, + resolved.providerOptions, ); } +interface RegisteredProvider { + definition: AgentProviderDefinition; + createClient: ProviderClientFactory; + contract: ProviderContract; + runtimeSettings?: ProviderRuntimeSettings; + override?: ProviderOverride; +} + +function resolveRegisteredProvider(input: RegisteredProvider): ResolvedProvider { + const { definition, override, contract } = input; + const runtimeSettings = mergeRuntimeSettings(input.runtimeSettings, toRuntimeSettings(override)); + return { + definition: applyOverrideToDefinition(definition, override), + runtimeSettings, + profileModels: override?.models ?? [], + additionalModels: override?.additionalModels ?? [], + profileModelsAreAdditive: false, + enabled: override?.enabled ?? definition.enabledByDefault ?? true, + derivedFromProviderId: null, + providerOptions: configuredProviderOptions(override), + createBaseClient: (logger) => input.createClient(logger, runtimeSettings), + contract, + }; +} + function buildResolvedBuiltinProviders( providerOverrides: Record, runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, @@ -717,30 +771,22 @@ function buildResolvedBuiltinProviders( for (const definition of definitions) { const override = providerOverrides[definition.id]; const factory = getProviderClientFactory(definition.id); - const mergedRuntimeSettings = mergeRuntimeSettings( - runtimeSettings?.[definition.id], - toRuntimeSettings(override), + resolvedProviders.set( + definition.id, + resolveRegisteredProvider({ + definition, + override, + runtimeSettings: runtimeSettings?.[definition.id], + createClient: (logger, settings) => + factory(logger, settings, { + workspaceGitService: options.workspaceGitService, + managedProcesses: options.managedProcesses, + ompRuntime: options.ompRuntime, + openCodeBridge: options.openCodeBridge, + }), + contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, + }), ); - - resolvedProviders.set(definition.id, { - definition: applyOverrideToDefinition(definition, override), - runtimeSettings: mergedRuntimeSettings, - profileModels: override?.models ?? [], - additionalModels: override?.additionalModels ?? [], - profileModelsAreAdditive: false, - enabled: override?.enabled ?? definition.enabledByDefault ?? true, - derivedFromProviderId: null, - providerParams: override?.params, - createBaseClient: (logger) => - factory(logger, mergedRuntimeSettings, { - workspaceGitService: options.workspaceGitService, - managedProcesses: options.managedProcesses, - ompRuntime: options.ompRuntime, - openCodeBridge: options.openCodeBridge, - providerParams: override?.params, - }), - contract: PROVIDER_CONTRACTS[definition.id] ?? UNSUPPORTED_PROVIDER_CONTRACT, - }); } return resolvedProviders; @@ -757,7 +803,8 @@ function addDerivedProviders( } if (!override.extends) { - throw new Error(`Custom provider '${providerId}' requires an extends value`); + // Plugin overrides can precede registration (including disabled or removed plugins). + continue; } if (override.extends === "acp") { @@ -785,7 +832,7 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: null, - providerParams: override.params, + providerOptions: configuredProviderOptions(override), createBaseClient: (logger) => { const acpOptions = { logger, @@ -793,7 +840,6 @@ function addDerivedProviders( env: override.env, providerId, label: override.label ?? providerId, - providerParams: override.params, }; if (providerId === "cursor") { return new CursorACPAgentClient(acpOptions); @@ -831,7 +877,7 @@ function addDerivedProviders( ); const baseDefinition = baseProvider.definition; const baseFactory = getProviderClientFactory(baseProviderId); - const providerParams = override.params ?? baseProvider.providerParams; + const providerOptions = configuredProviderOptions(override) ?? baseProvider.providerOptions; resolvedProviders.set(providerId, { definition: createDerivedDefinition(providerId, baseDefinition, override), @@ -841,12 +887,11 @@ function addDerivedProviders( profileModelsAreAdditive: false, enabled: override.enabled !== false, derivedFromProviderId: baseProviderId, - providerParams, + providerOptions, createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings, { managedProcesses: options.managedProcesses, openCodeBridge: options.openCodeBridge, - providerParams, customProvider: { id: providerId, label: override.label ?? providerId, @@ -875,6 +920,28 @@ export function buildProviderRegistry( }, options?.isDev === true, ); + for (const [provider, definition] of Object.entries(options?.pluginProviders ?? {})) { + if (resolvedProviders.has(provider)) { + throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`); + } + const override = providerOverrides[provider]; + if (override?.extends) { + logger.warn({ provider }, "Plugin provider shadowed by configured provider"); + continue; + } + resolvedProviders.set( + provider, + resolveRegisteredProvider({ + definition, + override, + runtimeSettings: runtimeSettings?.[provider], + createClient: definition.createClient, + contract: { + supportsExactMcpPreapproval: definition.supportsExactMcpPreapproval, + }, + }), + ); + } addDerivedProviders(resolvedProviders, providerOverrides, { managedProcesses: options?.managedProcesses, openCodeBridge: options?.openCodeBridge, @@ -940,3 +1007,11 @@ export async function shutdownAgentClients( }), ); } + +function configuredProviderOptions( + override: ProviderOverride | undefined, +): ProviderOptions | undefined { + // COMPAT(providerParams): added in v0.10.0, remove after 2027-03-30 once configs use options. + // options wins as a whole record when both spellings are supplied. + return override?.options ?? override?.params; +} diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 620d1c4960..66d86f8931 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,6 +1,12 @@ +import pino from "pino"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; +import type { + ProviderEvent, + ProviderRegistration, + ProviderLaunch, +} from "@getpaseo/plugin/server/provider"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { @@ -16,6 +22,7 @@ import type { ManagedAgent } from "./agent-manager.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, ProviderSnapshotManager, + type ProviderSnapshot, resolveSnapshotCwd, } from "./provider-snapshot-manager.js"; import { OpenCodeAgentClient } from "./providers/opencode-agent.js"; @@ -28,6 +35,34 @@ const TEST_CAPABILITIES = { supportsReasoningStream: false, supportsToolInvocations: false, } as const; +const CLOSED_PARENT: ManagedAgent = { + id: "parent-agent", + provider: "codex", + cwd: "/tmp/project", + capabilities: TEST_CAPABILITIES, + config: { provider: "codex", cwd: "/tmp/project" }, + createdAt: new Date(0), + updatedAt: new Date(0), + availableModes: [], + currentModeId: null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + persistence: null, + historyPrimed: false, + lastUserMessageAt: null, + activeTurnId: null, + activeTurnStartedAt: null, + attention: { requiresAttention: false }, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + labels: {}, + lifecycle: "closed", + session: null, + activeForegroundTurnId: null, +}; const TEST_REFRESH_TIMEOUT_MS = 120_000; // Builds an AgentClient that can be injected via the public extraClients option. @@ -39,6 +74,8 @@ function createExtraClient( return { provider, capabilities: TEST_CAPABILITIES, + getCatalogCacheKey: + provider === "codex" || provider === "claude" ? async () => "host" : undefined, async createSession() { throw new Error("not implemented"); }, @@ -105,7 +142,103 @@ async function runTestCatalogActivities( } describe("ProviderSnapshotManager public surface", () => { - test("validates complete Hub agent configurations through the current provider contract", async () => { + test("publishes one model per provider identity and retains the first definition", async () => { + const first = { provider: "codex", id: "shared", label: "First", isDefault: true }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [first, { ...first, label: "Duplicate" }], + modes: [], + }), + }), + claude: createExtraClient("claude", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [{ ...first, provider: "claude" }], modes: [] }), + }), + }, + }); + try { + const codex = await manager.getProvider({ provider: "codex", wait: true }); + const claude = await manager.getProvider({ provider: "claude", wait: true }); + expect(codex.models).toEqual([first]); + expect(claude.models).toEqual([{ ...first, provider: "claude" }]); + } finally { + manager.destroy(); + } + }); + + test("normalizes plugin model catalogs at the same publication boundary", async () => { + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + manager.replacePluginProviders([ + { + id: "catalog-plugin", + label: "Catalog plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [ + { id: "same", label: "First" }, + { id: "same", label: "Second" }, + ], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }, + ]); + try { + const entry = await manager.getProvider({ provider: "catalog-plugin", wait: true }); + expect(entry.models).toHaveLength(1); + expect(entry.models?.[0]).toMatchObject({ id: "same", label: "First" }); + } finally { + manager.destroy(); + } + }); + + test("carries a plugin provider icon in snapshot metadata", () => { + const iconSvg = ''; + const registration: ProviderRegistration = { + id: "icon-provider", + label: "Icon Provider", + icon: iconSvg, + async connect() { + throw new Error("not opened by this test"); + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + + try { + manager.replacePluginProviders([registration]); + + expect(manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry)).toContainEqual( + expect.objectContaining({ provider: "icon-provider", iconSvg }), + ); + } finally { + manager.destroy(); + } + }); + + test("validates catalog choices while leaving provider options opaque", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { @@ -160,11 +293,19 @@ describe("ProviderSnapshotManager public surface", () => { path: ["thinkingOptionId"], message: "Thinking option 'missing' is not available for provider 'codex'", }, - { - path: ["providerOptions", "sandbox_workspace_write", "network_access"], - message: "Invalid input: expected boolean, received string", - }, ]); + await expect( + manager.validateAgentConfiguration({ + provider: "codex", + model: "gpt-latest", + modeId: "auto-review", + thinkingOptionId: "xhigh", + providerOptions: { + sandbox_workspace_write: { network_access: "sometimes" }, + arbitrary: true, + }, + }), + ).resolves.toEqual([]); } finally { manager.destroy(); } @@ -229,7 +370,7 @@ describe("ProviderSnapshotManager public surface", () => { test("getSnapshot returns loading entries for built-in providers before warmup", () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); try { - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot("/tmp/project").records.map(({ entry }) => entry); const claude = snapshot.find((entry) => entry.provider === "claude"); const codex = snapshot.find((entry) => entry.provider === "codex"); expect(claude?.status).toBe("loading"); @@ -472,6 +613,7 @@ describe("ProviderSnapshotManager public surface", () => { test("defaults provider refreshes to a two-minute deadline", async () => { vi.useFakeTimers(); + let recovered = false; const manager = new ProviderSnapshotManager({ logger: createTestLogger(), providerOverrides: { @@ -481,7 +623,9 @@ describe("ProviderSnapshotManager public surface", () => { pi: { enabled: false }, }, extraClients: { - codex: createExtraClient("codex", { isAvailable: waitUntilAborted }), + codex: createExtraClient("codex", { + isAvailable: (signal) => (recovered ? Promise.resolve(true) : waitUntilAborted(signal)), + }), }, }); @@ -498,6 +642,14 @@ describe("ProviderSnapshotManager public surface", () => { status: "error", error: "Timed out refreshing Codex after 120000ms; pending: availability", }); + recovered = true; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + const ready = manager.getSnapshot("/tmp/project"); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); + expect(manager.getSnapshot("/tmp/project")).toBe(ready); + expect(transitions).toEqual([]); } finally { manager.destroy(); vi.useRealTimers(); @@ -587,7 +739,8 @@ describe("ProviderSnapshotManager public surface", () => { await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] }); expect(fetchCatalog).toHaveBeenCalledTimes(2); expect( - manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"), + manager.getSnapshot("/tmp/project").records.find(({ entry }) => entry.provider === "codex") + ?.entry, ).toMatchObject({ provider: "codex", status: "ready" }); } finally { manager.destroy(); @@ -865,7 +1018,7 @@ describe("ProviderSnapshotManager public surface", () => { { provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }, ]; const catalogModes: AgentMode[] = [{ id: "agent", label: "Agent" }]; - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: catalogModels, modes: catalogModes, })); @@ -1150,7 +1303,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes: childModes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.parent?.isUnattended ? "child-unattended" : undefined, @@ -1172,13 +1325,14 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "claude", currentModeId: "parent-unattended", availableModes: parentModes, config: { provider: "claude", cwd: "/tmp/project" }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1228,7 +1382,7 @@ describe("ProviderSnapshotManager public surface", () => { async fetchCatalog() { return { models: [] as AgentModelDefinition[], modes }; }, - async resolveCreateConfig(input) { + resolveCreateConfig(input) { resolverInputs.push(input); return { modeId: input.unattended ? "worker" : undefined, @@ -1293,7 +1447,8 @@ describe("ProviderSnapshotManager public surface", () => { }, }); try { - const parent = { + const parent: ManagedAgent = { + ...CLOSED_PARENT, id: "parent-agent", provider: "opencode", currentModeId: "orchestrator", @@ -1303,7 +1458,7 @@ describe("ProviderSnapshotManager public surface", () => { cwd: "/tmp/project", featureValues: { auto_accept: true }, }, - } as ManagedAgent; + }; const resolved = await manager.resolveCreateConfig({ cwd: "/tmp/project", @@ -1343,9 +1498,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(true); expect(state.providerDefinitions["zai-claude"]).toMatchObject({ enabled: true }); expect(manager.listRegisteredProviderIds()).toContain("zai-claude"); - expect(manager.getSnapshot().find((entry) => entry.provider === "zai-claude")?.source).toBe( - "custom", - ); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .find((entry) => entry.provider === "zai-claude")?.source, + ).toBe("custom"); } finally { manager.destroy(); } @@ -1365,7 +1523,12 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { expect(manager.hasProvider("zai-claude")).toBe(false); expect(state.providerDefinitions["zai-claude"]).toBeUndefined(); - expect(manager.getSnapshot().some((entry) => entry.provider === "zai-claude")).toBe(false); + expect( + manager + .getSnapshot() + .records.map(({ entry }) => entry) + .some((entry) => entry.provider === "zai-claude"), + ).toBe(false); manager.applyMutableProviderConfig({ codex: { enabled: false } }); expect(manager.hasProvider("zai-claude")).toBe(false); @@ -1427,79 +1590,268 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { "zai-claude": { extends: "claude", label: "ZAI", enabled: true }, }); - const cwds = listener.mock.calls.map((call) => call[1]).sort(); + const cwds = [...new Set(listener.mock.calls.map((call) => call[0].current.cwd))].sort(); expect(cwds).toEqual([cwdA, cwdB].sort()); } finally { manager.destroy(); } }); - test("stages provider state without events, then publishes or rolls back", () => { + test("changing one provider preserves other catalogs and clients across directories", async () => { + const calls = { claude: 0, codex: 0 }; + const clients = Object.fromEntries( + (["claude", "codex"] as const).map((provider) => [ + provider, + createExtraClient(provider, { + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls[provider]++; + return { models: [], modes: [] }; + }, + }), + ]), + ); + const config = { + claude: { enabled: true }, + codex: { enabled: true, command: ["codex"], env: { TEST_SETTING: "same" } }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, + providerOverrides: config, + runtimeSettings: { + codex: { command: { mode: "replace", argv: ["codex"] }, env: { TEST_SETTING: "same" } }, }, + extraClients: clients, }); + const cwds = [resolve("/tmp/catalog-a"), resolve("/tmp/catalog-b")]; try { - const cwd = resolve("/tmp/project"); - manager.getSnapshot(cwd); - const events: string[] = []; - manager.on("change", (_entries, changedCwd) => events.push(changedCwd)); - - const rolledBack = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + const getCodexEntry = (cwd: string) => + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === "codex")?.entry; + const before = cwds.map(getCodexEntry); + const definition = manager.getAgentManagerProviderState().providerDefinitions.codex; + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: true, label: "Renamed" } }, { replace: true }, ); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: false, - }); - rolledBack.rollback(); - expect(events).toEqual([]); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); - - const committed = manager.stageMutableProviderConfig( - { codex: { enabled: false } }, + expect(cwds.map(getCodexEntry)).toEqual(before); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toEqual(definition); + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + expect(calls).toEqual({ claude: 2, codex: 1 }); + const listener = vi.fn(); + manager.on("change", listener); + manager.applyMutableProviderConfig( + { ...config, claude: { label: "Renamed", enabled: true } }, { replace: true }, ); - expect(events).toEqual([]); - committed.publish(); - expect(events).toEqual([cwd]); + for (const cwd of cwds) await manager.warmUpSnapshotForCwd({ cwd }); + expect(calls).toEqual({ claude: 2, codex: 1 }); + expect(listener).not.toHaveBeenCalled(); } finally { manager.destroy(); } }); - test("restores provider state when applying a live config fails", () => { + test("reload preserves an unchanged lookup in flight through commit and discarded preparation", async () => { + let finish!: () => void; + const gate = new Promise((complete) => { + finish = complete; + }); + let calls = 0; + const config = { + claude: { enabled: false }, + codex: { enabled: true }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; const manager = new ProviderSnapshotManager({ logger: createTestLogger(), - providerOverrides: { - claude: { enabled: false }, - codex: { enabled: true }, - copilot: { enabled: false }, - opencode: { enabled: false }, - pi: { enabled: false }, + providerOverrides: config, + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls++; + await gate; + return { models: [], modes: [] }; + }, + }), }, }); - manager.getSnapshot("/tmp/project"); - manager.on("change", () => { - throw new Error("snapshot consumer failed"); - }); - + const cwd = resolve("/tmp/catalog-pending"); try { + const pending = manager.warmUpSnapshotForCwd({ cwd }); + await vi.waitFor(() => expect(calls).toBe(1)); + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Changed" } }, + { replace: true }, + ); + const staged = manager.prepareMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Rollback" } }, + { replace: true }, + ); + expect(staged.agentManagerState.providerDefinitions.claude?.enabled).toBe(false); + finish(); + await pending; + expect( + manager + .getSnapshot(cwd) + .records.map(({ entry }) => entry) + .find((e) => e.provider === "codex")?.status, + ).toBe("ready"); + expect(calls).toBe(1); + } finally { + finish(); + manager.destroy(); + } + }); + + test("reload replaces derived clients only when their provider configuration changes", async () => { + const config = { + claude: { enabled: true }, + codex: { enabled: true }, + "codex-2": { extends: "codex", label: "Codex 2", enabled: true }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + }); + try { + const before = manager.getAgentManagerProviderState().clients; + for (const provider of ["codex", "claude", "codex-2"]) { + expect( + await before[provider]!.getCatalogCacheKey?.({ scope: "global", force: false }), + ).toBe("host"); + expect( + await before[provider]!.getCatalogCacheKey?.({ + scope: "workspace", + cwd: resolveSnapshotCwd("/project"), + force: true, + }), + ).toBe("host"); + } + const unchanged = manager.applyMutableProviderConfig(config, { replace: true }).clients; + expect(unchanged.codex).toBe(before.codex); + expect(unchanged["codex-2"]).toBe(before["codex-2"]); + const changed = manager.applyMutableProviderConfig( + { ...config, codex: { enabled: true, env: { CODEX_HOME: "/tmp/other-codex-home" } } }, + { replace: true }, + ).clients; + expect(changed.claude).toBe(before.claude); + expect(changed.codex).not.toBe(before.codex); + expect(changed["codex-2"]).not.toBe(before["codex-2"]); + const { "codex-2": _removedProvider, ...withoutDerived } = config; + const removed = manager.applyMutableProviderConfig(withoutDerived, { replace: true }).clients; + expect(removed["codex-2"]).toBeUndefined(); + expect(removed.claude).toBe(before.claude); + } finally { + manager.destroy(); + } + }); + + test("preparation leaves installed reads untouched and commit installs all target memberships before notification", () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex") }, + }); + try { + const cwds = ["/tmp/install-a", "/tmp/install-b"]; + const previous = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observations: Array<{ current: ProviderSnapshot; reads: ProviderSnapshot[] }> = []; + manager.on("change", ({ current }) => + observations.push({ + current, + reads: [manager.getSnapshot(cwds[0]), manager.getSnapshot(cwds[1])], + }), + ); + const config = { + ...PUBLICATION_PROVIDERS, + codex: { enabled: false }, + claude: { enabled: false, label: "Changed" }, + custom: { extends: "codex", label: "Custom", enabled: false }, + }; + const prepared = manager.prepareMutableProviderConfig(config, { replace: true }); + expect(prepared.agentManagerState.providerDefinitions.custom?.enabled).toBe(false); + expect(manager.hasProvider("custom")).toBe(false); + expect(manager.listRegisteredProviderIds()).not.toContain("custom"); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(true); + expect(cwds.map((cwd) => manager.getSnapshot(cwd))).toEqual(previous); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + expect(observations).toEqual([]); expect(() => - manager.applyMutableProviderConfig({ codex: { enabled: false } }, { replace: true }), - ).toThrow("snapshot consumer failed"); - expect(manager.getAgentManagerProviderState().providerDefinitions.codex).toMatchObject({ - enabled: true, - }); + manager.prepareMutableProviderConfig( + { custom: { extends: "missing-provider" } }, + { replace: true }, + ), + ).toThrow(); + expect(manager.getSnapshot(cwds[0])).toBe(previous[0]); + prepared.commit(); + const committed = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observations).toHaveLength(2); + for (const [index, observation] of observations.entries()) { + expect(observation.current).toBe(committed[index]); + expect(observation.reads[0]).toBe(committed[0]); + expect(observation.reads[1]).toBe(committed[1]); + } + expect(manager.hasProvider("custom")).toBe(true); + expect( + committed[0]!.records.map(({ entry }) => [entry.provider, entry.status, entry.label]), + ).toContainEqual(["codex", "unavailable", "Codex"]); + expect(manager.getProviderLabel("claude")).toBe("Changed"); + } finally { + manager.destroy(); + } + }); + + test("subscriber failures cannot change discovery results, roll back commits or starve another listener", async () => { + const logs: string[] = []; + const logger = pino({ level: "warn" }, { write: (line) => logs.push(line) }); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, + }); + const transitions: ProviderSnapshot[] = []; + manager.on("change", () => { + throw new Error("subscriber failed"); + }); + manager.on("change", ({ current }) => transitions.push(current)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/subscribers" }); + expect( + manager + .getSnapshot("/tmp/subscribers") + .records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ).toBe("ready"); + manager.applyMutableProviderConfig( + { ...PUBLICATION_PROVIDERS, codex: { enabled: false } }, + { replace: true }, + ); + expect(manager.getAgentManagerProviderState().providerDefinitions.codex?.enabled).toBe(false); + const statuses = []; + for (const { records } of transitions) { + statuses.push(records.find(({ entry }) => entry.provider === "codex")!.entry.status); + } + expect(statuses).toEqual(["ready", "unavailable"]); + expect(logs.map((line) => JSON.parse(line))).toMatchObject([ + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + { msg: "Provider snapshot subscriber failed", err: { message: "subscriber failed" } }, + ]); } finally { manager.destroy(); } @@ -1530,23 +1882,24 @@ describe("ProviderSnapshotManager lifecycle", () => { }; const initialClient = trackShutdown(manager.getAgentManagerProviderState().clients.codex); - const published = manager.stageMutableProviderConfig(providerConfig("Published"), { + const published = manager.prepareMutableProviderConfig(providerConfig("Published"), { replace: true, }); const publishedClient = trackShutdown(published.agentManagerState.clients.codex); - published.publish(); + published.commit(); - const rolledBack = manager.stageMutableProviderConfig(providerConfig("Rolled back"), { + const rolledBack = manager.prepareMutableProviderConfig(providerConfig("Rolled back"), { replace: true, }); trackShutdown(rolledBack.agentManagerState.clients.codex); - rolledBack.rollback(); expect(manager.getAgentManagerProviderState().clients.codex).toBe(publishedClient); - const newest = manager.stageMutableProviderConfig(providerConfig("Newest"), { replace: true }); + const newest = manager.prepareMutableProviderConfig(providerConfig("Newest"), { + replace: true, + }); const newestClient = trackShutdown(newest.agentManagerState.clients.codex); - newest.publish(); + newest.commit(); expect(initialClient).not.toBe(publishedClient); expect(manager.getAgentManagerProviderState().clients.codex).toBe(newestClient); @@ -1573,12 +1926,12 @@ describe("ProviderSnapshotManager lifecycle", () => { const listener = vi.fn(); manager.on("change", listener); manager.getSnapshot("/tmp/project"); - manager.applyMutableProviderConfig({}); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); const firstCallCount = listener.mock.calls.length; - expect(firstCallCount).toBeGreaterThan(0); + expect(firstCallCount).toBe(1); manager.off("change", listener); - manager.applyMutableProviderConfig({}); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed again" } }); expect(listener.mock.calls.length).toBe(firstCallCount); } finally { manager.destroy(); @@ -1608,8 +1961,130 @@ describe("ProviderSnapshotManager lifecycle", () => { }); describe("ProviderSnapshotManager cwd routing", () => { + test("deduplicates cold global catalogue discovery across concurrent workspace reads", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((resolvePending) => { + release = resolvePending; + }); + const discoveryStarted = new Promise((resolveStarted) => { + started = resolveStarted; + }); + const requests: FetchCatalogOptions[] = []; + let availabilityProbes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + availabilityProbes++; + return true; + }, + async fetchCatalog(options) { + requests.push(options); + started(); + await pending; + return { + models: [{ provider: "codex", id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + const reads = Promise.all( + [ + undefined, + resolveSnapshotCwd("/tmp/concurrent-a"), + resolveSnapshotCwd("/tmp/concurrent-b"), + ].map((cwd) => manager.getProvider({ provider: "codex", cwd, wait: true })), + ); + await discoveryStarted; + expect(requests).toEqual([{ scope: "global", force: false }]); + release(); + const entries = await reads; + expect(entries.map((entry) => entry.models?.[0]?.id)).toEqual([ + "gpt-6-astra", + "gpt-6-astra", + "gpt-6-astra", + ]); + expect(requests).toHaveLength(1); + expect(availabilityProbes).toBe(1); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test.each(["codex", "custom-codex"])( + "shares %s discovery and refresh recovery across snapshot directories", + async (provider) => { + const requests: FetchCatalogOptions[] = []; + let available = false; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + "custom-codex": { extends: "codex", label: "Custom Codex", enabled: true }, + }, + extraClients: { + [provider]: createExtraClient(provider, { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return available; + }, + async fetchCatalog(options) { + requests.push(options); + return { + models: [{ provider, id: "gpt-6-astra", label: "GPT-6 Astra" }], + modes: [], + }; + }, + }), + }, + }); + try { + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "unavailable", + }); + available = true; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + providers: [provider], + }); + expect(await manager.getProvider({ provider, wait: true })).toMatchObject({ + status: "ready", + }); + await Promise.all([ + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-a"), + wait: true, + }), + manager.getProvider({ + provider, + cwd: resolveSnapshotCwd("/tmp/model-memory-b"), + wait: true, + }), + ]); + expect(requests).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/tmp/model-memory-a"), force: true }, + ]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + test("settings refresh passes the semantic global scope to providers", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); @@ -1632,22 +2107,22 @@ describe("ProviderSnapshotManager cwd routing", () => { }); test("global snapshot does not satisfy an explicit home workspace read", async () => { - const fetchCatalog = vi.fn(async () => ({ + const fetchCatalog = vi.fn(async (_options: FetchCatalogOptions) => ({ models: [] as AgentModelDefinition[], modes: [] as AgentMode[], })); const manager = new ProviderSnapshotManager({ logger: createTestLogger(), extraClients: { - codex: createExtraClient("codex", { + pi: createExtraClient("pi", { isAvailable: vi.fn(async () => true), fetchCatalog, }), }, }); try { - await manager.refreshSettingsSnapshot({ providers: ["codex"] }); - await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true }); + await manager.refreshSettingsSnapshot({ providers: ["pi"] }); + await manager.listProviders({ cwd: homedir(), providers: ["pi"], wait: true }); expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([ expect.objectContaining({ scope: "global", force: true }), @@ -1674,8 +2149,12 @@ describe("ProviderSnapshotManager cwd routing", () => { }, }); try { - const a = manager.getSnapshot("/tmp/project-a"); - const b = manager.getSnapshot("/tmp/project-b"); + const a = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-a")) + .records.map(({ entry }) => entry); + const b = manager + .getSnapshot(resolveSnapshotCwd("/tmp/project-b")) + .records.map(({ entry }) => entry); expect(a).not.toBe(b); expect(a.map((entry) => entry.provider).sort()).toEqual( b.map((entry) => entry.provider).sort(), @@ -1700,14 +2179,200 @@ describe("ProviderSnapshotManager cwd routing", () => { const listener = vi.fn(); manager.on("change", listener); manager.getSnapshot(); - manager.applyMutableProviderConfig({}); - const cwds = listener.mock.calls.map((call) => call[1]); + manager.applyMutableProviderConfig({ claude: { enabled: false, label: "Changed" } }); + const cwds = listener.mock.calls.map((call) => call[0].current.cwd); expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY); } finally { manager.destroy(); } }); + test.each([ + { sharing: "shared", expectedCwds: [resolveSnapshotCwd("/tmp/plugin-a")] }, + { + sharing: "distinct", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + { + sharing: "absent", + expectedCwds: [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ], + }, + ])( + "uses the plugin's provider-owned catalogue key: $sharing", + async ({ sharing, expectedCwds }) => { + let listener: ((event: ProviderEvent) => void) | null = null; + const cwds: Array = []; + const registration: ProviderRegistration = { + id: "scoped-plugin", + label: "Scoped plugin", + getCatalogCacheKey: + sharing === "absent" + ? undefined + : async (options) => + sharing === "shared" + ? "host" + : JSON.stringify(options.scope === "workspace" ? options.cwd : null), + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + cwds.push(input.cwd); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: input.cwd ?? "global-model", label: "Model" }], + modes: [], + }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + }); + try { + // Register after workspace views exist, as happens when reloading a plugin. + for (const cwd of [ + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + undefined, + ]) { + await manager.listProviders({ cwd, wait: true }); + } + const published: ProviderSnapshot[] = []; + manager.on("change", ({ current }) => published.push(current)); + manager.replacePluginProviders([registration]); + for (const cwd of [ + undefined, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]) { + const entry = await manager.getProvider({ provider: registration.id, cwd, wait: true }); + expect(entry.status).toBe("ready"); + expect(entry.models?.[0]?.id).toBe( + sharing === "shared" ? resolveSnapshotCwd("/tmp/plugin-a") : (cwd ?? "global-model"), + ); + } + expect(cwds).toEqual(expect.arrayContaining(expectedCwds)); + expect(cwds).toHaveLength(expectedCwds.length); + for (const snapshot of published) { + expect(snapshot.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + entry: expect.objectContaining({ provider: registration.id }), + }), + ]), + ); + } + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual( + new Set([ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ]), + ); + const lastFetchedAt = manager + .getSnapshot(resolveSnapshotCwd("/tmp/plugin-a")) + .records.find(({ entry }) => entry.provider === registration.id)!.entry.fetchedAt!; + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(lastFetchedAt))); + published.length = 0; + await manager.refreshSnapshotForCwd({ + cwd: resolveSnapshotCwd("/tmp/plugin-a"), + providers: [registration.id], + }); + const affectedCwds = + sharing === "shared" + ? [ + GLOBAL_PROVIDER_SNAPSHOT_KEY, + resolveSnapshotCwd("/tmp/plugin-a"), + resolveSnapshotCwd("/tmp/plugin-b"), + ] + : [resolveSnapshotCwd("/tmp/plugin-a")]; + expect(new Set(published.map((snapshot) => snapshot.cwd))).toEqual(new Set(affectedCwds)); + expect(published).toHaveLength(affectedCwds.length); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }, + ); + + test("registers and unregisters plugin providers without rebuilding built-in clients", async () => { + let listener: ((event: ProviderEvent) => void) | null = null; + const registration: ProviderRegistration = { + id: "plugin-provider", + label: "Plugin provider", + async connect() { + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: "plugin-model", label: "Plugin model" }], modes: [] }, + }); + }, + onEvent(nextListener) { + listener = nextListener; + return () => { + if (listener === nextListener) listener = null; + }; + }, + async close() {}, + }; + }, + }; + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + const state = manager.replacePluginProviders([registration]); + expect(state.clients[registration.id]?.provider).toBe(registration.id); + await expect( + manager.getProvider({ provider: registration.id, wait: true }), + ).resolves.toMatchObject({ + provider: registration.id, + source: "custom", + status: "ready", + models: [{ provider: registration.id, id: "plugin-model" }], + }); + + const withoutPlugin = manager.replacePluginProviders([]); + expect(manager.hasProvider(registration.id)).toBe(false); + expect(withoutPlugin.clients[registration.id]).toBeUndefined(); + expect(withoutPlugin.retiredProviders).toEqual([registration.id]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + test("resolveSnapshotCwd normalizes pure drive letters to append backslash on Windows", () => { const resolved = resolveSnapshotCwd("C:"); if (process.platform === "win32") { @@ -1717,3 +2382,1394 @@ describe("ProviderSnapshotManager cwd routing", () => { } }); }); + +describe("provider-owned catalogue identity", () => { + const disabledProviders = { + codex: { enabled: false }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }; + + test("shares effective runtime/configuration keys, preserves targets, and isolates provider identities", async () => { + const identities = new Map([ + [resolveSnapshotCwd("/a"), "runtime-1/config-1"], + [resolveSnapshotCwd("/b"), "runtime-1/config-1"], + [resolveSnapshotCwd("/c"), "runtime-2/config-1"], + ]); + const probes: Array<{ provider: string; options: FetchCatalogOptions | undefined }> = []; + const discoveries: Array<{ provider: string; options: FetchCatalogOptions }> = []; + const client = (provider: string) => + createExtraClient(provider, { + async getCatalogCacheKey(options) { + return options.scope === "workspace" ? identities.get(options.cwd) : undefined; + }, + async isAvailable(_signal, options) { + probes.push({ provider, options }); + return true; + }, + async fetchCatalog(options) { + discoveries.push({ provider, options }); + return { + models: [{ provider, id: `${provider}-${discoveries.length}`, label: "Model" }], + modes: [], + }; + }, + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + ...disabledProviders, + first: { extends: "codex", label: "First", enabled: true }, + second: { extends: "codex", label: "Second", enabled: true }, + }, + extraClients: { first: client("first"), second: client("second") }, + }); + try { + const read = (cwd: string, provider = "first") => + manager.getProvider({ provider, cwd, wait: true }); + const [a, b, c] = await Promise.all([ + read(resolveSnapshotCwd("/a")), + read(resolveSnapshotCwd("/b")), + read(resolveSnapshotCwd("/c")), + ]); + expect(a.models).toEqual(b.models); + expect(c.models).not.toEqual(a.models); + expect(discoveries).toHaveLength(2); + expect(probes).toEqual(discoveries); + expect(discoveries.map(({ options }) => options)).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/a"), force: false }, + { scope: "workspace", cwd: resolveSnapshotCwd("/c"), force: false }, + ]); + await read(resolveSnapshotCwd("/a"), "second"); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/b"), "runtime-2/config-1"); + expect((await read(resolveSnapshotCwd("/b"))).models).toEqual(c.models); + expect(discoveries).toHaveLength(3); + identities.set(resolveSnapshotCwd("/a"), "runtime-1/config-2"); + expect((await read(resolveSnapshotCwd("/a"))).models).not.toEqual(a.models); + expect(discoveries).toHaveLength(4); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["first"] }); + expect(discoveries).toHaveLength(5); + expect(discoveries[4]?.options).toEqual({ + scope: "workspace", + cwd: resolveSnapshotCwd("/b"), + force: true, + }); + expect((await read(resolveSnapshotCwd("/c"))).models).toEqual( + (await read(resolveSnapshotCwd("/b"))).models, + ); + expect(discoveries).toHaveLength(5); + expect(probes).toEqual(discoveries); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("a refresh supersedes an older in-flight result for the same provider key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/a"), + wait: true, + }); + await began; + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + release(); + await first; + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) { + expect( + (await manager.getProvider({ provider: "codex", cwd, wait: true })).models?.[0]?.id, + ).toBe("model-2"); + } + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } + }); + + test("key lookup failure detaches a view from its previous shared catalogue", async () => { + let broken = false; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...disabledProviders, codex: { enabled: true } }, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey(options) { + if (broken && options.scope === "workspace" && options.cwd === resolveSnapshotCwd("/a")) + throw new Error("configuration unreadable"); + return "runtime/config"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + calls++; + return { + models: [{ provider: "codex", id: `model-${calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + for (const cwd of [resolveSnapshotCwd("/a"), resolveSnapshotCwd("/b")]) + await manager.getProvider({ provider: "codex", cwd, wait: true }); + broken = true; + expect( + await manager.getProvider({ provider: "codex", cwd: resolveSnapshotCwd("/a"), wait: true }), + ).toMatchObject({ status: "error", error: "configuration unreadable" }); + const published: string[] = []; + manager.on("change", ({ current }) => published.push(current.cwd)); + await manager.refreshSnapshotForCwd({ cwd: resolveSnapshotCwd("/b"), providers: ["codex"] }); + expect(published).toEqual([resolveSnapshotCwd("/b")]); + expect(calls).toBe(2); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); +}); + +test("late key resolution cannot restore an old identity and concurrent readers wait for discovery", async () => { + let releaseKey!: () => void; + let releaseCatalog!: () => void; + let catalogStarted!: () => void; + const keyPending = new Promise((finish) => { + releaseKey = finish; + }); + const catalogPending = new Promise((finish) => { + releaseCatalog = finish; + }); + const began = new Promise((finish) => { + catalogStarted = finish; + }); + let keyCalls = 0; + let probes = 0; + const targets: FetchCatalogOptions[] = []; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 1) { + await keyPending; + return "old-runtime"; + } + return "current-runtime"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog(options) { + targets.push(options); + catalogStarted(); + await catalogPending; + return { models: [{ provider: "codex", id: "current", label: "Current" }], modes: [] }; + }, + }), + }, + }); + try { + const first = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + const second = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + const third = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + releaseKey(); + releaseCatalog(); + const results = await Promise.all([first, second, third]); + expect(results.map((result) => result.models?.[0]?.id)).toEqual([ + "current", + "current", + "current", + ]); + expect(targets).toEqual([ + { scope: "workspace", cwd: resolveSnapshotCwd("/project"), force: false }, + ]); + expect(probes).toBe(1); + } finally { + releaseKey(); + releaseCatalog(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("settings refresh starts independent workspace discoveries together and shares equivalent catalogues", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + const started: string[] = []; + let sharedFetches = 0; + const cwds = [resolveSnapshotCwd("/refresh-a"), resolveSnapshotCwd("/refresh-b")]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + pi: createExtraClient("pi", { + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (refreshing && options.scope === "workspace") { + started.push(options.cwd); + await pending; + } + return { models: [], modes: [] }; + }, + }), + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + sharedFetches++; + return { models: [], modes: [] }; + }, + }), + }, + }); + let refresh: Promise | undefined; + try { + await Promise.all( + cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi", "codex"] })), + ); + expect(sharedFetches).toBe(1); + refreshing = true; + refresh = manager.refreshSettingsSnapshot({ providers: ["pi", "codex"] }); + await expect.poll(() => [...started].sort()).toEqual([...cwds].sort()); + expect(sharedFetches).toBe(2); + release(); + await refresh; + } finally { + release(); + await refresh; + await manager.shutdown(); + manager.destroy(); + } +}); + +test("bounds each provider across workspaces without blocking another provider", async () => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + let refreshing = false; + let peerFetches = 0; + let active = 0; + let peak = 0; + const started = new Set(); + const updated = new Set(); + const cwds = Array.from({ length: 16 }, (_, index) => resolveSnapshotCwd(`/bounded-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + peerFetches++; + return { models: [], modes: [] }; + }, + }), + pi: createExtraClient("pi", { + async isAvailable(_signal, options) { + if (refreshing && options?.scope === "workspace") { + started.add(options.cwd); + peak = Math.max(peak, ++active); + await pending; + active--; + } + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "pi", id: refreshing ? "new" : "old", label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const requests: Promise[] = []; + try { + await Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] }))); + manager.on("change", ({ current }) => { + if ( + current.records.find((record) => record.entry.provider === "pi")?.entry.models?.[0]?.id === + "new" + ) { + updated.add(current.cwd); + } + }); + refreshing = true; + requests.push(manager.refreshSettingsSnapshot({ providers: ["pi"] })); + await expect.poll(() => started.size).toBeGreaterThanOrEqual(4); + requests.push(manager.warmUpSnapshotForCwd({ cwd: "/concurrent-read", providers: ["pi"] })); + expect(peak).toBe(4); + requests.push( + manager.warmUpSnapshotForCwd({ cwd: "/independent-provider", providers: ["codex"] }), + ); + await expect.poll(() => peerFetches).toBe(1); + release(); + await Promise.all(requests); + expect(started.size).toBe(cwds.length + 1); + expect(peak).toBe(4); + // Other sessions receive refreshed views without initiating a scoped read. + expect(cwds.every((cwd) => updated.has(cwd))).toBe(true); + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["configuration", "shutdown", "destroy"])( + "discards queued discovery after %s", + async (action) => { + let release!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const calls: string[] = []; + let keys = 0; + const cwds = Array.from({ length: 5 }, (_, index) => resolveSnapshotCwd(`/queued-${index}`)); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { ...PUBLICATION_PROVIDERS, pi: { enabled: true, label: "Initial" } }, + extraClients: { + pi: createExtraClient("pi", { + async getCatalogCacheKey() { + keys++; + return undefined; + }, + async isAvailable() { + return true; + }, + async fetchCatalog(options) { + if (options.scope === "workspace") calls.push(options.cwd); + await pending; + return { models: [], modes: [] }; + }, + }), + }, + }); + const requests = cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd, providers: ["pi"] })); + let transitions = 0; + manager.on("change", () => { + transitions++; + }); + try { + await expect.poll(() => keys).toBe(5); + await expect.poll(() => calls.length).toBeGreaterThanOrEqual(4); + expect(calls).toEqual(cwds.slice(0, 4)); + let queuedSettled = false; + void requests[4]!.then(() => { + queuedSettled = true; + return undefined; + }); + if (action === "configuration") { + manager.applyMutableProviderConfig({ + ...PUBLICATION_PROVIDERS, + pi: { enabled: true, label: "Updated" }, + }); + await expect.poll(() => queuedSettled).toBe(true); + // Replacing configuration must not reset the budget for still-active probes. + expect(calls).toEqual(cwds.slice(0, 4)); + release(); + await manager.warmUpSnapshotForCwd({ cwd: cwds[4], providers: ["pi"] }); + await Promise.all(requests); + expect(calls.filter((cwd) => cwd === cwds[4])).toHaveLength(1); + } else { + const beforeStop = transitions; + if (action === "shutdown") await manager.shutdown(); + else manager.destroy(); + await expect.poll(() => queuedSettled).toBe(true); + release(); + await Promise.all(requests); + expect(calls).toEqual(cwds.slice(0, 4)); + expect(transitions).toBe(beforeStop); + } + } finally { + release(); + await Promise.all(requests); + await manager.shutdown(); + manager.destroy(); + } + }, +); + +test("settings invalidation discards pending discovery even when the provider returns the same key", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + const call = ++calls; + if (call === 1) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: `model-${call}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + try { + const old = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + await manager.refreshSettingsSnapshot({ providers: ["codex"] }); + release(); + await old; + expect( + ( + await manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }) + ).models?.[0]?.id, + ).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("an unchanged provider publishes a pending key failure after an unrelated config reload", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + const config = { + codex: { enabled: true }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: config, + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + started(); + await pending; + throw new Error("unreadable config"); + }, + }), + }, + }); + try { + const read = manager.getProvider({ + provider: "codex", + cwd: resolveSnapshotCwd("/project"), + wait: true, + }); + await began; + manager.applyMutableProviderConfig( + { ...config, claude: { enabled: false, label: "Changed" } }, + { replace: true }, + ); + release(); + expect(await read).toMatchObject({ status: "error", error: "unreadable config" }); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test("a concurrent cached read cannot swallow a force refresh while its key is resolving", async () => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let keyCalls = 0; + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + async getCatalogCacheKey() { + if (++keyCalls === 2) { + started(); + await pending; + } + return "host"; + }, + async isAvailable() { + return true; + }, + async fetchCatalog() { + return { + models: [{ provider: "codex", id: `model-${++calls}`, label: "Model" }], + modes: [], + }; + }, + }), + }, + }); + const cwd = resolveSnapshotCwd("/project"); + try { + await manager.getProvider({ provider: "codex", cwd, wait: true }); + const refresh = manager.refreshSnapshotForCwd({ providers: ["codex"], cwd }); + await began; + const read = manager.getProvider({ provider: "codex", cwd, wait: true }); + release(); + await refresh; + expect((await read).models?.[0]?.id).toBe("model-2"); + expect(calls).toBe(2); + } finally { + release(); + await manager.shutdown(); + manager.destroy(); + } +}); + +test.each(["workspace", "settings"])( + "%s refresh retains the settled catalogue and publishes only the next result", + async (scope) => { + let release!: () => void; + let started!: () => void; + const pending = new Promise((finish) => { + release = finish; + }); + const began = new Promise((finish) => { + started = finish; + }); + let calls = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => "shared", + async isAvailable() { + return true; + }, + async fetchCatalog() { + if (++calls === 2) { + started(); + await pending; + } + return { + models: [{ provider: "codex", id: "astra", label: `Astra ${calls}` }], + modes: [], + }; + }, + }), + }, + }); + try { + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }); + const before = manager.getSnapshot("/project"); + await manager.warmUpSnapshotForCwd({ cwd: "/project", providers: ["codex"] }); + const statuses: string[] = []; + manager.on("change", ({ current }) => { + if (current.cwd !== resolveSnapshotCwd("/project")) return; + statuses.push( + current.records.find(({ entry }) => entry.provider === "codex")!.entry.status, + ); + }); + const refresh = + scope === "workspace" + ? manager.refreshSnapshotForCwd({ cwd: "/project", providers: ["codex"] }) + : manager.refreshSettingsSnapshot({ providers: ["codex"] }); + await began; + expect(statuses).toEqual([]); + expect(manager.getSnapshot("/project")).toBe(before); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: false }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 1" }] }); + release(); + await refresh; + expect(statuses).toEqual(["ready"]); + expect( + await manager.getProvider({ provider: "codex", cwd: "/project", wait: true }), + ).toMatchObject({ status: "ready", models: [{ id: "astra", label: "Astra 2" }] }); + expect(calls).toBe(2); + } finally { + release(); + manager.destroy(); + } + }, +); + +test("publication detaches provider-retained arrays, thinking options and nested metadata", async () => { + const models: AgentModelDefinition[] = [ + { + provider: "codex", + id: "astra", + label: "Astra", + thinkingOptions: [{ id: "high", label: "High" }], + metadata: { nested: { label: "original" } }, + }, + ]; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ models, modes: [] }), + }), + }, + }); + try { + const published = await manager.getProvider({ provider: "codex", wait: true }); + const record = manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const originalRecord = structuredClone(record); + const original = structuredClone(published); + models.push({ provider: "codex", id: "late", label: "Late" }); + models[0]!.thinkingOptions![0]!.label = "mutated"; + (models[0]!.metadata!.nested as { label: string }).label = "mutated"; + expect(record).toEqual(originalRecord); + expect(published).toBe(record.entry); + expect(published).toEqual(original); + expect(await manager.getProvider({ provider: "codex" })).toEqual(original); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("snapshot records share provider results across targets while retaining target-specific catalogues", async () => { + const calls = { codex: 0, opencode: 0 }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: Object.fromEntries( + (["codex", "opencode"] as const).map((provider) => [ + provider, + createExtraClient(provider, { + isAvailable: async () => true, + fetchCatalog: async (options) => { + calls[provider]++; + const target = options.scope === "workspace" ? options.cwd : "global"; + const id = provider === "codex" ? "shared" : target; + return { models: [{ provider, id, label: id }], modes: [] }; + }, + }), + ]), + ), + }); + try { + const targets = [undefined, "/tmp/identity-a", "/tmp/identity-b"]; + await Promise.all( + targets.map((cwd) => + manager.listProviders({ cwd, providers: ["codex", "opencode"], wait: true }), + ), + ); + const snapshots = targets.map((cwd) => manager.getSnapshot(cwd)); + const shared = snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "codex")!, + ); + expect(shared[0]).toBe(shared[1]); + expect(shared[1]).toBe(shared[2]); + expect( + new Set( + snapshots.map( + ({ records }) => records.find(({ entry }) => entry.provider === "opencode")!.contentHash, + ), + ).size, + ).toBe(3); + expect(calls).toEqual({ codex: 1, opencode: 3 }); + expect(await manager.getProvider({ provider: "codex", cwd: targets[1] })).toBe( + shared[0]!.entry, + ); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("result identity covers content, metadata and status while unchanged refresh advances only freshness", async () => { + const model: AgentModelDefinition = { + provider: "codex", + id: "astra", + label: "Astra", + metadata: { revision: 1 }, + }; + let available = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => available, + fetchCatalog: async () => ({ models: [model], modes: [] }), + }), + }, + }); + const read = () => manager.getSnapshot().records.find(({ entry }) => entry.provider === "codex")!; + const refresh = () => manager.refreshSettingsSnapshot({ providers: ["codex"] }); + try { + await manager.getProvider({ provider: "codex", wait: true }); + const first = read(); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.entry.fetchedAt!))); + await refresh(); + const fresh = read(); + expect(fresh).not.toBe(first); + expect(fresh.contentHash).toBe(first.contentHash); + expect(Date.parse(fresh.entry.fetchedAt!)).toBeGreaterThan(Date.parse(first.entry.fetchedAt!)); + model.label = "Astra updated"; + await refresh(); + const content = read(); + expect(content.contentHash).not.toBe(fresh.contentHash); + model.metadata!.revision = 2; + await refresh(); + const metadata = read(); + expect(metadata.contentHash).not.toBe(content.contentHash); + available = false; + await refresh(); + const unavailable = read(); + expect(unavailable.entry.status).toBe("unavailable"); + expect(unavailable.contentHash).not.toBe(metadata.contentHash); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +const PUBLICATION_PROVIDERS = Object.fromEntries( + ["claude", "codex", "copilot", "opencode", "pi", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), +); + +test("reads retain the target snapshot and cold binding publishes only the resolved transition", async () => { + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { codex: createExtraClient("codex", { isAvailable: async () => true }) }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + const initial = manager.getSnapshot("/tmp/published"); + expect(manager.getSnapshot("/tmp/published")).toBe(initial); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/published" }); + const ready = manager.getSnapshot("/tmp/published"); + expect(ready).not.toBe(initial); + expect(manager.getSnapshot("/tmp/published")).toBe(ready); + expect(changes).toEqual([{ previous: initial, current: ready }]); + } finally { + manager.destroy(); + } +}); + +test("identical key failures and unavailable results complete without duplicate transitions", async () => { + let keyFails = true; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => { + if (keyFails) throw new Error("same key failure"); + return "host"; + }, + }), + }, + }); + const changes: unknown[] = []; + manager.on("change", (change) => changes.push(change)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const failed = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(failed); + keyFails = false; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/failure" }); + const unavailable = manager.getSnapshot("/tmp/failure"); + changes.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/failure", providers: ["codex"] }); + expect(changes).toEqual([]); + expect(manager.getSnapshot("/tmp/failure")).toBe(unavailable); + } finally { + manager.destroy(); + } +}); + +test("shared result publication installs every target before notifying the first subscriber", async () => { + let label = "before"; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [{ provider: "codex", id: "astra", label }], + modes: [], + }), + }), + }, + }); + const targets = ["/tmp/atomic-a", "/tmp/atomic-b"]; + try { + await Promise.all(targets.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(targets.map((cwd) => manager.getSnapshot(cwd)))); + label = "after"; + await manager.refreshSnapshotForCwd({ cwd: targets[0]!, providers: ["codex"] }); + expect(observed).toHaveLength(2); + for (const snapshots of observed) { + expect(snapshots).toHaveLength(2); + for (const snapshot of snapshots) { + expect( + snapshot.records.find(({ entry }) => entry.provider === "codex")!.entry.models![0]!.label, + ).toBe("after"); + } + } + } finally { + manager.destroy(); + } +}); + +test("plugin runtime replacement invalidates only changed registrations and commits all targets together", async () => { + const calls: string[] = []; + const closed: string[] = []; + function registration(id: string, model: string): ProviderRegistration { + return { + id, + label: id, + getCatalogCacheKey: async () => "host", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: [], + async send(input) { + if (input.type !== "catalog") return; + calls.push(model); + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: model, label: model }], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() { + closed.push(model); + }, + }; + }, + }; + } + const first = registration("first-plugin", "first"); + const second = registration("second-plugin", "second"); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => { + calls.push("codex"); + return { models: [], modes: [] }; + }, + }), + }, + }); + const cwds = ["/tmp/plugin-install-a", "/tmp/plugin-install-b"]; + const warm = () => Promise.all(cwds.map((cwd) => manager.warmUpSnapshotForCwd({ cwd }))); + try { + manager.replacePluginProviders([first, second]); + await warm(); + const clients = manager.getAgentManagerProviderState().clients; + const snapshots = cwds.map((cwd) => manager.getSnapshot(cwd)); + const observed: ProviderSnapshot[][] = []; + manager.on("change", () => observed.push(cwds.map((cwd) => manager.getSnapshot(cwd)))); + manager.replacePluginProviders([first, second]); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "second"]); + expect(manager.getSnapshot(cwds[0])).toBe(snapshots[0]); + expect(observed).toEqual([]); + manager.replacePluginProviders([registration("first-plugin", "restarted"), second]); + const loading = cwds.map((cwd) => manager.getSnapshot(cwd)); + expect(observed).toHaveLength(2); + for (const reads of observed) { + expect(reads[0]).toBe(loading[0]); + expect(reads[1]).toBe(loading[1]); + } + const nextClients = manager.getAgentManagerProviderState().clients; + expect(nextClients.codex).toBe(clients.codex); + expect(nextClients["second-plugin"]).toBe(clients["second-plugin"]); + expect(nextClients["first-plugin"]).not.toBe(clients["first-plugin"]); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "codex")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "codex"), + ); + expect(loading[0]!.records.find(({ entry }) => entry.provider === "second-plugin")).toBe( + snapshots[0]!.records.find(({ entry }) => entry.provider === "second-plugin"), + ); + expect( + loading[0]!.records.find(({ entry }) => entry.provider === "first-plugin")!.entry.status, + ).toBe("loading"); + await warm(); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + expect( + manager.getSnapshot(cwds[0]).records.find(({ entry }) => entry.provider === "first-plugin")! + .entry.models![0]!.id, + ).toBe("restarted"); + manager.replacePluginProviders([second]); + await warm(); + expect(manager.hasProvider("first-plugin")).toBe(false); + expect(closed.toSorted()).toEqual(["first", "restarted"]); + await manager.shutdown(); + expect(closed.toSorted()).toEqual(["first", "restarted", "second"]); + expect(calls.toSorted()).toEqual(["codex", "first", "restarted", "second"]); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); + +test("binding a settled catalogue publishes once and rebinding an equal settled result publishes nothing", async () => { + let key = "first"; + let probes = 0; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: PUBLICATION_PROVIDERS, + extraClients: { + codex: createExtraClient("codex", { + getCatalogCacheKey: async () => key, + isAvailable: async () => { + probes++; + return false; + }, + }), + }, + }); + const transitions: unknown[] = []; + manager.on("change", (transition) => transitions.push(transition)); + try { + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + transitions.length = 0; + const initial = manager.getSnapshot("/tmp/bind-b"); + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + expect(transitions).toEqual([ + { previous: initial, current: manager.getSnapshot("/tmp/bind-b") }, + ]); + expect(probes).toBe(1); + key = "second"; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-b" }); + const retained = manager.getSnapshot("/tmp/bind-a"); + transitions.length = 0; + await manager.warmUpSnapshotForCwd({ cwd: "/tmp/bind-a" }); + expect(transitions).toEqual([]); + expect(manager.getSnapshot("/tmp/bind-a")).toBe(retained); + expect(probes).toBe(2); + } finally { + manager.destroy(); + } +}); + +describe("plugin provider overrides and launch", () => { + function provider() { + const launches: Array = []; + const registration: ProviderRegistration = { + id: "launch-plugin", + label: "Launch plugin", + command: [process.execPath, "default.js"], + async status({ launch }) { + if (launch?.env.PLUGIN_LOGIN !== "yes") { + return { available: false, diagnostic: "Sign in to the provider" }; + } + return { available: true }; + }, + async connect(request) { + launches.push(request.launch); + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: ["session.persistence"], + async send(input) { + if (input.type !== "catalog") return; + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [{ id: "runtime", label: "Runtime" }], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() {}, + }; + }, + }; + return { registration, launches }; + } + + test("reports plugin status diagnostics without connecting", async () => { + const { registration, launches } = provider(); + const manager = new ProviderSnapshotManager({ logger: createTestLogger() }); + try { + manager.replacePluginProviders([registration]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "unavailable", + }); + expect((await manager.getProviderDiagnostic(registration.id)).diagnostic).toContain( + "Sign in to the provider", + ); + expect(launches).toEqual([]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("applies plugin overrides and refreshes the resolved launch on config edits", async () => { + const { registration, launches } = provider(); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + [registration.id]: { + enabled: false, + label: "Configured plugin", + models: [{ id: "configured", label: "Configured" }], + additionalModels: [{ id: "extra", label: "Extra" }], + command: [process.execPath, "override.js"], + env: { PLUGIN_LOGIN: "yes", CLAUDECODE: "parent" }, + }, + }, + }); + try { + manager.replacePluginProviders([registration]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + enabled: false, + status: "unavailable", + label: "Configured plugin", + }); + expect(launches).toEqual([]); + manager.applyMutableProviderConfig({ [registration.id]: { enabled: true } }); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + expect( + (await manager.getProvider({ provider: registration.id })).models?.map((model) => model.id), + ).toEqual(["configured", "extra"]); + expect(launches[0]).toMatchObject({ + command: process.execPath, + args: ["override.js"], + env: { PLUGIN_LOGIN: "yes" }, + }); + expect(launches[0]!.env).not.toHaveProperty("CLAUDECODE"); + const previous = manager.getAgentManagerProviderState().clients[registration.id]; + manager.applyMutableProviderConfig({ + [registration.id]: { + enabled: true, + command: [process.execPath, "edited.js"], + env: { PLUGIN_LOGIN: "no" }, + }, + }); + expect(manager.getAgentManagerProviderState().clients[registration.id]).not.toBe(previous); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "unavailable", + }); + manager.applyMutableProviderConfig({ + [registration.id]: { + enabled: true, + command: [process.execPath, "edited.js"], + env: { PLUGIN_LOGIN: "yes" }, + }, + }); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + expect(launches.at(-1)).toMatchObject({ args: ["edited.js"] }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("does not warn for a plugin override during startup before provider registration settles", async () => { + const { registration } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + [registration.id]: { command: [process.execPath], env: { PLUGIN_LOGIN: "yes" } }, + }, + }); + try { + expect(warnings).toEqual([]); + manager.replacePluginProviders([]); + expect(warnings).toEqual([]); + manager.replacePluginProviders([registration]); + manager.settlePluginProviders(); + expect(warnings).toEqual([]); + expect(await manager.getProvider({ provider: registration.id, wait: true })).toMatchObject({ + status: "ready", + }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("warns for unknown overrides when plugin startup settles without registered plugins", async () => { + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { "typo-provider": { enabled: false } }, + }); + try { + expect(warnings).toEqual([]); + manager.settlePluginProviders(); + manager.settlePluginProviders(); + expect(warnings).toHaveLength(1); + expect(JSON.parse(warnings[0]!)).toMatchObject({ + provider: "typo-provider", + msg: "Provider override matches no registered provider", + }); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("warns once for unknown overrides after plugin startup settles without warning for known providers", async () => { + const { registration } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + "typo-provider": { enabled: false }, + [registration.id]: { enabled: false }, + codex: { enabled: false }, + }, + }); + try { + expect(warnings).toEqual([]); + manager.replacePluginProviders([registration]); + expect(warnings).toEqual([]); + manager.settlePluginProviders(); + manager.settlePluginProviders(); + const unknown = warnings.map((line) => JSON.parse(line) as { provider: string; msg: string }); + expect(unknown).toEqual([ + expect.objectContaining({ + provider: "typo-provider", + msg: "Provider override matches no registered provider", + }), + ]); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); + + test("a same-ID extends entry shadows the plugin without throwing", async () => { + const { registration, launches } = provider(); + const warnings: string[] = []; + const logger = pino( + { level: "warn" }, + { + write(line: string) { + warnings.push(line); + }, + }, + ); + const manager = new ProviderSnapshotManager({ + logger, + providerOverrides: { + [registration.id]: { + extends: "acp", + label: "Own provider", + command: [process.execPath], + enabled: true, + }, + }, + }); + try { + const previous = manager.getAgentManagerProviderState().clients[registration.id]; + manager.replacePluginProviders([registration]); + expect(manager.getAgentManagerProviderState().clients[registration.id]).toBe(previous); + expect(manager.getProviderLabel(registration.id)).toBe("Own provider"); + expect( + manager.getAgentManagerProviderState().providerDefinitions[registration.id], + ).toMatchObject({ enabled: true }); + expect(warnings.join("\n")).toContain("shadowed"); + expect(launches).toEqual([]); + manager.replacePluginProviders([]); + expect(manager.hasProvider(registration.id)).toBe(true); + } finally { + await manager.shutdown(); + manager.destroy(); + } + }); +}); + +test("model overrides preserve negotiated plugin capabilities and connection shutdown", async () => { + let closed = 0; + const registration: ProviderRegistration = { + id: "model-plugin", + label: "Model plugin", + async connect() { + let listener: ((event: ProviderEvent) => void) | undefined; + return { + version: 1, + capabilities: ["session.persistence"], + async send(input) { + if (input.type === "catalog") + listener?.({ + type: "catalog", + requestId: input.requestId, + catalog: { models: [], modes: [] }, + }); + }, + onEvent(next) { + listener = next; + return () => { + listener = undefined; + }; + }, + async close() { + closed += 1; + }, + }; + }, + }; + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + [registration.id]: { models: [{ id: "configured", label: "Configured" }] }, + }, + }); + try { + manager.replacePluginProviders([registration]); + await manager.getProvider({ provider: registration.id, wait: true }); + const client = manager.getAgentManagerProviderState().clients[registration.id]!; + expect(client.capabilities.supportsSessionPersistence).toBe(true); + await manager.shutdown(); + expect(closed).toBe(1); + } finally { + await manager.shutdown(); + manager.destroy(); + } +}); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index de7a69be39..82acd13e1f 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -1,8 +1,12 @@ +import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; +import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import type { Logger } from "pino"; +import pLimit, { type LimitFunction } from "p-limit"; import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; @@ -45,6 +49,8 @@ import { type AgentConfigurationValidationInput, validateAgentConfigurationAgainstProvider, } from "./agent-configuration-validator.js"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +import { PluginAgentClientRegistry } from "./plugin-provider.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 120_000; const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647; @@ -87,7 +93,23 @@ function omitProviderOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } -type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; +/** Published values are shared read-only; provider-owned data is detached at publication. */ +export interface ProviderSnapshotRecord { + readonly entry: ProviderSnapshotEntry; + readonly contentHash: string; +} + +export interface ProviderSnapshot { + readonly cwd: string; + readonly records: readonly ProviderSnapshotRecord[]; +} + +export interface ProviderSnapshotTransition { + readonly previous: ProviderSnapshot; + readonly current: ProviderSnapshot; +} + +type ProviderSnapshotChangeListener = (transition: ProviderSnapshotTransition) => void; export interface ProviderSnapshotManagerOptions { logger: Logger; @@ -123,10 +145,9 @@ interface ApplyMutableProviderConfigOptions { replace?: boolean; } -export interface StagedMutableProviderConfig { +export interface PreparedMutableProviderConfig { agentManagerState: AgentManagerProviderState; - publish(): void; - rollback(): void; + commit(): void; } interface ProviderSnapshotProviderOptions { @@ -164,13 +185,11 @@ export interface AgentManagerProviderState { providerDefinitions: Partial< Record< AgentProvider, - Pick< - ProviderDefinition, - "enabled" | "derivedFromProviderId" | "validateOptions" | "applyOptions" | "applyToolPolicy" - > + Pick > >; clients: Partial>; + retiredProviders?: readonly AgentProvider[]; } interface ProviderLoadOptions { @@ -179,18 +198,32 @@ interface ProviderLoadOptions { catalogScope: ProviderCatalogScope; force: boolean; } -interface ProviderLoad { +interface CatalogBinding { + key?: string; + failure?: ProviderSnapshotRecord; + /** Refresh intent survives reads that supersede an unfinished key lookup. */ + force: boolean; promise: Promise; } -interface MutableProviderState { - baseProviderOverrides: Record | undefined; - runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; - providerOverrides: Record | undefined; - providerRegistry: Record; - providerClients: Record; - snapshots: Map>; - providerLoads: Map>; +interface ProviderCatalog { + result?: ProviderSnapshotRecord; + stale?: boolean; + load?: Promise; +} + +interface RegistryGeneration { + definitions: Record; + order: readonly AgentProvider[]; + providerStates: ReadonlyMap< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >; +} + +interface Target { + bindings: Map; + snapshot: ProviderSnapshot; } type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string }; @@ -201,8 +234,8 @@ interface ProviderSnapshotTarget { } export class ProviderSnapshotManager { - private readonly snapshots = new Map>(); - private readonly providerLoads = new Map>(); + private readonly catalogs = new Map>(); + private readonly targets = new Map(); private readonly events = new EventEmitter(); private destroyed = false; private refreshTimeoutMs: number; @@ -216,12 +249,17 @@ export class ProviderSnapshotManager { private runtimeSettings: AgentProviderRuntimeSettingsMap | undefined; private providerOverrides: Record | undefined; private baseProviderOverrides: Record | undefined; - private providerRegistry: Record; + private generation: RegistryGeneration; private providerClients: Record; private readonly ownedClients = new Set(); + private readonly pluginProviders: PluginAgentClientRegistry; + private pluginProvidersSettled = false; constructor(options: ProviderSnapshotManagerOptions) { this.logger = options.logger; + this.pluginProviders = new PluginAgentClientRegistry( + options.logger.child({ module: "plugin-providers" }), + ); this.workspaceGitService = options.workspaceGitService; this.managedProcesses = options.managedProcesses; this.openCodeBridge = options.openCodeBridge; @@ -235,12 +273,17 @@ export class ProviderSnapshotManager { options.diagnosticTimeoutMs, this.refreshTimeoutMs, ); - this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; + this.generation = this.createGeneration( + this.buildRegistry(this.runtimeSettings, this.providerOverrides), + this.providerOverrides, + ); + this.providerClients = { + ...this.extraClients, + } as Record; for (const client of Object.values(this.providerClients)) this.ownedClients.add(client); } - getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + getSnapshot(cwd?: string): ProviderSnapshot { const target = resolveProviderSnapshotTarget(cwd); return this.getSnapshotForTarget(target); } @@ -249,9 +292,8 @@ export class ProviderSnapshotManager { const snapshotCwd = resolveSnapshotCwd(options.cwd); const target = createWorkspaceSnapshotTarget(snapshotCwd); const providers = this.resolveRefreshProviders(options.providers); - this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false }); - this.emitChange(snapshotCwd); - await this.refreshProviders(target, providers ?? this.getProviderIds()); + const providersToRefresh = providers ?? this.getProviderIds(); + await this.refreshProviders(target, providersToRefresh); } async refreshSettingsSnapshot( @@ -262,10 +304,21 @@ export class ProviderSnapshotManager { const providers = this.resolveRefreshProviders(options.providers); const providersToRefresh = providers ?? this.getProviderIds(); - this.clearCachedProviders(providers); - this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false }); - this.emitChange(homeCwd); + this.getOrCreateTarget(homeCwd); + for (const catalogs of this.catalogs.values()) { + for (const provider of providersToRefresh) { + const catalog = catalogs.get(provider); + if (catalog) catalogs.set(provider, { result: catalog.result, stale: true }); + } + } + // Refresh each known target: provider keys coalesce reads, while target-scoped + // providers must discover again in their own execution context. await this.refreshProviders(target, providersToRefresh); + await Promise.all( + [...this.targets.keys()] + .filter((cwd) => cwd !== homeCwd) + .map((cwd) => this.warmUp(createWorkspaceSnapshotTarget(cwd), providersToRefresh)), + ); } async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise { @@ -292,26 +345,31 @@ export class ProviderSnapshotManager { } hasProvider(provider: AgentProvider): boolean { - return Object.prototype.hasOwnProperty.call(this.providerRegistry, provider); + return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } getProviderLabel(provider: AgentProvider): string { - return this.providerRegistry[provider]?.label ?? provider; + return this.generation.definitions[provider]?.label ?? provider; } getAgentManagerProviderState(): AgentManagerProviderState { + return this.createAgentManagerState(this.generation.definitions, this.providerClients); + } + + private createAgentManagerState( + definitions: Record, + providerClients: Record, + ): AgentManagerProviderState { const providerDefinitions: AgentManagerProviderState["providerDefinitions"] = {}; const clients: AgentManagerProviderState["clients"] = {}; - for (const [provider, definition] of Object.entries(this.providerRegistry)) { + for (const [provider, definition] of Object.entries(definitions)) { providerDefinitions[provider] = { enabled: definition.enabled, derivedFromProviderId: definition.derivedFromProviderId, - validateOptions: definition.validateOptions, - applyOptions: definition.applyOptions, applyToolPolicy: definition.applyToolPolicy, }; if (definition.enabled) { - clients[provider] = this.ensureClient(provider, definition); + clients[provider] = this.ensureClient(provider, definition, providerClients); } } for (const [provider, client] of Object.entries(this.extraClients)) { @@ -322,13 +380,79 @@ export class ProviderSnapshotManager { return { providerDefinitions, clients }; } - private ensureClient(provider: AgentProvider, definition: ProviderDefinition): AgentClient { - const existing = this.providerClients[provider]; + /** Called after built-in and configured plugin startup has completed, including disabled plugins. */ + settlePluginProviders(): void { + if (this.pluginProvidersSettled) return; + this.pluginProvidersSettled = true; + this.warnUnknownProviderOverrides(); + } + + private warnUnknownProviderOverrides(): void { + if (!this.pluginProvidersSettled) return; + for (const [provider, override] of Object.entries(this.providerOverrides ?? {})) { + if (!override.extends && !this.generation.definitions[provider]) { + this.logger.warn({ provider }, "Provider override matches no registered provider"); + } + } + } + + replacePluginProviders( + registrations: readonly ProviderRegistration[], + ): AgentManagerProviderState { + for (const registration of registrations) { + if (BUILTIN_PROVIDER_IDS.includes(registration.id) || this.extraClients[registration.id]) { + throw new Error(`Plugin provider '${registration.id}' conflicts with a built-in provider`); + } + } + const previousPlugins = this.pluginProviders.definitions(); + const clients = { ...this.providerClients }; + // Materialize fallible installed clients before retiring any plugin runtime. + this.createAgentManagerState(this.generation.definitions, clients); + this.pluginProviders.replace(registrations); + const plugins = this.pluginProviders.definitions(); + const retiredProviders = Object.keys(previousPlugins).filter( + (provider) => + previousPlugins[provider] !== plugins[provider] && + !this.providerOverrides?.[provider]?.extends, + ); + const definitions = this.buildRegistry(this.runtimeSettings, this.providerOverrides); + const changed = new Set(); + for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) { + const before = this.generation.definitions[provider]; + const after = definitions[provider]; + const registrationChanged = + previousPlugins[provider] !== plugins[provider] && + !this.providerOverrides?.[provider]?.extends; + if ( + registrationChanged || + !before || + !after || + !isDeepStrictEqual(before.configuration, after.configuration) + ) { + changed.add(provider); + delete clients[provider]; + } else { + definitions[provider] = before; + } + } + for (const client of Object.values(clients)) this.ownedClients.add(client); + const generation = this.createGeneration(definitions, this.providerOverrides); + const state = this.createAgentManagerState(definitions, clients); + this.installGeneration(generation, clients, changed); + return { ...state, retiredProviders }; + } + + private ensureClient( + provider: AgentProvider, + definition: ProviderDefinition, + clients = this.providerClients, + ): AgentClient { + const existing = clients[provider]; if (existing) { return existing; } const client = definition.createClient(this.logger); - this.providerClients[provider] = client; + clients[provider] = client; this.ownedClients.add(client); return client; } @@ -339,7 +463,10 @@ export class ProviderSnapshotManager { await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers }); } const providerFilter = input.providers ? new Set(input.providers) : null; - const entries = this.getSnapshotForTarget(target); + const snapshot = input.wait + ? this.getOrCreateTarget(target.snapshotCwd).snapshot + : this.getSnapshotForTarget(target, input.providers); + const entries = snapshot.records.map(({ entry }) => entry); return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries; } @@ -384,11 +511,9 @@ export class ProviderSnapshotManager { ]; } - const definition = this.requireProvider(input.provider); return validateAgentConfigurationAgainstProvider({ input, provider, - validateOptions: definition.validateOptions, }); } @@ -442,7 +567,7 @@ export class ProviderSnapshotManager { } async getProviderDiagnostic(provider: AgentProvider): Promise { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { return { provider, @@ -453,7 +578,7 @@ export class ProviderSnapshotManager { } const baseDiagnosticPromise = this.getBaseProviderDiagnostic(provider, definition); - const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider, definition); + const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider); const [baseDiagnostic, entry] = await Promise.all([ baseDiagnosticPromise, snapshotEntryPromise, @@ -469,92 +594,77 @@ export class ProviderSnapshotManager { mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, ): AgentManagerProviderState { - const staged = this.stageMutableProviderConfig(mutableProviders, options); - try { - staged.publish(); - return staged.agentManagerState; - } catch (error) { - staged.rollback(); - throw error; - } + const prepared = this.prepareMutableProviderConfig(mutableProviders, options); + prepared.commit(); + return prepared.agentManagerState; } - stageMutableProviderConfig( + prepareMutableProviderConfig( mutableProviders: MutableDaemonConfig["providers"] | undefined, options: ApplyMutableProviderConfigOptions = {}, - ): StagedMutableProviderConfig { - const previous = this.captureMutableProviderState(); - const snapshotCwds = Array.from(this.snapshots.keys()); - try { - if (options.replace) { - this.baseProviderOverrides = undefined; - this.runtimeSettings = undefined; + ): PreparedMutableProviderConfig { + const baseProviderOverrides = options.replace + ? undefined + : omitProviderOverrides(this.baseProviderOverrides, options.removeProviders ?? []); + const runtimeSettings = options.replace ? undefined : this.runtimeSettings; + const providerOverrides = applyMutableProviderConfigToOverrides( + baseProviderOverrides, + mutableProviders, + ); + const definitions = this.buildRegistry(runtimeSettings, providerOverrides); + const changed = new Set(); + const clients = { ...this.providerClients }; + for (const provider of new Set([...this.generation.order, ...Object.keys(definitions)])) { + const before = this.generation.definitions[provider]; + const after = definitions[provider]; + if (!before || !after || !isDeepStrictEqual(before.configuration, after.configuration)) { + changed.add(provider); + delete clients[provider]; } else { - this.baseProviderOverrides = omitProviderOverrides( - this.baseProviderOverrides, - options.removeProviders ?? [], - ); + definitions[provider] = before; } - this.providerOverrides = applyMutableProviderConfigToOverrides( - this.baseProviderOverrides, - mutableProviders, - ); - // The mutable config is the complete provider source after startup. Keeping - // startup-derived runtime settings here would retain removed command/env fields. - if (options.replace) this.runtimeSettings = undefined; - this.providerRegistry = this.buildRegistry(); - this.providerClients = { ...this.extraClients } as Record; - - for (const cwd of this.snapshots.keys()) { - this.providerLoads.delete(cwd); - this.snapshots.set(cwd, this.reconcileSnapshotForRegistry(cwd)); - } - - return { - agentManagerState: this.getAgentManagerProviderState(), - publish: () => { - for (const cwd of snapshotCwds) { - this.emitChange(cwd); - const target = - cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY - ? createGlobalSnapshotTarget() - : createWorkspaceSnapshotTarget(cwd); - const providers = this.resolveProvidersToWarm(cwd); - if (providers.length > 0) void this.warmUp(target, providers); - } - }, - rollback: () => this.restoreMutableProviderState(previous), - }; - } catch (error) { - this.restoreMutableProviderState(previous); - throw error; } - } - - private captureMutableProviderState(): MutableProviderState { + Object.assign(clients, this.extraClients); + const generation = this.createGeneration(definitions, providerOverrides); + const agentManagerState = this.createAgentManagerState(definitions, clients); return { - baseProviderOverrides: this.baseProviderOverrides, - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, - providerRegistry: this.providerRegistry, - providerClients: this.providerClients, - // Preserve the inner map identities: in-flight refreshes close over them. - // Staging replaces active maps instead of mutating these originals. - snapshots: new Map(this.snapshots), - providerLoads: new Map(this.providerLoads), + agentManagerState, + commit: () => { + this.baseProviderOverrides = baseProviderOverrides; + this.runtimeSettings = runtimeSettings; + this.providerOverrides = providerOverrides; + this.installGeneration(generation, clients, changed); + }, }; } - private restoreMutableProviderState(previous: MutableProviderState): void { - this.baseProviderOverrides = previous.baseProviderOverrides; - this.runtimeSettings = previous.runtimeSettings; - this.providerOverrides = previous.providerOverrides; - this.providerRegistry = previous.providerRegistry; - this.providerClients = previous.providerClients; - this.snapshots.clear(); - for (const [cwd, entries] of previous.snapshots) this.snapshots.set(cwd, entries); - this.providerLoads.clear(); - for (const [cwd, loads] of previous.providerLoads) this.providerLoads.set(cwd, loads); + private installGeneration( + generation: RegistryGeneration, + clients: Record, + changed: ReadonlySet, + ): void { + for (const provider of changed) { + this.generation.providerStates.get(provider)?.discoveryLimit.clearQueue(); + } + this.generation = generation; + this.providerClients = clients; + this.warnUnknownProviderOverrides(); + for (const [key, catalogs] of this.catalogs) { + for (const provider of changed) catalogs.delete(provider); + if (catalogs.size === 0) this.catalogs.delete(key); + } + for (const target of this.targets.values()) { + for (const provider of changed) target.bindings.delete(provider); + } + this.publishTargets(this.targets.keys()); + const providers = [...changed].filter((provider) => generation.definitions[provider]); + if (providers.length === 0) return; + for (const cwd of this.targets.keys()) { + void this.warmUp( + resolveProviderSnapshotTarget(cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY ? undefined : cwd), + providers, + ); + } } setRefreshTimeoutMs(refreshTimeoutMs: number | undefined): void { @@ -573,6 +683,8 @@ export class ProviderSnapshotManager { } async shutdown(): Promise { + this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); // Materialize a client per enabled provider so provider-owned resources // (background processes, sockets, etc.) get a chance to release even when // a given provider hasn't been touched yet during this daemon's lifetime. @@ -582,15 +694,20 @@ export class ProviderSnapshotManager { destroy(): void { this.destroyed = true; + for (const state of this.generation.providerStates.values()) state.discoveryLimit.clearQueue(); + this.catalogs.clear(); + this.targets.clear(); this.events.removeAllListeners(); - this.snapshots.clear(); - this.providerLoads.clear(); } - private buildRegistry(): Record { + private buildRegistry( + runtimeSettings: AgentProviderRuntimeSettingsMap | undefined, + providerOverrides: Record | undefined, + ): Record { const registry = buildProviderRegistry(this.logger, { - runtimeSettings: this.runtimeSettings, - providerOverrides: this.providerOverrides, + runtimeSettings, + providerOverrides, + pluginProviders: this.pluginProviders.definitions(), workspaceGitService: this.workspaceGitService, managedProcesses: this.managedProcesses, openCodeBridge: this.openCodeBridge, @@ -630,12 +747,15 @@ export class ProviderSnapshotManager { }; } - private getSnapshotForTarget(target: ProviderSnapshotTarget): ProviderSnapshotEntry[] { - const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd); + private getSnapshotForTarget( + target: ProviderSnapshotTarget, + providers?: AgentProvider[], + ): ProviderSnapshot { + const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd, providers); if (providersToWarm.length > 0) { void this.warmUp(target, providersToWarm); } - return entriesToArray(this.getOrCreateSnapshot(target.snapshotCwd)); + return this.getOrCreateTarget(target.snapshotCwd).snapshot; } private async getReadyProvider( @@ -655,7 +775,7 @@ export class ProviderSnapshotManager { } private requireProvider(provider: AgentProvider): ProviderDefinition { - const definition = this.providerRegistry[provider]; + const definition = this.generation.definitions[provider]; if (!definition) { throw new Error(`Provider ${provider} is not configured`); } @@ -664,23 +784,15 @@ export class ProviderSnapshotManager { private async refreshDiagnosticSnapshotEntry( provider: AgentProvider, - definition: ProviderDefinition, ): Promise { try { const target = createGlobalSnapshotTarget(); - this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false }); - this.emitChange(target.snapshotCwd); await this.refreshProviders(target, [provider]); return await this.getProvider({ provider, wait: false }); } catch (error) { return { - provider, + ...this.generation.providerStates.get(provider)!.initial.entry, status: "error", - enabled: definition.enabled, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - defaultModeId: definition.defaultModeId, error: toErrorMessage(error), }; } @@ -711,64 +823,40 @@ export class ProviderSnapshotManager { } } - private getProviderSource(provider: AgentProvider): ProviderSnapshotEntry["source"] { - const isBuiltin = BUILTIN_PROVIDER_IDS.includes(provider); - return !isBuiltin && this.providerOverrides?.[provider]?.extends ? "custom" : "builtin"; - } - - private createLoadingEntries(): Map { - const entries = new Map(); - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - entries.set(provider, { - provider, - status: "loading", - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - defaultModeId: definition?.defaultModeId ?? null, - }); - } - return entries; - } - - private reconcileSnapshotForRegistry(cwd: string): Map { - const existing = this.snapshots.get(cwd); - const entries = new Map(); - - for (const provider of this.getProviderIds()) { - const definition = this.providerRegistry[provider]; - const current = existing?.get(provider); - const metadata = { - provider, - enabled: definition?.enabled ?? true, - source: this.getProviderSource(provider), - label: definition?.label, - description: definition?.description, - defaultModeId: definition?.defaultModeId ?? null, - }; - - if (!definition?.enabled) { - entries.set(provider, { - ...metadata, - status: "unavailable", - enabled: false, - }); + private createGeneration( + definitions: Record, + overrides: Record | undefined, + ): RegistryGeneration { + const order = Object.keys(definitions); + const providerStates = new Map< + AgentProvider, + { initial: ProviderSnapshotRecord; discoveryLimit: LimitFunction } + >(); + for (const provider of order) { + const definition = definitions[provider]!; + const previous = this.generation?.providerStates.get(provider); + if (this.generation?.definitions[provider] === definition) { + providerStates.set(provider, previous!); continue; } - - entries.set(provider, { - ...metadata, - status: "loading", - enabled: true, - models: current?.models, - modes: current?.modes, - fetchedAt: current?.fetchedAt, + const custom = + this.pluginProviders.has(provider) || + (!BUILTIN_PROVIDER_IDS.includes(provider) && !!overrides?.[provider]?.extends); + providerStates.set(provider, { + discoveryLimit: previous?.discoveryLimit ?? pLimit({ concurrency: 4, rejectOnClear: true }), + initial: identifyEntry({ + provider, + status: definition.enabled ? "loading" : "unavailable", + enabled: definition.enabled, + source: custom ? "custom" : "builtin", + label: definition.label, + description: definition.description, + iconSvg: definition.iconSvg, + defaultModeId: definition.defaultModeId ?? null, + }), }); } - - return entries; + return { definitions, order, providerStates }; } private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise { @@ -795,60 +883,9 @@ export class ProviderSnapshotManager { } private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] { - const providersToInspect = providers ?? this.getProviderIds(); - const snapshot = this.snapshots.get(cwd); - if (!snapshot) { - this.resetSnapshotToLoading(cwd, providers); - return providersToInspect; - } - - const missingProviders = providersToInspect.filter((provider) => !snapshot.has(provider)); - if (missingProviders.length > 0) { - this.resetSnapshotToLoading(cwd, missingProviders); - } - - return providersToInspect.filter((provider) => snapshot.get(provider)?.status === "loading"); - } - - private clearCachedProviders(providers?: AgentProvider[]): void { - const providerSet = providers ? new Set(providers) : null; - const loadingEntries = this.createLoadingEntries(); - - for (const [cwd, providerLoads] of Array.from(this.providerLoads.entries())) { - if (!providerSet) { - this.providerLoads.delete(cwd); - continue; - } - - for (const provider of providerSet) { - providerLoads.delete(provider); - } - if (providerLoads.size === 0) { - this.providerLoads.delete(cwd); - } - } - - for (const [cwd, snapshot] of this.snapshots.entries()) { - if (!providerSet) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - this.emitChange(cwd); - continue; - } - - let changed = false; - for (const provider of providerSet) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - snapshot.set(provider, loadingEntry); - changed = true; - } - if (changed) { - this.emitChange(cwd); - } - } + this.getOrCreateTarget(cwd); + // Identity is provider-owned and may change without a daemon config reload. + return providers ?? this.getProviderIds(); } private async loadProviders(options: ProviderLoadOptions): Promise { @@ -858,92 +895,137 @@ export class ProviderSnapshotManager { } private loadProvider(options: ProviderLoadOptions & { provider: AgentProvider }): Promise { - const definition = this.providerRegistry[options.provider]; - if (!definition) { - return Promise.resolve(); - } - - const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider); - if (existingLoad && !options.force) { - return existingLoad.promise; - } - const existingEntry = this.snapshots.get(options.snapshotCwd)?.get(options.provider); - if (existingEntry && existingEntry.status !== "loading" && !options.force) { - return Promise.resolve(); - } - - const load: ProviderLoad = { + if (this.destroyed || !this.generation.definitions[options.provider]) return Promise.resolve(); + const { bindings } = this.getOrCreateTarget(options.snapshotCwd); + const binding: CatalogBinding = { + key: bindings.get(options.provider)?.key, + failure: bindings.get(options.provider)?.failure, + force: options.force || bindings.get(options.provider)?.force === true, promise: Promise.resolve(), }; - this.setProviderLoad(options.snapshotCwd, options.provider, load); - load.promise = Promise.resolve() - .then(() => - this.refreshProvider({ - snapshotCwd: options.snapshotCwd, - catalogScope: options.catalogScope, - provider: options.provider, - definition, - load, - force: options.force, - }), - ) - .finally(() => { - const providerLoads = this.providerLoads.get(options.snapshotCwd); - if (providerLoads?.get(options.provider) === load) { - providerLoads.delete(options.provider); - } - if (providerLoads?.size === 0) { - this.providerLoads.delete(options.snapshotCwd); - } + bindings.set(options.provider, binding); + binding.promise = this.resolveCatalog({ ...options, force: binding.force }, binding); + return binding.promise; + } + + private async resolveCatalog( + options: ProviderLoadOptions & { provider: AgentProvider }, + binding: CatalogBinding, + ): Promise { + const { provider, snapshotCwd, force } = options; + const definition = this.generation.definitions[provider]!; + const initial = this.generation.providerStates.get(provider)!.initial.entry; + this.getOrCreateTarget(snapshotCwd); + if (!definition.enabled) return; + const currentBinding = () => this.targets.get(snapshotCwd)?.bindings.get(provider); + const client = this.ensureClient(provider, definition); + const catalogOptions = createFetchCatalogOptions(options.catalogScope, force); + let key: string; + try { + const sharedKey = client.getCatalogCacheKey + ? await withTimeout( + client.getCatalogCacheKey(catalogOptions), + this.refreshTimeoutMs, + `Timed out resolving ${provider} catalogue key`, + ) + : await Promise.resolve(undefined); + key = JSON.stringify( + sharedKey === undefined ? ["target", snapshotCwd] : ["provider", sharedKey], + ); + } catch (error) { + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = undefined; + binding.failure = identifyEntry({ + ...this.generation.providerStates.get(provider)!.initial.entry, + status: "error", + error: toErrorMessage(error), }); - return load.promise; + this.publishTargets([snapshotCwd]); + return; + } + // Only the latest key resolution for this target may bind it to a catalogue. + if (currentBinding() !== binding) return currentBinding()?.promise; + binding.force = false; + binding.key = key; + binding.failure = undefined; + let catalogs = this.catalogs.get(key); + if (!catalogs) { + catalogs = new Map(); + this.catalogs.set(key, catalogs); + } + let catalog = catalogs.get(provider); + if (!catalog) { + catalog = {}; + catalogs.set(provider, catalog); + } + this.publishTargets([snapshotCwd]); + if (!force && (catalog.load || (catalog.result && !catalog.stale))) return catalog.load; + catalog.stale = false; + + const current = catalog; + const isCurrent = (): boolean => + !this.destroyed && this.catalogs.get(key)?.get(provider) === current && current.load === load; + const load = this.generation.providerStates + .get(provider)! + .discoveryLimit(() => { + if (!isCurrent()) return; + return this.refreshProvider({ + catalogOptions, + provider, + definition, + initial, + client, + publish: (entry) => { + if (!isCurrent()) return false; + current.result = identifyEntry(structuredClone(entry)); + const boundTargets = [...this.targets].flatMap(([cwd, target]) => + target.bindings.get(provider)?.key === key ? [cwd] : [], + ); + this.publishTargets(boundTargets); + return true; + }, + }); + }) + .finally(() => { + if (current.load === load) current.load = undefined; + }); + current.load = load; + return load; } private async refreshProvider(options: { - snapshotCwd: string; - catalogScope: ProviderCatalogScope; + catalogOptions: FetchCatalogOptions; provider: AgentProvider; definition: ProviderDefinition; - load: ProviderLoad; - force: boolean; + initial: ProviderSnapshotEntry; + client: AgentClient; + publish: (entry: ProviderSnapshotEntry) => boolean; }): Promise { - const { snapshotCwd, catalogScope, provider, definition, load, force } = options; - const snapshot = this.getOrCreateSnapshot(snapshotCwd); - const base = { + const { + catalogOptions, provider, - source: this.getProviderSource(provider), - label: definition.label, - description: definition.description, - defaultModeId: definition.defaultModeId, - }; - const setEntry = (entry: ProviderSnapshotEntry) => { - if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) { - return false; - } - snapshot.set(provider, entry); - this.emitChange(snapshotCwd); - return true; - }; + definition, + initial: base, + client, + publish: setEntry, + } = options; try { - if (!definition.enabled) { - setEntry({ ...base, status: "unavailable", enabled: false }); - return; - } - - const client = this.ensureClient(provider, definition); const catalog = await runProviderRefreshWithDeadline({ label: definition.label, timeoutMs: this.refreshTimeoutMs, operation: async (context) => { const available = await context.runActivity("availability", () => - raceProviderRefreshAbort(context.signal, client.isAvailable(context.signal)), + raceProviderRefreshAbort( + context.signal, + client.isAvailable(context.signal, catalogOptions), + ), ); if (!available) { return null; } - const catalogOptions = createFetchCatalogOptions(catalogScope, force); return await definition.fetchCatalog(catalogOptions, client, context); }, }); @@ -952,13 +1034,20 @@ export class ProviderSnapshotManager { return; } + const models = normalizeAgentModelCatalog(catalog.models); + if (models.length !== catalog.models.length) { + this.logger.warn( + { provider, discardedRows: catalog.models.length - models.length }, + "Provider catalog contains repeated model IDs; retaining the first definition", + ); + } setEntry({ ...base, defaultModeId: - catalog.defaultModeId === undefined ? definition.defaultModeId : catalog.defaultModeId, + catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId, status: "ready", enabled: true, - models: catalog.models, + models, modes: catalog.modes, fetchedAt: new Date().toISOString(), }); @@ -971,93 +1060,64 @@ export class ProviderSnapshotManager { }); if (emitted) { this.logger.warn( - { err: error, provider, cwd: snapshotCwd }, + { err: error, provider, target: catalogOptions }, "Failed to refresh provider snapshot", ); } } } - private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined { - return this.providerLoads.get(cwdKey)?.get(provider); - } - - private setProviderLoad(cwdKey: string, provider: AgentProvider, load: ProviderLoad): void { - let providerLoads = this.providerLoads.get(cwdKey); - if (!providerLoads) { - providerLoads = new Map(); - this.providerLoads.set(cwdKey, providerLoads); - } - providerLoads.set(provider, load); - } - - private isCurrentProviderLoad( - cwdKey: string, - provider: AgentProvider, - load: ProviderLoad, - ): boolean { - return this.providerLoads.get(cwdKey)?.get(provider) === load; - } - - private emitChange(cwdKey: string): void { - if (this.destroyed) { - return; - } - const snapshot = this.snapshots.get(cwdKey); - if (!snapshot) { - return; - } - this.events.emit("change", entriesToArray(snapshot), cwdKey); - } - - private getOrCreateSnapshot(cwdKey: string): Map { - const existing = this.snapshots.get(cwdKey); - if (existing) { - return existing; - } - - const created = this.createLoadingEntries(); - this.snapshots.set(cwdKey, created); - return created; - } - - private resetSnapshotToLoading( - cwdKey: string, - providers?: AgentProvider[], - options: { preserveExisting?: boolean } = {}, - ): Map { - const snapshot = this.getOrCreateSnapshot(cwdKey); - const loadingEntries = this.createLoadingEntries(); - const preserveExisting = options.preserveExisting ?? true; - - if (!providers) { - snapshot.clear(); - for (const [provider, entry] of loadingEntries) { - snapshot.set(provider, entry); - } - return snapshot; - } - - for (const provider of providers) { - const loadingEntry = loadingEntries.get(provider); - if (!loadingEntry) continue; - const existing = snapshot.get(provider); - snapshot.set(provider, { - ...loadingEntry, - ...(preserveExisting - ? { - models: existing?.models, - modes: existing?.modes, - fetchedAt: existing?.fetchedAt, - } - : {}), + private publishTargets(cwds: Iterable): void { + if (this.destroyed) return; + const transitions: ProviderSnapshotTransition[] = []; + for (const cwd of cwds) { + const target = this.targets.get(cwd)!; + const records = this.generation.order.map((provider) => { + const binding = target.bindings.get(provider); + const result = binding?.key + ? this.catalogs.get(binding.key)?.get(provider)?.result + : undefined; + return binding?.failure ?? result ?? this.generation.providerStates.get(provider)!.initial; }); + const previous = target.snapshot; + if (sameSnapshotRecords(previous.records, records)) continue; + const current = { cwd, records }; + target.snapshot = current; + transitions.push({ previous, current }); } - return snapshot; + for (const transition of transitions) { + for (const listener of this.events.listeners("change")) { + try { + listener(transition); + } catch (error) { + this.logger.error( + { err: error, cwd: transition.current.cwd }, + "Provider snapshot subscriber failed", + ); + } + } + } + } + + private getOrCreateTarget(cwd: string): Target { + let target = this.targets.get(cwd); + if (!target) { + target = { + bindings: new Map(), + snapshot: { + cwd, + records: this.generation.order.map( + (provider) => this.generation.providerStates.get(provider)!.initial, + ), + }, + }; + this.targets.set(cwd, target); + } + return target; } private getProviderIds(): AgentProvider[] { - return Object.keys(this.providerRegistry); + return [...this.generation.order]; } private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { @@ -1123,18 +1183,12 @@ export function isGlobalProviderSnapshotKey(cwd: string): boolean { return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY; } -function entriesToArray( - entries: Map, -): ProviderSnapshotEntry[] { - return Array.from(entries.values(), cloneEntry); -} - -function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { - return { - ...entry, - models: entry.models?.map((model) => ({ ...model })), - modes: entry.modes?.map((mode) => ({ ...mode })), - }; +function identifyEntry(entry: ProviderSnapshotEntry): ProviderSnapshotRecord { + const { fetchedAt: _fetchedAt, ...content } = entry; + const contentHash = createHash("sha256") + .update(JSON.stringify(["paseo.provider-result/1", content])) + .digest("base64url"); + return { entry, contentHash }; } function toErrorMessage(error: unknown): string { @@ -1153,3 +1207,21 @@ function formatProviderStatus(entry: ProviderSnapshotEntry): string { if (entry.status === "unavailable") return "Unavailable"; return "Loading"; } + +export function sameSnapshotRecords( + previous: readonly ProviderSnapshotRecord[], + current: readonly ProviderSnapshotRecord[], +): boolean { + return ( + previous.length === current.length && + previous.every((before, index) => { + const after = current[index]!; + return ( + before === after || + (before.entry.provider === after.entry.provider && + before.contentHash === after.contentHash && + before.entry.fetchedAt === after.entry.fetchedAt) + ); + }) + ); +} diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts index ba1f3b5270..dc71c0f5c6 100644 --- a/packages/server/src/server/agent/provider-subagents/store.test.ts +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -45,7 +45,7 @@ describe("ProviderSubagentStore", () => { updatedAt: "2026-07-12T10:00:02.000Z", }), ]); - expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([ + expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([ { seq: 1, timestamp: "2026-07-12T10:00:01.000Z", @@ -98,8 +98,11 @@ describe("ProviderSubagentStore", () => { direction: "tail", limit: 1, }); - expect(page.rows).toHaveLength(101); - expect(page.rows[0]?.seq).toBe(1); + expect(page.rows).toHaveLength(1); + expect(page.rows[0]?.seqStart).toBe(1); + expect(page.rows[0]?.item).toMatchObject({ + text: Array.from({ length: 101 }, (_, i) => String(i)).join(""), + }); expect(page.rows.at(-1)?.seq).toBe(101); expect(page.hasOlder).toBe(false); }); diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index 8f2ccc60a5..b04246054a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -6,13 +6,14 @@ import type { AgentTimelineFetchResult, AgentTimelineRow, } from "../agent-timeline-store-types.js"; -import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; export interface ProviderSubagentDescriptor { id: string; parentAgentId: string; + /** Direct provider-subagent parent. Null identifies a child of the managed agent. */ + parentSubagentId: string | null; provider: AgentProvider; title: string | null; description: string | null; @@ -38,6 +39,7 @@ export type ProviderSubagentInputEvent = toolCallId?: string | null; cwd?: string | null; subtitle?: string | null; + parentSubagentId?: string | null; timestamp?: string; } | { @@ -123,6 +125,7 @@ export class ProviderSubagentStore { toolCallId: stickyField(event.toolCallId, previous?.toolCallId), cwd: stickyField(event.cwd, previous?.cwd), subtitle: stickyField(event.subtitle, previous?.subtitle), + parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId), }; this.descriptors.set(key, subagent); return { type: "upsert", subagent }; @@ -147,30 +150,7 @@ export class ProviderSubagentStore { subagentId: string, options?: AgentTimelineFetchOptions, ): AgentTimelineFetchResult { - const direction = options?.direction ?? "tail"; - const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit)); - const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), { - ...options, - limit: 0, - }); - if (limit === 0 || timeline.rows.length === 0) { - return timeline; - } - const selected = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - direction: timeline.reset ? "tail" : direction, - limit, - }); - const firstRow = selected.selectedRows[0]; - const lastRow = selected.selectedRows[selected.selectedRows.length - 1]; - return { - ...timeline, - rows: selected.selectedRows, - hasOlder: - timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq), - hasNewer: - timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq), - }; + return this.timelines.fetch(storeKey(parentAgentId, subagentId), options); } deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] { diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index ac4b407cf4..33c9696900 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1,6 +1,11 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AgentSideConnection, ClientSideConnection, @@ -8,6 +13,7 @@ import { RequestError, ndJsonStream, type Agent, + type CreateTerminalRequest, PermissionOption, PromptResponse, RequestPermissionRequest, @@ -45,7 +51,13 @@ import { GenericACPAgentClient } from "./generic-acp-agent.js"; import { parseKiroExtensionCommands } from "./kiro-acp-agent.js"; import { transformPiModels } from "./pi/agent.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; -import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js"; +import type { + AgentCapabilityFlags, + AgentPersistenceHandle, + AgentSession, + ProviderRefreshContext, +} from "../agent-sdk-types.js"; +import { buildProviderRegistry } from "../provider-registry.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js"; import { asInternals } from "../../test-utils/class-mocks.js"; @@ -124,7 +136,13 @@ interface ACPConfiguredOverrideInternals { applyConfiguredOverrides(): Promise; } -function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession { +function createSession( + options: { + terminateProcess?: ProcessTerminator; + launchEnv?: Record; + } = {}, +): ACPAgentSession { + const { terminateProcess, launchEnv } = options; return new ACPAgentSession( { provider: "claude-acp", @@ -144,6 +162,7 @@ function createSession(terminateProcess?: ProcessTerminator): ACPAgentSession { supportsToolInvocations: true, }, ...(terminateProcess ? { terminateProcess } : {}), + ...(launchEnv ? { launchEnv } : {}), }, ); } @@ -656,6 +675,114 @@ describe("ACPAgentSession terminal tools", () => { ); }); + // Terminals the daemon opens for an agent are siblings of the agent process, so + // they inherit nothing from it. They carry the agent's identity only if the + // session puts its launch environment on them, the way its transport spawn does. + describe("agent launch environment", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + const readLaunchIdentityArgs = [ + "-e", + "process.stdout.write(JSON.stringify({ id: process.env.PASEO_AGENT_ID ?? null, cwd: process.env.PASEO_AGENT_CWD ?? null, extra: process.env.PASEO_TEST_EXTRA ?? null }))", + ]; + + async function readLaunchIdentity( + session: ACPAgentSession, + params: Partial = {}, + ): Promise<{ id: string | null; cwd: string | null; extra: string | null }> { + const terminal = await session.createTerminal({ + sessionId: "session-1", + command: process.execPath, + args: readLaunchIdentityArgs, + cwd: process.cwd(), + ...params, + }); + await session.waitForTerminalExit({ + sessionId: "session-1", + terminalId: terminal.terminalId, + }); + const { output } = await session.terminalOutput({ + sessionId: "session-1", + terminalId: terminal.terminalId, + }); + return JSON.parse(output.trim()); + } + + test("gives the terminal the agent's identity", async () => { + // The daemon itself does not run inside a Paseo agent. + vi.stubEnv("PASEO_AGENT_ID", undefined); + vi.stubEnv("PASEO_AGENT_CWD", undefined); + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_AGENT_CWD: "/repo", + }, + }); + + await expect(readLaunchIdentity(session)).resolves.toMatchObject({ + id: "agent-1", + cwd: "/repo", + }); + }); + + test("prefers the agent's identity over the daemon's own environment", async () => { + // A daemon started from inside another Paseo agent carries that agent's id. + vi.stubEnv("PASEO_AGENT_ID", "daemon-host-agent"); + vi.stubEnv("PASEO_AGENT_CWD", "/elsewhere"); + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_AGENT_CWD: "/repo", + }, + }); + + await expect(readLaunchIdentity(session)).resolves.toMatchObject({ + id: "agent-1", + cwd: "/repo", + }); + }); + + test("lets the requested terminal environment win over the launch environment", async () => { + const session = createSession({ + launchEnv: { + PASEO_AGENT_ID: "agent-1", + PASEO_TEST_EXTRA: "from-launch", + }, + }); + + await expect( + readLaunchIdentity(session, { + env: [{ name: "PASEO_TEST_EXTRA", value: "from-request" }], + }), + ).resolves.toMatchObject({ id: "agent-1", extra: "from-request" }); + }); + + test("carries the agent's identity into single-string shell commands", async () => { + const child = createTerminalChildStub(); + const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } }); + + await session.createTerminal({ + sessionId: "session-1", + command: "paseo heartbeat create --every 5m", + cwd: "/repo", + }); + + expect(spawn).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ + envOverlay: expect.objectContaining({ + PASEO_AGENT_ID: "agent-1", + BASH_ENV: undefined, + }), + }), + ); + }); + }); + test("surfaces spawn errors through terminal output and waitForTerminalExit", async () => { const child = createTerminalChildStub(); vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); @@ -702,6 +829,46 @@ describe("mapACPUsage", () => { }); }); +describe("ACP context-window usage", () => { + async function emitUsageUpdate(update: { + used: number; + size: number; + }): Promise<{ events: unknown[] }> { + const session = createSessionWithConfig({ provider: "dsh" }); + asInternals(session).sessionId = "session-1"; + const events: unknown[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") events.push(event); + }); + await session.sessionUpdate({ + sessionId: "session-1", + update: { sessionUpdate: "usage_update", used: update.used, size: update.size }, + }); + return { events }; + } + + test("forwards usage_update as context-window usage state", async () => { + const { events } = await emitUsageUpdate({ used: 13_759, size: 1_000_000 }); + + expect(events).toEqual([ + { + type: "usage_updated", + provider: "dsh", + usage: { contextWindowMaxTokens: 1_000_000, contextWindowUsedTokens: 13_759 }, + }, + ]); + }); + + test("emits nothing when size and used cannot both drive a meter", async () => { + await expect( + emitUsageUpdate({ used: -1, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + await expect( + emitUsageUpdate({ used: 13_759, size: 0 }).then((result) => result.events), + ).resolves.toEqual([]); + }); +}); + describe("deriveModesFromACP", () => { test("prefers explicit ACP mode state", () => { const result = deriveModesFromACP( @@ -1790,10 +1957,9 @@ describe("ACPAgentClient modelTransformer", () => { describe("ACPAgentClient catalog discovery without a model resolver", () => { test("never switches models during catalog discovery even with multiple models and a thinking picker", async () => { - // The per-model probing that switches models lives on KimiACPAgentClient - // (see kimi-acp-agent.test.ts). The base client ships no catalog model resolver, so a - // slow or nonconforming ACP can't stall its catalog probe on extra setSessionConfigOption - // round trips. + // Model discovery extensions live on providers that opt in. The base + // client ships no catalog model resolver, so a slow or nonconforming ACP can't + // stall its catalog probe on extra setSessionConfigOption round trips. const setSessionConfigOption = vi.fn(); class TestACPAgentClient extends ACPAgentClient { @@ -3274,6 +3440,8 @@ interface ACPCloseInternals { child: ChildProcess | null; connection: unknown; sessionId: string | null; + activeForegroundTurnId: string | null; + agentCapabilities: { sessionCapabilities?: { close?: unknown } } | null; } async function startTerminal( @@ -3324,7 +3492,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("close() terminates the main child process via the process tree", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const internals = asInternals(session); const child = createTerminalChildStub(); @@ -3340,9 +3508,41 @@ describe("ACPAgentSession close() tree-kill", () => { expect(child.kill).not.toHaveBeenCalled(); }); + test("close() terminates the provider when cancel and closeSession never settle", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const session = createSession({ terminateProcess: terminator.terminate }); + const child = createTerminalChildStub(); + const cancel = vi.fn(() => new Promise(() => undefined)); + const unstableCloseSession = vi.fn(() => new Promise(() => undefined)); + const internals = asInternals(session); + internals.child = child; + internals.sessionId = "session-1"; + internals.activeForegroundTurnId = "turn-1"; + internals.agentCapabilities = { sessionCapabilities: { close: {} } }; + internals.connection = { cancel, unstable_closeSession: unstableCloseSession }; + + let settled = false; + const closing = (async () => { + await session.close(); + settled = true; + })(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(settled).toBe(true); + expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(unstableCloseSession).toHaveBeenCalledWith({ sessionId: "session-1" }); + expect(terminator.terminated).toContain(child); + await closing; + } finally { + vi.useRealTimers(); + } + }); + test("close() terminates running terminal child processes", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const terminalChild = createTerminalChildStub(); await startTerminal(session, terminalChild); @@ -3355,7 +3555,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("close() terminates terminal child processes in parallel", async () => { const terminator = new FakeTerminator("deferred"); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const firstChild = createTerminalChildStub(); const secondChild = createTerminalChildStub(); @@ -3373,7 +3573,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("killTerminal terminates the terminal process tree without a direct SIGTERM", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const child = createTerminalChildStub(); const terminalId = await startTerminal(session, child); @@ -3386,7 +3586,7 @@ describe("ACPAgentSession close() tree-kill", () => { test("releaseTerminal terminates and removes a running terminal", async () => { const terminator = new FakeTerminator(); - const session = createSession(terminator.terminate); + const session = createSession({ terminateProcess: terminator.terminate }); const child = createTerminalChildStub(); const terminalId = await startTerminal(session, child); @@ -3402,6 +3602,34 @@ describe("ACPAgentSession close() tree-kill", () => { }); describe("ACPAgentSession initialization cleanup", () => { + test("rejects a resume whose working directory was deleted instead of crashing", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-deleted-cwd-")); + const deletedCwd = path.join(root, "worktree"); + const terminator = new FakeTerminator(); + const session = new ACPAgentSession( + { provider: "test-acp", cwd: deletedCwd }, + { + provider: "test-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, "-e", "process.stdin.resume()"], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + }, + handle: { provider: "test-acp", sessionId: "archived-session" }, + terminateProcess: terminator.terminate, + }, + ); + + try { + await expect(session.initializeResumedSession()).rejects.toThrow("ENOENT"); + expect(terminator.terminated).toHaveLength(1); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + test("terminates the ACP process when session/new fails", async () => { const terminator = new FakeTerminator(); const child = createProbeChildStub(); @@ -3516,6 +3744,246 @@ describe("ACPAgentClient probe cleanup", () => { expect(child.stdout.destroyed).toBe(true); expect(child.stderr.destroyed).toBe(true); }); + + test("closes the native catalog probe session before terminating its process", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const closeSession = vi.fn().mockImplementation(async () => { + expect(terminator.terminated).toEqual([]); + return {}; + }); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }), + unstable_closeSession: closeSession, + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }); + + expect(closeSession).toHaveBeenCalledWith({ sessionId: "catalog-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("closes a catalog probe session that finishes being created after refresh aborts", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const abort = new AbortController(); + let resolveNewSession!: (value: { + sessionId: string; + modes: null; + models: null; + configOptions: []; + }) => void; + const newSession = vi.fn( + () => + new Promise<{ + sessionId: string; + modes: null; + models: null; + configOptions: []; + }>((resolve) => { + resolveNewSession = resolve; + }), + ); + const closeSession = vi.fn().mockResolvedValue({}); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { newSession, unstable_closeSession: closeSession }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + const context: ProviderRefreshContext = { + signal: abort.signal, + runActivity: async (_name, operation) => await operation(), + }; + const catalog = client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/acp-models", force: false }, + context, + ); + await vi.waitFor(() => expect(newSession).toHaveBeenCalledOnce()); + + abort.abort(new Error("refresh cancelled")); + resolveNewSession({ + sessionId: "late-catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }); + + await expect(catalog).rejects.toThrow("refresh cancelled"); + expect(closeSession).toHaveBeenCalledWith({ sessionId: "late-catalog-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("closes the native feature probe session before terminating its process", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const closeSession = vi.fn().mockResolvedValue({}); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "feature-probe-session", + modes: null, + models: null, + configOptions: [copilotAgentConfigOption("Probe Agent")], + }), + unstable_closeSession: closeSession, + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "copilot", + logger: createTestLogger(), + defaultCommand: ["copilot", "--acp"], + defaultModes: [], + configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION], + terminateProcess: terminator.terminate, + }); + + await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" }); + + expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" }); + expect(terminator.terminated).toContain(child); + }); + + test("still terminates the probe when native session close fails", async () => { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "catalog-probe-session", + modes: null, + models: null, + configOptions: [], + }), + unstable_closeSession: vi.fn().mockRejectedValue(new Error("close failed")), + }, + initialize: { agentCapabilities: { sessionCapabilities: { close: {} } } }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + + await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }); + + expect(terminator.terminated).toContain(child); + }); + + test("keeps a session visible and cleans up after its history load times out", async () => { + vi.useFakeTimers(); + try { + const terminator = new FakeTerminator(); + const child = createProbeChildStub(); + const loadSession = vi.fn(async () => await new Promise(() => undefined)); + + class TestACPAgentClient extends ACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child, + connection: { + listSessions: vi.fn().mockResolvedValue({ + sessions: [ + { + sessionId: "slow-session", + cwd: "/tmp/acp-import", + title: "Slow but real", + updatedAt: "2026-09-04T20:00:00.000Z", + }, + ], + nextCursor: null, + }), + loadSession, + unstable_closeSession: vi.fn().mockResolvedValue({}), + }, + initialize: { + agentCapabilities: { + loadSession: true, + sessionCapabilities: { list: {}, close: {} }, + }, + }, + } as unknown as SpawnedACPProcess; + } + } + + const client = new TestACPAgentClient({ + provider: "claude-acp", + logger: createTestLogger(), + defaultCommand: ["claude", "--acp"], + defaultModes: [], + terminateProcess: terminator.terminate, + }); + const listing = client.listImportableSessions({ limit: 1 }); + await vi.waitFor(() => expect(loadSession).toHaveBeenCalledOnce()); + + await vi.advanceTimersByTimeAsync(30_000); + + await expect(listing).resolves.toEqual([ + { + providerHandleId: "slow-session", + cwd: "/tmp/acp-import", + title: "Slow but real", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + expect(terminator.terminated).toContain(child); + } finally { + vi.useRealTimers(); + } + }); }); describe("ACP session/load invariant — cwd and mcpServers always passed", () => { @@ -3790,3 +4258,265 @@ describe("ACP session/load invariant — cwd and mcpServers always passed", () = }); }); }); + +const SILENT_CLOSE_ACP_AGENT = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +writeFileSync(process.env.ACP_PID_FILE, String(process.pid)); +new AgentSideConnection( + () => ({ + async initialize() { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }; + }, + async newSession() { + return { sessionId: "silent-close-session" }; + }, + async authenticate() {}, + async cancel() {}, + unstable_closeSession() { + return new Promise(() => {}); + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function settlesWithin(promise: Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + clearTimeout(timer); + } +} + +interface SilentCloseProvider { + session: ACPAgentSession; + readPid(): Promise; + dispose(): Promise; +} + +async function startSilentCloseProvider(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-silent-close-")); + const agentScript = path.join(dir, "agent.mjs"); + const pidFile = path.join(dir, "agent.pid"); + await writeFile(agentScript, SILENT_CLOSE_ACP_AGENT); + const session = new ACPAgentSession( + { provider: "silent-close-acp", cwd: dir }, + { + provider: "silent-close-acp", + logger: createTestLogger(), + defaultCommand: [process.execPath, agentScript], + defaultModes: [], + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + launchEnv: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ACP_PID_FILE: pidFile, + }, + }, + ); + const readPid = async () => Number(await readFile(pidFile, "utf8")); + return { + session, + readPid, + async dispose() { + const pid = await readPid().catch(() => null); + if (pid !== null && isProcessAlive(pid)) { + process.kill(pid, "SIGKILL"); + } + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +describe("ACPAgentSession close() with an unresponsive provider", () => { + let provider: SilentCloseProvider | null = null; + + afterEach(async () => { + await provider?.dispose(); + provider = null; + }); + + test("terminates a provider that never answers session/close", async () => { + provider = await startSilentCloseProvider(); + await provider.session.initializeNewSession(); + const pid = await provider.readPid(); + expect(isProcessAlive(pid)).toBe(true); + + expect(await settlesWithin(provider.session.close(), 8_000)).toBe(true); + expect(isProcessAlive(pid)).toBe(false); + }, 15_000); +}); + +// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but, +// like Cline, runs any model id it is given. +const PERMISSIVE_MODEL_ACP_AGENT = ` +import { Readable, Writable } from "node:stream"; +const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL); +let currentModelId = "advertised-default"; +const modelState = () => + process.env.ACP_ADVERTISE_MODELS === "none" + ? undefined + : { + currentModelId, + availableModels: [ + { modelId: "advertised-default", name: "Advertised default" }, + { modelId: "advertised-other", name: "Advertised other" }, + ], + }; +new AgentSideConnection( + () => ({ + async initialize() { + return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } }; + }, + async newSession() { + return { sessionId: "permissive-session", models: modelState() }; + }, + async loadSession() { + return { models: modelState() }; + }, + async unstable_setSessionModel({ modelId }) { + currentModelId = modelId; + return {}; + }, + async authenticate() {}, + async cancel() {}, + async prompt() { + return { stopReason: "end_turn" }; + }, + }), + ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +); +`; + +describe("custom ACP provider with configured models", () => { + let dir: string; + let agentScript: string; + let session: AgentSession | null = null; + + beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-")); + agentScript = path.join(dir, "agent.mjs"); + await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT); + }); + + afterEach(async () => { + await session?.close(); + session = null; + await rm(dir, { recursive: true, force: true }); + }); + + function buildRegistry( + override: { + models?: Array<{ id: string; label: string }>; + additionalModels?: Array<{ id: string; label: string }>; + }, + agentEnv: Record = {}, + ) { + return buildProviderRegistry(createTestLogger(), { + providerOverrides: { + permissive: { + extends: "acp", + label: "Permissive", + command: [process.execPath, agentScript], + env: { + ACP_SDK_URL: pathToFileURL( + createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"), + ).href, + ...agentEnv, + }, + ...override, + }, + }, + }); + } + + async function createSessionOnModel( + registry: ReturnType, + model: string, + ): Promise { + const client = registry.permissive.createClient(createTestLogger()); + session = await client.createSession({ provider: "permissive", cwd: dir, model }); + return session; + } + + test("runs on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from a replacement models list that the agent does not advertise", async () => { + const registry = buildRegistry({ + models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("runs on a model from additionalModels when the agent advertises no models", async () => { + const registry = buildRegistry( + { additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] }, + { ACP_ADVERTISE_MODELS: "none" }, + ); + + const created = await createSessionOnModel(registry, "stealth/pixel-canary"); + + expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("resumes on a model added through additionalModels that the agent does not advertise", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + const client = registry.permissive.createClient(createTestLogger()); + + session = await client.resumeSession( + { provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } }, + { model: "stealth/pixel-canary" }, + ); + + expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary"); + }); + + test("keeps the agent's model when the requested model is neither advertised nor configured", async () => { + const registry = buildRegistry({ + additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }], + }); + + const created = await createSessionOnModel(registry, "stale/removed-model"); + + expect((await created.getRuntimeInfo()).model).toBe("advertised-default"); + }); +}); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index fab367e90a..6bded49feb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1,3 +1,4 @@ +import { ACPProviderOptionsSchema } from "./acp-options.js"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; @@ -75,6 +76,9 @@ import { type AgentPersistenceHandle, type AgentPromptContentBlock, type AgentPromptInput, + type AgentResumePurpose, + type AgentCreateSessionOptions, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -136,6 +140,29 @@ function assertChildWithPipes( } } +// Node reports a failed spawn (missing binary or missing cwd) as a child +// `error` event; without a listener it becomes an uncaught exception. +function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise { + return new Promise((_, reject) => { + child.once("error", (error) => { + const stderr = stderrChunks.join("").trim(); + reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); + }); + }); +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return false; + } + throw error; + } +} + function isRecord(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value); } @@ -144,6 +171,10 @@ function isACPError(value: unknown): value is ACPError { return isRecord(value) && typeof value.message === "string" && typeof value.code === "number"; } +function isACPInvalidParams(error: unknown): boolean { + return isACPError(error) && error.code === -32602; +} + function extractACPErrorDataMessage(data: unknown): string | null { if (!isRecord(data)) { return null; @@ -185,15 +216,22 @@ export function summarizeACPRequestError(error: unknown): { return { message: String(error) }; } +// Keeps the JSON-RPC code so callers can still tell invalid params from other failures. +class ACPRequestError extends Error { + readonly code: number; + + constructor(error: ACPError) { + super(summarizeACPRequestError(error).message, { cause: error }); + this.name = "ACPRequestError"; + this.code = error.code; + } +} + function toACPRequestError(error: unknown): Error { if (!isACPError(error)) { return error instanceof Error ? error : new Error(String(error)); } - - const summary = summarizeACPRequestError(error); - const next = new Error(summary.message); - next.name = "ACPRequestError"; - return next; + return new ACPRequestError(error); } function resolveTerminalCommand( @@ -277,6 +315,13 @@ export function buildACPClientCapabilities( // NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it. const PROBE_ENV: Record = { NO_BROWSER: "true" }; const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000; +const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000; +// Archive and delete await close(). The ACP SDK does not reject a pending +// request when the provider never answers, so these calls must be bounded +// or the provider process is never terminated. +const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000; +const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000; +const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000; function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } { return { @@ -390,7 +435,7 @@ export type ACPExtensionCommandsParser = ( * the catalog plumbing. */ export interface ACPCatalogModelResolverContext { - connection: ClientSideConnection; + connection: Pick; sessionId: string; models: AgentModelDefinition[]; configOptions: SessionConfigOption[] | null | undefined; @@ -401,10 +446,9 @@ export interface ACPCatalogModelResolverContext { } /** - * Optional hook that refines the catalog's model list using the live probe session. - * The base client ships no resolver — catalog discovery derives models from the initial - * session response and never mutates the probe. Providers that need per-model data (Kimi) - * inject a resolver so the extra round trips stay off every other ACP. + * Providers own model discovery through this hook, including extension RPCs. Without a + * resolver, the base client derives models from the initial session response. The client + * owns the probe lifetime and refresh deadline; resolvers return the complete model list. */ export type ACPCatalogModelResolver = ( context: ACPCatalogModelResolverContext, @@ -439,6 +483,7 @@ interface ACPAgentClientOptions { waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; terminateProcess?: ProcessTerminator; + now?: () => number; } interface ACPAgentSessionOptions { @@ -467,6 +512,8 @@ interface ACPAgentSessionOptions { capabilities: AgentCapabilityFlags; extensionCommandsParser?: ACPExtensionCommandsParser; handle?: AgentPersistenceHandle; + resumePurpose?: AgentResumePurpose; + configuredModelIds?: readonly string[]; agentId?: string; launchEnv?: Record; waitForInitialCommands?: boolean; @@ -493,6 +540,78 @@ interface ACPProcessTransport { spawnError: Promise; } +interface ACPImportPromptPreviews { + firstPromptPreview: string | null; + lastPromptPreview: string | null; + hasConversation: boolean; +} + +interface ACPImportPromptCacheEntry extends ACPImportPromptPreviews { + updatedAt: string | null; +} + +class ACPImportHistoryCollector { + private readonly prompts = new Map< + string, + { + completed: string[]; + current: { messageId: string | null; text: string } | null; + hasConversation: boolean; + } + >(); + + begin(sessionId: string): void { + this.prompts.set(sessionId, { completed: [], current: null, hasConversation: false }); + } + + accept(params: SessionNotification): void { + const state = this.prompts.get(params.sessionId); + if (!state) return; + const update = params.update; + if (isACPConversationUpdate(update)) state.hasConversation = true; + if (update.sessionUpdate !== "user_message_chunk") { + this.flushCurrent(state); + return; + } + + const text = contentBlockToText(update.content); + if (!text) return; + const messageId = update.messageId ?? null; + if (state.current?.messageId && messageId !== state.current.messageId) { + this.flushCurrent(state); + } + state.current ??= { messageId, text: "" }; + state.current.text += text; + } + + finish(sessionId: string): ACPImportPromptPreviews { + const state = this.prompts.get(sessionId); + if (!state) { + return { firstPromptPreview: null, lastPromptPreview: null, hasConversation: false }; + } + this.flushCurrent(state); + this.prompts.delete(sessionId); + return { + firstPromptPreview: normalizeACPImportPromptPreview(state.completed[0] ?? null), + lastPromptPreview: normalizeACPImportPromptPreview(state.completed.at(-1) ?? null), + hasConversation: state.hasConversation, + }; + } + + discard(sessionId: string): void { + this.prompts.delete(sessionId); + } + + private flushCurrent(state: { + completed: string[]; + current: { messageId: string | null; text: string } | null; + hasConversation: boolean; + }): void { + if (state.current?.text.trim()) state.completed.push(state.current.text); + state.current = null; + } +} + export interface ACPToolSnapshot { toolCallId: string; title: string; @@ -826,6 +945,8 @@ export class ACPAgentClient implements AgentClient { private readonly waitForInitialCommands: boolean; private readonly initialCommandsWaitTimeoutMs: number; private readonly extensionCommandsParser?: ACPExtensionCommandsParser; + private readonly importPromptCache = new Map(); + private readonly now: () => number; protected readonly terminateProcess: ProcessTerminator; constructor(options: ACPAgentClientOptions) { @@ -854,13 +975,16 @@ export class ACPAgentClient implements AgentClient { this.waitForInitialCommands = options.waitForInitialCommands ?? false; this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500; this.extensionCommandsParser = options.extensionCommandsParser; + this.now = options.now ?? Date.now; } async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, + options?: AgentCreateSessionOptions, ): Promise { this.assertProvider(config); + const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {}); const session = new ACPAgentSession( { ...config, provider: this.provider }, { @@ -873,14 +997,19 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -896,6 +1025,7 @@ export class ACPAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { if (handle.provider !== this.provider) { throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`); @@ -913,6 +1043,7 @@ export class ACPAgentClient implements AgentClient { provider: this.provider, cwd, }; + const providerOptions = ACPProviderOptionsSchema.parse(mergedConfig.providerOptions ?? {}); const session = new ACPAgentSession(mergedConfig, { provider: this.provider, logger: this.logger, @@ -923,15 +1054,21 @@ export class ACPAgentClient implements AgentClient { sessionResponseTransformer: this.sessionResponseTransformer, configOptionsTransformer: this.configOptionsTransformer, configFeatureOptions: this.configFeatureOptions, - clientCapabilities: this.clientCapabilities, + clientCapabilities: providerOptions.clientCapabilities ?? this.clientCapabilities, clientCapabilityMeta: this.clientCapabilityMeta, modeIdTransformer: this.modeIdTransformer, toolSnapshotTransformer: this.toolSnapshotTransformer, providerModeWriter: this.providerModeWriter, beforeModeWriter: this.beforeModeWriter, thinkingOptionWriter: this.thinkingOptionWriter, - capabilities: this.capabilities, + capabilities: { + ...this.capabilities, + supportsMcpServers: + providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers, + }, handle, + resumePurpose: options?.purpose, + configuredModelIds: options?.configuredModelIds, agentId: launchContext?.agentId, launchEnv: launchContext?.env, extensionCommandsParser: this.extensionCommandsParser, @@ -948,10 +1085,30 @@ export class ACPAgentClient implements AgentClient { ): Promise { const cwd = options.scope === "global" ? homedir() : options.cwd; let probe: UninitializedACPProcess | null = null; + let probeSessionId: string | null = null; + let probeSessionPromise: Promise | null = null; let closePromise: Promise | null = null; const closeProbe = (): Promise => { if (!probe) return Promise.resolve(); - closePromise ??= this.closeProbe(probe); + closePromise ??= (async () => { + if (!probeSessionId && probeSessionPromise) { + try { + probeSessionId = ( + await withTimeout( + probeSessionPromise, + ACP_PROBE_CLOSE_TIMEOUT_MS, + `ACP probe session/new cleanup timed out after ${ACP_PROBE_CLOSE_TIMEOUT_MS}ms`, + ) + ).sessionId; + } catch (error) { + this.logger.debug( + { err: error }, + "ACP probe session/new did not finish during cleanup", + ); + } + } + await this.closeProbe(probe, probeSessionId); + })(); return closePromise; }; const handleAbort = () => void closeProbe().catch(() => undefined); @@ -962,6 +1119,7 @@ export class ACPAgentClient implements AgentClient { raceProviderRefreshAbort( context?.signal, this.spawnProcess(PROBE_ENV, { + providerOptions: options.providerOptions, onSpawned: (spawned) => { probe = spawned; if (context?.signal.aborted) void closeProbe().catch(() => undefined); @@ -970,17 +1128,16 @@ export class ACPAgentClient implements AgentClient { ), ); probe = initializedProbe; - const response = await runProviderRefreshActivity(context, "session/new", () => - raceProviderRefreshAbort( - context?.signal, - this.runACPRequest(() => - initializedProbe.connection.newSession({ - cwd, - mcpServers: [], - }), - ), - ), + probeSessionPromise = this.runACPRequest(() => + initializedProbe.connection.newSession({ + cwd, + mcpServers: [], + }), ); + const response = await runProviderRefreshActivity(context, "session/new", () => + raceProviderRefreshAbort(context?.signal, probeSessionPromise!), + ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); const derivedModels = deriveModelDefinitionsFromACP( this.provider, @@ -1030,6 +1187,7 @@ export class ACPAgentClient implements AgentClient { this.assertProvider(config); const probe = await this.spawnProcess(PROBE_ENV); + let probeSessionId: string | null = null; try { const response = await this.runACPRequest(() => probe.connection.newSession({ @@ -1037,53 +1195,154 @@ export class ACPAgentClient implements AgentClient { mcpServers: [], }), ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); return [ autoAcceptFeature, ...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions), ]; } finally { - await this.closeProbe(probe); + await this.closeProbe(probe, probeSessionId); } } async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const probe = await this.spawnProcess(PROBE_ENV); + const history = new ACPImportHistoryCollector(); + const probe = await this.spawnProcess(PROBE_ENV, { + client: this.buildProbeClient((params) => history.accept(params)), + }); try { if (!probe.initialize.agentCapabilities?.sessionCapabilities?.list) { return []; } const sessions: ImportableProviderSession[] = []; - const scanLimit = Math.min(options?.scanLimit ?? options?.limit ?? 500, 500); + const resultLimit = options?.limit ?? Number.POSITIVE_INFINITY; + const scanLimit = Math.min(options?.scanLimit ?? 500, 500); + const canLoadHistory = probe.initialize.agentCapabilities.loadSession === true; + const historyDeadline = this.now() + ACP_IMPORT_HISTORY_BUDGET_MS; + let scanned = 0; let cursor: string | null | undefined; for (;;) { const page: ListSessionsResponse = await this.runACPRequest(() => probe.connection.listSessions(acpSessionListRequest(cursor, options?.cwd)), ); for (const session of page.sessions) { - sessions.push({ - providerHandleId: session.sessionId, - cwd: session.cwd, - title: session.title ?? null, - firstPromptPreview: null, - lastPromptPreview: null, - lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), - }); + if (scanned >= scanLimit || sessions.length >= resultLimit) break; + scanned += 1; + const descriptor = await this.describeImportableSession( + probe, + history, + session, + canLoadHistory, + historyDeadline, + ); + if (descriptor) sessions.push(descriptor); } cursor = page.nextCursor ?? null; if (!cursor) break; - if (sessions.length >= scanLimit) break; + if (scanned >= scanLimit || sessions.length >= resultLimit) break; } - return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions; + return sessions; } finally { await this.closeProbe(probe); } } + private async describeImportableSession( + probe: SpawnedACPProcess, + history: ACPImportHistoryCollector, + session: ListSessionsResponse["sessions"][number], + canLoadHistory: boolean, + historyDeadline: number, + ): Promise { + const loadedPreviews = canLoadHistory + ? await this.loadImportPromptPreviews(probe, history, session, historyDeadline) + : null; + if (loadedPreviews?.hasConversation === false) return null; + return { + providerHandleId: session.sessionId, + cwd: session.cwd, + title: session.title ?? null, + firstPromptPreview: loadedPreviews?.firstPromptPreview ?? null, + lastPromptPreview: loadedPreviews?.lastPromptPreview ?? null, + lastActivityAt: session.updatedAt ? new Date(session.updatedAt) : new Date(0), + }; + } + + private async loadImportPromptPreviews( + probe: SpawnedACPProcess, + history: ACPImportHistoryCollector, + session: ListSessionsResponse["sessions"][number], + historyDeadline: number, + ): Promise { + const updatedAt = session.updatedAt ?? null; + const cached = this.importPromptCache.get(session.sessionId); + if (updatedAt !== null && cached?.updatedAt === updatedAt) { + return { + firstPromptPreview: cached.firstPromptPreview, + lastPromptPreview: cached.lastPromptPreview, + hasConversation: cached.hasConversation, + }; + } + + const remainingMs = historyDeadline - this.now(); + if (remainingMs <= 0) return null; + const loadTimeoutMs = Math.min(ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS, remainingMs); + + history.begin(session.sessionId); + try { + await withTimeout( + this.runACPRequest(() => + probe.connection.loadSession({ + sessionId: session.sessionId, + cwd: session.cwd, + mcpServers: [], + }), + ), + loadTimeoutMs, + `ACP import history load timed out after ${loadTimeoutMs}ms`, + ); + const previews = history.finish(session.sessionId); + if (updatedAt !== null) { + this.importPromptCache.set(session.sessionId, { updatedAt, ...previews }); + } + return previews; + } catch (error) { + history.discard(session.sessionId); + this.logger.debug( + { err: error, sessionId: session.sessionId }, + "ACP import history load failed; keeping the session visible", + ); + return null; + } finally { + await this.closeLoadedSession(probe, session.sessionId, historyDeadline); + } + } + + private async closeLoadedSession( + probe: SpawnedACPProcess, + sessionId: string, + historyDeadline: number, + ): Promise { + if (!probe.initialize.agentCapabilities?.sessionCapabilities?.close) return; + const remainingMs = historyDeadline - this.now(); + if (remainingMs <= 0) return; + const closeTimeoutMs = Math.min(ACP_PROBE_CLOSE_TIMEOUT_MS, remainingMs); + try { + await withTimeout( + probe.connection.unstable_closeSession({ sessionId }), + closeTimeoutMs, + `ACP loaded session/close timed out after ${closeTimeoutMs}ms`, + ); + } catch (error) { + this.logger.debug({ err: error, sessionId }, "ACP loaded session close failed"); + } + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { return importSessionFromPersistence({ provider: this.provider, @@ -1106,10 +1365,12 @@ export class ACPAgentClient implements AgentClient { launchEnv?: Record, options?: { initializeTimeoutMs?: number; + providerOptions?: Record; onSpawned?: (probe: UninitializedACPProcess) => void; + client?: ACPClient; }, ): Promise { - const transport = await this.spawnTransport(launchEnv); + const transport = await this.spawnTransport(launchEnv, options?.client); const probe: UninitializedACPProcess = { child: transport.child, connection: transport.connection, @@ -1117,7 +1378,11 @@ export class ACPAgentClient implements AgentClient { }; options?.onSpawned?.(probe); try { - const initialize = await this.initializeTransport(transport, options?.initializeTimeoutMs); + const initialize = await this.initializeTransport( + transport, + options?.initializeTimeoutMs, + options?.providerOptions, + ); const initializedProbe: SpawnedACPProcess = { ...probe, initialize, @@ -1130,7 +1395,10 @@ export class ACPAgentClient implements AgentClient { } } - protected async spawnTransport(launchEnv?: Record): Promise { + protected async spawnTransport( + launchEnv?: Record, + client: ACPClient = this.buildProbeClient(), + ): Promise { const { command, args } = await this.resolveLaunchCommand(); const child = spawnProcess(command, args, { cwd: process.cwd(), @@ -1147,12 +1415,7 @@ export class ACPAgentClient implements AgentClient { stderrChunks.push(chunk.toString()); }); - const spawnErrorPromise = new Promise((_, reject) => { - child.once("error", (error) => { - const stderr = stderrChunks.join("").trim(); - reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error))); - }); - }); + const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks); const spawnReadyPromise = new Promise((resolve) => { child.once("spawn", () => { resolve(); @@ -1164,7 +1427,7 @@ export class ACPAgentClient implements AgentClient { Readable.toWeb(child.stdout), { logger: this.logger, provider: this.provider }, ); - const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); + const connection = new ClientSideConnection(() => client, stream); return { child, @@ -1178,7 +1441,9 @@ export class ACPAgentClient implements AgentClient { protected async initializeTransport( transport: ACPProcessTransport, initializeTimeoutMs?: number, + providerOptions?: Record, ): Promise { + const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {}); let timeout: ReturnType | null = null; const initializeTimeoutPromise = initializeTimeoutMs ? new Promise((_, reject) => { @@ -1195,7 +1460,7 @@ export class ACPAgentClient implements AgentClient { protocolVersion: PROTOCOL_VERSION, clientCapabilities: buildACPClientCapabilities( this.clientCapabilityMeta, - this.clientCapabilities, + configuredOptions.clientCapabilities ?? this.clientCapabilities, ), clientInfo: { name: "Paseo", version: "dev" }, }), @@ -1210,12 +1475,16 @@ export class ACPAgentClient implements AgentClient { } } - protected buildProbeClient(): ACPClient { + protected buildProbeClient( + onSessionUpdate?: (params: SessionNotification) => void | Promise, + ): ACPClient { return { async requestPermission(): Promise { return { outcome: { outcome: "cancelled" } }; }, - async sessionUpdate(): Promise {}, + async sessionUpdate(params): Promise { + await onSessionUpdate?.(params); + }, async readTextFile(params: ReadTextFileRequest) { const content = await fs.readFile(params.path, "utf8"); return { content }; @@ -1231,11 +1500,20 @@ export class ACPAgentClient implements AgentClient { }; } - protected async closeProbe(probe: UninitializedACPProcess): Promise { + protected async closeProbe( + probe: UninitializedACPProcess, + sessionId: string | null = null, + ): Promise { try { - if (probe.initialize?.agentCapabilities?.sessionCapabilities?.close) { - // No active session to close here; ignore capability. + if (sessionId && probe.initialize?.agentCapabilities?.sessionCapabilities?.close) { + await withTimeout( + probe.connection.unstable_closeSession({ sessionId }), + ACP_PROBE_CLOSE_TIMEOUT_MS, + `ACP probe session/close timed out after ${ACP_PROBE_CLOSE_TIMEOUT_MS}ms`, + ); } + } catch (error) { + this.logger.debug({ err: error, sessionId }, "ACP probe closeSession failed during cleanup"); } finally { await terminateChildProcess(probe.child, 2_000, this.terminateProcess); } @@ -1259,6 +1537,8 @@ export class ACPAgentClient implements AgentClient { const phaseTimeoutMs = options.phaseTimeoutMs ?? ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS; const cwd = options.cwd ?? homedir(); let transport: ACPProcessTransport | null = null; + let initialize: InitializeResponse | null = null; + let probeSessionId: string | null = null; try { const spawnStartedAt = Date.now(); @@ -1284,7 +1564,7 @@ export class ACPAgentClient implements AgentClient { const initializeStartedAt = Date.now(); try { - await this.initializeTransport(activeTransport, phaseTimeoutMs); + initialize = await this.initializeTransport(activeTransport, phaseTimeoutMs); rows.push({ label: "ACP initialize", value: `ok (${formatDurationMs(initializeStartedAt)})`, @@ -1310,6 +1590,7 @@ export class ACPAgentClient implements AgentClient { phaseTimeoutMs, `ACP session/new timed out after ${phaseTimeoutMs}ms`, ); + probeSessionId = response.sessionId; const transformed = this.transformSessionResponse(response); const models = deriveModelDefinitionsFromACP( this.provider, @@ -1342,7 +1623,15 @@ export class ACPAgentClient implements AgentClient { if (transport) { const cleanupStartedAt = Date.now(); try { - await terminateChildProcess(transport.child, 2_000, this.terminateProcess); + await this.closeProbe( + { + child: transport.child, + connection: transport.connection, + stderrChunks: transport.stderrChunks, + ...(initialize ? { initialize } : {}), + }, + probeSessionId, + ); rows.push({ label: "ACP cleanup", value: `ok (${formatDurationMs(cleanupStartedAt)})`, @@ -1408,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { configOptions: SessionConfigOption[], ) => SessionConfigOption[]; private readonly configFeatureOptions: ACPConfigFeatureOption[]; + private readonly configuredModelIds: ReadonlySet; private readonly clientCapabilities?: ACPClientCapabilities; private readonly clientCapabilityMeta?: ACPClientCapabilityMeta; private readonly modeIdTransformer?: (modeId: string) => string | null; @@ -1433,12 +1723,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { private readonly terminalEntries = new Map(); private readonly persistedHistory: AgentTimelineItem[] = []; private readonly initialHandle?: AgentPersistenceHandle; + private readonly resumePurpose: AgentResumePurpose; private readonly config: AgentSessionConfig; private child: ChildProcessWithoutNullStreams | null = null; private connection: ClientSideConnection | null = null; private agentCapabilities: ACPAgentCapabilities | null = null; private sessionId: string | null = null; + private readonly earlySessionUpdates: SessionNotification[] = []; private currentMode: string | null = null; private availableModes: AgentMode[]; private currentModel: string | null = null; @@ -1474,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionResponseTransformer = options.sessionResponseTransformer; this.configOptionsTransformer = options.configOptionsTransformer; this.configFeatureOptions = options.configFeatureOptions ?? []; + this.configuredModelIds = new Set(options.configuredModelIds); this.clientCapabilities = options.clientCapabilities; this.clientCapabilityMeta = options.clientCapabilityMeta; this.modeIdTransformer = options.modeIdTransformer; @@ -1485,6 +1778,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.agentId = options.agentId; this.launchEnv = options.launchEnv; this.initialHandle = options.handle; + this.resumePurpose = options.resumePurpose ?? "interactive"; this.config = { ...config, provider: options.provider }; this.currentMode = config.modeId ?? null; this.currentModel = config.model ?? null; @@ -1515,6 +1809,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.sessionId = response.sessionId; this.bootstrapThreadEventPending = true; this.applySessionState(response); + for (const update of this.earlySessionUpdates.splice(0)) { + await this.sessionUpdate(update); + } await this.applyConfiguredOverrides(); } catch (error) { await this.closeAfterInitializationFailure(error); @@ -1780,8 +2077,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { } const context = this.createProviderModeWriterContext(modeId, selection); - const providerResult = this.providerModeWriter - ? await this.providerModeWriter(context) + const providerModeWriter = this.providerModeWriter; + const providerResult = providerModeWriter + ? await this.runACPRequest(() => providerModeWriter(context)) : { handled: false }; if (providerResult.handled) { this.currentMode = providerResult.currentModeId ?? modeId; @@ -1826,15 +2124,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } - if (this.beforeModeWriter) { - const beforeResult = await this.beforeModeWriter(context); + const beforeModeWriter = this.beforeModeWriter; + if (beforeModeWriter) { + const beforeResult = await this.runACPRequest(() => beforeModeWriter(context)); if (beforeResult?.configOptions) { this.configOptions = this.transformConfigOptions(beforeResult.configOptions); } } if (selection.hasAvailableModes) { - await this.connection.setSessionMode({ sessionId: this.sessionId, modeId }); + const { connection, sessionId } = this; + await this.runACPRequest(() => connection.setSessionMode({ sessionId, modeId })); this.currentMode = modeId; this.pushEvent({ type: "mode_changed", @@ -1850,11 +2150,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error(`${this.provider} does not expose ACP mode switching`); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: modeOption.id, - value: modeId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: modeOption.id, + value: modeId, + }), + ); this.currentMode = this.applyConfigOptionResponse({ response, configId: modeOption.id, @@ -1898,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } + await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) }); + } + + private resolveModelSelection(modelId: string): ACPModelSelection { const selection = resolveACPModelSelection({ modelId, availableModels: this.availableModels, configOptions: this.configOptions, }); - await this.setModelWithSelection({ modelId, selection }); + if (selection.availableModel || !this.configuredModelIds.has(modelId)) { + return selection; + } + // A model the user added in provider config is applied even when the agent does not advertise it. + return { ...selection, availableModel: { modelId, name: modelId } }; } private async setModelWithSelection({ @@ -1917,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient { throw new Error("ACP session not initialized"); } - if (selection.hasAvailableModels) { - if (!selection.availableModel) { - this.warnInvalidSelection( - modelId, - `is not a valid ${this.provider} model. Available options: ${this.availableModels - ?.map((model) => model.modelId) - .join(", ")}`, - ); - return; - } - - if (typeof this.connection.unstable_setSessionModel !== "function") { - throw new Error(this.modelSelectionUnavailableMessage()); - } + if (selection.hasAvailableModels && !selection.availableModel) { + this.warnInvalidSelection( + modelId, + `is not a valid ${this.provider} model. Available options: ${this.availableModels + ?.map((model) => model.modelId) + .join(", ")}`, + ); + return; + } + if ( + selection.availableModel && + typeof this.connection.unstable_setSessionModel === "function" + ) { try { await this.connection.unstable_setSessionModel({ sessionId: this.sessionId, @@ -1965,11 +2275,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: modelOption.id, - value: modelId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: modelOption.id, + value: modelId, + }), + ); this.currentModel = this.applyConfigOptionResponse({ response, configId: modelOption.id, @@ -1993,8 +2306,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - if (this.thinkingOptionWriter) { - await this.thinkingOptionWriter(this.connection, this.sessionId, thinkingOptionId); + const thinkingOptionWriter = this.thinkingOptionWriter; + if (thinkingOptionWriter) { + const { connection, sessionId } = this; + await this.runACPRequest(() => thinkingOptionWriter(connection, sessionId, thinkingOptionId)); this.thinkingOptionId = thinkingOptionId; this.pushEvent({ type: "thinking_option_changed", @@ -2011,11 +2326,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!option) { throw new Error(`${this.provider} does not expose ACP thought-level selection`); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: option.id, - value: thinkingOptionId, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: option.id, + value: thinkingOptionId, + }), + ); this.thinkingOptionId = this.applyConfigOptionResponse({ response, configId: option.id, @@ -2050,7 +2368,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const option = findSelectConfigFeatureOption(this.configOptions, featureOption); if (!option) { - throw new Error(`${this.provider} does not expose ACP feature '${featureId}'`); + throw new Error(this.featureUnavailableMessage(featureId)); } const requestedValue = normalizeConfigFeatureValue(value); @@ -2061,11 +2379,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { ); } - const response = await this.connection.setSessionConfigOption({ - sessionId: this.sessionId, - configId: option.id, - value: requestedValue, - }); + const { connection, sessionId } = this; + const response = await this.runACPRequest(() => + connection.setSessionConfigOption({ + sessionId, + configId: option.id, + value: requestedValue, + }), + ); const currentValue = this.applyConfigOptionResponse({ response, configId: option.id, @@ -2154,12 +2475,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!this.sessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; return { provider: this.provider, sessionId: this.sessionId, nativeHandle: this.sessionId, metadata: { - ...this.config, + ...persistedConfig, title: this.currentTitle, }, }; @@ -2170,10 +2492,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { return; } - for (const pending of this.pendingPermissions.values()) { - pending.resolve({ outcome: { outcome: "cancelled" } }); - } - this.pendingPermissions.clear(); + this.cancelPendingPermissions(); if (this.activeForegroundTurnId) { await this.connection.cancel({ sessionId: this.sessionId }); @@ -2189,21 +2508,26 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.deliverTranslatedEvents(this.flushPendingUserMessage()); this.settleCommandsReady(); - for (const pending of this.pendingPermissions.values()) { - pending.resolve({ outcome: { outcome: "cancelled" } }); - } - this.pendingPermissions.clear(); + this.cancelPendingPermissions(); if (this.connection && this.sessionId) { try { if (this.activeForegroundTurnId) { - await this.connection.cancel({ sessionId: this.sessionId }); + await withTimeout( + this.connection.cancel({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP cancel timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch {} try { if (this.agentCapabilities?.sessionCapabilities?.close) { - await this.connection.unstable_closeSession({ sessionId: this.sessionId }); + await withTimeout( + this.connection.unstable_closeSession({ sessionId: this.sessionId }), + ACP_CLOSE_REQUEST_TIMEOUT_MS, + `ACP closeSession timed out after ${ACP_CLOSE_REQUEST_TIMEOUT_MS}ms`, + ); } } catch (error) { this.logger.debug({ err: error }, "ACP closeSession failed during shutdown"); @@ -2284,6 +2608,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { }, "provider.acp.raw_event", ); + if (!this.sessionId) { + this.earlySessionUpdates.push(params); + return; + } if (params.sessionId !== this.sessionId) { return; } @@ -2387,8 +2715,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { (params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]), ); const terminalCommand = resolveTerminalCommand(params.command, params.args); + // The terminal is a sibling of the agent process, not a child, so it inherits + // nothing from it. Carry the agent's launch environment the way spawnProcess + // does, keeping the requested terminal environment on top. const commandEnvOverlays = - terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env]; + terminalCommand.shell === false + ? [this.launchEnv, env, createStringCommandShellEnvOverlay()] + : [this.launchEnv, env]; const child = spawnProcess(terminalCommand.command, terminalCommand.args, { cwd: params.cwd ?? this.config.cwd, ...createProviderEnvSpec({ @@ -2470,6 +2803,16 @@ export class ACPAgentSession implements AgentSession, ACPClient { return {}; } + // Loading an archived agent's history runs nothing in its working directory, which + // may have been removed with its worktree. session/load still names the original + // directory; only the process starts from the home directory. + private async resolveProcessCwd(): Promise { + if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) { + return this.config.cwd; + } + return homedir(); + } + private async spawnProcess(): Promise { const prefix = await resolveProviderLaunch({ commandConfig: this.runtimeSettings?.command, @@ -2483,7 +2826,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { const command = prefix.command; const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const child = spawnProcess(command, args, { - cwd: this.config.cwd, + cwd: await this.resolveProcessCwd(), ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings, overlays: [this.launchEnv], @@ -2496,6 +2839,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { child.stderr.on("data", (chunk: Buffer | string) => { stderrChunks.push(chunk.toString()); }); + const spawnError = rejectOnSpawnError(child, stderrChunks); child.once("exit", (code, signal) => { if (this.closed) { return; @@ -2523,14 +2867,17 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.child = child; this.connection = connection; const initialize = await this.runACPRequest(() => - connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: buildACPClientCapabilities( - this.clientCapabilityMeta, - this.clientCapabilities, - ), - clientInfo: { name: "Paseo", version: "dev" }, - }), + Promise.race([ + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: buildACPClientCapabilities( + this.clientCapabilityMeta, + this.clientCapabilities, + ), + clientInfo: { name: "Paseo", version: "dev" }, + }), + spawnError, + ]), ); return { child, connection, initialize }; @@ -2587,14 +2934,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { await this.setModeWithSelection({ modeId: configuredModeId, selection }); } const configuredModelId = this.config.model; + let switchedModel = false; if (configuredModelId && configuredModelId !== this.currentModel) { - const selection = resolveACPModelSelection({ - modelId: configuredModelId, - availableModels: this.availableModels, - configOptions: this.configOptions, - }); try { - await this.setModelWithSelection({ modelId: configuredModelId, selection }); + await this.setModelWithSelection({ + modelId: configuredModelId, + selection: this.resolveModelSelection(configuredModelId), + }); + switchedModel = true; } catch (error) { if (!this.isModelSelectionUnavailableError(error)) { throw error; @@ -2613,10 +2960,39 @@ export class ACPAgentSession implements AgentSession, ACPClient { if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) { continue; } - await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]); + try { + await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]); + } catch (error) { + if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) { + throw error; + } + this.logger.warn( + { err: error, featureId: featureOption.id, model: this.currentModel }, + `${this.provider} cannot apply ACP feature '${featureOption.id}' to the current model; using the provider default`, + ); + } } } + /** + * A stored feature value is a preference carried over from whichever model the user + * last configured, so the session it lands on may have no such option. Paseo's own + * guard says so when the session's options are accurate. A model switch answers with + * an empty response, leaving Paseo holding the previous model's options, and then the + * provider is the one that rejects the write as invalid params. Outside those two + * cases the write failed for a reason the user needs to see. + */ + private isStaleFeatureValueError( + error: unknown, + featureId: string, + switchedModel: boolean, + ): boolean { + if (this.isFeatureUnavailableError(error, featureId)) { + return true; + } + return switchedModel && isACPInvalidParams(error); + } + private warnInvalidSelection(value: string, message: string): void { this.logger.warn({ value }, message); } @@ -2629,6 +3005,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { return error instanceof Error && error.message === this.modelSelectionUnavailableMessage(); } + private featureUnavailableMessage(featureId: string): string { + return `${this.provider} does not expose ACP feature '${featureId}'`; + } + + private isFeatureUnavailableError(error: unknown, featureId: string): boolean { + return error instanceof Error && error.message === this.featureUnavailableMessage(featureId); + } + private translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[] { if (update.sessionUpdate === "user_message_chunk") { return this.handleUserMessageChunk(update); @@ -2841,8 +3225,29 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + // ACP reports context occupancy out of band from the prompt response, so the + // composer's context meter cannot read it from `turn_completed`. Forward it as + // the same `usage_updated` snapshot the native adapters emit, preserving the + // turn usage this session already recorded. The guards match the meter's own + // validity rules, so a provider cannot push a window the meter would reject. private handleUsageUpdate(update: UsageUpdate): void { - void update; + const contextWindowMaxTokens = + Number.isFinite(update.size) && update.size > 0 ? update.size : undefined; + const contextWindowUsedTokens = + Number.isFinite(update.used) && update.used >= 0 ? update.used : undefined; + if (contextWindowMaxTokens === undefined || contextWindowUsedTokens === undefined) { + return; + } + this.pushEvent({ + type: "usage_updated", + provider: this.provider, + usage: { + ...this.currentTurnUsage, + contextWindowMaxTokens, + contextWindowUsedTokens, + }, + turnId: this.activeForegroundTurnId ?? undefined, + }); } private handlePromptResponse(response: PromptResponse, turnId: string): void { @@ -2940,6 +3345,11 @@ export class ACPAgentSession implements AgentSession, ACPClient { event: Extract, ): void { this.deliverTranslatedEvents(this.flushPendingUserMessage()); + // A turn that ends without completing leaves no way to answer its open + // permission requests, so tell the agent they were cancelled. + if (event.type !== "turn_completed") { + this.cancelPendingPermissions(); + } this.activeForegroundTurnId = null; this.fallbackAssistantMessageId = null; if (this.submittedUserMessageTurnId === event.turnId) { @@ -2948,6 +3358,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.pushEvent(event); } + private cancelPendingPermissions(): void { + for (const pending of this.pendingPermissions.values()) { + pending.resolve({ outcome: { outcome: "cancelled" } }); + } + this.pendingPermissions.clear(); + } + private emitBootstrapThreadEvent(): void { if (!this.bootstrapThreadEventPending || !this.sessionId) { return; @@ -3225,6 +3642,23 @@ function contentBlockToText(content: ContentBlock): string { } } +function normalizeACPImportPromptPreview(text: string | null): string | null { + const normalized = text?.trim().replace(/\s+/g, " ") ?? ""; + if (!normalized) return null; + return normalized.length > 160 ? normalized.slice(0, 160) : normalized; +} + +function isACPConversationUpdate(update: SessionUpdate): boolean { + return ( + update.sessionUpdate === "user_message_chunk" || + update.sessionUpdate === "agent_message_chunk" || + update.sessionUpdate === "agent_thought_chunk" || + update.sessionUpdate === "tool_call" || + update.sessionUpdate === "tool_call_update" || + update.sessionUpdate === "plan" + ); +} + function coalesceDefined(next: T | undefined, previous: T | undefined, fallback: T): T { if (next !== undefined) { return next; diff --git a/packages/server/src/server/agent/providers/acp-options.ts b/packages/server/src/server/agent/providers/acp-options.ts new file mode 100644 index 0000000000..4f75456e38 --- /dev/null +++ b/packages/server/src/server/agent/providers/acp-options.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +export const ACPProviderOptionsSchema = z + .object({ + supportsMcpServers: z.boolean().optional(), + clientCapabilities: z + .object({ + fs: z + .object({ + readTextFile: z.boolean().optional(), + writeTextFile: z.boolean().optional(), + }) + .optional(), + terminal: z.boolean().optional(), + }) + .optional(), + }) + .passthrough(); diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 694c804e38..630bdbf0a6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -313,4 +313,222 @@ describe("background Claude subagents", () => { }, }); }); + + test("keeps the Task card labeled when the child's frames arrive after its launch result", async () => { + // Claude Code answers a background Agent call at once ("Agent launched") and only then streams + // the child's frames, so they arrive after the parent's tool call has its result. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + const rows: AgentTimelineRow[] = events + .filter((event) => event.type === "timeline") + .map((event, index) => ({ + seq: index + 1, + timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, index)).toISOString(), + item: event.item, + })); + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }); + + test.each([ + { + source: "system task protocol", + notification: { + type: "system", + subtype: "task_notification", + task_id: "bash-task", + tool_use_id: "toolu_bash", + status: "completed", + summary: "sleep 2; printf NESTED_BACKGROUND_SENTINEL", + }, + }, + { + source: "queued user envelope", + notification: { + type: "user", + uuid: "bash-notification", + message: { + role: "user", + content: + "\nbash-task\ntoolu_bash\ncompleted\nsleep 2; printf NESTED_BACKGROUND_SENTINEL\n", + }, + }, + }, + ])( + "routes a child-owned background notification from $source to that child's timeline", + async ({ notification }) => { + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "nested-session" }, + { + type: "system", + subtype: "task_started", + task_id: "direct-task", + tool_use_id: "toolu_direct", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Run a background command", + spawn_depth: 1, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { + type: "tool_use", + id: "toolu_bash", + name: "Bash", + input: { command: "sleep 2; printf NESTED_BACKGROUND_SENTINEL" }, + }, + ], + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "bash-task", + tool_use_id: "toolu_bash", + task_type: "local_bash", + owned_by_subagent: true, + is_backgrounded: true, + }, + notification, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: "toolu_direct", + item: expect.objectContaining({ type: "tool_call", name: "task_notification" }), + }), + }), + ); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts new file mode 100644 index 0000000000..3008f3d4d5 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-usage.local.e2e.test.ts @@ -0,0 +1,133 @@ +import { createServer } from "node:http"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +// Exercise the real Claude Code process against a local Messages API fixture. +// The input counter's location is the only difference between the two backends. +test.each([ + { placement: "start", cacheTokens: 0 }, + { placement: "delta", cacheTokens: 0 }, + { placement: "start", cacheTokens: 1_000 }, + { placement: "delta", cacheTokens: 1_000 }, +])( + "counts $placement input and $cacheTokens cached tokens through Claude Code", + async ({ placement, cacheTokens }) => { + const cwd = await mkdtemp(path.resolve(".claude-context-usage-")); + const configDir = path.join(cwd, "config"); + await mkdir(configDir); + let requestCount = 0; + const server = createServer(async (request, response) => { + for await (const chunk of request) { + void chunk; + } + if (!request.url?.startsWith("/v1/messages")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end("{}"); + return; + } + if (request.url.includes("count_tokens")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ input_tokens: 20_236 })); + return; + } + const inputTokens = 20_236 + requestCount++; + response.writeHead(200, { "content-type": "text/event-stream" }); + const emit = (data: { type: string; [key: string]: unknown }) => { + response.write(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`); + }; + emit({ + type: "message_start", + message: { + id: `msg_context_usage_${requestCount}`, + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: placement === "start" ? inputTokens : 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: cacheTokens, + }, + }, + }); + emit({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); + emit({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello" } }); + emit({ type: "content_block_stop", index: 0 }); + emit({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { + output_tokens: 64, + ...(placement === "delta" ? { input_tokens: inputTokens } : {}), + }, + }); + emit({ type: "message_stop" }); + response.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Fixture has no TCP address"); + const client = new ClaudeAgentClient({ + logger: pino({ level: "silent" }), + runtimeSettings: { + env: { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, + ANTHROPIC_AUTH_TOKEN: "fixture-token", + ANTHROPIC_API_KEY: "", + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", + }, + }, + }); + const session = await client.createSession( + { provider: "claude", cwd, model: "claude-sonnet-4-6" }, + undefined, + { persistSession: false }, + ); + try { + for (let turn = 0; turn < 2; turn++) { + const events = await collectSessionTurnEvents(session, "say hello", { + signal: AbortSignal.timeout(20_000), + }); + const expectedTokens = 20_236 + turn + cacheTokens + 64; + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ + inputTokens: 20_236 + turn, + outputTokens: 64, + cachedInputTokens: cacheTokens, + contextWindowUsedTokens: expectedTokens, + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }), + }), + ); + } + expect(requestCount).toBe(2); + } finally { + try { + await session.close(); + } finally { + try { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + } + } + }, + 60_000, +); diff --git a/packages/server/src/server/agent/providers/claude/agent.env.test.ts b/packages/server/src/server/agent/providers/claude/agent.env.test.ts index 642e562f61..0d2748fd62 100644 --- a/packages/server/src/server/agent/providers/claude/agent.env.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.env.test.ts @@ -31,6 +31,7 @@ function createQueryMock(events: unknown[]): Query { describe("Claude SDK env", () => { test("forwards launch-context env through Claude process env", async () => { let capturedEnv: Record | undefined; + let capturedPerTaskStopAffordance: boolean | undefined; const launchContext: AgentLaunchContext = { env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201", @@ -39,6 +40,7 @@ describe("Claude SDK env", () => { }; const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { capturedEnv = options.env; + capturedPerTaskStopAffordance = options.perTaskStopAffordance; return createQueryMock([ { type: "system", @@ -90,8 +92,14 @@ describe("Claude SDK env", () => { expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout"); expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout"); + expect(session.usageSession?.()?.env).toBe(capturedEnv); + // Paseo reads session_state_changed to know when an autonomous turn is over. + expect(capturedEnv?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1"); + // Without it, Stop and replace kill every background subagent along with the turn. + expect(capturedPerTaskStopAffordance).toBe(true); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } }); @@ -150,12 +158,19 @@ describe("Claude SDK env", () => { ); try { + const descriptor = session.usageSession?.(); + expect(descriptor?.env.PASEO_TEST_FLAG).toBe("resume-launch-value"); + expect(descriptor?.sessionKey).toEqual(expect.any(String)); + expect(queryFactory).not.toHaveBeenCalled(); const result = await session.run("resume env check"); + expect(session.usageSession?.()?.sessionKey).toBe(descriptor?.sessionKey); + expect(capturedEnv).toBe(descriptor?.env); expect(result.sessionId).toBe("persisted-session"); expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); } finally { await session.close(); + expect(session.usageSession?.()).toBeNull(); } }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts new file mode 100644 index 0000000000..50d9c56ab5 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.history-load.test.ts @@ -0,0 +1,118 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import pino from "pino"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +/** + * A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline. + * The daemon log at its default level has to say which, and where it looked. + */ + +const SESSION_ID = "history-load-session"; + +interface LogRecord { + level: number; + msg: string; + sessionId?: string; + historyPath?: string; + err?: { message?: string }; +} + +describe("ClaudeAgentSession persisted history load", () => { + let tempRoot: string; + let cwd: string; + let configDir: string; + let records: LogRecord[]; + + async function resume(): Promise { + const client = new ClaudeAgentClient({ + logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }), + queryFactory: vi.fn(() => { + throw new Error("history load must not start a query"); + }), + resolveVersion: async () => "2.1.220", + }); + const session = await client.resumeSession( + { provider: "claude", sessionId: SESSION_ID }, + { cwd }, + ); + await session.close(); + } + + function transcriptPath(): string { + return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`); + } + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-history-load-")); + cwd = path.join(tempRoot, "repo"); + configDir = path.join(tempRoot, "claude-config"); + mkdirSync(cwd, { recursive: true }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + records = []; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("logs the resolved transcript path when the transcript does not exist", async () => { + await resume(); + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.info, + msg: "No Claude transcript to load history from", + sessionId: SESSION_ID, + historyPath: transcriptPath(), + }), + ); + }); + + test("logs a warning with the error when the transcript cannot be read", async () => { + // A directory at the transcript path exists but cannot be read as a file. + mkdirSync(transcriptPath(), { recursive: true }); + + await resume(); + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to load Claude history from transcript", + sessionId: SESSION_ID, + historyPath: transcriptPath(), + err: expect.objectContaining({ message: expect.stringContaining("EISDIR") }), + }), + ); + }); + + // chmod does not remove read access on Windows, or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "logs a warning with the error when Claude's project folders cannot be listed", + async () => { + // The transcript is not under cwd's folder, so the lookup lists the other project folders. + mkdirSync(path.join(configDir, "projects"), { recursive: true }); + chmodSync(path.join(configDir, "projects"), 0o000); + + try { + await resume(); + } finally { + chmodSync(path.join(configDir, "projects"), 0o755); + } + + expect(records).toContainEqual( + expect.objectContaining({ + level: pino.levels.values.warn, + msg: "Failed to load Claude history from transcript", + sessionId: SESSION_ID, + err: expect.objectContaining({ message: expect.stringContaining("EACCES") }), + }), + ); + }, + ); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts index dada35945c..02a09ba44b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.interrupt-restart-regression.test.ts @@ -91,6 +91,17 @@ function buildUsage() { }; } +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function buildTurnInit(sessionId: string) { + return { + type: "system", + subtype: "init", + session_id: sessionId, + permissionMode: "default", + model: "opus", + }; +} + function buildSuccessResult(sessionId: string) { return { type: "result", @@ -153,13 +164,7 @@ function createScriptedQuery(params: { }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: params.sessionId, - permissionMode: "default", - model: "opus", - }); + scriptedQuery.emit(buildTurnInit(params.sessionId)); void (async () => { for await (const prompt of params.prompt) { @@ -252,6 +257,21 @@ async function waitFor( } } +/** + * Paseo interrupts Claude only while a main-session turn is in flight, which it learns from the + * init. The pump asks for its next frame only after routing the last one, so a second next() call + * proves the init was read. + */ +async function waitForInitRouted( + query: () => { next: ReturnType } | null | undefined, +) { + await waitFor(() => (query()?.next.mock.calls.length ?? 0) >= 2); +} + +function buildCommandLifecycle(commandUuid: string | null | undefined, state: string) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + afterEach(() => { queryFactory.mockReset(); }); @@ -282,6 +302,7 @@ test("interrupt only calls query.interrupt and leaves the query open", async () const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await waitFor(() => queries[0]?.interrupt.mock.calls.length === 1); @@ -322,6 +343,7 @@ async function startSteeredTurn(sessionId: string): Promise<{ throw new Error("Expected the original Claude turn to start"); } await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); const steered = await session.steerActiveTurn!("queued steer", { expectedTurnId: start.value.turnId, @@ -371,11 +393,8 @@ test("interrupt still stops the turn when Claude has already dequeued the steer" test("a steer Claude has already read is no longer discardable on interrupt", async () => { const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session"); - query()?.emit({ - type: "command_lifecycle", - command_uuid: query()?.prompts[1]?.uuid, - state: "completed", - }); + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "completed")); // Frames are translated in order, so the marker landing proves the lifecycle frame was read. query()?.emit({ type: "assistant", message: { content: "STEER_READ" } }); await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ")); @@ -388,6 +407,23 @@ test("a steer Claude has already read is no longer discardable on interrupt", as await session.close(); }); +test("a steer Claude cancelled is no longer withdrawn on interrupt", async () => { + const { session, query, turn } = await startSteeredTurn("queued-steer-cancelled-session"); + + query()?.emit(buildCommandLifecycle(query()?.prompts[0]?.uuid, "started")); + // "cancelled" is terminal: API errors, interrupts and "now" pre-emption all end a message. + query()?.emit(buildCommandLifecycle(query()?.prompts[1]?.uuid, "cancelled")); + query()?.emit({ type: "assistant", message: { content: "STEER_CANCELLED" } }); + await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_CANCELLED")); + + await session.interrupt(); + await waitFor(() => query()?.interrupt.mock.calls.length === 1); + + expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled(); + await collectUntilTerminal(turn); + await session.close(); +}); + function buildStoppedTaskNotification(sessionId: string) { return { type: "system", @@ -473,6 +509,7 @@ async function startInterruptedToolTurn(sessionId: string): Promise<{ const turn = streamSession(session, "run the slow tool"); await turn.next(); await waitFor(() => query?.prompts.length === 1); + await waitForInitRouted(() => query); await session.interrupt(); await collectUntilTerminal(turn); @@ -523,6 +560,7 @@ test("Claude can still wake into an autonomous turn once the interrupted request query()?.emit(buildStoppedTaskNotification(sessionId)); query()?.emit(buildAbortedResult(sessionId)); + query()?.emit(buildTurnInit(sessionId)); query()?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -579,6 +617,7 @@ test("reuses the existing query after interrupt before starting the next prompt" const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); await waitFor(() => queries[0]?.prompts.length === 1); + await waitForInitRouted(() => queries[0]); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -652,6 +691,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const output = createAsyncQueue>(); const prompts: PromptRecord[] = []; let throwAbortOnNext = false; + let queryRef: Pick | null = null; queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable }) => { const scriptedQuery = { @@ -685,13 +725,8 @@ test("recovers when the query pump sees a single interrupt abort before the next }, } satisfies ScriptedQuery; - scriptedQuery.emit({ - type: "system", - subtype: "init", - session_id: "interrupt-abort-recovery-session", - permissionMode: "default", - model: "opus", - }); + queryRef = scriptedQuery; + scriptedQuery.emit(buildTurnInit("interrupt-abort-recovery-session")); void (async () => { for await (const promptMessage of prompt) { @@ -730,6 +765,7 @@ test("recovers when the query pump sees a single interrupt abort before the next const firstTurn = streamSession(session, "first prompt"); await firstTurn.next(); + await waitForInitRouted(() => queryRef); await session.interrupt(); await collectUntilTerminal(firstTurn); @@ -768,6 +804,7 @@ test("stale abort result after replacement start does not poison the new foregro const firstTurn = streamSession(session, "first prompt"); const firstStarted = await firstTurn.next(); await waitFor(() => queryRef?.prompts.length === 1); + await waitForInitRouted(() => queryRef); await session.interrupt(); const firstTurnEvents = [firstStarted.value!, ...(await collectUntilTerminal(firstTurn))]; @@ -812,7 +849,7 @@ test("stale abort result after replacement start does not poison the new foregro await session.close(); }); -test("creates an autonomous live turn when assistant output arrives without a foreground run", async () => { +test("creates an autonomous live turn when Claude starts a turn without a foreground run", async () => { const logger = createTestLogger(); let queryRef: ScriptedQuery | null = null; @@ -848,6 +885,7 @@ test("creates an autonomous live turn when assistant output arrives without a fo await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-live-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_WAKE_RESPONSE" }, @@ -916,6 +954,7 @@ test("steers an autonomous turn through its existing query without restarting it await collectUntilTerminal(streamSession(session, "seed prompt")); const autonomousEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-steer-session")); queryRef?.emit({ type: "assistant", message: { content: "AUTONOMOUS_RESPONSE" }, @@ -999,6 +1038,7 @@ test("auto-completes an open autonomous turn when a foreground prompt starts", a await collectUntilTerminal(streamSession(session, "seed prompt")); const subscribedEvents = subscribeToEvents(session); + queryRef?.emit(buildTurnInit("autonomous-handoff-session")); queryRef?.emit({ type: "assistant", message: { content: "BACKGROUND_ONLY_RESPONSE" }, diff --git a/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts new file mode 100644 index 0000000000..af66ba9ac8 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.nested-subagent-ownership.real.e2e.test.ts @@ -0,0 +1,149 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. +Name the agent direct_owner and give it this complete task: + +You are DIRECT_OWNER. Use Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it this complete task: +You are NESTED_OWNER. Use Bash exactly once to run \`sleep 2; printf 'NESTED_BACKGROUND_SENTINEL\\n'\` with run_in_background true. Use TaskOutput with block true to wait for the Bash task, verify its exit code is 0 and output is NESTED_BACKGROUND_SENTINEL, and wait for its completion notification before replying exactly NESTED_DONE. Do not finish while the command is running. +Wait for nested_owner to finish, then reply exactly DIRECT_DONE. + +Wait for direct_owner to finish, then reply exactly ROOT_DONE.`; + +test("attributes a nested Claude child and its background notification to their direct owners", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ownership-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "trace" }) }); + const session = await client.createSession({ + provider: "claude", + cwd, + model: "claude-sonnet-5", + modeId: "bypassPermissions", + }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + + expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" })); + expect( + events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ) + .join("") + .trim(), + ).toBe("ROOT_DONE"); + const descriptors = events + .filter((event) => event.type === "provider_subagent" && event.event.type === "upsert") + .map((event) => event.event) + .filter((event) => event.type === "upsert" && event.description); + const direct = descriptors.find((event) => event.description === "direct_owner"); + const nested = descriptors.find((event) => event.description === "nested_owner"); + + expect(direct).toMatchObject({ description: "direct_owner" }); + expect(nested).toMatchObject({ description: "nested_owner" }); + expect(nested?.parentSubagentId).toBe(direct?.id); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === nested?.id, + ), + ).toEqual([]); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: nested?.id, + item: expect.objectContaining({ + type: "tool_call", + name: "task_notification", + }), + }), + }), + ); + const notification = events + .flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === nested?.id && + event.event.item.type === "tool_call" && + event.event.item.name === "task_notification" + ? [event.event.item] + : [], + ) + .at(-1); + expect(notification?.metadata?.status).toBe("completed"); + const outputFile = notification?.metadata?.outputFile; + expect(typeof outputFile).toBe("string"); + expect(readFileSync(outputFile as string, "utf8").trim()).toMatch( + /^NESTED_BACKGROUND_SENTINEL\s+\[exited with code 0\]$/, + ); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expect( + replayed.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification", + ), + ).toEqual([]); + expect(replayed).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "upsert", + id: nested?.id, + parentSubagentId: direct?.id, + }), + }), + ); + expect(replayed).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "timeline", + id: nested?.id, + item: expect.objectContaining({ + name: "task_notification", + metadata: expect.objectContaining({ + toolUseId: notification?.metadata?.toolUseId, + status: "completed", + }), + }), + }), + }), + ); + } finally { + await restored.close(); + } + } finally { + await session.close().catch(() => undefined); + rmSync(cwd, { recursive: true, force: true }); + } +}, 360_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index 5069c3653f..5393926520 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => { } }); +test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => { + const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK; + process.env.CLAUDE_CODE_USE_BEDROCK = "1"; + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdkQueryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "auto", + }); + + try { + await session.setMode("acceptEdits"); + + expect(await session.getCurrentMode()).toBe("acceptEdits"); + expect(sdkQueryFactory).toHaveBeenCalledTimes(1); + expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits"); + } finally { + restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock); + await session.close(); + } +}); + +test("does not keep a query started in a mode that Claude Code rejected", async () => { + const launchedModes: Array = []; + sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => { + launchedModes.push(options.permissionMode); + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + if (launchedModes.length === 1) { + queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused")); + } + return queryMock; + }); + const session = await createSession(); + + try { + await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused"); + await session.listCommands(); + + expect(await session.getCurrentMode()).toBe("default"); + expect(launchedModes).toEqual(["bypassPermissions", "default"]); + } finally { + await session.close(); + } +}); + test("logs redacted query summary and never leaks sentinel secrets", async () => { const envSecret = "PASEO_ENV_SENTINEL_SECRET"; const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET"; @@ -435,6 +486,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; + mainTurnInFlight: boolean; interruptActiveTurn: () => Promise; } = asInternals(session); const interrupt = vi.fn(async () => undefined); @@ -447,6 +499,7 @@ test("interruptActiveTurn only interrupts the active query without info logs", a }; internal.input = { end }; internal.queryRestartNeeded = false; + internal.mainTurnInFlight = true; try { await internal.interruptActiveTurn(); @@ -1037,11 +1090,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch expect(capturedOptions).toHaveLength(2); expect(capturedOptions[0]).toMatchObject({ - permissionMode: "default", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, }); expect(capturedOptions[1]).toMatchObject({ - permissionMode: "acceptEdits", + permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, effort: "high", }); @@ -1140,6 +1193,7 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); internal.turnState = "idle"; + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); await internal.routeSdkMessageFromPump( { type: "stream_event", @@ -1182,6 +1236,468 @@ test("reuses one autonomous run for unbound stream_event bursts with no foregrou await session.close(); }); +interface SessionStateInternals { + turnState: "idle" | "foreground" | "autonomous"; + routeSdkMessageFromPump: ( + message: Record, + activeQuery: QueryMock, + ) => Promise; + autonomousTurn: { id: string } | null; + activeForegroundTurnId: string | null; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { + type: "system", + subtype: "session_state_changed", + state, + uuid: `state-${state}`, + session_id: "sess-1", + }; +} + +/** Claude opening a turn of its own. Every main-session turn starts with one. */ +function claudeTurnInit() { + return { + type: "system", + subtype: "init", + session_id: "sess-1", + permissionMode: "default", + model: "opus", + }; +} + +function successResult() { + return { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }; +} + +function commandLifecycle( + commandUuid: string, + state: "queued" | "started" | "completed" | "cancelled", +) { + return { type: "command_lifecycle", command_uuid: commandUuid, state }; +} + +function unboundTextDelta(text: string) { + return { + type: "stream_event", + parent_tool_use_id: null, + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + }; +} + +// What a background subagent emits on the main stream with no parent tool use id, so by frame +// shape alone each looks like main-session work. Verified on the wire: none arrives inside a turn +// Claude opened, and no result follows any of them. +const helperTaskStarted = { + type: "system", + subtype: "task_started", + task_id: "helper-task", + tool_use_id: "toolu_helper", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Background helper", + is_backgrounded: true, + uuid: "uuid-task-started", + session_id: "sess-1", +}; +const helperTaskProgress = { + type: "system", + subtype: "task_progress", + task_id: "helper-task", + tool_use_id: "toolu_helper", + description: "Background helper", + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 900 }, + last_tool_name: "Bash", + uuid: "uuid-task-progress", + session_id: "sess-1", +}; +// A subagent's own background Bash job completing. +const subagentOwnedTaskNotification = { + type: "system", + subtype: "task_notification", + task_id: "bg-job-of-subagent", + tool_use_id: "toolu_subagent_bash", + status: "completed", + summary: "Background command completed", + output_file: "/tmp/bg-job-of-subagent.output", + uuid: "uuid-task-notification", + session_id: "sess-1", +}; +const helperCompleted = { + type: "system", + subtype: "task_notification", + task_id: "helper-task", + tool_use_id: "toolu_helper", + status: "completed", + summary: "Background helper finished", + output_file: "/tmp/helper-task.output", + uuid: "uuid-helper-completed", + session_id: "sess-1", +}; +const rateLimitEvent = { + type: "rate_limit_event", + rate_limit_info: { status: "allowed" }, + uuid: "uuid-rate-limit", + session_id: "sess-1", +}; + +function createPendingQueryMock(): QueryMock { + return createBaseQueryMock(vi.fn(() => new Promise(() => {}))); +} + +test("a main-session frame opens no turn until Claude starts one", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Main-session output only ever arrives inside a turn Claude opened with system/init, so a + // stray unbound frame is not the start of one. + await internal.routeSdkMessageFromPump(unboundTextDelta("stray"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + // The wake-up case autonomous turns exist for: a completed background subagent makes the main + // session start a turn of its own, with no prompt from the user. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(unboundTextDelta("picking this up"), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + +test("a background subagent's frames never open a turn while the main session is idle", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + // Claude Code 2.1.280 reports "running" for as long as any background subagent is alive. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + for (const frame of [ + helperTaskStarted, + helperTaskProgress, + subagentOwnedTaskNotification, + rateLimitEvent, + helperCompleted, + ]) { + await internal.routeSdkMessageFromPump(frame, queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + + // The next user message starts its own foreground turn rather than landing on a phantom one. + const { turnId } = await session.startTurn("PING"); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await session.close(); +}); + +test("Claude waking up after a helper finishes gets an autonomous turn that its result closes", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // The helper's completion arrives outside any turn. Claude then starts one to react to it. + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(helperCompleted, queryMock); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + expect(internal.turnState).toBe("autonomous"); + + await internal.routeSdkMessageFromPump(unboundTextDelta("the helper is done"), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("a steer folded into a turn keeps one turn, and a turn Claude starts after it gets its own", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const { turnId } = await session.startTurn("hello"); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + // A message pushed mid-turn is folded in at the next tool boundary: Claude acknowledges it, but + // starts no new turn. + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "queued"), queryMock); + await internal.routeSdkMessageFromPump(commandLifecycle("folded-steer", "started"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + // A message that arrived while Claude wrote its final answer runs as a turn of its own. + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-2"); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(2); + + await session.close(); +}); + +test("session state never opens a turn", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // "running" covers background subagents too, so it says nothing about the main session. + for (const state of ["idle", "running", "requires_action", "idle"] as const) { + await internal.routeSdkMessageFromPump(sessionState(state), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + } + // State frames are bookkeeping, never timeline content. + expect(events).toEqual([]); + + await session.close(); +}); + +test("an idle session state closes an autonomous turn that no result closed", async () => { + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // Safety net: Claude says nothing at all is running any more. + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("idle"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + + await session.close(); +}); + +test("session state never opens or closes a foreground turn", async () => { + sdkQueryFactory.mockImplementation(() => createPendingQueryMock()); + const session = await createSession(); + const internal: SessionStateInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + const { turnId } = await session.startTurn("hello"); + + // The send's own running, then an idle that trails the previous turn and lands late. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(sessionState("idle"), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.autonomousTurn).toBeNull(); + expect(internal.turnState).toBe("foreground"); + + await internal.routeSdkMessageFromPump( + { type: "result", subtype: "success", usage: buildUsage(), total_cost_usd: 0 }, + queryMock, + ); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(internal.turnState).toBe("idle"); + + await session.close(); +}); + +interface InterruptInternals extends SessionStateInternals { + pendingInterruptAbort: boolean; +} + +type InterruptibleQueryMock = QueryMock & { cancelAsyncMessage: ReturnType }; + +function createInterruptibleQueryMock(): InterruptibleQueryMock { + return Object.assign(createPendingQueryMock(), { + cancelAsyncMessage: vi.fn(async () => true), + }); +} + +/** The uuid Paseo stamped on the first message it pushed into the SDK input. */ +async function readFirstPromptUuid(): Promise { + const input = sdkQueryFactory.mock.calls[0]?.[0] as { prompt: AsyncIterable }; + return await createPromptUuidReader(input.prompt)(); +} + +function abortedResult() { + return { + type: "result", + subtype: "error_during_execution", + errors: [], + usage: buildUsage(), + total_cost_usd: 0, + }; +} + +test("an interrupt before Claude starts the message withdraws it instead of interrupting", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("hello"); + // Claude has not acknowledged the message yet: no init, no command_lifecycle "started". + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(queryMock.interrupt).not.toHaveBeenCalled(); + // No result follows an interrupt that never happened, so no flag may wait for one. + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_canceled")).toHaveLength(1); + + await session.close(); +}); + +test("an interrupt after Claude took the message but before it started stops it", async () => { + const queryMock = createInterruptibleQueryMock(); + // Claude has dequeued the message for its next turn, so withdrawing it is a no-op. + queryMock.cancelAsyncMessage.mockImplementation(async () => false); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("hello"); + // No command_lifecycle "started" or init has reached Paseo yet. + await session.interrupt(); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(await readFirstPromptUuid()); + expect(internal.pendingInterruptAbort).toBe(true); + + await session.close(); +}); + +test("a prompt sent while a stopped prompt is being withdrawn is not withdrawn too", async () => { + const queryMock = createInterruptibleQueryMock(); + let settleWithdrawal: (cancelled: boolean) => void = () => {}; + queryMock.cancelAsyncMessage.mockImplementationOnce( + () => new Promise((resolve) => (settleWithdrawal = resolve)), + ); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + + await session.startTurn("first"); + const firstUuid = await readFirstPromptUuid(); + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1)); + + await session.startTurn("second"); + settleWithdrawal(false); + + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledTimes(1); + expect(queryMock.cancelAsyncMessage).toHaveBeenCalledWith(firstUuid); + + await session.close(); +}); + +test("an interrupt while only background subagents run never reaches Claude", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + + await session.startTurn("delegate to a helper"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(helperTaskStarted, queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + // The main session is done; its helper keeps working. + await internal.routeSdkMessageFromPump(sessionState("running"), queryMock); + await internal.routeSdkMessageFromPump(helperTaskProgress, queryMock); + + // Without perTaskStopAffordance, an interrupt here kills every running helper. + await session.interrupt(); + + expect(queryMock.interrupt).not.toHaveBeenCalled(); + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(false); + + await session.close(); +}); + +test("an interrupt during a main turn interrupts Claude once and drops the aborted result", async () => { + const queryMock = createInterruptibleQueryMock(); + sdkQueryFactory.mockImplementation(() => queryMock); + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("long task"); + const promptUuid = await readFirstPromptUuid(); + await internal.routeSdkMessageFromPump(commandLifecycle(promptUuid ?? "", "started"), queryMock); + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + + await session.interrupt(); + await vi.waitFor(() => expect(queryMock.interrupt).toHaveBeenCalledTimes(1)); + // Claude already started the message, so there is nothing to withdraw. + expect(queryMock.cancelAsyncMessage).not.toHaveBeenCalled(); + expect(internal.pendingInterruptAbort).toBe(true); + + // A replacement starts before the aborted turn reports its result. + const { turnId } = await session.startTurn("replacement"); + await internal.routeSdkMessageFromPump(abortedResult(), queryMock); + expect(internal.activeForegroundTurnId).toBe(turnId); + expect(internal.pendingInterruptAbort).toBe(false); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + await internal.routeSdkMessageFromPump(successResult(), queryMock); + expect(internal.activeForegroundTurnId).toBeNull(); + expect(queryMock.interrupt).toHaveBeenCalledTimes(1); + + await session.close(); +}); + +test("a new turn clears an interrupt flag no result will consume", async () => { + const session = await createSession(); + const internal: InterruptInternals = asInternals(session); + const queryMock = createPendingQueryMock(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // An interrupt that landed just after its turn had ended: Claude emits no result for it. + internal.pendingInterruptAbort = true; + + await internal.routeSdkMessageFromPump(claudeTurnInit(), queryMock); + expect(internal.pendingInterruptAbort).toBe(false); + expect(internal.autonomousTurn?.id).toBe("autonomous-turn-1"); + + // This turn's own failure is reported, not swallowed as the interrupted turn's leftover. + await internal.routeSdkMessageFromPump( + { ...abortedResult(), errors: ["API Error: 500 upstream failure"] }, + queryMock, + ); + expect(events.filter((event) => event.type === "turn_failed")).toHaveLength(1); + expect(internal.autonomousTurn).toBeNull(); + + await session.close(); +}); + test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; diff --git a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts new file mode 100644 index 0000000000..1b1932e9cd --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; +import type { AgentSession } from "../../agent-sdk-types.js"; + +const SESSION_ID = "session-1"; + +/** Claude Code replies to a turn either with an assistant message or with nothing at all. */ +type TurnOutcome = { assistantMessageId: string; subagentMessageId?: string } | "no-response"; + +function initMessage(): Record { + return { + type: "system", + subtype: "init", + session_id: SESSION_ID, + permissionMode: "default", + model: "claude-sonnet-4-6", + }; +} + +/** Claude Code echoes the user message back under the uuid Paseo minted for it. */ +function userEcho(uuid: string): Record { + return { + type: "user", + uuid, + session_id: SESSION_ID, + message: { role: "user", content: [{ type: "text", text: "prompt" }] }, + }; +} + +function assistantReply(uuid: string): Record { + return { + type: "assistant", + uuid, + session_id: SESSION_ID, + message: { id: uuid, role: "assistant", content: [{ type: "text", text: "ok" }] }, + }; +} + +/** + * A message from a Task subagent. The CLI streams these on the same query as the main + * conversation, and their uuids live on the subagent's sidechain, not on the session's + * own message chain, so `forkSession` cannot resolve one. + */ +function subagentReply(uuid: string): Record { + return { + type: "assistant", + uuid, + session_id: SESSION_ID, + parent_tool_use_id: "toolu_subagent_1", + message: { id: uuid, role: "assistant", content: [{ type: "text", text: "subagent work" }] }, + }; +} + +function successResult(): Record { + return { + type: "result", + subtype: "success", + uuid: "result-ok", + session_id: SESSION_ID, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + num_turns: 1, + result: "", + total_cost_usd: 0, + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + permission_denials: [], + }; +} + +/** A turn that dies upstream before the model emits its first token. */ +function failedResult(): Record { + return { + type: "result", + subtype: "error_during_execution", + uuid: "result-failed", + session_id: SESSION_ID, + duration_ms: 1, + duration_api_ms: 1, + is_error: true, + num_turns: 1, + errors: ["upstream gateway returned 503"], + total_cost_usd: 0, + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 0 }, + permission_denials: [], + }; +} + +interface Conversation { + queryFactory: ReturnType; + /** The uuid Paseo minted for each prompt, in the order the turns ran. */ + userMessageIds: string[]; +} + +function createConversation(outcomes: TurnOutcome[]): Conversation { + const userMessageIds: string[] = []; + const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable }) => { + const queued: Array> = []; + const waiters: Array<() => void> = []; + const closedRef = { value: false }; + let turnIndex = 0; + + const wake = () => waiters.shift()?.(); + const enqueue = (message: Record) => { + queued.push(message); + wake(); + }; + + void (async () => { + for await (const sent of prompt) { + const userMessageId = String((sent as { uuid?: unknown }).uuid); + userMessageIds.push(userMessageId); + const outcome = outcomes[turnIndex]; + turnIndex += 1; + if (turnIndex === 1) { + enqueue(initMessage()); + } + enqueue(userEcho(userMessageId)); + if (outcome === "no-response" || !outcome) { + enqueue(failedResult()); + continue; + } + enqueue(assistantReply(outcome.assistantMessageId)); + if (outcome.subagentMessageId) { + enqueue(subagentReply(outcome.subagentMessageId)); + } + enqueue(successResult()); + } + closedRef.value = true; + wake(); + })(); + + return { + next: vi.fn(async () => { + while (queued.length === 0 && !closedRef.value) { + await new Promise((resolve) => { + waiters.push(resolve); + }); + } + if (queued.length === 0) { + return { done: true, value: undefined }; + } + return { done: false, value: queued.shift() }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => { + closedRef.value = true; + wake(); + return undefined; + }), + close: vi.fn(() => { + closedRef.value = true; + wake(); + }), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; + }); + return { queryFactory, userMessageIds }; +} + +async function createSession( + conversation: Conversation, + rewindSdk: FakeClaudeSdk, +): Promise { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: conversation.queryFactory as never, + resolveBinary: async () => "/test/claude/bin", + rewindSdk, + }); + return client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-sonnet-4-6", + }); +} + +async function runTurns(session: AgentSession, count: number): Promise { + for (let index = 0; index < count; index += 1) { + for await (const _ of streamSession(session, `turn ${index + 1}`)) { + // drain to the terminal event + } + } +} + +describe("Claude rewind across a turn that produced no response", () => { + test("forks at the most recent turn that did produce a response", async () => { + const conversation = createConversation([ + { assistantMessageId: "assistant-1" }, + "no-response", + { assistantMessageId: "assistant-3" }, + ]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 3); + await session.revertConversation?.({ messageId: conversation.userMessageIds[2] }); + } finally { + await session.close(); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + }); + + test("starts a fresh session when no earlier turn produced a response", async () => { + const conversation = createConversation(["no-response", { assistantMessageId: "assistant-2" }]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 2); + await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + } finally { + await session.close(); + } + + expect(rewindSdk.recordedForks).toEqual([]); + }); + + test("still forks at the previous turn when every turn produced a response", async () => { + const conversation = createConversation([ + { assistantMessageId: "assistant-1" }, + { assistantMessageId: "assistant-2" }, + ]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 2); + await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + } finally { + await session.close(); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + }); +}); + +describe("Claude rewind after a turn whose last assistant message came from a subagent", () => { + test("forks at the turn's own reply, not at the subagent message", async () => { + const conversation = createConversation([ + { assistantMessageId: "assistant-1", subagentMessageId: "subagent-1" }, + { assistantMessageId: "assistant-2" }, + ]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 2); + await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + } finally { + await session.close(); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts index 4ced29b489..48ddab5973 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -259,6 +259,89 @@ describe("ClaudeAgentSession persisted subagent replay", () => { rmSync(tempRoot, { recursive: true, force: true }); }); + test.each(["system", "user", "queue-operation"] as const)( + "restores nested background notification ownership from %s history", + async (form) => { + const nestedId = "toolu_nested"; + const bashId = "toolu_nested_bash"; + const timestamp = "2026-07-26T06:28:01.000Z"; + function notification(toolUseId: string) { + const content = `${toolUseId}-task${toolUseId}completedBackground command completed`; + const records = { + system: { + subtype: "task_notification", + task_id: `${toolUseId}-task`, + tool_use_id: toolUseId, + status: "completed", + summary: "Background command completed", + }, + user: { uuid: `${toolUseId}-notification`, message: { role: "user", content } }, + "queue-operation": { operation: "enqueue", content }, + }; + return JSON.stringify({ type: form, timestamp, ...records[form] }); + } + const subagentDir = writeParentSession([ + taskToolUse(), + taskToolResult(), + notification(bashId), + notification("toolu_root_bash"), + notification("toolu_orphan_bash"), + ]); + function toolEntry(agentId: string, id: string, name: string) { + return JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId, + message: { role: "assistant", content: [{ type: "tool_use", id, name, input: {} }] }, + }); + } + writeSubagent({ + subagentDir, + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [toolEntry(AGENT_ID, nestedId, "Agent")], + }); + writeSubagent({ + subagentDir, + agentId: "nested-agent", + meta: JSON.stringify({ toolUseId: nestedId }), + sidechainLines: [toolEntry("nested-agent", bashId, "Bash")], + }); + writeSubagent({ + subagentDir, + agentId: "orphan-agent", + meta: JSON.stringify({ toolUseId: "missing-parent-tool" }), + sidechainLines: [toolEntry("orphan-agent", "toolu_orphan_bash", "Bash")], + }); + + const replayed = await replayEvents(); + const rootNotifications = replayed.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "task_notification" + ? [event.item.metadata?.toolUseId] + : [], + ); + expect(rootNotifications).toEqual(["toolu_root_bash", "toolu_orphan_bash"]); + const childNotifications = replayed.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "tool_call" && + event.event.item.name === "task_notification" + ? [event.event] + : [], + ); + expect(childNotifications).toEqual([ + expect.objectContaining({ + id: nestedId, + timestamp, + item: expect.objectContaining({ + metadata: expect.objectContaining({ toolUseId: bashId }), + }), + }), + ]); + }, + ); + test("links a subagent to its Task call through the meta sidecar", async () => { writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -551,6 +634,72 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(events[0]).toMatchObject({ id: TOOL_USE_ID }); }); + test.each(["Agent", "Task"] as const)( + "labels a background %s call's card with its type and task after a restart", + async (toolName) => { + writeSession({ + parentLines: [ + parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: toolName, + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + }, + ]), + JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:48.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: [ + { + type: "text", + text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`, + }, + ], + }, + ], + }, + toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID }, + }), + ], + meta: JSON.stringify({ + agentType: "general-purpose", + description: "Count files here", + toolUseId: TOOL_USE_ID, + spawnDepth: 1, + requestShape: "background", + }), + }); + + const cards = (await replayEvents()).flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === TOOL_USE_ID + ? [event.item] + : [], + ); + expect(cards.at(-1)).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Count files here", + }, + }); + }, + ); + test("replays the subagent's own transcript onto its timeline", async () => { writeSession({ parentLines: [taskToolUse(), taskToolResult()], @@ -563,4 +712,56 @@ describe("ClaudeAgentSession persisted subagent replay", () => { expect(timeline.length).toBeGreaterThan(0); expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); }); + + test("replays a SubagentHandback report as the subagent's final message", async () => { + const handbackId = "toolu_handback"; + const report = "## Verdict\n\n- **Coherent**"; + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + sidechainLines: [ + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: handbackId, + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }), + JSON.stringify({ + type: "user", + isSidechain: true, + agentId: AGENT_ID, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: handbackId, + content: [ + { + type: "text", + text: '{"success":true,"message":"Report delivered to your caller."}', + }, + ], + }, + ], + }, + }), + ], + }); + + const items = (await replayDescriptors()).flatMap((event) => + event.event.type === "timeline" ? [event.event.item] : [], + ); + expect(items).toEqual([{ type: "assistant_message", text: report }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index efb452e9bf..84293afa6a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -587,4 +587,75 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { expect(latest.detail.log).toContain("[Read] file-6.md"); expect(latest.detail.log).toContain("[Read] file-205.md"); }); + + test("keeps a long main-thread tool call out of the subagents track", async () => { + // Claude Code sends a heartbeat every 30 s while a tool runs, with the tool's own id as + // parent_tool_use_id. Frames captured from Claude Code 2.1.291 for a 40 s MCP call. + queryFactory.mockImplementation(() => + buildQueryMock([ + { + type: "system", + subtype: "init", + session_id: "heartbeat-session", + permissionMode: "bypassPermissions", + model: "opus", + }, + { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg-heartbeat-1", + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_slow", name: "mcp__slow__slow_check", input: {} }, + ], + }, + }, + { + type: "tool_progress", + tool_use_id: "toolu_slow-heartbeat-0", + tool_name: "mcp__slow__slow_check", + parent_tool_use_id: "toolu_slow", + elapsed_time_seconds: 30, + heartbeat: true, + }, + { + type: "user", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_slow", + content: [{ type: "text", text: "SLOW_CHECK_OK" }], + is_error: false, + }, + ], + }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "run the slow check")); + await session.close(); + + expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]); + const toolCallNames = events.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" ? [event.item.name] : [], + ); + expect(toolCallNames).toContain("mcp__slow__slow_check"); + expect(toolCallNames).not.toContain("Task"); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts new file mode 100644 index 0000000000..e5e8d117a4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-handback.real.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; +import { ClaudeAgentClient } from "./agent.js"; + +const ROOT_PROMPT = `Use Claude Code's native Agent tool exactly once, never Paseo tools, with this complete task: + +Do not use any tools. Reply with exactly this markdown report and nothing else: +## HANDBACK_HEADING +- **HANDBACK_BOLD** item + +Then reply exactly ROOT_DONE.`; + +function subagentItems(events: AgentStreamEvent[]): AgentTimelineItem[] { + return events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event.item] : [], + ); +} + +function expectHandbackAsReport(items: AgentTimelineItem[]): void { + expect(items.filter((item) => item.type === "tool_call")).toEqual([]); + expect( + items.flatMap((item) => (item.type === "assistant_message" ? [item.text] : [])).join(""), + ).toContain("## HANDBACK_HEADING\n- **HANDBACK_BOLD** item"); +} + +test("an auto-mode Claude subagent's handback report shows as its final message", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-subagent-handback-")); + const client = new ClaudeAgentClient({ logger: pino({ level: "warn" }) }); + const session = await client.createSession({ provider: "claude", cwd, modeId: "auto" }); + try { + const events = await collectSessionTurnEvents(session, ROOT_PROMPT); + expectHandbackAsReport(subagentItems(events)); + + const persistence = session.describePersistence(); + expect(persistence).not.toBeNull(); + await session.close(); + const restored = await client.resumeSession(persistence!, { cwd }); + try { + const replayed: AgentStreamEvent[] = []; + for await (const event of restored.streamHistory()) replayed.push(event); + expectHandbackAsReport(subagentItems(replayed)); + } finally { + await restored.close(); + } + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 240_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts index d4fc0bcc0c..1223bbc541 100644 --- a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts @@ -78,7 +78,7 @@ describe("subagent lifecycle across an interrupt", () => { queryFactory.mockReset(); }); - async function startInterruptibleTurn() { + async function startInterruptibleTurn(taskStarted: Record = {}) { const channel = buildOpenQueryMock(); queryFactory.mockImplementation(() => channel.query); const session = await new ClaudeAgentClient({ @@ -107,6 +107,7 @@ describe("subagent lifecycle across an interrupt", () => { task_type: "local_agent", subagent_type: "general-purpose", description: "Reply with banana", + ...taskStarted, }); await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0)); @@ -169,6 +170,31 @@ describe("subagent lifecycle across an interrupt", () => { await session.close(); }); + test("a child spawned in the background is not canceled with the turn that spawned it", async () => { + // Claude announces a background spawn on task_started itself and sends no task_updated patch. + const { channel, session, subagentEvents } = await startInterruptibleTurn({ + is_backgrounded: true, + }); + + await session.interrupt(); + channel.push({ + type: "system", + subtype: "task_notification", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + status: "completed", + }); + + await vi.waitFor(() => + expect(subagentEvents).toContainEqual( + expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }), + ), + ); + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]); + + await session.close(); + }); + test("a foreground child is canceled with the turn that spawned it", async () => { const { session, subagentEvents } = await startInterruptibleTurn(); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 5b5a9deda6..5e26e6a55a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -17,13 +17,26 @@ import { } from "./agent.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; -import type { AgentSession, AgentTimelineItem, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { + AgentPromptInput, + AgentSession, + AgentTimelineItem, + AgentStreamEvent, +} from "../../agent-sdk-types.js"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; interface TestClaudeSession { translateMessageToEvents(message: SDKMessage): AgentStreamEvent[]; close(): Promise; } +function isLoadingCompactionEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading" + ); +} + function isPermissionResolvedEvent( event: AgentStreamEvent, ): event is Extract { @@ -413,9 +426,17 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveBinary: async () => "/test/claude/bin", resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -425,6 +446,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -459,10 +481,18 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveVersion: async () => { throw new Error("unrecognized version output"); }, - configDir: emptyConfigDir, + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -470,7 +500,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { force: false, }); - expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); expect(models.map((model) => model.id)).toContain("claude-fable-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); @@ -482,9 +512,17 @@ describe("ClaudeAgentClient.fetchCatalog", () => { try { const client = new ClaudeAgentClient({ logger, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], resolveBinary: async () => "/test/claude/bin", - resolveVersion: async () => "2.1.219", - configDir: emptyConfigDir, + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } }, }); const { models } = await client.fetchCatalog({ scope: "workspace", @@ -496,12 +534,31 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }; expect(getThinkingIds("claude-opus-5")).toContain("ultracode"); + expect(getThinkingIds("claude-mythos-5-1")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode"); expect(getThinkingIds("claude-fable-5")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode"); expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh"); expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode"); + expect(getThinkingIds("claude-haiku-5-5")).toEqual([ + "off", + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); + expect(getThinkingIds("claude-sonnet-5-5")).toContain("xhigh"); + expect(getThinkingIds("claude-sonnet-5-5")).not.toContain("off"); expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode"); expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode"); @@ -772,6 +829,29 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("passes extra Claude Code CLI arguments to the SDK", async () => { + const { queryFactory, launches } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + providerOptions: { + extraArgs: { chrome: null, model: "x" }, + }, + }); + + await expect(session.startTurn("hello")).resolves.toEqual({ + turnId: expect.stringMatching(/^foreground-turn-/), + }); + + expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" }); + await session.close(); + }); + test("lists fast mode only for supported Opus models", async () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); @@ -860,6 +940,97 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + async function captureSdkUserMessage(prompt: AgentPromptInput): Promise { + const { queryFactory, queryMock } = createQueryMock(); + let resolveSent: ((message: SDKUserMessage) => void) | null = null; + const sent = new Promise((resolve) => { + resolveSent = resolve; + }); + queryFactory.mockImplementation((input: { prompt: AsyncIterable }) => { + void (async () => { + for await (const message of input.prompt) { + resolveSent?.(message); + break; + } + })(); + return queryMock; + }); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + try { + await session.startTurn(prompt); + return await sent; + } finally { + await session.close(); + } + } + + const issueAttachment: AgentAttachment = { + type: "forge_issue", + mimeType: "application/paseo-forge-issue", + forge: "github", + number: 12, + title: "Fake issue for QA", + url: "https://example.invalid/acme/app/issues/12", + body: "This is an attached issue body.", + }; + + // Claude Code expands a slash command only when it is the last content block of the user + // message, so an auto-attached issue or a pasted screenshot must not be appended after it. + test("sends a typed slash command last when an attachment follows it", async () => { + const message = await captureSdkUserMessage( + buildAgentPrompt("/hello please", undefined, [issueAttachment]), + ); + + expect(message.message.content).toEqual([ + { type: "text", text: renderPromptAttachmentAsText(issueAttachment) }, + { type: "text", text: "/hello please" }, + ]); + }); + + test("sends a typed slash command last when an image follows it", async () => { + const message = await captureSdkUserMessage( + buildAgentPrompt("/hello please", [{ data: "aGk=", mimeType: "image/png" }], undefined), + ); + + expect(message.message.content).toEqual([ + { type: "image", source: { type: "base64", media_type: "image/png", data: "aGk=" } }, + { type: "text", text: "/hello please" }, + ]); + }); + + test("keeps typed text before attachments when it is not a slash command", async () => { + const message = await captureSdkUserMessage( + buildAgentPrompt("please look at this issue", undefined, [issueAttachment]), + ); + + expect(message.message.content).toEqual([ + { type: "text", text: "please look at this issue" }, + { type: "text", text: renderPromptAttachmentAsText(issueAttachment) }, + ]); + }); + + test("moves the typed slash command, not an attachment that reads like one", async () => { + const chatHistory: AgentAttachment = { + type: "text", + mimeType: "text/plain", + contextKind: "chat_history", + text: "/earlier command quoted from another chat", + }; + const message = await captureSdkUserMessage( + buildAgentPrompt("/hello please", undefined, [chatHistory, issueAttachment]), + ); + + expect(message.message.content).toEqual([ + { type: "text", text: chatHistory.text }, + { type: "text", text: renderPromptAttachmentAsText(issueAttachment) }, + { type: "text", text: "/hello please" }, + ]); + }); + test("maps Ultracode to xhigh effort and Claude ultracode settings", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ @@ -880,13 +1051,42 @@ describe("ClaudeAgentSession features", () => { expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ effort: "xhigh", - thinking: { type: "adaptive" }, + thinking: { type: "adaptive", display: "summarized" }, settings: { ultracode: true }, }); await session.close(); }); + test("disables Claude hooks for internal agents only", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const internalSession = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + internal: true, + }); + const userSession = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + await internalSession.startTurn("hello"); + await userSession.startTurn("hello"); + + expect(queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({ + disableAllHooks: true, + }); + expect(queryFactory.mock.calls[1]?.[0].options.settings).toBeUndefined(); + + await internalSession.close(); + await userSession.close(); + }); + test("turns Claude thinking off without retaining an effort level", async () => { const { queryFactory, launches } = createQueryMock(); const client = new ClaudeAgentClient({ @@ -1065,7 +1265,9 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], - ["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"], + ["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"], + ["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined], + ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], ["provider default", null, undefined, undefined], ])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => { @@ -1111,6 +1313,26 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("rejects disabled thinking on Sonnet 5.5, which only runs with adaptive thinking", async () => { + const { queryFactory } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-sonnet-5-5", + }); + + await expect(session.setThinkingOption?.("off")).rejects.toThrow( + "Thinking option 'off' is not available for model 'claude-sonnet-5-5'", + ); + + await session.close(); + }); + test("rejects an initial disabled-thinking config for an unsupported model", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ @@ -1913,13 +2135,15 @@ describe("ClaudeAgentSession context window usage", () => { } function createMessageDeltaEvent(outputTokens: number): Record { + return createMessageDeltaUsageEvent({ output_tokens: outputTokens }); + } + + function createMessageDeltaUsageEvent(usage: Record): Record { return { type: "stream_event", event: { type: "message_delta", - usage: { - output_tokens: outputTokens, - }, + usage, }, session_id: "session-1", }; @@ -1981,6 +2205,15 @@ describe("ClaudeAgentSession context window usage", () => { }; } + function createCompactingStatus(): Record { + return { + type: "system", + subtype: "status", + status: "compacting", + session_id: "session-1", + }; + } + test("emits turn_started before the submitted user message", async () => { const session = await createSessionForTurns([[]]); const events: AgentStreamEvent[] = []; @@ -2656,6 +2889,119 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("message_delta input usage replaces an empty message_start count", async () => { + // Translating gateways (LiteLLM in front of an OpenAI-compatible backend) do not know the + // prompt size when message_start is emitted, so they report input_tokens: 0 there and send + // the cumulative counts on message_delta, as the Anthropic Messages API allows. + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageStartEvent({ + input_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }), + createSuccessResult({ + usage: { + input_tokens: 20_236, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + output_tokens: 64, + iterations: [], + }, + }), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 20_300, + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ + inputTokens: 20_236, + outputTokens: 64, + contextWindowUsedTokens: 20_300, + }), + }), + ); + } finally { + await session.close(); + } + }); + + test("message_delta without cache counters keeps the message_start cache usage", async () => { + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageStartEvent({ + input_tokens: 5, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 1_000, + }), + createMessageDeltaUsageEvent({ input_tokens: 5, output_tokens: 64 }), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + provider: "claude", + usage: { + contextWindowUsedTokens: 1_069, + }, + }), + ); + } finally { + await session.close(); + } + }); + + test("message_delta before this request's message_start does not seed usage", async () => { + // A canceled request can leave its terminal message_delta queued behind the next turn. + // Nothing is known about the new request until its message_start, so that straggler + // must not produce a usage_updated event or leak into the next request's counters. + const session = await createSessionForTurns([ + [ + createInitMessage(), + createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }), + createMessageStartEvent({ + input_tokens: 40, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 10, + }), + createMessageDeltaEvent(7), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session); + + const usedTokens = events + .filter((event) => event.type === "usage_updated") + .map((event) => event.usage.contextWindowUsedTokens); + expect(usedTokens).toEqual([55, 62]); + } finally { + await session.close(); + } + }); + test("per-request stream usage is not cumulative across API calls in a turn", async () => { const session = await createSessionForTurns([ [ @@ -2857,6 +3203,153 @@ describe("ClaudeAgentSession context window usage", () => { } }); + test("repeated compacting statuses open a single compaction marker", async () => { + const session = await createSessionForTurns([ + [ + createCompactingStatus(), + createCompactingStatus(), + createCompactingStatus(), + createCompactBoundary(), + createCompactingStatus(), + createCompactingStatus(), + createCompactBoundary(), + createSuccessResult(), + ], + ]); + + try { + const events = await collectStreamEvents(session, "compact twice"); + const compactions = events.flatMap((event) => + event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [], + ); + expect(compactions).toEqual(["loading", "completed", "loading", "completed"]); + } finally { + await session.close(); + } + }); + + test("reports the plan mode Claude enters on its own with EnterPlanMode", async () => { + // Claude Code announces a mode it switched to itself as a status message + // carrying the new permissionMode, right after the EnterPlanMode tool call. + const client = new ClaudeAgentClient({ + logger, + queryFactory: createQueryFactoryForTurns([ + [ + { ...createInitMessage(), permissionMode: "acceptEdits" }, + { + type: "system", + subtype: "status", + status: null, + permissionMode: "plan", + session_id: "session-1", + }, + createSuccessResult(), + ], + ]), + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + modeId: "acceptEdits", + }); + + try { + const events = await collectStreamEvents(session, "enter plan mode"); + + expect(events).toContainEqual( + expect.objectContaining({ type: "mode_changed", currentModeId: "plan" }), + ); + expect(await session.getCurrentMode()).toBe("plan"); + } finally { + await session.close(); + } + }); + + test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => { + // The first turn starts compacting and then ends without ever reaching a + // compact_boundary, so the marker it opened is never resolved. + const session = await createSessionForTurns([ + [createCompactingStatus(), createSuccessResult()], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const abandonedTurn = await collectStreamEvents(session, "abandoned compaction"); + expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("an interrupted compaction does not suppress the next compaction marker", async () => { + // The first turn starts compacting and is then interrupted, so it never reaches a + // compact_boundary and the marker it opened is never resolved. + const session = await createSessionForTurns([ + [createCompactingStatus()], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const interruptedTurn: AgentStreamEvent[] = []; + const streaming = (async () => { + for await (const event of streamSession(session, "interrupted compaction")) { + interruptedTurn.push(event); + } + })(); + + await vi.waitFor(() => { + expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + }); + await session.interrupt(); + await streaming; + + expect(interruptedTurn).toContainEqual( + expect.objectContaining({ type: "turn_canceled", provider: "claude" }), + ); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => { + // Claude starts a turn of its own after the foreground result, which starts compacting and is + // then ended by the next foreground turn, never reaching a boundary. + const session = await createSessionForTurns([ + [ + createSuccessResult(), + createInitMessage(), + createMessageStartEvent(), + createCompactingStatus(), + ], + [createCompactingStatus(), createSuccessResult()], + ]); + + try { + const observed: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => { + observed.push(event); + }); + + await collectStreamEvents(session, "foreground turn"); + await vi.waitFor(() => { + expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1); + }); + unsubscribe(); + + const nextTurn = await collectStreamEvents(session, "next compaction"); + expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1); + } finally { + await session.close(); + } + }); + test("result.result is surfaced as an assistant message when no model output was produced", async () => { const session = await createSessionForTest(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b041c3b233..51885f74e8 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,8 +1,8 @@ +import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { promises } from "node:fs"; -import os from "node:os"; import path from "node:path"; import { type AgentDefinition, @@ -44,6 +44,7 @@ import { } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { readClaudeSubagentHandback } from "./subagent-handback.js"; import { ClaudeTaskState } from "./task-state.js"; import { ClaudeTaskProtocolSource, @@ -75,10 +76,16 @@ import { type ClaudeProviderOptions, } from "./options.js"; import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { discoverClaudeModels } from "./model-discovery.js"; import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js"; -import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js"; +import { + realClaudeRewindSdk, + revertClaudeConversation, + revertClaudeFiles, + type ClaudeRewindSdk, +} from "./rewind.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; -import { claudeProjectDirSync } from "./project-dir.js"; +import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js"; import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js"; import { isProviderImageMarkdown, @@ -125,6 +132,7 @@ import { type ProviderCatalog, type ProviderRefreshContext, type ResolveAgentDefaultModeInput, + type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -287,6 +295,18 @@ interface AsyncMessageInput { iterable: AsyncIterable; } +interface ClaudeReplayOwnership { + restoredIds: ReadonlySet; + toolOwners: ReadonlyMap; + /** The parent's Task calls, keyed by tool-call id, so their cards replay labeled as they were live. */ + subagentToolCalls: ReadonlyMap; +} + +interface ClaudeSubagentCardFacts { + title?: string; + description?: string; +} + interface PersistedTimelineEntry { item: AgentTimelineItem; timestamp?: string; @@ -393,13 +413,14 @@ export interface ClaudeContentChunk { } interface ClaudeAgentClientOptions { + discoverModels?: typeof discoverClaudeModels; defaults?: { agents?: Record }; logger: Logger; runtimeSettings?: ProviderRuntimeSettings; queryFactory?: ClaudeQueryFactory; resolveBinary?: () => Promise; resolveVersion?: (signal?: AbortSignal) => Promise; - configDir?: string; + rewindSdk?: ClaudeRewindSdk; } interface ClaudeAgentSessionOptions { @@ -412,6 +433,7 @@ interface ClaudeAgentSessionOptions { logger: Logger; queryFactory?: ClaudeQueryFactory; resolveBinary: () => Promise; + rewindSdk?: ClaudeRewindSdk; } type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max"; @@ -432,6 +454,15 @@ function errorToMessageString(error: unknown): string { return ""; } +/** + * Claude opens every main-session turn with system/init and closes it with exactly one result: + * a prompt, a wake-up after background work, a slash command, an API error. Background subagents + * never emit either on the main stream. + */ +function isClaudeTurnInit(message: SDKMessage): boolean { + return message.type === "system" && message.subtype === "init"; +} + function firstStringField( input: Record, primaryKey: string, @@ -1485,6 +1516,7 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { } export class ClaudeAgentClient implements AgentClient { + private readonly discoverModels: typeof discoverClaudeModels; readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; @@ -1494,9 +1526,10 @@ export class ClaudeAgentClient implements AgentClient { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private readonly resolveVersion: (signal?: AbortSignal) => Promise; - private readonly configDir?: string; + private readonly rewindSdk: ClaudeRewindSdk; constructor(options: ClaudeAgentClientOptions) { + this.discoverModels = options.discoverModels ?? discoverClaudeModels; this.defaults = options.defaults; this.logger = options.logger.child({ module: "agent", provider: "claude" }); this.runtimeSettings = options.runtimeSettings; @@ -1505,7 +1538,7 @@ export class ClaudeAgentClient implements AgentClient { this.resolveVersion = options.resolveVersion ?? ((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal)); - this.configDir = options.configDir; + this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; } resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition { @@ -1527,6 +1560,7 @@ export class ClaudeAgentClient implements AgentClient { logger: this.logger, queryFactory: this.queryFactory, resolveBinary: this.resolveBinary, + rewindSdk: this.rewindSdk, }); } @@ -1555,9 +1589,15 @@ export class ClaudeAgentClient implements AgentClient { logger: this.logger, queryFactory: this.queryFactory, resolveBinary: this.resolveBinary, + rewindSdk: this.rewindSdk, }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, @@ -1571,12 +1611,38 @@ export class ClaudeAgentClient implements AgentClient { } catch (error) { this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog"); } + const env = this.buildProviderEnv(); + let discoveredModelIds = new Set(); + try { + const advertisedModels = await runProviderRefreshActivity(context, "models", () => + this.discoverModels({ + resolveBinary: this.resolveBinary, + runtimeSettings: this.runtimeSettings, + env, + signal: context?.signal, + }), + ); + discoveredModelIds = new Set( + advertisedModels + .map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value)) + .filter((id): id is string => id !== null), + ); + } catch (error) { + context?.signal.throwIfAborted(); + this.logger.warn( + { err: error }, + "Claude model discovery failed; omitting discovery-required models", + ); + } const models = await runProviderRefreshActivity(context, "settings", () => - getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion), - ); - const modeCatalog = claudeModeCatalog( - createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }), + getClaudeModelsWithSettings( + this.logger, + claudeConfigDir(env), + claudeCodeVersion, + discoveredModelIds, + ), ); + const modeCatalog = claudeModeCatalog(env); return { models, ...modeCatalog, @@ -1584,12 +1650,15 @@ export class ClaudeAgentClient implements AgentClient { } async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise { - const env = createProviderEnv({ + return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId; + } + + private buildProviderEnv(launchEnv?: Record): NodeJS.ProcessEnv { + return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, overlays: [launchEnv], }); - return claudeModeCatalog(env).defaultModeId; } async listFeatures(config: AgentSessionConfig): Promise { @@ -1603,7 +1672,7 @@ export class ClaudeAgentClient implements AgentClient { async listImportableSessions( options?: ListImportableSessionsOptions, ): Promise { - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); + const configDir = claudeConfigDir(this.buildProviderEnv()); const sessionsRoot = options?.cwd ? claudeProjectDirSync(options.cwd, { configDir }) : path.join(configDir, "projects"); @@ -1673,7 +1742,8 @@ export class ClaudeAgentClient implements AgentClient { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); } const model = config.model?.trim(); - const providerOptions = ClaudeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("claude", ClaudeProviderOptionsSchema, config.providerOptions) ?? {}; return { ...config, provider: "claude", @@ -1785,30 +1855,59 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined { return maxContextWindow; } -function readStreamRequestInputTokens(event: Record): number | undefined { - const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage); - if (!messageUsage) { +interface StreamRequestInputUsage { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; +} + +function readUsageCount(usage: Record, key: string): number | undefined { + const value = usage[key]; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return undefined; } - const usage = messageUsage; - const inputTokens = - typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens) - ? usage.input_tokens - : undefined; - const cacheCreationInputTokens = - typeof usage.cache_creation_input_tokens === "number" && - Number.isFinite(usage.cache_creation_input_tokens) - ? usage.cache_creation_input_tokens - : 0; - const cacheReadInputTokens = - typeof usage.cache_read_input_tokens === "number" && - Number.isFinite(usage.cache_read_input_tokens) - ? usage.cache_read_input_tokens - : 0; - if (typeof inputTokens !== "number" || inputTokens < 0) { + return value; +} + +function readStreamRequestInputUsage( + event: Record, +): StreamRequestInputUsage | undefined { + const usage = toObjectRecord(toObjectRecord(event.message)?.usage); + if (!usage) { return undefined; } - return inputTokens + cacheCreationInputTokens + cacheReadInputTokens; + const inputTokens = readUsageCount(usage, "input_tokens"); + if (inputTokens === undefined) { + return undefined; + } + return { + inputTokens, + cacheCreationInputTokens: readUsageCount(usage, "cache_creation_input_tokens") ?? 0, + cacheReadInputTokens: readUsageCount(usage, "cache_read_input_tokens") ?? 0, + }; +} + +/** + * The input counters on `message_delta.usage` are cumulative whole-request totals and are + * optional. Mirror the Anthropic SDK's MessageStream: a counter present on the delta replaces + * the `message_start` value, a missing counter keeps it. Translating gateways report + * `input_tokens: 0` on `message_start` and only know the real count by `message_delta`. + */ +function mergeStreamDeltaInputUsage( + current: StreamRequestInputUsage, + event: Record, +): StreamRequestInputUsage { + const usage = toObjectRecord(event.usage); + if (!usage) { + return current; + } + return { + inputTokens: readUsageCount(usage, "input_tokens") ?? current.inputTokens, + cacheCreationInputTokens: + readUsageCount(usage, "cache_creation_input_tokens") ?? current.cacheCreationInputTokens, + cacheReadInputTokens: + readUsageCount(usage, "cache_read_input_tokens") ?? current.cacheReadInputTokens, + }; } function readStreamRequestOutputTokens(event: Record): number | undefined { @@ -1877,13 +1976,18 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null { if (!("parent_tool_use_id" in message)) { return null; } + // Claude Code's liveness tick for a tool still running after 30 s names that tool as its + // parent, so a long main-thread call would otherwise read as a subagent. + if (message.type === "tool_progress" && message.heartbeat === true) { + return null; + } const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id; return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null; } class ClaudeContextUsageState { private contextWindowMaxTokens: number | undefined; - private streamRequestInputTokens: number | undefined; + private streamRequestInputUsage: StreamRequestInputUsage | undefined; private streamRequestOutputTokens: number | undefined; private compactedContextWindowUsedTokens: number | undefined; private completedResultTurns = 0; @@ -1893,7 +1997,7 @@ class ClaudeContextUsageState { } beginTurn(): void { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = undefined; } @@ -1917,11 +2021,11 @@ class ClaudeContextUsageState { } const eventType = readTrimmedString(streamEvent.type); if (eventType === "message_start") { - const inputTokens = readStreamRequestInputTokens(streamEvent); - if (typeof inputTokens !== "number") { + const inputUsage = readStreamRequestInputUsage(streamEvent); + if (!inputUsage) { return null; } - this.streamRequestInputTokens = inputTokens; + this.streamRequestInputUsage = inputUsage; this.streamRequestOutputTokens = 0; } else if (eventType === "message_delta") { const outputTokens = readStreamRequestOutputTokens(streamEvent); @@ -1929,6 +2033,12 @@ class ClaudeContextUsageState { return null; } this.streamRequestOutputTokens = outputTokens; + if (this.streamRequestInputUsage) { + this.streamRequestInputUsage = mergeStreamDeltaInputUsage( + this.streamRequestInputUsage, + streamEvent, + ); + } } else { return null; } @@ -1975,13 +2085,15 @@ class ClaudeContextUsageState { } private streamUsedTokens(): number | undefined { - if ( - typeof this.streamRequestInputTokens !== "number" || - typeof this.streamRequestOutputTokens !== "number" - ) { + const inputUsage = this.streamRequestInputUsage; + if (!inputUsage || typeof this.streamRequestOutputTokens !== "number") { return undefined; } - const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens; + const usedTokens = + inputUsage.inputTokens + + inputUsage.cacheCreationInputTokens + + inputUsage.cacheReadInputTokens + + this.streamRequestOutputTokens; return usedTokens > 0 ? usedTokens : undefined; } @@ -2000,7 +2112,7 @@ class ClaudeContextUsageState { } buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent { - this.streamRequestInputTokens = undefined; + this.streamRequestInputUsage = undefined; this.streamRequestOutputTokens = undefined; this.compactedContextWindowUsedTokens = postTokens; const usage: AgentUsage = {}; @@ -2032,17 +2144,20 @@ class ClaudeAgentSession implements AgentSession { private readonly queryFactory?: ClaudeQueryFactory; private readonly resolveBinary: () => Promise; private query: Query | null = null; + private readonly harnessEnvironment: Record; + private readonly usageSessionKey = randomUUID(); private childProcess: ChildProcess | null = null; private input: AsyncMessageInput | null = null; /** The exact SDK query/input pair that owns the current foreground turn. */ private activeForegroundQuery: Query | null = null; private activeForegroundInput: AsyncMessageInput | null = null; /** - * Steers pushed into the live SDK input that Claude may not have read yet. Interrupting the turn - * has to discard them, or the SDK dequeues one and resumes the turn we just stopped. SDK user - * UUIDs are provider-private; never let them escape the adapter boundary. + * Messages pushed into the live SDK input (steers, and a foreground turn's own prompt) whose + * command_lifecycle has not reached "started". Interrupting has to withdraw them, or the SDK + * dequeues one and resumes the turn we just stopped. SDK user UUIDs are provider-private; never + * let them escape the adapter boundary. */ - private readonly queuedSteerUuids = new Set(); + private readonly unstartedMessageUuids = new Set(); /** Human steers whose text has not reached Claude yet and therefore supersede blocking cards. */ private readonly permissionClearingSteerUuids = new Set(); private claudeSessionId: string | null; @@ -2053,6 +2168,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map(); private toolUseIndexToId = new Map(); private toolUseInputBuffers = new Map(); + private subagentHandbackIds = new Set(); private pendingPermissions = new Map(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; @@ -2069,7 +2185,11 @@ class ClaudeAgentSession implements AgentSession { // identity and status from frames for them. Detecting the capability beats comparing version // strings: it reacts to what this session actually does. isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, + // The parent's tool call owns its card once its result arrives. A background child streams + // its frames after Claude has already answered the call, and re-emitting the card from them + // would replace the settled card with an unlabeled, running one. needsSyntheticParentToolCard: (toolUseId) => + this.toolUseCache.has(toolUseId) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), }); private persistedHistory: PersistedTimelineEntry[] = []; @@ -2085,15 +2205,23 @@ class ClaudeAgentSession implements AgentSession { private lastOptionsModel: string | null = null; private lastRuntimeModel: string | null = null; private compacting = false; + private compactionMarkerOpen = false; private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; + /** + * Whether Claude's main session is inside one of its own turns: set by system/init and by a + * pushed message's command_lifecycle "started" (/compact works for seconds before its init), + * cleared by the turn's result or an idle session state. See observeMainTurnBracket. + */ + private mainTurnInFlight = false; private foregroundHasVisibleActivity = false; private activeTurnHasAssistantText = false; private readonly contextUsage: ClaudeContextUsageState; private userMessageIds: string[] = []; private readonly emittedUserMessageIds = new Set(); private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = []; + private readonly rewindSdk: ClaudeRewindSdk; private pendingFreshSessionId: string | null = null; private recentStderr = ""; private closed = false; @@ -2105,10 +2233,12 @@ class ClaudeAgentSession implements AgentSession { this.agentId = options.agentId; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; + this.harnessEnvironment = this.buildSdkEnv(); this.persistSession = options.persistSession; this.logger = options.logger.child({ agentId: this.agentId }); this.queryFactory = options.queryFactory; this.resolveBinary = options.resolveBinary; + this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; this.contextUsage = new ClaudeContextUsageState( findClaudeModel(this.config.model)?.contextWindowMaxTokens, ); @@ -2231,6 +2361,7 @@ class ClaudeAgentSession implements AgentSession { this.contextUsage.beginTurn(); this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); + if (sdkUserMessageId) this.unstartedMessageUuids.add(sdkUserMessageId); let cancelIssued = false; const requestCancel = () => { @@ -2241,13 +2372,18 @@ class ClaudeAgentSession implements AgentSession { if (this.cancelCurrentTurn === requestCancel) { this.cancelCurrentTurn = null; } + // Whether Claude has this message in hand. A late idle from the previous turn can clear + // mainTurnInFlight after Claude started it, so the message's own lifecycle counts too. + const claudeStartedTurn = + this.mainTurnInFlight || + (sdkUserMessageId !== null && !this.unstartedMessageUuids.has(sdkUserMessageId)); this.rejectAllPendingPermissions(new Error("Permission request canceled")); this.finishForegroundTurn({ type: "turn_canceled", provider: "claude", reason: "Interrupted", }); - void this.interruptActiveTurn().catch((error) => { + void this.interruptActiveTurn(claudeStartedTurn).catch((error) => { this.logger.warn({ err: error }, "Failed to interrupt during cancel"); }); }; @@ -2257,6 +2393,11 @@ class ClaudeAgentSession implements AgentSession { try { await this.ensureQuery(); + if (cancelIssued) { + // Stopped while Claude was still starting up: withdrawn by never sending it. + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); + return { turnId }; + } if (!this.input) { throw new Error("Claude session input stream not initialized"); } @@ -2270,6 +2411,7 @@ class ClaudeAgentSession implements AgentSession { } }, 0); } catch (error) { + if (sdkUserMessageId) this.unstartedMessageUuids.delete(sdkUserMessageId); this.finishForegroundTurn( this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"), ); @@ -2318,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession { clearPendingPermissions: boolean, ): void { const uuid = message.uuid; - if (uuid) this.queuedSteerUuids.add(uuid); + if (uuid) this.unstartedMessageUuids.add(uuid); if (uuid && clearPendingPermissions) { this.permissionClearingSteerUuids.add(uuid); } @@ -2329,7 +2471,7 @@ class ClaudeAgentSession implements AgentSession { } } catch (error) { if (uuid) { - this.queuedSteerUuids.delete(uuid); + this.unstartedMessageUuids.delete(uuid); this.permissionClearingSteerUuids.delete(uuid); } throw error; @@ -2398,10 +2540,17 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; - assertClaudeModeCanRun(normalized, this.buildSdkEnv()); + assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const activeQuery = await this.ensureQuery(); - await activeQuery.setPermissionMode(normalized); + const launchesQuery = !this.query || this.queryRestartNeeded; + const activeQuery = await this.ensureQuery(normalized); + try { + await activeQuery.setPermissionMode(normalized); + } catch (error) { + // The query was launched in the rejected mode; relaunch in the current mode next time. + if (launchesQuery) this.queryRestartNeeded = true; + throw error; + } if (normalized === "plan") { if (previousMode !== "plan") { this.planResumeMode = previousMode; @@ -2504,9 +2653,8 @@ class ClaudeAgentSession implements AgentSession { } /** - * A denied request is the only record the transcript gets. Plans especially: - * the pending card is the only place the plan text lives, so losing it means - * the user can no longer read what they just declined. + * Settle the original call immediately, before waiting for the SDK tool result. + * Plan resolution must use the native call ID so it cannot move past a follow-up. */ private recordDeniedPermissionTimeline( request: AgentPermissionRequest, @@ -2527,28 +2675,24 @@ class ClaudeAgentSession implements AgentSession { return; } if (request.kind === "plan") { - let planText: string | null = null; - if (typeof request.metadata?.planText === "string") { - planText = request.metadata.planText; - } else if (typeof request.input?.plan === "string") { - planText = request.input.plan; - } - if (!planText) return; - this.pushToolCall({ - type: "tool_call", - name: "plan_approval", - callId: request.id, - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { - approved: false, - actionId: response.selectedActionId ?? "reject", - }, - }); + this.pushToolCall( + mapClaudeFailedToolCall({ + name: "ExitPlanMode", + callId: this.planToolCallId(request), + input: request.input, + error: response.message ?? "Permission denied", + metadata: { actionId: response.selectedActionId ?? "reject" }, + }), + ); } } + private planToolCallId(request: AgentPermissionRequest): string { + return typeof request.metadata?.toolUseId === "string" + ? request.metadata.toolUseId + : request.id; + } + private resolveDeniedPermission( request: AgentPermissionRequest, response: Extract, @@ -2599,8 +2743,8 @@ class ClaudeAgentSession implements AgentSession { await this.setMode(targetMode); this.pushToolCall( mapClaudeCompletedToolCall({ - name: "plan_approval", - callId: pending.request.id, + name: "ExitPlanMode", + callId: this.planToolCallId(pending.request), input: pending.request.input ?? null, output: { approved: true, @@ -2642,11 +2786,12 @@ class ClaudeAgentSession implements AgentSession { if (!this.claudeSessionId) { return null; } + const { providerOptions: _providerOptions, ...persistedConfig } = this.config; this.persistence = { provider: "claude", sessionId: this.claudeSessionId, nativeHandle: this.claudeSessionId, - metadata: { ...this.config }, + metadata: { ...persistedConfig }, }; return this.persistence; } @@ -2754,7 +2899,7 @@ class ClaudeAgentSession implements AgentSession { return; } await revertClaudeConversation({ - sdk: realClaudeRewindSdk, + sdk: this.rewindSdk, sessionId: this.claudeSessionId, messageId: target.messageId, resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId), @@ -3035,6 +3180,13 @@ class ClaudeAgentSession implements AgentSession { if (!messageId) { return; } + // Subagent frames ride the same stream as the conversation, but their uuids live on the + // subagent's sidechain, so forkSession cannot resolve one. Anchoring a turn on one leaves + // rewind throwing "Message not found in session". The persisted history path already + // skips sidechain entries; the live stream has to skip them too. + if (readClaudeParentToolUseId(message)) { + return; + } if ( message.type === "user" && !isSyntheticUserEntry(message) && @@ -3070,20 +3222,21 @@ class ClaudeAgentSession implements AgentSession { throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`); } - if (index === 0) { - return { kind: "fresh-session" }; + // A turn the model never answered — an instant gateway error, or an abort before the first + // token — leaves its anchor without an assistant id, and carries no conversation state worth + // preserving. Fork at the most recent turn before the target that did answer. Reading only + // the immediately preceding anchor let one such turn fail every later rewind in the + // conversation. + for (let previous = index - 1; previous >= 0; previous -= 1) { + const assistantMessageId = this.rewindTurnAnchors[previous]?.assistantMessageId; + if (assistantMessageId) { + return { kind: "fork", messageId: assistantMessageId }; + } } - - const previousTurn = this.rewindTurnAnchors[index - 1]; - if (!previousTurn?.assistantMessageId) { - throw new Error( - `Claude rewind cannot preserve turn ${index} because its assistant response id was not observed`, - ); - } - return { kind: "fork", messageId: previousTurn.assistantMessageId }; + return { kind: "fresh-session" }; } - private async ensureQuery(): Promise { + private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { if (this.query && !this.queryRestartNeeded) { return this.query; } @@ -3127,9 +3280,11 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput(); - const options = await this.buildOptions(); + const options = await this.buildOptions(launchMode); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; + // A fresh Claude process has no turn of its own in flight. + this.mainTurnInFlight = false; this.query = claudeQuery( { prompt: input.iterable, options }, { @@ -3213,10 +3368,18 @@ class ClaudeAgentSession implements AgentSession { return { thinking: { type: "disabled" }, effort: undefined, ultracode: false }; } if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) { - return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + ultracode: true, + }; } if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - return { thinking: { type: "adaptive" }, effort: thinkingOptionId, ultracode: false }; + return { + thinking: { type: "adaptive", display: "summarized" }, + effort: thinkingOptionId, + ultracode: false, + }; } return { thinking: undefined, effort: undefined, ultracode: false }; } @@ -3227,15 +3390,30 @@ class ClaudeAgentSession implements AgentSession { ); } - private buildSdkEnv(): NodeJS.ProcessEnv { + usageSession() { + if (this.closed) return null; + return { + provider: "claude", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + + private buildSdkEnv() { return createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings, - overlays: [this.launchEnv], + overlays: [ + this.launchEnv, + // Makes the CLI announce session_state_changed, whose "idle" is the safety net that closes + // an autonomous turn (see consumeSessionStateChange). + { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1" }, + ], }); } - private async buildOptions(): Promise { + private async buildOptions(permissionMode: PermissionMode): Promise { const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const providerOptions = applyClaudeToolPolicy( @@ -3243,8 +3421,8 @@ class ClaudeAgentSession implements AgentSession { this.config.toolPolicy, ); const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode }); - const sdkEnv = this.buildSdkEnv(); - assertClaudeModeCanRun(this.currentMode, sdkEnv); + const sdkEnv = this.harnessEnvironment; + assertClaudeModeCanRun(permissionMode, sdkEnv); const claudeBinary = await this.resolveBinary(); this.logger.debug( @@ -3267,7 +3445,7 @@ class ClaudeAgentSession implements AgentSession { const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, - permissionMode: this.currentMode, + permissionMode, // Dynamic mode switching can recreate the underlying Claude query. Keep the // bypass launch capability available so later setPermissionMode("bypassPermissions") // calls do not fail after a model/thinking/rewind-driven restart. @@ -3298,6 +3476,10 @@ class ClaudeAgentSession implements AgentSession { ...settingsOptions, // Provider subagent panes render the child's nested transcript. forwardSubagentText: true, + // Stop and replace abort only the main turn, like Esc in Claude Code, and background + // subagents keep running. Without this declaration an interrupt kills every one of them. + // Claude stops a helper itself with its TaskStop tool. + perTaskStopAffordance: true, hooks: this.buildSubagentEffortHooks(), ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, @@ -3328,13 +3510,17 @@ class ClaudeAgentSession implements AgentSession { input: { ultracode: boolean }, ): Pick | Record { const fastMode = this.resolveFastModeSetting(); - if (fastMode === null && !input.ultracode) { + // Internal agents do daemon work such as naming a branch, so the user's and + // project's hooks must not run for them. + const disableAllHooks = this.config.internal === true; + if (fastMode === null && !input.ultracode && !disableAllHooks) { return {}; } return { settings: mergeClaudeSettings(providerOptions.settings, { ...(fastMode === null ? {} : { fastMode }), ...(input.ultracode ? { ultracode: true } : {}), + ...(disableAllHooks ? { disableAllHooks: true } : {}), }), }; } @@ -3368,9 +3554,19 @@ class ClaudeAgentSession implements AgentSession { }; } > = []; + // Claude Code expands a slash command only when it is the last content block of the user + // message. buildAgentPrompt places the typed text ahead of images and rendered attachments, + // so a "/command" sent with a pasted link or a screenshot would otherwise reach the model as + // literal text instead of the command it names. + let typedSlashCommandIndex = -1; if (Array.isArray(prompt)) { for (const chunk of prompt) { if (chunk.type === "text") { + // Attachments rendered as text always carry a mimeType; the composer's typed text does + // not. Only the typed block can be a slash command the user meant to invoke. + if (!("mimeType" in chunk) && this.parseSlashCommandInput(chunk.text)) { + typedSlashCommandIndex = content.length; + } content.push({ type: "text", text: chunk.text }); } else if (chunk.type === "image") { if (isImageMimeType(chunk.mimeType)) { @@ -3390,6 +3586,10 @@ class ClaudeAgentSession implements AgentSession { } else { content.push({ type: "text", text: prompt }); } + if (typedSlashCommandIndex >= 0 && typedSlashCommandIndex < content.length - 1) { + const [slashCommand] = content.splice(typedSlashCommandIndex, 1); + content.push(slashCommand); + } const messageId = randomUUID(); this.rememberUserMessageId(messageId); @@ -3562,6 +3762,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundInput = null; this.cancelCurrentTurn = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("foreground turn terminal"); } @@ -3573,6 +3774,7 @@ class ClaudeAgentSession implements AgentSession { } if (terminalSeen) { + this.compactionMarkerOpen = false; if (this.activeForegroundTurnId) { this.activeForegroundTurnId = null; this.activeForegroundQuery = null; @@ -3614,6 +3816,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundQuery = null; this.activeForegroundInput = null; this.activeTurnHasAssistantText = false; + this.compactionMarkerOpen = false; this.syncTurnState("autonomous turn completed"); } @@ -3807,35 +4010,67 @@ class ClaudeAgentSession implements AgentSession { return false; } - private isAssistantishMessage(message: SDKMessage): boolean { - return ( - message.type === "assistant" || - message.type === "stream_event" || - message.type === "tool_progress" || - (message.type === "system" && message.subtype === "task_notification") - ); + /** + * Claude's account of whether anything in the session is working, which is not the same as the + * main session working: Claude Code 2.1.280 stays "running" for as long as any background + * subagent is alive. So "running" never opens a turn. + * + * "idle" means nothing at all is running, so it closes an autonomous turn whatever opened it. + * It never closes a foreground turn: an idle can trail the previous turn and land just after a + * new send, and it must not end that send. + * + * Returns true when the frame was a state change, which is bookkeeping and never reaches the + * timeline. + */ + private consumeSessionStateChange(message: SDKMessage): boolean { + if (message.type !== "system" || message.subtype !== "session_state_changed") { + return false; + } + if (message.state === "idle") { + this.mainTurnInFlight = false; + this.completeAutonomousTurn(); + } + return true; + } + + /** Tracks mainTurnInFlight from Claude's own turn brackets (isClaudeTurnInit). */ + private observeMainTurnBracket(message: SDKMessage): void { + if (message.type === "result") { + this.mainTurnInFlight = false; + return; + } + if (isClaudeTurnInit(message)) { + this.mainTurnInFlight = true; + // A new turn has begun, so the interrupted one is over. An interrupt that landed after its + // turn had already ended gets no result, and the leftover flag would swallow this turn's. + this.pendingInterruptAbort = false; + return; + } + if (readClaudeCommandLifecycle(message)?.state === "started") { + this.mainTurnInFlight = true; + } } /** - * Claude keeps talking about the request it was told to kill — the notification for the tool it - * just stopped, trailing assistant output. That is not new work, so it must not open a turn: the - * only message that would close that turn is the result shouldSuppressStaleResult drops, leaving - * the agent stuck reporting "running" forever. + * Only the main session's own turn start opens an autonomous turn. Everything a background + * subagent emits (its task frames, its own background jobs' notifications, rate limit events) + * arrives outside one, and no result would ever close a turn it opened. */ - private shouldStartAutonomousTurn(message: SDKMessage): boolean { - if (this.activeForegroundTurnId || this.pendingInterruptAbort) { - return false; - } - return this.isAssistantishMessage(message); + private opensAutonomousTurn(message: SDKMessage): boolean { + return isClaudeTurnInit(message) && !this.activeForegroundTurnId; } private async routeSdkMessageFromPump(message: SDKMessage): Promise { + if (this.consumeSessionStateChange(message)) { + return; + } + this.observeMainTurnBracket(message); if (this.shouldSuppressStaleResult(message)) { return; } const isForeground = Boolean(this.activeForegroundTurnId); - if (this.shouldStartAutonomousTurn(message)) { + if (this.opensAutonomousTurn(message)) { this.startAutonomousTurn(); } if (!isForeground && !this.autonomousTurn && message.type === "result") { @@ -3977,7 +4212,13 @@ class ClaudeAgentSession implements AgentSession { return true; } - private async interruptActiveTurn(): Promise { + /** + * Interrupts Claude only when a turn of its main session is running. With nothing running there + * is nothing to stop but our own queued messages, so those are withdrawn instead: an interrupt + * would reach only background helpers (and kill them on a CLI that ignores + * perTaskStopAffordance), and no result would follow to consume pendingInterruptAbort. + */ + private async interruptActiveTurn(claudeStartedTurn = this.mainTurnInFlight): Promise { const queryToInterrupt = this.query; if (!queryToInterrupt || typeof queryToInterrupt.interrupt !== "function") { this.logger.trace( @@ -3991,8 +4232,16 @@ class ClaudeAgentSession implements AgentSession { ); return; } - this.pendingInterruptAbort = true; - await this.discardQueuedSteers(queryToInterrupt); + if (claudeStartedTurn) { + this.pendingInterruptAbort = true; + await this.withdrawUnstartedMessages(queryToInterrupt); + } else { + // Claude can dequeue a message for its next turn before announcing it started; withdrawing + // it is then a no-op, and only an interrupt stops it. Withdraw once: a prompt sent while + // this withdrawal was in flight belongs to the next turn. + if (await this.withdrawUnstartedMessages(queryToInterrupt)) return; + this.pendingInterruptAbort = true; + } try { await this.awaitWithTimeout( queryToInterrupt.interrupt(), @@ -4004,14 +4253,15 @@ class ClaudeAgentSession implements AgentSession { } /** - * Interrupt means interrupt: a steer Claude never read dies with the turn instead of resuming it. - * A steer already dequeued cannot be recalled, and does not need to be — the interrupt kills it. + * Interrupt means interrupt: a message Claude never started dies with the turn instead of + * resuming it. One already dequeued cannot be recalled, and does not need to be, since the + * interrupt kills it. Returns whether Claude confirmed every message withdrawn. */ - private async discardQueuedSteers(query: Query): Promise { - const uuids = [...this.queuedSteerUuids]; - this.queuedSteerUuids.clear(); + private async withdrawUnstartedMessages(query: Query): Promise { + const uuids = [...this.unstartedMessageUuids]; + this.unstartedMessageUuids.clear(); this.permissionClearingSteerUuids.clear(); - if (uuids.length === 0) return; + if (uuids.length === 0) return true; // The SDK runtime supports this, but its public Query type has not caught up. Keep the // compatibility escape hatch inside the Claude adapter. const cancelAsyncMessage = ( @@ -4019,14 +4269,17 @@ class ClaudeAgentSession implements AgentSession { cancelAsyncMessage?: (uuid: string) => Promise; } ).cancelAsyncMessage; - if (!cancelAsyncMessage) return; + if (!cancelAsyncMessage) return false; + let withdrewAll = true; for (const uuid of uuids) { try { - await cancelAsyncMessage.call(query, uuid); + if (!(await cancelAsyncMessage.call(query, uuid))) withdrewAll = false; } catch (error) { - this.logger.warn({ err: error }, "Failed to discard a queued Claude steer"); + withdrewAll = false; + this.logger.warn({ err: error }, "Failed to withdraw a queued Claude message"); } } + return withdrewAll; } private translateMessageToEvents( @@ -4075,7 +4328,7 @@ class ClaudeAgentSession implements AgentSession { } } - this.forgetReadSteer(message); + this.forgetStartedMessage(message); switch (message.type) { case "system": @@ -4109,11 +4362,14 @@ class ClaudeAgentSession implements AgentSession { return events; } - /** Once Claude has read a steer there is nothing left to discard on interrupt. */ - private forgetReadSteer(message: unknown): void { + /** + * Once Claude has started a message, or cancelled it, there is nothing left to withdraw on + * interrupt. + */ + private forgetStartedMessage(message: unknown): void { const lifecycle = readClaudeCommandLifecycle(message); if (!lifecycle || lifecycle.state === "queued") return; - this.queuedSteerUuids.delete(lifecycle.commandUuid); + this.unstartedMessageUuids.delete(lifecycle.commandUuid); this.permissionClearingSteerUuids.delete(lifecycle.commandUuid); } @@ -4162,6 +4418,9 @@ class ClaudeAgentSession implements AgentSession { private buildSubagentToolCallCard( declaration: Extract, ): AgentStreamEvent | null { + // The launching sidechain already owns and renders this tool call. Mirroring it into the + // managed parent's transcript would flatten both the card and the child relationship. + if (declaration.parentSubagentId) return null; const toolCall = mapClaudeRunningToolCall({ name: "Task", callId: declaration.id, @@ -4172,16 +4431,7 @@ class ClaudeAgentSession implements AgentSession { return { type: "timeline", provider: "claude", - item: { - ...toolCall, - detail: { - type: "sub_agent", - ...(declaration.title ? { subAgentType: declaration.title } : {}), - ...(declaration.description ? { description: declaration.description } : {}), - log: "", - actions: [], - }, - }, + item: { ...toolCall, detail: buildClaudeSubagentCardDetail(declaration) }, }; } @@ -4244,18 +4494,38 @@ class ClaudeAgentSession implements AgentSession { return; } if (message.subtype === "status") { + // Claude Code reports a mode it switched to on its own, such as entering + // plan mode with the EnterPlanMode tool, through the status message. + if ( + isPermissionMode(message.permissionMode) && + this.observePermissionMode(message.permissionMode) + ) { + events.push({ + type: "mode_changed", + provider: "claude", + currentModeId: this.currentMode, + availableModes: this.availableModes, + }); + } const status = toObjectRecord(message)?.status; if (status === "compacting") { this.compacting = true; - events.push({ - type: "timeline", - item: { type: "compaction", status: "loading" }, - provider: "claude", - }); + // Claude Code repeats this status every 30 seconds until the compaction + // finishes. Each repeat used to open its own marker, and the app only ever + // resolves one of them, so the rest stayed on "Compacting..." forever. + if (!this.compactionMarkerOpen) { + this.compactionMarkerOpen = true; + events.push({ + type: "timeline", + item: { type: "compaction", status: "loading" }, + provider: "claude", + }); + } } return; } if (message.subtype === "compact_boundary") { + this.compactionMarkerOpen = false; const compactMetadata = readCompactionMetadata(message); events.push({ type: "timeline", @@ -4283,11 +4553,6 @@ class ClaudeAgentSession implements AgentSession { message: Extract, events: AgentStreamEvent[], ): void { - // TODO: subagent timelines are best-effort. Subagent task_notifications - // arrive without parent_tool_use_id but with tool_use_id pointing at the - // the parent's subagent tool call, so they slip past the sidechain router and pollute - // the parent timeline. Drop them here; eventually thread them into the - // parent tool call's sub_agent log instead. const taskUseId = message.tool_use_id; const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined; // The task protocol owns provider-subagent identity. Workflow launch results arrive before @@ -4300,6 +4565,15 @@ class ClaudeAgentSession implements AgentSession { return; } const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message); + const ownerSubagentId = this.taskProtocolSource.resolveTaskOwner(message.task_id, taskUseId); + if (taskNotificationItem && ownerSubagentId) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: ownerSubagentId, item: taskNotificationItem }, + }); + return; + } if (taskNotificationItem) { events.push({ type: "timeline", @@ -4333,11 +4607,7 @@ class ClaudeAgentSession implements AgentSession { messageId, }); if (taskNotificationItem) { - events.push({ - type: "timeline", - item: taskNotificationItem, - provider: "claude", - }); + this.appendUserTaskNotificationEvent(taskNotificationItem, events); return; } if (typeof content === "string" && content.length > 0) { @@ -4359,6 +4629,28 @@ class ClaudeAgentSession implements AgentSession { } } + private appendUserTaskNotificationEvent( + item: Extract, + events: AgentStreamEvent[], + ): void { + const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined; + const toolUseId = + typeof item.metadata?.toolUseId === "string" ? item.metadata.toolUseId : undefined; + if (taskId && this.taskProtocolSource.isDeclaredTask(taskId)) return; + const ownerSubagentId = taskId + ? this.taskProtocolSource.resolveTaskOwner(taskId, toolUseId) + : undefined; + if (ownerSubagentId) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: ownerSubagentId, item }, + }); + return; + } + events.push({ type: "timeline", item, provider: "claude" }); + } + private appendUserContentArrayEvents( content: ReadonlyArray, messageId: string | undefined, @@ -4526,10 +4818,7 @@ class ClaudeAgentSession implements AgentSession { notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId); } this.availableModes = DEFAULT_MODES; - this.currentMode = message.permissionMode; - if (this.currentMode !== "plan") { - this.planResumeMode = this.currentMode; - } + this.observePermissionMode(message.permissionMode); this.persistence = null; if (message.model) { const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model); @@ -4548,6 +4837,16 @@ class ClaudeAgentSession implements AgentSession { return { threadStartedSessionId, notice }; } + /** Records a mode Claude Code reports, returning whether it differs from the current one. */ + private observePermissionMode(mode: PermissionMode): boolean { + const changed = this.currentMode !== mode; + this.currentMode = mode; + if (mode !== "plan") { + this.planResumeMode = mode; + } + return changed; + } + private readMissingResumedConversationError(message: SDKMessage): string | null { if (message.type !== "result" || message.subtype !== "error_during_execution") { return null; @@ -4800,34 +5099,39 @@ class ClaudeAgentSession implements AgentSession { } private loadPersistedHistory(sessionId: string): void { + let historyPath: string | null = null; try { this.taskState.reset(); - const historyPath = this.resolveHistoryPath(sessionId); + historyPath = this.resolveHistoryPath(sessionId); if (!historyPath || !fs.existsSync(historyPath)) { + this.logger.info( + { sessionId, cwd: this.config.cwd, historyPath }, + "No Claude transcript to load history from", + ); return; } const content = fs.readFileSync(historyPath, "utf8"); - const restoredProviderSubagentIds = this.ingestPersistedSidechains( + const replay = this.ingestPersistedSidechains( content, readClaudeSidechainHistory(historyPath), ); - this.ingestPersistedHistory(content, restoredProviderSubagentIds); - } catch { - // ignore history load failures + this.ingestPersistedHistory(content, replay); + } catch (error) { + this.logger.warn( + { err: error, sessionId, historyPath }, + "Failed to load Claude history from transcript", + ); } } - private ingestPersistedHistory( - content: string, - restoredProviderSubagentIds: ReadonlySet, - ): void { + private ingestPersistedHistory(content: string, replay: ClaudeReplayOwnership): void { if (!content) { return; } const timeline: PersistedTimelineEntry[] = []; for (const line of content.split(/\r?\n/)) { - this.ingestPersistedHistoryLine(line, timeline, restoredProviderSubagentIds); + this.ingestPersistedHistoryLine(line, timeline, replay); } if (timeline.length > 0) { @@ -4839,26 +5143,29 @@ class ClaudeAgentSession implements AgentSession { private ingestPersistedSidechains( parentContent: string, sidechains: ClaudeSidechainHistory, - ): Set { + ): ClaudeReplayOwnership { const parentEntries = parseClaudeHistoryRecords(parentContent).filter( (entry) => entry.isSidechain !== true, ); const sidechainEntries = [parentContent, ...sidechains.contents] .flatMap(parseClaudeHistoryRecords) .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); + const parentFacts = readClaudeReplayParentFacts(parentEntries); // Replay produces the same observations the live task protocol produces, then folds them // with the same function, so identity and status are derived once for both paths. + const subagentReplay = observeReplaySubagents({ + subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ + agentId, + meta: sidechains.metaByAgentId.get(agentId) ?? null, + entries, + parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]), + })), + parent: parentFacts, + convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + }); const observations = [ - ...observeReplaySubagents({ - subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ - agentId, - meta: sidechains.metaByAgentId.get(agentId) ?? null, - entries, - })), - parent: readClaudeReplayParentFacts(parentEntries), - convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), - }), + ...subagentReplay.observations, ...observeReplayWorkflows({ workflows: sidechains.workflowContents .map(parseClaudeWorkflowRun) @@ -4880,7 +5187,12 @@ class ClaudeAgentSession implements AgentSession { .filter((observation) => observation.kind === "declared") .map((observation) => observation.id), ); - if (observations.length === 0) return restoredProviderSubagentIds; + const replay = { + restoredIds: restoredProviderSubagentIds, + toolOwners: subagentReplay.toolOwners, + subagentToolCalls: parentFacts.toolCalls, + }; + if (observations.length === 0) return replay; this.persistedProviderSubagentEvents.push( ...foldSubagentObservations(observations).map( @@ -4892,13 +5204,13 @@ class ClaudeAgentSession implements AgentSession { ), ); this.historyPending = true; - return restoredProviderSubagentIds; + return replay; } private ingestPersistedHistoryLine( line: string, timeline: PersistedTimelineEntry[], - restoredProviderSubagentIds: ReadonlySet, + replay: ClaudeReplayOwnership, ): void { const trimmed = line.trim(); if (!trimmed) { @@ -4921,13 +5233,36 @@ class ClaudeAgentSession implements AgentSession { return; } const notificationToolUseId = readTaskNotificationToolUseIdFromHistoryRecord(entry); - if (notificationToolUseId && restoredProviderSubagentIds.has(notificationToolUseId)) { + if (notificationToolUseId && replay.restoredIds.has(notificationToolUseId)) { return; } const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp); + const notificationOwner = notificationToolUseId + ? replay.toolOwners.get(notificationToolUseId) + : undefined; + if (notificationOwner) { + for (const item of this.convertHistoryEntry(entry)) { + this.persistedProviderSubagentEvents.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id: notificationOwner, + item, + timestamp: historyTimestamp ?? undefined, + }, + }); + } + return; + } const taskSnapshot = this.taskState.observe(entry); - const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)]; + const items = [ + ...(taskSnapshot ? [taskSnapshot] : []), + ...this.convertHistoryEntry(entry).map((item) => + labelReplayedSubagentCard(item, replay.subagentToolCalls), + ), + ]; const isVisibleUserEntry = entry.type === "user" && typeof entry.uuid === "string" && @@ -4954,26 +5289,11 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); - const candidates = [cwd]; - try { - const realCwd = fs.realpathSync(cwd); - if (realCwd !== cwd) { - candidates.push(realCwd); - } - } catch { - // Fall back to the configured cwd when the path has already disappeared. - } - for (const candidate of candidates) { - const historyPath = path.join( - claudeProjectDirSync(candidate, { configDir }), - `${sessionId}.jsonl`, - ); - if (fs.existsSync(historyPath)) { - return historyPath; - } - } - return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`); + return claudeTranscriptPathSync({ + cwd, + sessionId, + configDir: claudeConfigDir(this.harnessEnvironment), + }); } private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] { @@ -5023,7 +5343,7 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - if (!isClaudeContentChunk(block)) { + if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) { continue; } this.mapBlockToTimeline(block, { @@ -5111,6 +5431,24 @@ class ClaudeAgentSession implements AgentSession { } } + // A subagent's handback call is its final message, and the call's result is only an + // acknowledgement, so neither becomes a tool call. + private mapSubagentHandbackBlock(block: ClaudeContentChunk, items: AgentTimelineItem[]): boolean { + if (typeof block.tool_use_id === "string" && this.subagentHandbackIds.has(block.tool_use_id)) { + this.subagentHandbackIds.delete(block.tool_use_id); + return true; + } + const handback = readClaudeSubagentHandback(block); + if (!handback) { + return false; + } + this.subagentHandbackIds.add(handback.callId); + if (handback.report) { + items.push({ type: "assistant_message", text: handback.report }); + } + return true; + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { @@ -5677,6 +6015,33 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { return entries; } +/** The parent's card for a subagent, labeled with its type and task. */ +function buildClaudeSubagentCardDetail( + facts: ClaudeSubagentCardFacts, +): Extract { + return { + type: "sub_agent", + ...(facts.title ? { subAgentType: facts.title } : {}), + ...(facts.description ? { description: facts.description } : {}), + log: "", + actions: [], + }; +} + +/** + * Live, a Task call's card is built from the task protocol's declaration. Replay has no task + * protocol, so the generic tool mapper leaves the card unlabeled; label it from the same facts. + */ +function labelReplayedSubagentCard( + item: AgentTimelineItem, + subagentToolCalls: ReadonlyMap, +): AgentTimelineItem { + if (item.type !== "tool_call") return item; + const facts = subagentToolCalls.get(item.callId); + if (!facts) return item; + return { ...item, detail: buildClaudeSubagentCardDetail(facts) }; +} + /** * Collect what the parent transcript knows about its Task calls, in the shape the shared replay * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before @@ -6304,7 +6669,8 @@ function extractClaudeUserText(messageRaw: unknown): string | null { interface ClaudeCommandLifecycle { commandUuid: string; - state: "queued" | "started" | "completed"; + /** "cancelled" is terminal too: API errors, interrupts and "now" pre-emption end a message. */ + state: "queued" | "started" | "completed" | "cancelled"; } /** Runtime-only Claude frames are validated here because the SDK's public union omits them. */ @@ -6313,7 +6679,7 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | if (record?.type !== "command_lifecycle") return null; if ( typeof record.command_uuid !== "string" || - !["queued", "started", "completed"].includes(String(record.state)) + !["queued", "started", "completed", "cancelled"].includes(String(record.state)) ) { return null; } diff --git a/packages/server/src/server/agent/providers/claude/model-discovery.ts b/packages/server/src/server/agent/providers/claude/model-discovery.ts new file mode 100644 index 0000000000..d74898472b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/model-discovery.ts @@ -0,0 +1,89 @@ +import type { ChildProcess } from "node:child_process"; +import os from "node:os"; +import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { terminateWithTreeKill } from "../../../../utils/tree-kill.js"; +import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js"; + +interface ClaudeModelDiscoveryOptions { + resolveBinary: () => Promise; + runtimeSettings?: ProviderRuntimeSettings; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; +} + +type OpenDiscoveryQuery = ( + input: ClaudeQueryInput, + context: ClaudeQueryContext, +) => Pick; + +// The curated catalog survives a failed or slow probe. This process never receives +// a prompt and must not persist a session or launch user MCP servers/hooks. +export async function discoverClaudeModels( + options: ClaudeModelDiscoveryOptions, + openQuery: OpenDiscoveryQuery = claudeQuery, +): Promise { + const controller = new AbortController(); + const signal = options.signal + ? AbortSignal.any([options.signal, controller.signal]) + : controller.signal; + const timer = setTimeout( + () => controller.abort(new Error("Claude model discovery timed out")), + 5_000, + ); + let endInput!: () => void; + const ended = new Promise((resolve) => { + endInput = resolve; + }); + const input: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => { + await ended; + return { done: true, value: undefined }; + }, + }; + }, + }; + let query: ReturnType | undefined; + let child: ChildProcess | undefined; + try { + signal.throwIfAborted(); + const binary = await raceProviderRefreshAbort(signal, options.resolveBinary()); + query = openQuery( + { + prompt: input, + options: { + pathToClaudeCodeExecutable: binary, + cwd: os.homedir(), + env: options.env, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + abortController: controller, + }, + }, + { + runtimeSettings: options.runtimeSettings, + onChildProcess: (process) => { + child = process; + }, + }, + ); + return await raceProviderRefreshAbort(signal, query.supportedModels()); + } finally { + clearTimeout(timer); + endInput(); + try { + query?.close(); + } finally { + if (child) { + await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 }); + } + } + } +} diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index e7e660a229..45a5441109 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -9,8 +9,10 @@ interface ClaudeModelManifestEntry { description: string; defaultPriority?: number; minimumClaudeCodeVersion?: string; + requiresDiscovery?: boolean; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; + defaultThinkingOptionId?: ClaudeEffortLevel; supportsThinkingDisabled?: boolean; supportsFastMode?: boolean; } @@ -34,10 +36,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; export const CLAUDE_MODEL_MANIFEST = [ + { + id: "claude-opus-5-5", + label: "Opus 5.5", + description: "Opus 5.5 · Latest release", + defaultPriority: 3, + minimumClaudeCodeVersion: "2.1.280", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsFastMode: true, + }, { id: "claude-opus-5", label: "Opus 5", - description: "Opus 5 · Latest release", + description: "Opus 5 · Previous release", defaultPriority: 2, minimumClaudeCodeVersion: "2.1.219", contextWindowMaxTokens: 1_000_000, @@ -45,6 +58,14 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + id: "claude-mythos-5-1", + label: "Mythos 5.1", + requiresDiscovery: true, + description: "Mythos 5.1 · Requires verified access", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + }, { id: "claude-fable-5-1", label: "Fable 5.1", @@ -81,10 +102,19 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + id: "claude-sonnet-5-5", + label: "Sonnet 5.5", + description: "Sonnet 5.5 · Best for everyday tasks", + minimumClaudeCodeVersion: "2.1.284", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + }, { id: "claude-sonnet-5", label: "Sonnet 5", - description: "Sonnet 5 · Best for everyday tasks", + description: "Sonnet 5 · Previous release", contextWindowMaxTokens: 200_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, @@ -149,17 +179,32 @@ export const CLAUDE_MODEL_MANIFEST = [ effortLevels: CLAUDE_EFFORT_LEVELS.standard, supportsThinkingDisabled: true, }, + { + id: "claude-haiku-5-5", + label: "Haiku 5.5", + description: "Haiku 5.5 · Fastest for quick answers", + minimumClaudeCodeVersion: "2.1.293", + defaultThinkingOptionId: "medium", + contextWindowMaxTokens: 1_000_000, + effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, + supportsThinkingDisabled: true, + }, { id: "claude-haiku-4-5", label: "Haiku 4.5", - description: "Haiku 4.5 · Fastest for quick answers", + description: "Haiku 4.5 · Previous release", contextWindowMaxTokens: 200_000, }, ] as const satisfies readonly ClaudeModelManifestEntry[]; +function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel { + return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID; +} + function buildThinkingOptions( effortLevels: readonly ClaudeEffortLevel[] | undefined, supportsThinkingDisabled: boolean, + defaultThinkingOptionId: ClaudeEffortLevel, ): AgentSelectOption[] | undefined { if (!effortLevels) { return undefined; @@ -170,7 +215,7 @@ function buildThinkingOptions( ...effortLevels.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id], - ...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}), + ...(id === defaultThinkingOptionId ? { isDefault: true } : {}), })), ]; @@ -181,9 +226,16 @@ function buildThinkingOptions( return options; } -export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] { +export function getClaudeManifestModels( + claudeCodeVersion?: string, + discoveredModelIds?: ReadonlySet, +): AgentModelDefinition[] { const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter( - (model) => isModelAvailableInClaudeCode(model, claudeCodeVersion), + (model) => + isModelAvailableInClaudeCode(model, claudeCodeVersion) && + (discoveredModelIds === undefined || + !("requiresDiscovery" in model && model.requiresDiscovery) || + discoveredModelIds.has(model.id)), ); const defaultModel = availableModels.reduce( (selected, candidate) => @@ -196,6 +248,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD const thinkingOptions = buildThinkingOptions( model.effortLevels, model.supportsThinkingDisabled === true, + getDefaultThinkingOptionId(model), ); const definition: AgentModelDefinition = { provider: "claude", @@ -214,7 +267,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD } if (thinkingOptions) { definition.thinkingOptions = thinkingOptions; - definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID; + definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model); } definitions.push(definition); if (!("aliases" in model) || !model.aliases) { @@ -286,7 +339,7 @@ export function resolveClaudeDisabledThinkingForModel( supported: !!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true, fallbackThinkingOptionId: - model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined, + model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined, }; } @@ -322,7 +375,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const singleSegmentMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (singleSegmentMatch) { return normalizeSingleSegmentClaudeModelId( @@ -333,7 +386,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const runtimeMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (!runtimeMatch) { return null; @@ -350,7 +403,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) /** * Normalize a Claude Code runtime/config model string to a known manifest ID. * Runtime metadata may include provider prefixes such as Bedrock model IDs; feature - * gates should use normalizeClaudeManifestModelId instead. + * gates should use normalizeClaudeManifestModelId instead. The prefixed matches are + * unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the + * "claude-opus-5" entry. */ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null { const normalizedManifestModelId = normalizeClaudeManifestModelId(value); @@ -363,13 +418,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): return null; } - const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + const runtimeMatch = trimmed.match( + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); - if (singleSegmentMatch) { - const normalizedModelId = normalizeSingleSegmentClaudeModelId( - singleSegmentMatch[1], - singleSegmentMatch[2], + if (runtimeMatch) { + const normalizedModelId = normalizeMajorMinorClaudeModelId( + runtimeMatch[1], + runtimeMatch[2], + runtimeMatch[3], trimmed.toLowerCase().includes("[1m]"), ); if (normalizedModelId) { @@ -377,17 +433,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } } - const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + const singleSegmentMatch = trimmed.match( + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); - if (!runtimeMatch) { + if (!singleSegmentMatch) { return null; } - return normalizeMajorMinorClaudeModelId( - runtimeMatch[1], - runtimeMatch[2], - runtimeMatch[3], + return normalizeSingleSegmentClaudeModelId( + singleSegmentMatch[1], + singleSegmentMatch[2], trimmed.toLowerCase().includes("[1m]"), ); } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 9e3cc088bd..1ef8177bbf 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -13,6 +13,8 @@ import { parseClaudeCodeVersion, resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; +import { discoverClaudeModels } from "./model-discovery.js"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js"; const createdClaudeConfigDirs: string[] = []; @@ -39,10 +41,18 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise claudeCodeVersion, + discoverModels: async () => [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ], }); } @@ -50,12 +60,15 @@ describe("getClaudeModels", () => { it("returns all claude models", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ + "claude-opus-5-5", "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", "claude-opus-4-8[1m]", "claude-opus-4-8", + "claude-sonnet-5-5", "claude-sonnet-5", "claude-sonnet-5[1m]", "claude-opus-4-7[1m]", @@ -64,6 +77,7 @@ describe("getClaudeModels", () => { "claude-opus-4-6", "claude-sonnet-4-6[1m]", "claude-sonnet-4-6", + "claude-haiku-5-5", "claude-haiku-4-5", ]); }); @@ -72,7 +86,7 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); const defaults = models.filter((m) => m.isDefault); expect(defaults).toHaveLength(1); - expect(defaults[0].id).toBe("claude-opus-5"); + expect(defaults[0].id).toBe("claude-opus-5-5"); }); it("defines context window sizes in the catalog", () => { @@ -82,12 +96,15 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ + ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], + ["claude-mythos-5-1", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], ["claude-opus-4-8[1m]", 1_000_000], ["claude-opus-4-8", 200_000], + ["claude-sonnet-5-5", 1_000_000], ["claude-sonnet-5", 200_000], ["claude-sonnet-5[1m]", 1_000_000], ["claude-opus-4-7[1m]", 1_000_000], @@ -96,6 +113,7 @@ describe("getClaudeModels", () => { ["claude-opus-4-6", 200_000], ["claude-sonnet-4-6[1m]", 1_000_000], ["claude-sonnet-4-6", 200_000], + ["claude-haiku-5-5", 1_000_000], ["claude-haiku-4-5", 200_000], ]), ); @@ -109,6 +127,20 @@ describe("getClaudeModels", () => { expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5"); expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5"); + + expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5"); + expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5"); + expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + + expect(getClaudeModels("2.1.283").map((model) => model.id)).not.toContain("claude-sonnet-5-5"); + expect(getClaudeModels("2.1.284").map((model) => model.id)).toContain("claude-sonnet-5-5"); + }); + + it("gates Haiku 5.5 on Claude Code 2.1.293 without changing the default", () => { + expect(getClaudeModels("2.1.292").map((model) => model.id)).not.toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").map((model) => model.id)).toContain("claude-haiku-5-5"); + expect(getClaudeModels("2.1.293").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); }); it("derives thinking options from model effort capabilities", () => { @@ -169,6 +201,10 @@ describe("getClaudeModels", () => { }); it.each([ + ["claude-opus-5-5", false, "medium"], + ["claude-opus-5-5-20260401", false, "medium"], + ["claude-sonnet-5-5", false, "medium"], + ["claude-sonnet-5-5-20260928", false, "medium"], ["claude-opus-5", true, "high"], ["claude-opus-5-20260724", true, "high"], ["claude-sonnet-5", true, "high"], @@ -176,6 +212,8 @@ describe("getClaudeModels", () => { ["claude-sonnet-5-20260101", true, "high"], ["claude-fable-5", false, "high"], ["claude-fable-5-1", false, "high"], + ["claude-haiku-5-5", true, "medium"], + ["claude-haiku-5-5-20261007", true, "medium"], ["claude-haiku-4-5", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined], [null, false, undefined], @@ -195,12 +233,125 @@ describe("getClaudeModels", () => { }); describe("ClaudeAgentClient.fetchCatalog", () => { + it.each([ + { value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1", expected: true }, + { value: "claude-mythos-5-1[1m]", expected: true }, + { value: "global.anthropic.claude-mythos-5-1", expected: true }, + { value: "mythos", expected: false }, + { value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false }, + { value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false }, + ])( + "uses advertised identity $value / $resolvedModel to gate Mythos", + async ({ value, resolvedModel, expected }) => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { value, resolvedModel, displayName: "Untrusted label", description: "" }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual( + expected ? findClaudeModel("claude-mythos-5-1") : undefined, + ); + }, + ); + + it("preserves the curated catalog and explicitly configured models after discovery failure", async () => { + const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" }); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + throw new Error("discovery unavailable"); + }, + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual( + getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"), + ); + expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({ + ...findClaudeModel("claude-mythos-5-1"), + label: "claude-mythos-5-1", + description: "From Claude settings.json model", + }); + }); + + it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => { + const configDir = await createClaudeConfigDir({}); + let fail = true; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => { + if (fail) throw new Error("discovery unavailable"); + return [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + }, + }); + const options = { scope: "workspace" as const, cwd: configDir, force: true }; + expect( + (await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"), + ).toBe(false); + fail = false; + expect( + (await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"), + ).toEqual(findClaudeModel("claude-mythos-5-1")); + }); + + it("keeps the curated catalog when discovery does not advertise Mythos", async () => { + const configDir = await createClaudeConfigDir({}); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.293", + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } }, + discoverModels: async () => [ + { + value: "opus", + resolvedModel: "claude-opus-5-5", + displayName: "Opus", + description: "Latest", + }, + ], + }); + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + expect(models.map((model) => model.id)).toEqual( + getClaudeModels("2.1.293") + .filter((model) => model.id !== "claude-mythos-5-1") + .map((model) => model.id), + ); + }); + it("appends concrete models from Claude settings.json", async () => { const configDir = await createClaudeConfigDir({ model: "us.anthropic.claude-opus-4-7[1m]", env: { ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5", ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder", + ANTHROPIC_DEFAULT_FABLE_MODEL: "claude-fable-5-1[1M]", ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env", ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1", ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5", @@ -235,6 +386,12 @@ describe("ClaudeAgentClient.fetchCatalog", () => { label: "ollama/qwen3-coder", description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL", }, + { + provider: "claude", + id: "claude-fable-5-1[1M]", + label: "claude-fable-5-1[1M]", + description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL", + }, { provider: "claude", id: "bedrock-opus-from-env", @@ -254,6 +411,28 @@ describe("ClaudeAgentClient.fetchCatalog", () => { description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL", }, ]); + expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1); + expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true); + }); + + it("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => { + const daemonConfigDir = await createClaudeConfigDir({}); + const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" }); + vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveVersion: async () => "2.1.280", + discoverModels: async () => [], + runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, + }); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); + + expect(models.map((model) => model.id)).toContain("glm-5.1"); }); it("falls back to hardcoded models when settings.json is missing", async () => { @@ -417,6 +596,29 @@ describe("normalizeClaudeRuntimeModelId", () => { "claude-opus-4-8", ); }); + + it("does not collapse a prefixed minor release onto the major it extends", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe( + "claude-opus-5-5", + ); + expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe( + "claude-fable-5-1", + ); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe( + "claude-opus-5", + ); + }); + + it("does not collapse a prefixed Sonnet 5.5 onto Sonnet 5", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-sonnet-5-5")).toBe("claude-sonnet-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-5-20260928-v1:0")).toBe( + "claude-sonnet-5-5", + ); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-sonnet-5-20260101-v1:0")).toBe( + "claude-sonnet-5", + ); + }); }); describe("parseClaudeCodeVersion", () => { @@ -438,7 +640,7 @@ describe("findClaudeModel", () => { describe("Claude Opus 5 catalog", () => { it("offers a single Opus 5 entry with a 1M context window", () => { const opus5Models = getClaudeModels() - .filter((model) => model.id.startsWith("claude-opus-5")) + .filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id)) .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); expect(opus5Models).toEqual([ @@ -515,6 +717,122 @@ describe("Claude Fable 5.1 catalog", () => { }); }); +describe("Claude Opus 5.5 catalog", () => { + it("offers one Opus 5.5 entry with a 1M context window", () => { + const opus55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-opus-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(opus55Models).toEqual([ + { id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => { + const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5"); + + expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(opus55?.defaultThinkingOptionId).toBe("medium"); + expect( + opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => { + expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5"); + expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5"); + }); +}); + +describe("Claude Sonnet 5.5 catalog", () => { + it("offers one Sonnet 5.5 entry with a 1M context window", () => { + const sonnet55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-sonnet-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(sonnet55Models).toEqual([ + { id: "claude-sonnet-5-5", label: "Sonnet 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers every effort level except off, because Sonnet 5.5 cannot disable thinking", () => { + const sonnet55 = getClaudeModels().find((model) => model.id === "claude-sonnet-5-5"); + + expect(sonnet55?.thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(sonnet55?.defaultThinkingOptionId).toBe("medium"); + expect( + sonnet55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("resolves suffixed and dated Sonnet 5.5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-sonnet-5-5[1m]")?.id).toBe("claude-sonnet-5-5"); + expect(findClaudeModel("claude-sonnet-5-5-20260928")?.id).toBe("claude-sonnet-5-5"); + expect(findClaudeModel("claude-sonnet-5-5-20260928[1m]")?.id).toBe("claude-sonnet-5-5"); + expect(findClaudeModel("claude-sonnet-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); +}); + +describe("Claude Haiku 5.5 catalog", () => { + it("offers one Haiku 5.5 entry with a 1M context window", () => { + const haiku55Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-haiku-5-5")) + .map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens })); + + expect(haiku55Models).toEqual([ + { id: "claude-haiku-5-5", label: "Haiku 5.5", contextWindowMaxTokens: 1_000_000 }, + ]); + }); + + it("offers Off and every effort level with medium as the default", () => { + const haiku55 = getClaudeModels().find((model) => model.id === "claude-haiku-5-5"); + + expect(haiku55?.thinkingOptions?.map((option) => option.id)).toEqual([ + CLAUDE_DISABLED_THINKING_OPTION_ID, + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect(haiku55?.defaultThinkingOptionId).toBe("medium"); + expect( + haiku55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["medium"]); + }); + + it("normalizes provider-prefixed runtime IDs", () => { + expect(normalizeClaudeRuntimeModelId("anthropic/claude-haiku-5-5")).toBe("claude-haiku-5-5"); + expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-haiku-5-5-v1:0")).toBe( + "claude-haiku-5-5", + ); + expect(claudeManifestModelSupportsFastMode("claude-haiku-5-5")).toBe(false); + }); + + it("resolves suffixed and dated Haiku 5.5 IDs to the single catalog entry", () => { + expect(findClaudeModel("claude-haiku-5-5[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5-20261007[1m]")?.id).toBe("claude-haiku-5-5"); + expect(findClaudeModel("claude-haiku-5-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); @@ -523,9 +841,132 @@ describe("claudeManifestModelSupportsFastMode", () => { }); it("supports fast mode on Opus 5 but not on other Claude 5 models", () => { + expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true); expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true); + expect(claudeManifestModelSupportsFastMode("claude-sonnet-5-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false); expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false); }); }); + +describe("Claude Mythos 5.1 catalog", () => { + it("offers one 1M model with high default effort and always-on thinking", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-mythos-5-1", + label: "Mythos 5.1", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "high", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect( + models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["high"]); + expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({ + supported: false, + fallbackThinkingOptionId: "high", + }); + expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false); + expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + + it.each([ + "claude-mythos-5-1[1m]", + "claude-mythos-5-1-20260901", + "claude-mythos-5-1-20260901[1m]", + "Mythos 5.1", + ])("resolves %s to the single catalog entry", (id) => { + expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1"); + }); + + it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])( + "normalizes runtime identity without granting first-party capabilities: %s", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1"); + expect(normalizeClaudeManifestModelId(id)).toBeNull(); + }, + ); +}); + +describe("Claude model discovery process", () => { + it("does not send a prompt or persist a session and closes after discovery", async () => { + let closed = false; + let promptEnded: Promise> | undefined; + const advertised: ModelInfo[] = [ + { + value: "mythos", + resolvedModel: "claude-mythos-5-1", + displayName: "Mythos", + description: "", + }, + ]; + const models = await discoverClaudeModels( + { resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } }, + (input) => { + expect(input.options).toMatchObject({ + pathToClaudeCodeExecutable: "/claude", + env: { CLAUDE_CONFIG_DIR: "/provider-config" }, + persistSession: false, + tools: [], + mcpServers: {}, + strictMcpConfig: true, + settingSources: ["user"], + settings: { disableAllHooks: true }, + }); + if (typeof input.prompt === "string") throw new Error("unexpected inference prompt"); + promptEnded = input.prompt[Symbol.asyncIterator]().next(); + return { + supportedModels: async () => advertised, + close: () => { + closed = true; + }, + }; + }, + ); + expect(models).toEqual(advertised); + expect(closed).toBe(true); + await expect(promptEnded).resolves.toEqual({ done: true, value: undefined }); + }); + + it("times out and closes an unresponsive discovery process", async () => { + let closed = false; + await expect( + discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({ + supportedModels: () => new Promise(() => {}), + close: () => { + closed = true; + }, + })), + ).rejects.toThrow("Claude model discovery timed out"); + expect(closed).toBe(true); + }, 10_000); + + it("closes a stalled probe when its refresh is canceled", async () => { + const controller = new AbortController(); + const failure = new Error("refresh canceled"); + let closed = false; + const result = discoverClaudeModels( + { resolveBinary: async () => "/claude", env: {}, signal: controller.signal }, + () => ({ + supportedModels: () => { + controller.abort(failure); + return new Promise(() => {}); + }, + close: () => { + closed = true; + }, + }), + ); + await expect(result).rejects.toBe(failure); + expect(closed).toBe(true); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index c24e5ad36c..39e0dcf1dd 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import type { Logger } from "pino"; @@ -14,6 +13,7 @@ import { const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [ "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", + "ANTHROPIC_DEFAULT_FABLE_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", @@ -50,10 +50,11 @@ export function findClaudeModel( export async function getClaudeModelsWithSettings( logger: Logger, - configDir?: string, + configDir: string, claudeCodeVersion?: string, + discoveredModelIds: ReadonlySet = new Set(), ): Promise { - const hardcodedModels = getClaudeModels(claudeCodeVersion); + const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds); const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; @@ -70,7 +71,10 @@ export async function getClaudeModelsWithSettings( } continue; } - models.push(model); + const manifestModel = getClaudeModels(claudeCodeVersion).find( + (candidate) => candidate.id === model.id, + ); + models.push({ ...manifestModel, ...model }); } return models; @@ -78,9 +82,9 @@ export async function getClaudeModelsWithSettings( async function readClaudeSettingsModels( logger: Logger, - configDir?: string, + configDir: string, ): Promise { - const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json"); + const settingsPath = path.join(configDir, "settings.json"); let parsed: unknown; try { @@ -115,10 +119,6 @@ async function readClaudeSettingsModels( return models; } -function resolveClaudeConfigDir(configDir?: string): string { - return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); -} - function addSettingsModel( models: AgentModelDefinition[], value: unknown, diff --git a/packages/server/src/server/agent/providers/claude/options.ts b/packages/server/src/server/agent/providers/claude/options.ts index 78766c504b..591accce19 100644 --- a/packages/server/src/server/agent/providers/claude/options.ts +++ b/packages/server/src/server/agent/providers/claude/options.ts @@ -48,6 +48,7 @@ export const ClaudeProviderOptionsSchema = z allowedTools: z.array(z.string()).optional(), disallowedTools: z.array(z.string()).optional(), additionalDirectories: z.array(z.string()).optional(), + extraArgs: z.record(z.string(), z.string().nullable()).optional(), sandbox: z .object({ enabled: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts index 6a89711eea..030a54c813 100644 --- a/packages/server/src/server/agent/providers/claude/project-dir.ts +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -1,4 +1,4 @@ -import { realpathSync } from "node:fs"; +import { existsSync, readdirSync, realpathSync } from "node:fs"; import { realpath } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -30,6 +30,46 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti return join(projectsRoot, encode(canonical)); } +// Claude Code resumes a session by id from any working directory and keeps appending to the +// transcript in the project folder where the session started. That folder differs from the +// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the +// cwd under another name (a Windows binary under WSL). Returns the expected path when no +// transcript exists anywhere. +export function claudeTranscriptPathSync(input: { + cwd: string; + sessionId: string; + configDir?: string; +}): string { + const options = { configDir: input.configDir }; + const fileName = `${input.sessionId}.jsonl`; + const expected = join(claudeProjectDirSync(input.cwd, options), fileName); + if (existsSync(expected)) { + return expected; + } + const projectsRoot = join(resolveConfigDir(options), "projects"); + let projectDirs: string[]; + try { + projectDirs = readdirSync(projectsRoot); + } catch (error) { + if (isMissingDirectoryError(error)) { + return expected; + } + throw error; + } + for (const projectDir of projectDirs) { + const candidate = join(projectsRoot, projectDir, fileName); + if (existsSync(candidate)) { + return candidate; + } + } + return expected; +} + +function isMissingDirectoryError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + return code === "ENOENT" || code === "ENOTDIR"; +} + async function canonicalize(input: string): Promise { try { return normalizeProjectPath(await realpath(input)); @@ -66,6 +106,11 @@ function hashSuffix(input: string): string { return Math.abs(hash).toString(36); } +// Claude Code keeps its settings and transcripts here for a process launched with `env`. +export function claudeConfigDir(env: NodeJS.ProcessEnv): string { + return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); +} + function resolveConfigDir(options?: ClaudeProjectDirOptions): string { - return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); + return options?.configDir ?? claudeConfigDir(process.env); } diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index 615ca81f3a..f762557879 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -35,4 +35,64 @@ describe("ClaudeSidechainTracker", () => { }, }); }); + + it("shows a SubagentHandback report as the subagent's final message", () => { + const tracker = new ClaudeSidechainTracker({ getToolInput: () => null }); + const report = " indented code\n\n## Verdict\n\n- **Coherent**"; + const frames = [ + { + type: "stream_event", + parent_tool_use_id: "task-1", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: {}, + }, + }, + }, + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { + id: "msg-1", + content: [ + { + type: "tool_use", + id: "handback-1", + name: "SubagentHandback", + input: { message: report }, + }, + ], + }, + }, + { + type: "user", + parent_tool_use_id: "task-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "handback-1", + tool_name: "SubagentHandback", + content: [{ type: "text", text: '{"success":true}' }], + }, + ], + }, + }, + ]; + + const childItems = frames + .flatMap((frame) => tracker.handleMessage(frame as unknown as SDKMessage, "task-1")) + .flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" + ? [event.event.item] + : [], + ); + + expect(childItems).toEqual([{ type: "assistant_message", text: report, messageId: "msg-1" }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index 6973d1db5d..195e21f857 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -6,6 +6,10 @@ import { mapClaudeRunningToolCall, } from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; +import { + isClaudeSubagentHandbackToolName, + readClaudeSubagentHandback, +} from "./subagent-handback.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -91,7 +95,7 @@ export class ClaudeSidechainTracker { const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); const childTimelineItems = [ - ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentTimelineItems(message, state), ...this.extractSubAgentToolResults(message, state), ]; let actionUpdated = false; @@ -233,7 +237,10 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } - private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + private extractSubAgentTimelineItems( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { return []; } @@ -253,6 +260,18 @@ export class ClaudeSidechainTracker { } else if (block.type === "thinking") { const text = readTrimmedString(block.thinking); if (text) items.push({ type: "reasoning", text }); + } else { + const handback = readClaudeSubagentHandback(block); + if (!handback) continue; + // The handback's result is only an acknowledgement, never a tool card. + state.completedActionKeys.add(handback.callId); + if (handback.report) { + items.push({ + type: "assistant_message", + text: handback.report, + ...(messageId ? { messageId } : {}), + }); + } } } return items; @@ -391,6 +410,12 @@ export class ClaudeSidechainTracker { } private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + return this.extractToolActionCandidates(message).filter( + (action) => !isClaudeSubagentHandbackToolName(action.toolName), + ); + } + + private extractToolActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { if (message.type === "assistant") { return this.extractAssistantMessageActions(message); } diff --git a/packages/server/src/server/agent/providers/claude/subagent-handback.ts b/packages/server/src/server/agent/providers/claude/subagent-handback.ts new file mode 100644 index 0000000000..2a8613ed31 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagent-handback.ts @@ -0,0 +1,27 @@ +/** + * Since Claude Code 2.1.271, a subagent in auto mode delivers its report to its caller through a + * `SubagentHandback` tool call instead of its final text. The report is the subagent's final + * message, so Paseo shows it as one and hides the call and its acknowledgement. + */ +const SUBAGENT_HANDBACK_TOOL_NAME = "SubagentHandback"; + +export interface ClaudeSubagentHandback { + callId: string; + /** Null while the call is still streaming its input. */ + report: string | null; +} + +export function isClaudeSubagentHandbackToolName(name: unknown): boolean { + return name === SUBAGENT_HANDBACK_TOOL_NAME; +} + +export function readClaudeSubagentHandback(block: { + type: string; + [key: string]: unknown; +}): ClaudeSubagentHandback | null { + if (block.type !== "tool_use" || !isClaudeSubagentHandbackToolName(block.name)) return null; + if (typeof block.id !== "string" || block.id.length === 0) return null; + const input = block.input as { message?: unknown } | null | undefined; + const report = typeof input?.message === "string" ? input.message : ""; + return { callId: block.id, report: report.trim().length > 0 ? report : null }; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts index 3b2ef59a80..b0bfc3d755 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -180,6 +180,83 @@ describe("ClaudeTaskProtocolSource", () => { ]); }); + it("declares a nested task beneath the sidechain agent that launched it", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe( + taskStarted({ + task_id: "direct-task", + tool_use_id: "toolu_direct", + spawn_depth: 1, + }), + ); + source.observeSidechainFrame( + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { + type: "tool_use", + id: "toolu_nested", + name: "Agent", + input: { name: "nested-owner" }, + }, + ], + }, + } as unknown as SDKMessage, + "toolu_direct", + ); + + expect( + source.observe( + taskStarted({ + task_id: "nested-task", + tool_use_id: "toolu_nested", + spawn_depth: 2, + }), + ), + ).toContainEqual( + expect.objectContaining({ + kind: "declared", + id: "toolu_nested", + parentSubagentId: "toolu_direct", + }), + ); + expect(source.needsSyntheticParentToolCard("toolu_nested")).toBe(false); + }); + + it("resolves a background task to the sidechain agent that launched it", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe( + taskStarted({ task_id: "direct-task", tool_use_id: "toolu_direct", spawn_depth: 1 }), + ); + source.observeSidechainFrame( + { + type: "assistant", + parent_tool_use_id: "toolu_direct", + message: { + model: "claude-sonnet-5", + content: [ + { type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "sleep 2" } }, + ], + }, + } as unknown as SDKMessage, + "toolu_direct", + ); + source.observe( + taskStarted({ + task_id: "bash-task", + tool_use_id: "toolu_bash", + task_type: "local_bash", + subagent_type: undefined, + owned_by_subagent: true, + }), + ); + + expect(source.resolveTaskOwner("bash-task", "toolu_bash")).toBe("toolu_direct"); + }); + it("does not re-announce a status that already holds", () => { const source = new ClaudeTaskProtocolSource(); source.observe(taskStarted()); @@ -434,6 +511,33 @@ describe("ClaudeTaskProtocolSource", () => { expect(source.cancelRunningForegroundTasks()).toEqual([]); }); + it("does not cancel a subagent announced as backgrounded at spawn", () => { + // Claude sets is_backgrounded on task_started for a child spawned in the background, and + // sends no task_updated patch for it. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a resumed subagent, which Claude always runs in the background", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ tool_use_id: "toolu_original", is_backgrounded: false })); + source.observe(taskUpdated("completed")); + source.observe(taskStarted({ tool_use_id: "toolu_resumed", is_backgrounded: true })); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still cancels a subagent announced in the foreground", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted({ is_backgrounded: false })); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + it("still routes a backgrounded subagent that settles after the interrupt", () => { // The headline case: interrupt, continue, and the child that was told to outlive the turn // reports completion later. Wiping the routing table on cancel drops this on the floor and diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index 308c1a6087..2149fae139 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -16,7 +16,8 @@ import { * * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): * - * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_started task_id, tool_use_id, description, subagent_type, task_type, + * is_backgrounded * task_updated task_id, patch.status, patch.is_backgrounded * task_notification task_id, tool_use_id, status * @@ -39,6 +40,11 @@ interface TaskStartedMessage { task_type?: string; prompt?: string; skip_transcript?: boolean; + /** + * Set at spawn: true for a task registered in the background. A resumed subagent is always + * registered in the background. + */ + is_backgrounded?: boolean; } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ @@ -71,8 +77,8 @@ interface TaskUpdatedMessage { task_id: string; /** * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground - * task is backgrounded, which is the only signal that separates a child that dies with its turn - * from one that was explicitly told to outlive it. + * task is moved to the background after `task_started`. Together with `task_started`'s own + * flag it separates a child that dies with its turn from one told to outlive it. */ patch?: { status?: string; is_backgrounded?: boolean }; } @@ -154,6 +160,10 @@ export class ClaudeTaskProtocolSource { private readonly subagentIdByTaskId = new Map(); /** Every announced tool id -> the first tool id that publicly identifies the child. */ private readonly canonicalIdByToolUseId = new Map(); + /** Tool calls made inside a sidechain, keyed to the direct child that emitted them. */ + private readonly ownerSubagentIdByToolUseId = new Map(); + /** Announced tasks inherit the owner recorded for their tool call, including local_bash. */ + private readonly ownerSubagentIdByTaskId = new Map(); /** * Every subagent id this source declared. It is the source's whole vocabulary: an id that is * not in here was either filtered at declaration or never announced, and this source has @@ -167,8 +177,8 @@ export class ClaudeTaskProtocolSource { /** Workflow invocations already own a real Workflow card in the parent timeline. */ private readonly idsWithExistingParentToolCard = new Set(); /** - * Declared subagents that were moved to the background. They outlive the turn that spawned - * them, so a turn ending is not evidence that they stopped. + * Declared subagents running in the background, whether spawned there or moved there later. + * They outlive the turn that spawned them, so a turn ending is not evidence that they stopped. */ private readonly backgroundedIds = new Set(); /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ @@ -229,6 +239,14 @@ export class ClaudeTaskProtocolSource { return subagentId !== undefined && this.declaredIds.has(subagentId); } + /** Resolve a non-subagent task (for example local_bash) to its emitting sidechain. */ + resolveTaskOwner(taskId: string, toolUseId?: string): string | undefined { + return ( + this.ownerSubagentIdByTaskId.get(taskId) ?? + (toolUseId ? this.ownerSubagentIdByToolUseId.get(toolUseId) : undefined) + ); + } + needsSyntheticParentToolCard(subagentId: string): boolean { return !this.idsWithExistingParentToolCard.has(subagentId); } @@ -259,6 +277,8 @@ export class ClaudeTaskProtocolSource { reset(): void { this.subagentIdByTaskId.clear(); this.canonicalIdByToolUseId.clear(); + this.ownerSubagentIdByToolUseId.clear(); + this.ownerSubagentIdByTaskId.clear(); this.declaredIds.clear(); this.workflowTaskIds.clear(); this.lastWorkflowResultByTaskId.clear(); @@ -310,32 +330,51 @@ export class ClaudeTaskProtocolSource { this.sawAnyTask = true; const id = readString(message.tool_use_id); + const parentSubagentId = id ? this.ownerSubagentIdByToolUseId.get(id) : undefined; + if (parentSubagentId) this.ownerSubagentIdByTaskId.set(message.task_id, parentSubagentId); // skip_transcript marks ambient housekeeping the transcript should not show. if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return []; this.sawTaskStarted = true; const existingId = this.subagentIdByTaskId.get(message.task_id); + this.recordBackgrounded(existingId ?? id, message.is_backgrounded); if (existingId) { - this.canonicalIdByToolUseId.set(id, existingId); - const observations: SubagentObservation[] = []; - if (this.lastStatusById.get(existingId) !== "running") { - this.lastStatusById.set(existingId, "running"); - observations.push({ kind: "status", id: existingId, status: "running" }); - } - const prompt = - message.task_type === CLAUDE_WORKFLOW_TASK_TYPE - ? readString(message.description) - : readString(message.prompt); - if (prompt) { - observations.push({ - kind: "timeline", - id: existingId, - item: { type: "user_message", text: prompt }, - }); - } - return observations; + return this.observeExistingTaskStart(message, id, existingId); } + return this.observeNewTaskStart(message, id, parentSubagentId); + } + + private observeExistingTaskStart( + message: TaskStartedMessage, + toolUseId: string, + existingId: string, + ): SubagentObservation[] { + this.canonicalIdByToolUseId.set(toolUseId, existingId); + const observations: SubagentObservation[] = []; + if (this.lastStatusById.get(existingId) !== "running") { + this.lastStatusById.set(existingId, "running"); + observations.push({ kind: "status", id: existingId, status: "running" }); + } + const prompt = + message.task_type === CLAUDE_WORKFLOW_TASK_TYPE + ? readString(message.description) + : readString(message.prompt); + if (prompt) { + observations.push({ + kind: "timeline", + id: existingId, + item: { type: "user_message", text: prompt }, + }); + } + return observations; + } + + private observeNewTaskStart( + message: TaskStartedMessage, + id: string, + parentSubagentId: string | undefined, + ): SubagentObservation[] { this.subagentIdByTaskId.set(message.task_id, id); this.canonicalIdByToolUseId.set(id, id); this.declaredIds.add(id); @@ -344,8 +383,10 @@ export class ClaudeTaskProtocolSource { // An explicit `name` on the Task call wins over the agent type, matching how replay titles the // same subagent. Without it a fan-out of five Explores reads as five identical rows. const isWorkflow = message.task_type === CLAUDE_WORKFLOW_TASK_TYPE; - if (isWorkflow) { + if (isWorkflow || parentSubagentId) { this.idsWithExistingParentToolCard.add(id); + } + if (isWorkflow) { this.workflowTaskIds.add(message.task_id); } const title = isWorkflow @@ -359,6 +400,7 @@ export class ClaudeTaskProtocolSource { toolCallId: id, ...(title ? { title } : {}), ...(description ? { description } : {}), + ...(parentSubagentId ? { parentSubagentId } : {}), }, ]; const initialPresentation = title ? { title } : {}; @@ -379,14 +421,15 @@ export class ClaudeTaskProtocolSource { private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { const id = this.subagentIdByTaskId.get(message.task_id); - const backgrounded = message.patch?.is_backgrounded; - if (id && typeof backgrounded === "boolean") { - if (backgrounded) this.backgroundedIds.add(id); - else this.backgroundedIds.delete(id); - } + if (id) this.recordBackgrounded(id, message.patch?.is_backgrounded); return this.observeStatus(message.task_id, message.patch?.status); } + private recordBackgrounded(id: string, backgrounded: boolean | undefined): void { + if (backgrounded === true) this.backgroundedIds.add(id); + else if (backgrounded === false) this.backgroundedIds.delete(id); + } + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { const observations = this.observeWorkflowResult(message); observations.push(...this.observeUsage(message.task_id, message.usage)); @@ -447,13 +490,23 @@ export class ClaudeTaskProtocolSource { * The model the child is actually running, read off its own assistant frames. * * Routed through the declaration for the same reason status is: a frame carrying the tool_use - * id of a task this source filtered out — ambient housekeeping, a workflow child, a nested - * grandchild announced in someone else's session — would otherwise fold to an upsert with no + * id of a task this source filtered out — ambient housekeeping or a workflow child — would + * otherwise fold to an upsert with no * identity and a defaulted "running" status. That is the nameless, never-finishing row the * declaration filter exists to prevent, arriving by a different door. */ observeSidechainFrame(message: SDKMessage, subagentId: string): SubagentObservation[] { if (message.type !== "assistant" || !this.declaredIds.has(subagentId)) return []; + const content = message.message?.content; + if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== "object") continue; + const record = block as { type?: unknown; id?: unknown }; + if (record.type === "tool_use" && typeof record.id === "string") { + this.ownerSubagentIdByToolUseId.set(record.id, subagentId); + } + } + } const model = resolveObservedClaudeModelId( typeof message.message?.model === "string" ? message.message.model : undefined, ); diff --git a/packages/server/src/server/agent/providers/claude/subagents/observation.ts b/packages/server/src/server/agent/providers/claude/subagents/observation.ts index 718a9fc10a..147d8690a4 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/observation.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/observation.ts @@ -24,6 +24,8 @@ export type SubagentObservation = /** The task this child was given. What actually distinguishes it from its siblings. */ description?: string; toolCallId?: string; + /** Provider subagent that launched this one. Absent means a direct child of the agent. */ + parentSubagentId?: string; timestamp?: string; } | { kind: "status"; id: string; status: ProviderSubagentStatus; timestamp?: string } @@ -84,6 +86,9 @@ export function foldSubagentObservations( ...(observation.title === undefined ? {} : { title: observation.title }), ...(observation.description === undefined ? {} : { description: observation.description }), ...(observation.toolCallId === undefined ? {} : { toolCallId: observation.toolCallId }), + ...(observation.parentSubagentId === undefined + ? {} + : { parentSubagentId: observation.parentSubagentId }), ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), }); } diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts index 6dceb8d0ce..e28d15b8c1 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -84,7 +84,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations[0]).toMatchObject({ kind: "declared", @@ -95,25 +95,39 @@ describe("observeReplaySubagents", () => { }); }); - it("replays only this session's own children, not their descendants", () => { - // Claude Code writes every descendant into the same subagents/ directory. A grandchild's - // toolUseId names a Task call made inside its parent's session, so nothing here can resolve - // it: it would replay as an extra row the live stream never showed, with no Task card and no - // outcome. One recorded session showed 10 subagents live and 22 on reopen. + it("replays descendants beneath the sidechain that declared their Task", () => { + const nestedToolUseId = "toolu_012rzYnFZA"; const observations = observeReplaySubagents({ subagents: [ - { agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] }, + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, + entries: [], + parentFacts: { + toolCalls: new Map([ + [nestedToolUseId, { title: "Explore", description: "Nested audit" }], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]), + }, + }, { agentId: "a6acb4b898", - meta: { toolUseId: "toolu_012rzYnFZA", agentType: "Explore", spawnDepth: 2 }, + meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 }, entries: [], }, ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; - expect([...new Set(observations.map((observation) => observation.id))]).toEqual([TOOL_USE_ID]); + expect(observations).toContainEqual( + expect.objectContaining({ + kind: "declared", + id: nestedToolUseId, + parentSubagentId: TOOL_USE_ID, + }), + ); }); it("keeps a subagent recorded before spawnDepth existed", () => { @@ -121,7 +135,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); }); @@ -137,7 +151,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -153,7 +167,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], parent, convertEntry: () => [], - }); + }).observations; expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); @@ -164,7 +178,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -188,7 +202,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toBeNull(); @@ -214,7 +228,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toBeNull(); @@ -230,7 +244,7 @@ describe("observeReplaySubagents", () => { outcomesByToolCallId: new Map(), }, convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" }); @@ -253,7 +267,7 @@ describe("observeReplaySubagents", () => { ], parent: emptyParent(), convertEntry: () => [], - }); + }).observations; expect(observations).toEqual([]); }); @@ -276,7 +290,7 @@ describe("observeReplaySubagents", () => { ], parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, convertEntry: () => [], - }), + }).observations, ); expect(descriptor).toMatchObject({ status: "completed" }); @@ -287,7 +301,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, convertEntry: () => [], - }); + }).observations; expect(observations.some((o) => o.kind === "status")).toBe(false); }); @@ -296,7 +310,7 @@ describe("observeReplaySubagents", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(true), convertEntry: () => [], - }); + }).observations; expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); }); @@ -311,7 +325,7 @@ describe("observeReplaySubagents", () => { ], parent: parentWithTaskCall(), convertEntry: () => [{ type: "reasoning", text: "thinking" }], - }); + }).observations; expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ kind: "timeline", @@ -333,7 +347,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toEqual({ kind: "subtitle", @@ -356,7 +370,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · Sonnet 5 · Low", @@ -368,7 +382,7 @@ describe("replay runtime", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.some((o) => o.kind === "subtitle")).toBe(false); }); @@ -384,7 +398,7 @@ describe("replay runtime", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ subtitle: "general-purpose · glm-5.1", }); @@ -397,7 +411,7 @@ describe("replay usage", () => { subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], parent: parentWithTaskCall(), convertEntry: () => [], - }).find((observation) => observation.kind === "subtitle"); + }).observations.find((observation) => observation.kind === "subtitle"); } it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { @@ -532,7 +546,7 @@ describe("replay usage", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; for (const event of foldSubagentObservations(observations)) { store.apply("parent", "claude", event); } @@ -579,7 +593,7 @@ describe("live and replay agree", () => { ], parent: parentWithTaskCall(), convertEntry: () => [], - }); + }).observations; const fromWire = applyToStore(liveObservations); const fromDisk = applyToStore(replayObservations); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts index 99eb8e6c69..372cbc01b2 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -142,6 +142,8 @@ export interface ClaudeReplaySubagentInput { agentId: string; meta: ClaudeSubagentMeta | null; entries: ClaudeReplayEntry[]; + /** Task declarations made by this subagent, used to attach its direct children. */ + parentFacts?: ClaudeReplayParentFacts; } export interface ClaudeReplayParentFacts { @@ -229,6 +231,7 @@ function declareSubagent( subagent: ClaudeReplaySubagentInput, link: ParentLink, toolCall: { title?: string; description?: string } | undefined, + parentSubagentId?: string, ): SubagentObservation { const title = toolCall?.title ?? subagent.meta?.agentType; const description = toolCall?.description ?? subagent.meta?.description; @@ -239,6 +242,7 @@ function declareSubagent( ...(title ? { title } : {}), ...(description ? { description } : {}), ...(link.toolCallId ? { toolCallId: link.toolCallId } : {}), + ...(parentSubagentId ? { parentSubagentId } : {}), ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), }; } @@ -274,12 +278,15 @@ function observeSubagent( subagent: ClaudeReplaySubagentInput, parent: ClaudeReplayParentFacts, convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], + parentSubagentId?: string, ): SubagentObservation[] { const link = resolveParentLink(subagent, parent); if (!link) return []; const toolCall = parent.toolCalls.get(link.toolCallId); - const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)]; + const observations: SubagentObservation[] = [ + declareSubagent(subagent, link, toolCall, parentSubagentId), + ]; const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); if (subtitle) observations.push(subtitle); @@ -311,34 +318,72 @@ function observeSubagent( return observations; } -/** - * Whether this transcript belongs to a child of the session being replayed. - * - * Claude Code writes every descendant into the same `subagents/` directory, not just the session's - * own children, and `spawnDepth` is what separates them: 1 is a direct child, 2+ was spawned by - * another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session, - * so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1 - * id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them - * anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome. - * One session recorded 10 subagents live and would replay 22. - * - * Absent depth means a session recorded before the field existed: keep the subagent rather than - * lose it, matching how every other missing field here is treated. - */ -function isDirectChild(subagent: ClaudeReplaySubagentInput): boolean { - const depth = subagent.meta?.spawnDepth; - return depth === undefined || depth <= 1; +function recordReplayToolOwners( + owners: Map, + entries: readonly ClaudeReplayEntry[], + subagentId: string, +): void { + for (const entry of entries) { + if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue; + for (const block of entry.message.content) { + if (block?.type === "tool_use" && typeof block.id === "string") { + owners.set(block.id, subagentId); + } + } + } } export function observeReplaySubagents(input: { subagents: readonly ClaudeReplaySubagentInput[]; parent: ClaudeReplayParentFacts; convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; -}): SubagentObservation[] { +}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap } { const observations: SubagentObservation[] = []; - for (const subagent of input.subagents) { - if (!isDirectChild(subagent)) continue; - observations.push(...observeSubagent(subagent, input.parent, input.convertEntry)); + const toolOwners = new Map(); + const unresolved = [...input.subagents].sort( + (left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1), + ); + const resolvedParents = new Map(); + + // Claude stores every descendant beside the root transcript. Resolve one generation at a + // time: a child is admitted only when its tool id is declared by a transcript whose own parent + // has already been proven. This preserves the old boundary against unrelated ambient sidecars. + let madeProgress = true; + while (unresolved.length > 0 && madeProgress) { + madeProgress = false; + for (let index = unresolved.length - 1; index >= 0; index--) { + const subagent = unresolved[index]; + if (!subagent) continue; + const resolved = resolveReplayOwner(subagent, input.parent, resolvedParents); + if (!resolved) continue; + const { ownerId, parent, link } = resolved; + + // Only proven descendants may own notifications; ambient sidecars cannot claim them. + recordReplayToolOwners(toolOwners, subagent.entries, link.id); + observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId)); + if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts); + unresolved.splice(index, 1); + madeProgress = true; + } } - return observations; + return { observations, toolOwners }; +} + +function resolveReplayOwner( + subagent: ClaudeReplaySubagentInput, + rootParent: ClaudeReplayParentFacts, + resolvedParents: ReadonlyMap, +): { + ownerId?: string; + parent: ClaudeReplayParentFacts; + link: NonNullable>; +} | null { + const rootLink = resolveParentLink(subagent, rootParent); + if (rootLink) return { parent: rootParent, link: rootLink }; + + for (const [ownerId, parent] of resolvedParents) { + const link = resolveParentLink(subagent, parent); + if (link) return { ownerId, parent, link }; + } + return null; } diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts index cbf65194de..d538e14c29 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -7,6 +7,18 @@ import { } from "./task-notification-tool-call.js"; describe("task-notification-tool-call", () => { + it.each([undefined, "ordinary queued prompt"])( + "ignores queue bookkeeping without a task notification (%s)", + (content) => { + expect( + mapTaskNotificationSystemRecordToToolCall({ + type: "queue-operation", + operation: "enqueue", + content, + }), + ).toBeNull(); + }, + ); it("detects task notification user content in string payloads", () => { expect( isTaskNotificationUserContent( diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts index d305e72e89..2db4beddb0 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.ts @@ -178,8 +178,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data; const isSystemTaskNotification = systemRecord.type === "system" && systemRecord.subtype === "task_notification"; - const isQueueOperation = systemRecord.type === "queue-operation"; - if (!isSystemTaskNotification && !isQueueOperation) { + const isQueuedTaskNotification = + systemRecord.type === "queue-operation" && isTaskNotificationUserContent(systemRecord.content); + if (!isSystemTaskNotification && !isQueuedTaskNotification) { return null; } const rawText = toNonEmptyString(systemRecord.content); @@ -278,6 +279,7 @@ function toTaskNotificationToolCall( synthetic: true, source: "claude_task_notification", ...(envelope.taskId ? { taskId: envelope.taskId } : {}), + ...(envelope.toolUseId ? { toolUseId: envelope.toolUseId } : {}), ...(envelope.status ? { status: envelope.status } : {}), ...(envelope.outputFile ? { outputFile: envelope.outputFile } : {}), }; diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index 0522d0cd50..f0b3ea2af1 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + mapClaudeCanceledToolCall, mapClaudeCompletedToolCall, mapClaudeFailedToolCall, mapClaudeRunningToolCall, @@ -15,6 +16,40 @@ function expectMapped(item: T | null): T { } describe("claude tool-call mapper", () => { + it("preserves a plan's original tool identity and outcome for live and replayed results", () => { + const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } }; + const pending = expectMapped(mapClaudeRunningToolCall(proposal)); + const rejected = expectMapped( + mapClaudeFailedToolCall({ ...proposal, error: "Denied by user" }), + ); + const approved = expectMapped(mapClaudeCompletedToolCall(proposal)); + const canceled = expectMapped(mapClaudeCanceledToolCall(proposal)); + expect(canceled).toMatchObject({ + callId: proposal.callId, + name: "plan_approval", + status: "canceled", + detail: { type: "plan", text: "Ship it" }, + }); + expect(pending).toMatchObject({ + callId: "plan-tool-1", + detail: { type: "plan", text: "Ship it" }, + }); + expect(rejected).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + error: null, + detail: pending.detail, + metadata: { approved: false }, + }); + expect(approved).toMatchObject({ + callId: pending.callId, + name: "plan_approval", + status: "completed", + detail: pending.detail, + metadata: { approved: true }, + }); + }); it("maps running shell calls with canonical fields", () => { const item = expectMapped( mapClaudeRunningToolCall({ diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index cc8ed529f0..896cdc962a 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -12,6 +12,8 @@ interface MapperParams { metadata?: Record; } +const ClaudePlanInputSchema = z.object({ plan: z.string() }); + const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]); type ClaudeToolCallStatus = z.infer; @@ -113,6 +115,28 @@ function mapClaudeToolCall( } const trimmedName = raw.name.trim(); + if (trimmedName === "ExitPlanMode") { + const plan = ClaudePlanInputSchema.safeParse(raw.input); + if (plan.success) { + // The permission callback and transcript tool_result describe the same call. + // Keep its native identity so resolution updates the proposal's original position. + return { + type: "tool_call", + callId, + // Older clients suppress the pending native call while showing its approval card. + name: status === "running" ? trimmedName : "plan_approval", + status: status === "failed" ? "completed" : status, + error: null, + detail: { type: "plan", text: plan.data.plan }, + metadata: { + ...raw.metadata, + ...(status === "completed" || status === "failed" + ? { approved: status === "completed" } + : {}), + }, + }; + } + } const toolKind = resolveClaudeToolKind(trimmedName); const name = toolKind === "speak" ? "speak" : trimmedName; const input = raw.input ?? null; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index d6583a2759..8f3f8fc76d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -19,6 +19,41 @@ interface CollaborationModeRecord { developer_instructions?: string | null; } +const TEST_SPEED_MODELS = [ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +].map((id) => ({ + id, + isDefault: id === "gpt-5.4", + defaultReasoningEffort: "medium", + serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }], +})); + +function speedFeature(value: string) { + return { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }; +} + const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [ { name: "Code", @@ -65,7 +100,7 @@ function createConfig(overrides: Partial = {}): AgentSession function createSessionHarness( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): { session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -73,6 +108,7 @@ function createSessionHarness( const config = createConfig(configOverrides); const appServer = createFakeCodexAppServer({ "collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }), + "model/list": () => ({ data: options.models ?? TEST_SPEED_MODELS }), }); const session = new CodexAppServerAgentSession( { ...config, provider: CODEX_PROVIDER }, @@ -85,7 +121,7 @@ function createSessionHarness( async function createConnectedSession( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): Promise<{ session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -97,19 +133,256 @@ async function createConnectedSession( } describe("Codex app-server provider features", () => { - test("features returns fast and plan toggles when supported", async () => { + test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => { + const { session, appServer } = await createConnectedSession( + { model: "gpt-6.1-sol" }, + { + models: [ + { + id: "gpt-6.1-sol", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Faster processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Lowest latency" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + type: "select", + value: "default", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "ultrafast", label: "Ultrafast" }, + ], + }), + ); + await session.setFeature?.("service_tier", "ultrafast"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "ultrafast", + }); + } finally { + await session.close(); + } + }); + + test("new models and service tiers work without a Paseo catalog update", async () => { + const { session, appServer } = await createConnectedSession( + { model: "future-codex-model" }, + { + models: [ + { + id: "future-codex-model", + serviceTiers: [ + { id: "future-tier", name: "Future speed", description: "A newly advertised tier" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "future-tier", label: "Future speed" }, + ], + }), + ); + await session.setFeature?.("service_tier", "future-tier"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model: "future-codex-model", + serviceTier: "future-tier", + }); + } finally { + await session.close(); + } + }); + + test.each(["priority", "ultrafast"])( + "restores %s and Normal explicitly overrides an existing thread tier", + async (tier) => { + const { session, appServer } = await createConnectedSession( + { model: "future-model", featureValues: { service_tier: tier } }, + { + models: [ + { + id: "future-model", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Fast processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: tier }), + ); + await session.getRuntimeInfo(); + await session.setFeature?.("service_tier", "default"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", + }); + } finally { + await session.close(); + } + }, + ); + + test("rejects unadvertised tiers and non-string values", async () => { + const { session } = await createConnectedSession(); + try { + for (const value of ["ultrafast", "bogus", true, null]) { + await expect(session.setFeature?.("service_tier", value)).rejects.toThrow( + "is not available", + ); + } + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + } finally { + await session.close(); + } + }); + + test("switching to a model without the selected tier resets speed", async () => { + const { session, appServer } = await createConnectedSession( + { model: "ultra-model", featureValues: { service_tier: "ultrafast" } }, + { + models: [ + { + id: "ultra-model", + serviceTiers: [{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }], + }, + ...TEST_SPEED_MODELS, + ], + }, + ); + try { + await session.setModel("gpt-6.1-sol"); + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); + } finally { + await session.close(); + } + }); + + test("maps legacy Fast commands to the catalog's request id", async () => { + const { session, appServer } = await createConnectedSession(); + try { + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "priority", + }); + } finally { + await session.close(); + } + }); + + test.each([ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", + ])("exposes and sends Fast for %s", async (model) => { + const { session, appServer } = await createConnectedSession({ model }); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + value: "default", + }), + ); + await session.setFeature?.("service_tier", "priority"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model, + serviceTier: "priority", + }); + } finally { + await session.close(); + } + }); + + test.each([ + "gpt-5.3-codex-spark", + "gpt-5.3-codex", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5-pro", + "gpt-5", + "gpt-4.1", + "o3", + "o4-mini", + "gpt-6-unknown", + ])("does not expose or restore Fast for %s", async (model) => { + const { session, appServer } = await createConnectedSession({ + model, + featureValues: { fast_mode: true }, + }); + try { + expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]); + await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow( + `Codex fast mode is not available for model '${model}'`, + ); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", + }); + } finally { + await session.close(); + } + }); + + test("restores Fast on Astra and preserves it when switching supported models", async () => { + const { session, appServer } = await createConnectedSession({ + model: "gpt-6-astra", + featureValues: { fast_mode: true }, + }); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + value: "priority", + }), + ); + await session.setModel("gpt-5.6-sol"); + await session.setModel("gpt-6-astra"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model: "gpt-6-astra", + serviceTier: "priority", + }); + } finally { + await session.close(); + } + }); + + test("features returns speed selector and plan toggle when supported", async () => { const { session } = await createConnectedSession(); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at 2x usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: false, - }, + speedFeature("default"), { type: "toggle", id: "plan_mode", @@ -121,19 +394,11 @@ describe("Codex app-server provider features", () => { }, ]); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setFeature?.("plan_mode", true); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at 2x usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -181,23 +446,23 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); - test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { + test("selecting Fast sends the catalog service tier", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); - test("setFeature('fast_mode', false) clears serviceTier to null", async () => { + test("legacy Fast off explicitly selects Normal", async () => { const { session, appServer } = await createConnectedSession({ featureValues: { fast_mode: true }, }); @@ -205,8 +470,8 @@ describe("Codex app-server provider features", () => { await session.setFeature?.("fast_mode", false); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); @@ -246,15 +511,7 @@ describe("Codex app-server provider features", () => { }); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at 2x usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -268,7 +525,7 @@ describe("Codex app-server provider features", () => { await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", collaborationMode: expect.objectContaining({ mode: "plan", }), @@ -278,11 +535,11 @@ describe("Codex app-server provider features", () => { test("startTurn includes serviceTier when fast mode is enabled", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); @@ -304,7 +561,7 @@ describe("Codex app-server provider features", () => { msg: "Starting Codex app-server turn", model: "gpt-5.4", modeId: "auto", - serviceTier: "fast", + serviceTier: "priority", cwd: "/tmp/codex-fast-mode-test", }); expect(JSON.stringify(entry)).not.toContain(prompt); @@ -313,7 +570,7 @@ describe("Codex app-server provider features", () => { test("setModel clears fast mode when switching to an unsupported model", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setModel("gpt-3.5-turbo"); expect(session.features).toEqual([ @@ -329,8 +586,8 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 28d0252cf5..0469d9fbe1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; @@ -6,6 +6,9 @@ import { createServer } from "node:http"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { once } from "node:events"; +import { AgentManager } from "../agent-manager.js"; +import { AgentStorage } from "../agent-storage.js"; + import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { AgentStreamEvent } from "../agent-sdk-types.js"; @@ -173,7 +176,7 @@ function writeMockCodexConfig(codexHome: string, serverUrl: string): void { path.join(codexHome, "config.toml"), ` model = "mock-model" -approval_policy = "untrusted" +approval_policy = "on-request" sandbox_mode = "read-only" model_provider = "mock_provider" @@ -214,6 +217,53 @@ function waitForEvent(params: { } describe("Codex app-server provider (local e2e)", () => { + test.runIf(isCodexInstalled())( + "reloads a persisted idle thread repeatedly without overlapping writers", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-reload-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-reload-home-")); + const mockServer = await startMockResponsesServer([assistantMessageSse("saved")]); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + writeMockCodexConfig(codexHome, mockServer.url); + vi.stubEnv("CODEX_HOME", codexHome); + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + await manager.runAgent(agentId, "save this turn"); + const threadId = manager.getAgent(agentId)?.persistence?.sessionId; + expect(threadId).toBeTruthy(); + for (let i = 0; i < 3; i++) { + const reloaded = await manager.reloadAgentSession(agentId, undefined, { + rehydrateFromDisk: true, + }); + expect(reloaded.persistence?.sessionId).toBe(threadId); + expect(reloaded.lifecycle).toBe("idle"); + } + await manager.runAgent(agentId, "continue after reload"); + expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + vi.unstubAllEnvs(); + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); + test.runIf(isCodexInstalled())( "surfaces request_user_input from the app-server as question permissions and timeline tool calls", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 63a45be061..9cfeca60d3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -11,6 +11,7 @@ import type { AgentLaunchContext, AgentSession, AgentSessionConfig, + AgentStreamEvent, } from "../agent-sdk-types.js"; import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { @@ -20,6 +21,87 @@ import { const logger = createTestLogger(); +interface PendingPlanLifecycle { + close(): Promise; + crashDuringActiveTurn(): Promise; + expectCanceledPlan(): void; +} + +async function withPendingPlan( + runScenario: (plan: PendingPlanLifecycle) => Promise, +): Promise { + const workdir = mkdtempSync(join(tmpdir(), "codex-plan-cancel-")); + const appServer = createFakeCodexAppServer(); + const client = new ProcessExitCodexClient([appServer]); + const session = await client.createSession({ + provider: "codex", + cwd: workdir, + modeId: "auto", + model: "gpt-5.4", + featureValues: { plan_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + const run = session.run("make a plan"); + await appServer.waitForTurnStart(); + appServer.startsTurn({ threadId: "thread-1", turnId: "plan-turn" }); + appServer.updatesPlan({ threadId: "thread-1", steps: ["Inspect README"] }); + appServer.completeTurn(); + await run; + const proposal = events.find( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.name === "plan_approval", + ); + expect(proposal).toBeDefined(); + const [request] = session.getPendingPermissions?.() ?? []; + expect(request).toBeDefined(); + await runScenario({ + close: () => session.close(), + async crashDuringActiveTurn() { + appServer.startsTurn({ threadId: "thread-1", turnId: "autonomous-turn" }); + await expect + .poll(() => events.filter((event) => event.type === "turn_started").length) + .toBe(2); + appServer.child.emit("exit", 17, null); + await expect.poll(() => events.some((event) => event.type === "turn_failed")).toBe(true); + }, + expectCanceledPlan() { + expect(session.getPendingPermissions?.()).toEqual([]); + expect(events).toContainEqual({ + ...proposal, + item: expect.objectContaining({ + type: "tool_call", + name: "plan_approval", + callId: request?.id, + status: "canceled", + detail: { type: "plan", text: "- Inspect README" }, + }), + }); + }, + }); + } finally { + await session.close(); + rmSync(workdir, { recursive: true, force: true }); + } +} + +test("closing a session retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.close(); + plan.expectCanceledPlan(); + }); +}); + +test("an active provider crash retains a canceled pending plan", async () => { + await withPendingPlan(async (plan) => { + await plan.crashDuringActiveTurn(); + plan.expectCanceledPlan(); + }); +}); + class ProcessExitCodexClient extends CodexAppServerAgentClient implements AgentClient { constructor(private readonly appServers: FakeCodexAppServer[]) { super(logger); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts index bd1e0580f7..bff992e2ff 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.spawn-error.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { createCodexAppServerChildProcess } from "./codex/test-utils/fake-app-server.js"; describe("CodexAppServerAgentClient spawn error handling", () => { const logger = createTestLogger(); @@ -32,6 +34,31 @@ describe("CodexAppServerAgentClient spawn error handling", () => { } }); + test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => { + const client = new CodexAppServerAgentClient(logger, { + command: { + mode: "replace", + argv: ["/nonexistent/codex-binary-that-does-not-exist"], + }, + }); + const child = createCodexAppServerChildProcess(); + const kill = vi.spyOn(child, "kill"); + Object.assign(client, { spawnAppServer: async () => child }); + + await expect( + runProviderRefreshWithDeadline({ + label: "Codex", + timeoutMs: 50, + operation: (context) => + client.fetchCatalog( + { scope: "workspace", cwd: "/tmp/codex-models", force: false }, + context, + ), + }), + ).rejects.toThrow("Timed out refreshing Codex after 50ms"); + expect(kill).toHaveBeenCalled(); + }); + test("listImportableSessions rejects gracefully when the codex binary does not exist", async () => { const client = new CodexAppServerAgentClient(logger, { command: { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1046532867..c8269722a7 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import type { AgentLaunchContext, + AgentPermissionRequest, AgentSession, AgentSessionConfig, AgentSlashCommand, @@ -111,7 +112,7 @@ interface CodexSessionTestAccess { ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; - loadPersistedHistory(): Promise; + loadPersistedHistory(client: CodexClientLike | null): Promise; refreshResolvedCollaborationMode(): void; serviceTier: "fast" | null; planModeEnabled: boolean; @@ -124,7 +125,7 @@ interface CodexClientLike { } type CodexTestSession = AgentSession & { - connected: boolean; + connectionState: "disconnected" | "history-ready" | "connected"; currentThreadId: string | null; activeForegroundTurnId: string | null; client: CodexClientLike | null; @@ -165,14 +166,22 @@ function createSession( options.goalsEnabled === true, options.autoReviewEnabled === true, ) as CodexTestSession; - session.connected = true; + session.connectionState = "connected"; session.currentThreadId = "test-thread"; session.activeForegroundTurnId = "test-turn"; return session; } -function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient { - const provider = new CodexAppServerAgentClient(createTestLogger()); +function createProviderWithFakeAppServer( + appServer: FakeCodexAppServer, + options?: { + runtimeSettings?: ConstructorParameters[1]; + customProvider?: { id: string; label: string; extends: string }; + }, +): CodexAppServerAgentClient { + const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, { + customProvider: options?.customProvider, + }); const internals = castInternals<{ goalsEnabledPromise: Promise | null; autoReviewEnabledPromise: Promise | null; @@ -609,6 +618,131 @@ process.stdin.on("data", (chunk) => { } } +async function withCustomCodexProviderHome( + run: (input: { + session: AgentSession; + readCaptured: () => CapturedFakeCodexRecord[]; + }) => Promise, + providerPrompts: Record = {}, +): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-")); + const daemonCodexHome = path.join(tempDir, "daemon-codex-home"); + const providerCodexHome = path.join(tempDir, "provider-codex-home"); + const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs"); + const capturedRequestsPath = path.join(tempDir, "requests.jsonl"); + mkdirSync(path.join(daemonCodexHome, "prompts"), { recursive: true }); + mkdirSync(path.join(providerCodexHome, "prompts"), { recursive: true }); + writeFileSync( + path.join(daemonCodexHome, "prompts", "probe-default.md"), + "---\ndescription: Daemon home prompt\n---\nfrom the daemon home\n", + ); + writeFileSync( + path.join(providerCodexHome, "prompts", "probe-profile.md"), + "---\ndescription: Provider home prompt\n---\nfrom the provider home\n", + ); + for (const [name, content] of Object.entries(providerPrompts)) { + writeFileSync(path.join(providerCodexHome, "prompts", `${name}.md`), content); + } + writeFileSync( + fakeAppServerPath, + ` +const fs = require("node:fs"); + +const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE; +let buffer = ""; + +fs.appendFileSync(capturePath, JSON.stringify({ kind: "env", CODEX_HOME: process.env.CODEX_HOME }) + "\\n"); + +function resultFor(method) { + if (method === "collaborationMode/list") return { data: [] }; + if (method === "skills/list") return { data: [] }; + if (method === "model/list") return { data: [{ id: "profile-model", isDefault: true }] }; + if (method === "thread/start") return { thread: { id: "thread-1" } }; + return {}; +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + const message = JSON.parse(line); + fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method: message.method, params: message.params }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n"); + } +}); +`, + ); + + vi.stubEnv("CODEX_HOME", daemonCodexHome); + const registry = buildProviderRegistry(createTestLogger(), { + providerOverrides: { + "profile-codex": { + extends: "codex", + label: "Profile Codex", + command: [process.execPath, fakeAppServerPath], + env: { + CODEX_HOME: providerCodexHome, + PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath, + }, + }, + }, + }); + const session = await registry["profile-codex"].createClient(createTestLogger()).createSession({ + provider: "profile-codex", + cwd: tempDir, + modeId: "auto", + }); + + try { + expect(session.usageSession?.()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: providerCodexHome }, + }); + expect(session.usageSession?.()?.sessionKey).toBe(session.usageSession?.()?.sessionKey); + return await run({ + session, + readCaptured: () => + readFileSync(capturedRequestsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as CapturedFakeCodexRecord), + }); + } finally { + await session.close(); + expect(session.usageSession?.()).toBeNull(); + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +async function listPromptCommandsFromCustomCodexHome(): Promise { + return withCustomCodexProviderHome(async ({ session }) => { + const commands = await session.listCommands!(); + return commands.map((command) => command.name).filter((name) => name.startsWith("prompts:")); + }); +} + +async function runPromptFromCustomCodexHome( + prompt: string, + providerPrompts: Record = {}, +): Promise { + return withCustomCodexProviderHome(async ({ session, readCaptured }) => { + await session.startTurn(prompt); + return readCaptured(); + }, providerPrompts); +} + +async function expandCustomPrompt(template: string, args: string): Promise { + const records = await runPromptFromCustomCodexHome(`/prompts:template ${args}`, { template }); + const turnStart = records.find((record) => record.method === "turn/start"); + const params = turnStart?.params as { input: Array<{ text: string }> }; + return params.input[0].text; +} + function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown { const threadStart = records.find((record) => record.method === "thread/start"); const params = threadStart?.params as Record | undefined; @@ -639,6 +773,7 @@ let buffer = ""; function resultFor(method, params) { if (method === "initialize") return {}; if (method === "collaborationMode/list") return { data: [] }; + if (method === "model/list") return { data: [] }; if (method === "skills/list") { const cwds = params && params.cwds; const projectCwd = ${JSON.stringify(projectCwd)}; @@ -744,6 +879,94 @@ describe("Codex app-server provider", () => { }); }); + test("Default Permissions pins the user as approvals reviewer on thread/start", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const session = createSession( + { modeId: "auto", thinkingOptionId: "medium" }, + { autoReviewEnabled: true }, + ); + session.currentThreadId = null; + session.activeForegroundTurnId = null; + session.client = { + request: vi.fn(async (method: string, params: unknown) => { + requests.push({ method, params }); + if (method === "thread/start") { + return { thread: { id: "default-mode-thread" } }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }), + }; + + await session.startTurn("trigger thread creation"); + + const startCall = requests.find((req) => req.method === "thread/start"); + expect(startCall?.params).toMatchObject({ + approvalPolicy: "on-request", + sandbox: "workspace-write", + approvalsReviewer: "user", + }); + }); + + test("switching from auto-review back to Default returns approvals to the user", async () => { + const session = createSession({ modeId: "auto-review" }, { autoReviewEnabled: true }); + const request = vi.fn(async (method: string) => { + if (method === "thread/loaded/list") { + return { data: ["test-thread"] }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + session.activeForegroundTurnId = null; + session.client = createStub({ request }); + + await session.setMode("auto"); + await session.startTurn("needs approval"); + + const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start"); + expect(turnStartCall?.[1]).toEqual( + expect.objectContaining({ + approvalPolicy: "on-request", + approvalsReviewer: "user", + }), + ); + }); + + test("Read-only pins the user as approvals reviewer on thread/start", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const session = createSession( + { modeId: "read-only", thinkingOptionId: "medium" }, + { autoReviewEnabled: true }, + ); + session.currentThreadId = null; + session.activeForegroundTurnId = null; + session.client = { + request: vi.fn(async (method: string, params: unknown) => { + requests.push({ method, params }); + if (method === "thread/start") { + return { thread: { id: "read-only-thread" } }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }), + }; + + await session.startTurn("trigger thread creation"); + + const startCall = requests.find((req) => req.method === "thread/start"); + expect(startCall?.params).toMatchObject({ + approvalPolicy: "on-request", + sandbox: "read-only", + approvalsReviewer: "user", + }); + }); + test("setMode and setThinkingOption return a next-turn notice while a turn is active", async () => { const session = createSession({ modeId: "auto", thinkingOptionId: "medium" }); @@ -834,6 +1057,7 @@ describe("Codex app-server provider", () => { const turnStart = request.mock.calls.find(([method]) => method === "turn/start")?.[1]; expect(turnStart).not.toHaveProperty("approvalPolicy"); expect(turnStart).not.toHaveProperty("sandboxPolicy"); + expect(turnStart).not.toHaveProperty("approvalsReviewer"); }); test("carries the complete native workspace-write policy including writable roots", async () => { @@ -916,6 +1140,53 @@ describe("Codex app-server provider", () => { } }); + test("keeps the default effort when a session without one is resumed after a restart", async () => { + const beforeRestart = createFakeCodexAppServer(); + const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession( + createConfig({ thinkingOptionId: undefined }), + ); + await firstSession.startTurn("first turn"); + await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + const handle = firstSession.describePersistence()!; + await firstSession.close(); + + const afterRestart = createFakeCodexAppServer(); + // The daemon resumes with the stored agent config, which never recorded an effort. + const resumed = await createProviderWithFakeAppServer(afterRestart).resumeSession(handle, { + model: "gpt-5.4", + thinkingOptionId: undefined, + }); + + try { + await expect(resumed.getRuntimeInfo()).resolves.toMatchObject({ + thinkingOptionId: "medium", + }); + await resumed.startTurn("turn after restart"); + await expect(afterRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + beforeRestart.assertNoErrors(); + afterRestart.assertNoErrors(); + } finally { + await resumed.close(); + } + }); + + test("provider persistence leaves options owned by the stored agent config", async () => { + const appServer = createFakeCodexAppServer(); + const provider = createProviderWithFakeAppServer(appServer); + const session = await provider.createSession( + createConfig({ providerOptions: { sandbox_mode: "read-only", approval_policy: "never" } }), + ); + try { + await session.startTurn("persist the configured session"); + await appServer.waitForTurnStart(); + const handle = session.describePersistence()!; + expect(handle.metadata).not.toHaveProperty("providerOptions"); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -1022,7 +1293,13 @@ describe("Codex app-server provider", () => { child.stderr = new PassThrough() as ChildProcessWithoutNullStreams["stderr"]; child.exitCode = null; child.signalCode = null; - child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"]; + child.kill = vi.fn((signal) => { + if (signal === "SIGKILL") { + child.signalCode = "SIGKILL"; + child.emit("exit", null, "SIGKILL"); + } + return true; + }) as ChildProcessWithoutNullStreams["kill"]; const client = new CodexAppServerClient(child, createTestLogger()); try { @@ -1093,6 +1370,75 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("answers each concurrent command approval callback that shares one item", async () => { + const appServer = createFakeCodexAppServer({ + initialize: () => ({}), + "collaborationMode/list": () => ({ data: [] }), + "skills/list": () => ({ data: [] }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const requested: AgentPermissionRequest[] = []; + session.subscribe((event) => { + if (event.type === "permission_requested") requested.push(event.request); + }); + + try { + await session.connect(); + + const firstPermission = waitForNextPermission(session); + appServer.requestCommandApproval({ + itemId: "shared-item", + approvalId: "callback-a", + threadId: "thread-1", + turnId: "turn-1", + command: "git add a.txt", + cwd: "/workspace/project", + reason: "subcommand a", + }); + await firstPermission; + const secondPermission = waitForNextPermission(session); + appServer.requestCommandApproval({ + itemId: "shared-item", + approvalId: "callback-b", + threadId: "thread-1", + turnId: "turn-1", + command: "git add b.txt", + cwd: "/workspace/project", + reason: "subcommand b", + }); + await secondPermission; + + expect(requested.map((request) => request.input?.command)).toEqual([ + "git add a.txt", + "git add b.txt", + ]); + expect(new Set(requested.map((request) => request.id)).size).toBe(2); + expect(session.getPendingPermissions()).toHaveLength(2); + + await session.respondToPermission(requested[0]!.id, { behavior: "allow" }); + await session.respondToPermission(requested[1]!.id, { + behavior: "deny", + message: "not b", + }); + + await expect(appServer.waitForCommandApprovalDecision("callback-a")).resolves.toEqual({ + decision: "accept", + }); + await expect(appServer.waitForCommandApprovalDecision("callback-b")).resolves.toEqual({ + decision: "decline", + }); + expect(session.getPendingPermissions()).toHaveLength(0); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("shows a successful shell command that produces no output", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( @@ -1255,6 +1601,51 @@ describe("Codex app-server provider", () => { } }); + test.each(["python3 -i", "/usr/bin/zsh -lc 'python3 -i'"])( + "labels a terminal write started as %s with the command that started the terminal", + async (command) => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + await session.connect(); + appServer.startsTerminalCommand({ + threadId: "thread-1", + turnId: "turn-1", + itemId: "interactive-shell", + processId: "73", + command, + }); + + const terminalWrite = waitForTimelineToolCall(session, "terminal-session-73-1"); + appServer.typesIntoTerminal({ + threadId: "thread-1", + turnId: "turn-1", + itemId: "interactive-shell", + processId: "73", + text: "print(1)\n", + }); + + await expect(terminalWrite).resolves.toMatchObject({ + item: { + callId: "terminal-session-73-1", + name: "terminal", + detail: { type: "plain_text", label: "python3 -i", text: "print(1)\n" }, + metadata: { processId: "73" }, + }, + }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }, + ); + test("keeps repeated writes to one terminal as separate timeline rows", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( @@ -1407,6 +1798,28 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("resumed session exposes usage before connecting and not after close", async () => { + let spawns = 0; + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + { sessionId: "saved-thread" }, + createTestLogger(), + async () => { + spawns++; + throw new Error("unexpected spawn"); + }, + { environment: { HOME: "/fixture/codex", CODEX_HOME: "/fixture/profile" } }, + ); + expect(session.usageSession()).toMatchObject({ + provider: "codex", + env: { CODEX_HOME: "/fixture/profile" }, + sessionKey: expect.any(String), + }); + expect(spawns).toBe(0); + await session.close(); + expect(session.usageSession()).toBeNull(); + }); + test("loads archived Codex history without resuming the native thread", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ @@ -1429,7 +1842,7 @@ describe("Codex app-server provider", () => { purpose: "history", }); - expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]); + expect(threadRequests).toEqual(["thread/read"]); await session.close(); appServer.assertNoErrors(); }); @@ -1674,6 +2087,66 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("rewinds a legacy conversation with a bounded fork on Codex without thread/rollback", async () => { + const appServer = createFakeCodexAppServer({ + initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }), + "thread/read": () => ({ + thread: { id: "thread-1", historyMode: "legacy", turns: [] }, + }), + "thread/rollback": () => ({ + __jsonRpcError: { + code: -32600, + message: "Invalid request: unknown variant `thread/rollback`", + }, + }), + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await session.startTurn("remember first"); + emitCodexUserMessage(appServer, { + id: "codex-first", + text: "remember first", + turnId: "turn-first", + }); + appServer.completeTurn(); + await session.startTurn("remember second"); + emitCodexUserMessage(appServer, { + id: "codex-second", + text: "remember second", + turnId: "turn-second", + }); + appServer.completeTurn(); + + await session.revertConversation({ messageId: "codex-second" }); + + const forkRequests = appServer + .requests() + .filter((request) => request.method === "thread/fork") + .map((request) => request.params); + expect(forkRequests).toEqual([ + { + threadId: "thread-1", + beforeTurnId: "turn-second", + cwd: "/workspace/project", + model: "gpt-5.4", + serviceTier: null, + excludeTurns: false, + persistExtendedHistory: true, + }, + ]); + expect(appServer.recordedRollbacks).toEqual([]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + sessionId: "forked-thread", + }); + appServer.assertNoErrors(); + await session.close(); + }); + test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ "thread/read": () => ({ @@ -1730,6 +2203,79 @@ describe("Codex app-server provider", () => { await session.close(); }); + test.each(["legacy", "paginated"] as const)( + "rewinds a %s thread onto a fork that keeps the custom provider and runtime MCP servers", + async (historyMode) => { + const appServer = createFakeCodexAppServer( + historyMode === "paginated" + ? { + "thread/read": () => ({ + thread: { id: "thread-1", historyMode: "paginated", turns: [] }, + }), + } + : undefined, + ); + const customCodexConfig = { + model_provider: "codex-custom", + model_providers: { + "codex-custom": { + name: "Custom Codex", + base_url: "https://custom-relay.example.com/v1", + env_key: "OPENAI_API_KEY", + requires_openai_auth: false, + wire_api: "responses", + }, + }, + }; + const session = new CodexAppServerAgentSession( + createConfig({ + cwd: "/workspace/project", + mcpServers: { + paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" }, + }, + }), + null, + createTestLogger(), + async () => appServer.child, + { customCodexConfig }, + ); + + await session.startTurn("remember first"); + emitCodexUserMessage(appServer, { + id: "codex-first", + text: "remember first", + turnId: "turn-first", + }); + appServer.completeTurn(); + await session.startTurn("remember second"); + emitCodexUserMessage(appServer, { + id: "codex-second", + text: "remember second", + turnId: "turn-second", + }); + appServer.completeTurn(); + + await session.revertConversation({ messageId: "codex-first" }); + + const forkConfigs = appServer + .requests() + .filter((request) => request.method === "thread/fork") + .map((request) => (request.params as { config?: unknown }).config); + expect(forkConfigs).toEqual([ + expect.objectContaining({ + ...customCodexConfig, + mcp_servers: { + paseo: expect.objectContaining({ + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + }), + }, + }), + ]); + appServer.assertNoErrors(); + await session.close(); + }, + ); + test("correlates a Codex user message with the submitting client message", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( @@ -1866,7 +2412,12 @@ describe("Codex app-server provider", () => { throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`); } - expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]); + expect(threadRequests).toEqual([ + "model/list", + "config/read", + "thread/loaded/list", + "thread/resume", + ]); expect(outcome).toBe("rejected"); appServer.assertNoErrors(); }); @@ -1886,7 +2437,9 @@ describe("Codex app-server provider", () => { }; try { - await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({ + await expect( + listCodexSkills(cwd, path.join(tempDir, "codex-home"), workspaceGitService), + ).resolves.toContainEqual({ name: "shipper", description: "Ship changes carefully.", argumentHint: "", @@ -2038,6 +2591,71 @@ describe("Codex app-server provider", () => { }); }); + test("keeps an output field named properties as a field", () => { + const input = { + type: "object", + properties: { properties: { type: "string" } }, + required: ["properties"], + additionalProperties: false, + }; + + expect(normalizeCodexOutputSchema(input)).toEqual(input); + }); + + test("normalizes schemas under keywords and leaves literal values alone", () => { + const literal = { type: "object", properties: { type: "string" } }; + const input = { + type: "object", + properties: { + result: { $ref: "#/$defs/result" }, + choice: { anyOf: [{ type: "object", properties: { id: { type: "string" } } }] }, + }, + $defs: { + properties: { type: "string" }, + result: { + type: "object", + properties: { value: { type: "string" } }, + default: literal, + examples: [literal], + enum: [literal], + const: literal, + }, + }, + }; + + expect(normalizeCodexOutputSchema(input)).toEqual({ + type: "object", + properties: { + result: { $ref: "#/$defs/result" }, + choice: { + anyOf: [ + { + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + additionalProperties: false, + }, + ], + }, + }, + required: ["result", "choice"], + additionalProperties: false, + $defs: { + properties: { type: "string" }, + result: { + type: "object", + properties: { value: { type: "string" } }, + default: literal, + examples: [literal], + enum: [literal], + const: literal, + required: ["value"], + additionalProperties: false, + }, + }, + }); + }); + test("passes a normalized output schema to turn/start", async () => { const session = createSession(); const request = vi.fn(async (method: string) => { @@ -2147,6 +2765,45 @@ describe("Codex app-server provider", () => { }); }); + test("lists custom prompts from the CODEX_HOME a custom provider runs Codex with", async () => { + await expect(listPromptCommandsFromCustomCodexHome()).resolves.toEqual([ + "prompts:probe-profile", + ]); + }); + + test("runs a custom prompt from the CODEX_HOME a custom provider runs Codex with", async () => { + const records = await runPromptFromCustomCodexHome("/prompts:probe-profile"); + + expect(records.find((record) => record.kind === "env")?.CODEX_HOME).toMatch( + /provider-codex-home$/, + ); + const turnStart = records.find((record) => record.method === "turn/start"); + expect(JSON.stringify(turnStart?.params)).toContain("from the provider home"); + }); + + test("inserts custom prompt arguments literally", async () => { + await expect( + expandCustomPrompt("Implement this exact request: $ARGUMENTS", "Preserve $1 unchanged"), + ).resolves.toBe("Implement this exact request: Preserve $1 unchanged"); + await expect( + expandCustomPrompt("Treat this as literal replacement text: $VALUE.", 'VALUE="$&"'), + ).resolves.toBe("Treat this as literal replacement text: $&."); + await expect(expandCustomPrompt("$1 then $2", '"$2" two')).resolves.toBe("$2 then two"); + await expect(expandCustomPrompt("$A and $B", 'A="$B" B=b')).resolves.toBe("$B and b"); + await expect( + expandCustomPrompt("$ARGUMENTS", "keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"), + ).resolves.toBe("keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"); + }); + + test("expands custom prompt placeholders and escapes", async () => { + await expect( + expandCustomPrompt("$1/$2 $NAME costs $$5, all: $ARGUMENTS, $MISSING", "one NAME=n two"), + ).resolves.toBe("one/two n costs $5, all: one NAME=n two, $MISSING"); + await expect( + expandCustomPrompt("$$ARGUMENTS $$1 $$NAME $9|$NAME_SUFFIX", "first NAME=value"), + ).resolves.toBe("$ARGUMENTS $1 $NAME |$NAME_SUFFIX"); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { @@ -3266,6 +3923,22 @@ describe("Codex app-server provider", () => { turn: { status: "completed" }, }); + const providerSubagents = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(providerSubagents).toContainEqual( + expect.objectContaining({ + id: "child-thread-root", + parentSubagentId: null, + }), + ); + expect(providerSubagents).toContainEqual( + expect.objectContaining({ + id: "grandchild-thread", + parentSubagentId: "child-thread-root", + }), + ); + const beforeParentCompletes = events .filter((event) => event.type === "timeline" && event.item.type === "tool_call") .map((event) => event.item); @@ -3288,6 +3961,70 @@ describe("Codex app-server provider", () => { }); }); + test("keeps a nested child under its spawning parent after a root interaction", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-child-root", + kind: "started", + agentThreadId: "child-thread-root", + agentPath: "/root/child", + }, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "child-thread-root", + item: { + type: "subAgentActivity", + id: "spawn-grandchild", + kind: "started", + agentThreadId: "grandchild-thread", + agentPath: "/root/child/grandchild", + }, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "wait-for-grandchild", + tool: "wait", + status: "completed", + receiverThreadIds: ["grandchild-thread"], + agentsStates: { + "grandchild-thread": { status: "running", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/agentMessage/delta", { + threadId: "grandchild-thread", + itemId: "grandchild-after-wait", + delta: "Still nested.", + }); + + const grandchildUpserts = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "grandchild-thread" + ? [event.event] + : [], + ); + expect(grandchildUpserts.map((event) => event.parentSubagentId)).toEqual([ + "child-thread-root", + "child-thread-root", + ]); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "spawn-child-root", + detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") }, + }, + }); + }); + test("never treats an unmapped foreign terminal as the root terminal", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -3845,7 +4582,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -3899,7 +4636,7 @@ describe("Codex app-server provider", () => { })), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({ index: 0, @@ -3968,7 +4705,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4085,6 +4822,69 @@ describe("Codex app-server provider", () => { }); }); + test("restores nested MultiAgentV2 ownership from persisted child threads", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async (method: string, params: unknown) => { + if (method !== "thread/read") { + return {}; + } + const threadId = (params as { threadId?: string }).threadId; + const itemsByThreadId = { + "test-thread": [ + { + type: "subAgentActivity", + id: "spawn-persisted-child", + kind: "started", + agentThreadId: "persisted-child", + agentPath: "/root/persisted-child", + }, + { + type: "collabAgentToolCall", + id: "root-wait-grandchild", + tool: "wait", + status: "completed", + receiverThreadIds: ["persisted-grandchild"], + agentsStates: { "persisted-grandchild": { status: "completed" } }, + }, + ], + "persisted-child": [ + { + type: "subAgentActivity", + id: "spawn-persisted-grandchild", + kind: "started", + agentThreadId: "persisted-grandchild", + agentPath: "/root/persisted-child/grandchild", + }, + ], + } as const; + const items = threadId ? itemsByThreadId[threadId as keyof typeof itemsByThreadId] : []; + return { + thread: { + turns: items ? [{ items }] : [], + }, + }; + }), + }; + + await asInternals(session).loadPersistedHistory(session.client); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + const upserts = history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(upserts).toEqual([ + expect.objectContaining({ id: "persisted-child", parentSubagentId: null }), + expect.objectContaining({ + id: "persisted-grandchild", + parentSubagentId: "persisted-child", + }), + ]); + }); + test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => { const session = createSession(); session.client = { @@ -4132,7 +4932,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4306,7 +5106,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4367,7 +5167,7 @@ describe("Codex app-server provider", () => { }), }; - await asInternals(session).loadPersistedHistory(); + await asInternals(session).loadPersistedHistory(session.client); const history: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -4874,14 +5674,22 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - expect( - events.some( - (event) => - event.type === "timeline" && - event.item.type === "tool_call" && - event.item.detail.type === "plan", - ), - ).toBe(false); + expect(events.at(-3)).toEqual({ + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: session.getPendingPermissions()[0]?.id, + name: "plan_approval", + status: "running", + error: null, + detail: { + type: "plan", + text: "- Inspect the existing auth flow\n- Implement the button behavior", + }, + }, + }); expect(events.at(-2)).toEqual({ type: "permission_requested", provider: "codex", @@ -4916,7 +5724,7 @@ describe("Codex app-server provider", () => { }); }); - test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => { + test("does not complete Codex plan timeline cards while plan approval is pending", () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -4942,6 +5750,7 @@ describe("Codex app-server provider", () => { type: "timeline", item: expect.objectContaining({ type: "tool_call", + status: "completed", detail: expect.objectContaining({ type: "plan" }), }), }), @@ -5411,6 +6220,107 @@ describe("Codex app-server provider", () => { ]); }); + test.each([ + [ + "a usage limit", + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: 1786150800 }, + ], + ["no failure details", null, { message: "Image generation failed" }], + ])( + "emits failed imageGeneration items with %s as failed tool calls", + (_label, failure, expectedError) => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + item: { + id: "image-generation-failed", + type: "imageGeneration", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure, + savedPath: null, + }, + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: expectedError, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }, + ); + + test("keeps failed imageGeneration items in persisted history", async () => { + const session = createSession(); + session.client = { + request: vi.fn(async () => ({ + thread: { + turns: [ + { + items: [ + { + type: "imageGeneration", + id: "image-generation-failed", + status: "failed", + revisedPrompt: "paint a blue whale", + result: "", + transparentBackground: null, + failure: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + savedPath: null, + }, + ], + }, + ], + }, + })), + }; + + await asInternals(session).loadPersistedHistory(session.client); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "codex", + item: { + type: "tool_call", + callId: "image-generation-failed", + name: "image_generation", + status: "failed", + error: { type: "usageLimitExceeded", limitId: "image_gen", resetsAt: null }, + detail: { + type: "unknown", + input: { prompt: "paint a blue whale" }, + output: null, + }, + }, + }, + ]); + }); + test("emits usage_updated on token usage updates and keeps usage on turn completion", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -5833,6 +6743,187 @@ describe("Codex app-server provider", () => { }); describe("Codex importable sessions", () => { + const CODEX_THREAD_PAGE_CAP = 100; + + // Codex answers thread/list with at most 100 rows per response whatever limit + // the caller asks for, and hands back a cursor for the remaining threads. + function cappedThreadListHandler(threads: Array>) { + return (input: unknown) => { + const params = (input ?? {}) as { + limit?: number; + cursor?: string; + cwd?: string; + sortKey?: string; + }; + const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt"; + const matching = threads + .filter((thread) => !params.cwd || thread.cwd === params.cwd) + .sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey])); + const offset = Number(params.cursor ?? 0); + const page = matching.slice( + offset, + offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP), + ); + const nextOffset = offset + page.length; + return { + data: page, + nextCursor: nextOffset < matching.length ? String(nextOffset) : null, + }; + }; + } + + function threadsUsedInReverseCreationOrder(count: number) { + // The most recently used thread is also the oldest one created, so a scan + // that follows Codex's creation-time page order misses it. + return Array.from({ length: count }, (_, index) => ({ + id: `thread-${index}`, + cwd: "/workspace/project-a", + preview: `Session ${index}`, + createdAt: index, + updatedAt: count - index, + })); + } + + test("fills the requested window across the pages Codex caps at 100 rows", async () => { + const threads = threadsUsedInReverseCreationOrder(250); + const appServer = createFakeCodexAppServer({ + "thread/list": cappedThreadListHandler([ + ...threads, + { id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 }, + ]), + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ + limit: 240, + cwd: "/workspace/project-a", + }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual( + threads.slice(0, 240).map((thread) => thread.id), + ); + appServer.assertNoErrors(); + }); + + test("keeps the scanned window on the most recently used threads", async () => { + const threads = threadsUsedInReverseCreationOrder(550); + const appServer = createFakeCodexAppServer({ + "thread/list": cappedThreadListHandler(threads), + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual( + threads.slice(0, 500).map((thread) => thread.id), + ); + appServer.assertNoErrors(); + }); + + test("keeps a thread once when it moves onto a later page mid-scan", async () => { + // A thread used while the scan is paging sorts to the front and comes back + // on the next page. + const appServer = createFakeCodexAppServer({ + "thread/list": (input) => { + const { cursor } = (input ?? {}) as { cursor?: string }; + return cursor + ? { + data: [ + { id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 }, + { id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 }, + ], + nextCursor: null, + } + : { + data: [ + { id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 }, + { id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 }, + ], + nextCursor: "page-2", + }; + }, + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual([ + "thread-1", + "thread-2", + "thread-3", + ]); + appServer.assertNoErrors(); + }); + + test("stops scanning when Codex pages without handing back a new thread", async () => { + const appServer = createFakeCodexAppServer({ + // A cursor cycle: the two pages after the first serve threads the scan + // already holds and point at each other, so it can never fill its window. + "thread/list": (input) => { + const { cursor } = (input ?? {}) as { cursor?: string }; + const thread = { id: "thread-1", cwd: "/workspace/project-a" }; + if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" }; + if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" }; + return { data: [thread], nextCursor: "page-a" }; + }, + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]); + appServer.assertNoErrors(); + }); + + // Codex filters thread/list by model provider: with no `modelProviders` it + // returns only threads of the provider its own config selects, an empty + // list returns every provider, and a list returns only those providers. + function providerFilteringThreadListHandler(threads: Array>) { + return (input: unknown) => { + const { modelProviders } = (input ?? {}) as { modelProviders?: string[] }; + const included = (thread: Record) => { + if (modelProviders === undefined) return thread.modelProvider === "openai"; + if (modelProviders.length === 0) return true; + return modelProviders.includes(String(thread.modelProvider)); + }; + return { data: threads.filter(included), nextCursor: null }; + }; + } + + const threadsByModelProvider = [ + { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, + { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, + ]; + + test("a custom Codex provider lists the threads it created", async () => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), + }); + const provider = createProviderWithFakeAppServer(appServer, { + runtimeSettings: { + env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" }, + }, + customProvider: { id: "my-codex", label: "My Codex", extends: "codex" }, + }); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + appServer.assertNoErrors(); + }); + + test("stock Codex keeps listing only its own threads", async () => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), + }); + const provider = createProviderWithFakeAppServer(appServer); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]); + appServer.assertNoErrors(); + }); + test("listImportableSessions uses thread list metadata without hydrating thread history", async () => { const allThreads = [ { @@ -5913,7 +7004,10 @@ describe("Codex importable sessions", () => { capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true }, }, }, - { method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } }, + { + method: "thread/list", + params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" }, + }, ]); }); }); @@ -5924,7 +7018,8 @@ describe("Codex denied plan approvals", () => { (event) => event.type === "timeline" && event.item.type === "tool_call" && - event.item.name === "plan_approval", + event.item.name === "plan_approval" && + event.item.status === "completed", ); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 48744f84ac..36267592f8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, type AgentPermissionAction, @@ -52,15 +53,16 @@ import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; +import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js"; import { mapCodexToolCallEnvelope, mapCodexToolCallFromThreadItem, + normalizeCommandExecutionCommand, splitCodexMcpToolResultImages, } from "./codex/tool-call-mapper.js"; import { checkProviderLaunchAvailable, createProviderEnv, - createProviderEnvSpec, resolveProviderLaunch, type ProviderRuntimeSettings, type ResolvedProviderLaunch, @@ -72,7 +74,12 @@ import { import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; -import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + buildCodexFeatures, + CodexServiceTierSchema, + readCodexServiceTier, + type CodexServiceTier, +} from "./codex-feature-definitions.js"; import { CodexAppServerClient, CodexAppServerRpcError, @@ -168,6 +175,9 @@ const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = // (and the /goal slash command) when the binary is too old. const CODEX_GOALS_MIN_VERSION: readonly [number, number, number] = [0, 128, 0]; const CODEX_AUTO_REVIEW_MIN_VERSION: readonly [number, number, number] = [0, 115, 0]; +// Codex removed `thread/rollback` in 0.156.0, so rewinding a legacy thread there +// has to fork before the target turn instead. +const CODEX_THREAD_ROLLBACK_REMOVED_VERSION: readonly [number, number, number] = [0, 156, 0]; function parseCodexVersion(versionOutput: string): [number, number, number] | null { const match = versionOutput.match(/(\d+)\.(\d+)\.(\d+)/); @@ -188,6 +198,15 @@ function codexVersionAtLeast( return true; } +// The initialize userAgent starts with `/`. An agent we +// cannot parse is treated as an older server that still has rollback. +function codexServerHasThreadRollback(userAgent: unknown): boolean { + return ( + typeof userAgent !== "string" || + !codexVersionAtLeast(userAgent, CODEX_THREAD_ROLLBACK_REMOVED_VERSION) + ); +} + type GoalSubcommand = | { kind: "set"; objective: string } | { kind: "pause" } @@ -252,13 +271,16 @@ interface CodexAppServerClientLike { } interface CodexAppServerAgentDeps { + environment?: Record; workspaceGitService?: Pick; customProvider?: { id: string; label: string; extends: string; }; - customCodexConfig?: Record | null; + customCodexConfig?: CodexCustomProviderConfig | null; + // The CODEX_HOME the session's app-server runs with; prompts and skills are read from it. + codexHome?: string; _createCodexClient?: ( child: ChildProcessWithoutNullStreams, logger: Logger, @@ -272,17 +294,19 @@ interface CodexAppServerAgentDeps { interface CodexModePreset { approvalPolicy: string; sandbox: string; - approvalsReviewer?: "auto_review"; + approvalsReviewer: "user" | "auto_review"; } const MODE_PRESETS: Record = { "read-only": { approvalPolicy: "on-request", sandbox: "read-only", + approvalsReviewer: "user", }, auto: { approvalPolicy: "on-request", sandbox: "workspace-write", + approvalsReviewer: "user", }, "auto-review": { approvalPolicy: "on-request", @@ -292,6 +316,7 @@ const MODE_PRESETS: Record = { "full-access": { approvalPolicy: "never", sandbox: "danger-full-access", + approvalsReviewer: "user", }, }; @@ -299,15 +324,6 @@ function isAutoReviewReviewer(value: string | undefined): boolean { return value === "auto_review" || value === "guardian_subagent"; } -function applyApprovalsReviewerParam( - params: Record, - preset: CodexModePreset, -): void { - if (preset.approvalsReviewer) { - params.approvalsReviewer = preset.approvalsReviewer; - } -} - function shouldPromoteThreadResponseToAutoReview(params: { approvalsReviewer: string | undefined; approvalPolicy: string; @@ -368,19 +384,67 @@ function isObjectSchemaNode(schema: Record): boolean { ); } -function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown { +// Keywords whose value maps names to schemas. +const SCHEMA_MAP_KEYWORDS = [ + "properties", + "patternProperties", + "$defs", + "definitions", + "dependentSchemas", +] as const; + +// Keywords whose value is a schema or an array of schemas. +const SUBSCHEMA_KEYWORDS = [ + "items", + "prefixItems", + "additionalItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "allOf", + "anyOf", + "oneOf", + "not", + "if", + "then", + "else", +] as const; + +function normalizeCodexOutputSubschema(schema: unknown, schemaPath: string): unknown { if (Array.isArray(schema)) { return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${schemaPath}[${index}]`), ); } + return normalizeCodexOutputSchemaNode(schema, schemaPath); +} + +function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): unknown { if (!isSchemaRecord(schema)) { return schema; } - const normalized: Record = {}; - for (const [key, value] of Object.entries(schema)) { - normalized[key] = normalizeCodexOutputSchemaNode(value, `${schemaPath}.${key}`); + const normalized: Record = { ...schema }; + for (const keyword of SCHEMA_MAP_KEYWORDS) { + const schemas = schema[keyword]; + if (isSchemaRecord(schemas)) { + normalized[keyword] = Object.fromEntries( + Object.entries(schemas).map(([name, child]) => [ + name, + normalizeCodexOutputSchemaNode(child, `${schemaPath}.${keyword}.${name}`), + ]), + ); + } + } + for (const keyword of SUBSCHEMA_KEYWORDS) { + if (keyword in schema) { + normalized[keyword] = normalizeCodexOutputSubschema( + schema[keyword], + `${schemaPath}.${keyword}`, + ); + } } if (!isObjectSchemaNode(normalized)) { @@ -550,8 +614,8 @@ async function checkCodexLaunchAvailable(launch: ResolvedProviderLaunch) { }); } -function resolveCodexHomeDir(): string { - return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); +function resolveCodexHomeDir(env: NodeJS.ProcessEnv): string { + return env.CODEX_HOME ?? path.join(os.homedir(), ".codex"); } function decodeEscapedChar(next: string): string { @@ -661,8 +725,7 @@ function parseFrontMatter(markdown: string): { return { frontMatter, body }; } -async function listCodexCustomPrompts(): Promise { - const codexHome = resolveCodexHomeDir(); +async function listCodexCustomPrompts(codexHome: string): Promise { const promptsDir = path.join(codexHome, "prompts"); let entries: Dirent[]; try { @@ -704,6 +767,7 @@ async function listCodexCustomPrompts(): Promise { export async function listCodexSkills( cwd: string, + codexHome: string, workspaceGitService?: Pick, ): Promise { const candidates: string[] = []; @@ -717,7 +781,7 @@ export async function listCodexSkills( candidates.push(path.join(repoRoot, ".codex", "skills")); } - candidates.push(path.join(resolveCodexHomeDir(), "skills")); + candidates.push(path.join(codexHome, "skills")); const candidateReads = await Promise.all( candidates.map(async (dir) => { @@ -789,25 +853,21 @@ function expandCodexCustomPrompt(template: string, args: string | undefined): st positional.push(token); } - const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__"; - let out = template.split("$$").join(dollarPlaceholder); + // Match every placeholder in one pass, so inserted argument text is never expanded again. + const namedPlaceholders = Object.keys(named) + .sort((a, b) => b.length - a.length) + .map((key) => `${escapeRegExp(key)}\\b`); + const placeholder = new RegExp( + `\\$(${["\\$", "ARGUMENTS", "[1-9]", ...namedPlaceholders].join("|")})`, + "g", + ); - out = out.split("$ARGUMENTS").join(trimmedArgs); - - for (let i = 1; i <= 9; i += 1) { - const value = positional[i - 1] ?? ""; - out = out.split(`$${i}`).join(value); - } - - const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length); - for (const key of namedKeys) { - const value = named[key] ?? ""; - const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g"); - out = out.replace(re, value); - } - - out = out.split(dollarPlaceholder).join("$"); - return out; + return template.replace(placeholder, (match, name: string) => { + if (name === "$") return "$"; + if (name === "ARGUMENTS") return trimmedArgs; + if (/^[1-9]$/.test(name)) return positional[Number(name) - 1] ?? ""; + return named[name] ?? match; + }); } interface CodexMcpServerConfig { @@ -888,6 +948,7 @@ interface CodexModel { model?: string; defaultReasoningEffort?: string; supportedReasoningEfforts?: CodexReasoningEffortEntry[]; + serviceTiers?: CodexServiceTier[]; } const CodexModelListResponseSchema = z.object({ @@ -895,6 +956,7 @@ const CodexModelListResponseSchema = z.object({ .array( z.object({ id: z.string(), + serviceTiers: z.array(CodexServiceTierSchema).optional(), displayName: z.string().optional(), description: z.string().optional(), isDefault: z.boolean().optional(), @@ -913,6 +975,61 @@ const CodexModelListResponseSchema = z.object({ .optional(), }); +/** + * Read the newest `window.limit` threads Codex knows about. + * + * Codex caps every `thread/list` response at 100 rows whatever limit it is + * given (codex-cli 0.153 and 0.155), so one request only ever sees the newest + * page and hands back a cursor for the rest. Follow that cursor until the + * caller's window is full or Codex runs out of threads. + */ +async function readCodexThreadWindow( + client: Pick, + logger: Logger, + window: { limit: number; cwd?: string; modelProviders?: string[] }, +): Promise>> { + // A thread updated while the scan is paging moves under `updated_at` order + // and can come back on a later page, so identify each one and keep it once. + // A row Codex sends without an id stands for itself. + const threads = new Map>(); + let cursor: string | undefined; + while (threads.size < window.limit) { + const response = toObjectRecord( + await client.request("thread/list", { + limit: window.limit - threads.size, + // Rank the window by last use. Codex pages by creation time by default, + // which drops an old conversation that is still being worked in. + // Older Codex builds ignore the unknown key and keep that order. + sortKey: "updated_at", + ...(window.cwd ? { cwd: window.cwd } : {}), + ...(window.modelProviders ? { modelProviders: window.modelProviders } : {}), + ...(cursor ? { cursor } : {}), + }), + ); + const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; + const sizeBeforePage = threads.size; + for (const thread of page) { + const identity = typeof thread.id === "string" ? thread.id : thread; + if (!threads.has(identity)) threads.set(identity, thread); + } + const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined; + if (!nextCursor) break; + if (threads.size === sizeBeforePage) { + // Every page that continues the scan brings a thread the scan has not + // seen, so the loop is bounded by the window. A page that brings none + // would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for + // ever behind a caller that has already timed out. + logger.warn( + { cursor: nextCursor }, + "codex thread/list returned no new threads mid-scan, stopping the session scan", + ); + break; + } + cursor = nextCursor; + } + return [...threads.values()]; +} + function filterCodexThreadsByCwd( threads: Array>, cwd: string | undefined, @@ -1781,13 +1898,17 @@ function updateHistoricalSubAgentActivity( function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] { const activity = readCodexSubAgentActivity(item); if (activity) { - return [activity.agentThreadId]; + return activity.kind === "started" ? [activity.agentThreadId] : []; } const record = toObjectRecord(item); const normalizedType = normalizeCodexThreadItemType( typeof record?.type === "string" ? record.type : undefined, ); - if (normalizedType !== "collabAgentToolCall" || !Array.isArray(record?.receiverThreadIds)) { + if ( + normalizedType !== "collabAgentToolCall" || + record?.tool !== "spawnAgent" || + !Array.isArray(record.receiverThreadIds) + ) { return []; } return record.receiverThreadIds.filter( @@ -1828,6 +1949,15 @@ function mapCodexThreadImageItem( }); } + if (normalizedItem.status === "failed") { + return mapCodexToolCallEnvelope({ + callId: firstStringField(normalizedItem, ["id"]), + name: "image_generation", + input: { prompt: firstStringField(normalizedItem, ["revisedPrompt", "revised_prompt"]) }, + error: normalizedItem.failure ?? { message: "Image generation failed" }, + }); + } + const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]); const result = codexImageOutputFromResult(normalizedItem.result); return renderProviderImageOutputAsAssistantMarkdown( @@ -1841,6 +1971,17 @@ function mapCodexThreadImageItem( ); } +function mapCodexAgentMessage(item: Record): AgentTimelineItem { + const question = codexAsyncQuestionToTimeline(item); + if (question) return question; + const messageId = nonEmptyString(item.id); + return { + type: "assistant_message", + text: typeof item.text === "string" ? item.text : "", + ...(messageId ? { messageId } : {}), + }; +} + export function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, @@ -1867,14 +2008,8 @@ export function threadItemToTimeline( switch (normalizedType) { case "userMessage": return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage); - case "agentMessage": { - const messageId = nonEmptyString(normalizedItem.id); - return { - type: "assistant_message", - text: typeof normalizedItem.text === "string" ? normalizedItem.text : "", - ...(messageId ? { messageId } : {}), - }; - } + case "agentMessage": + return mapCodexAgentMessage(normalizedItem); case "plan": return mapCodexThreadPlanItem(normalizedItem); case "reasoning": @@ -3188,7 +3323,7 @@ function toCodexTextInput(text: string): Extract, -): NodeJS.ProcessEnv { +) { return createProviderEnv({ runtimeSettings, overlays: [launchEnv], @@ -3220,10 +3355,15 @@ function normalizeOpenAICompatibleBaseUrl(value: string): string | null { return `${withoutTrailingSlashes}/v1`; } +interface CodexCustomProviderConfig { + model_provider: string; + model_providers: Record>; +} + function buildCodexCustomProviderConfig( runtimeSettings: ProviderRuntimeSettings | undefined, customProvider: CodexAppServerAgentDeps["customProvider"], -): Record | null { +): CodexCustomProviderConfig | null { if (customProvider?.extends !== CODEX_PROVIDER) { return null; } @@ -3256,6 +3396,7 @@ interface CodexSubAgentCallState { callId: string; toolCall: ToolCallTimelineItem; parentCallId: string | null; + parentSubagentId: string | null; activityItemIds: Set; pendingCommandOutputDeltas: Map; pendingFileChangeOutputDeltas: Map; @@ -3264,11 +3405,18 @@ interface CodexSubAgentCallState { childThreadIds: Set; } +function resolveCodexParentSubagentId( + parentCallId: string | null, + emittingThreadId: string | null, +): string | null { + return parentCallId ? emittingThreadId : null; +} + interface CodexPendingPermissionHandler { resolve: (value: unknown) => void; kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; questions?: CodexQuestionPrompt[]; - planText?: string; + plan?: { text: string; turnId: string | undefined }; } interface ConsumedRootCompaction { @@ -3281,6 +3429,8 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly logger: Logger; private readonly config: AgentSessionConfig; + private readonly asyncQuestions: CodexAsyncQuestions; + private readonly codexHome: string; private currentMode: string; private hasWorkflowModeOverride: boolean; private readonly providerOptions: CodexProviderOptions; @@ -3301,12 +3451,14 @@ export class CodexAppServerAgentSession implements AgentSession { cancelRequested: boolean; } | null = null; private client: CodexAppServerClient | null = null; + private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private activeClientMessageId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; - private serviceTier: "fast" | null = null; + private serviceTier: string | null = null; + private speedModels: CodexModel[] = []; private planModeEnabled = false; private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; @@ -3352,7 +3504,7 @@ export class CodexAppServerAgentSession implements AgentSession { // `thread/compacted` and a completed `contextCompaction` item. private unpairedCompactionNotificationCompletions = 0; private unpairedCompactionItemCompletions = 0; - private connected = false; + private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected"; private connectionPromise: Promise | null = null; private closed = false; private collaborationModes: Array<{ @@ -3369,6 +3521,19 @@ export class CodexAppServerAgentSession implements AgentSession { } | null = null; private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null; + private readonly usageSessionKey = randomUUID(); + private readonly harnessEnvironment: Record; + + usageSession() { + if (this.closed) return null; + return { + provider: "codex", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + constructor( config: AgentSessionConfig, private readonly resumeHandle: { sessionId: string; metadata?: Record } | null, @@ -3391,12 +3556,14 @@ export class CodexAppServerAgentSession implements AgentSession { } this.hasWorkflowModeOverride = config.modeId !== undefined; this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; - this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); + this.providerOptions = + validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {}; this.config = config; + this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv(); + this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); + this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); - if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = "fast"; - } + this.serviceTier = readCodexServiceTier(this.config.featureValues); if (this.config.featureValues?.plan_mode) { this.planModeEnabled = true; } @@ -3413,8 +3580,8 @@ export class CodexAppServerAgentSession implements AgentSession { get features(): AgentFeature[] { return buildCodexFeatures({ - modelId: this.config.model, - fastModeEnabled: this.serviceTier === "fast", + serviceTiers: this.currentServiceTiers(), + serviceTier: this.serviceTier ?? "default", planModeEnabled: this.planModeEnabled, planModeAvailable: this.hasPlanCollaborationMode(), }); @@ -3424,7 +3591,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.closed) { throw this.createClosedError(); } - if (this.connected) return; + if (this.connectionState !== "disconnected") return; if (this.connectionPromise) { await this.connectionPromise; if (this.closed) { @@ -3448,6 +3615,11 @@ export class CodexAppServerAgentSession implements AgentSession { } private async establishConnection(): Promise { + if (this.initialResumePurpose === "history") { + await this.readArchivedHistory(); + this.connectionState = "history-ready"; + return; + } const child = await this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); if (this.closed) { @@ -3462,24 +3634,29 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerRequestHandlers(); try { - await client.request("initialize", buildCodexAppServerInitializeParams()); + const initialized = toObjectRecord( + await client.request("initialize", buildCodexAppServerInitializeParams()), + ); + this.threadRollbackAvailable = codexServerHasThreadRollback(initialized?.userAgent); client.notify("initialized", {}); + this.speedModels = + CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; + this.reconcileServiceTier(); await this.loadResolvedWorkspaceWrite(); await this.loadCollaborationModes(); await this.loadSkills(); if (this.currentThreadId) { - await this.ensureThreadLoaded({ - allowArchivedHistory: this.initialResumePurpose === "history", - }); - await this.loadPersistedHistory(); + await this.ensureThreadLoaded(); + await this.loadPersistedHistory(this.client); + await this.applyDefaultModelAndThinking(); } if (this.closed) { throw this.createClosedError(); } - this.connected = true; + this.connectionState = "connected"; } catch (error) { try { if (this.client === client) { @@ -3530,7 +3707,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private handleUnexpectedTermination(error: Error): void { - this.connected = false; + this.connectionState = "disconnected"; const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -3670,18 +3847,30 @@ export class CodexAppServerAgentSession implements AgentSession { this.resolvedCollaborationMode = this.resolveCollaborationMode(); } - private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void { - this.config.featureValues = { - ...this.config.featureValues, - [featureId]: value, - }; + private currentServiceTiers(): CodexServiceTier[] { + const selectedModel = this.config.model + ? this.speedModels.find( + (model) => model.id === this.config.model || model.model === this.config.model, + ) + : (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]); + return selectedModel?.serviceTiers ?? []; + } - if (featureId === "fast_mode") { - this.serviceTier = value ? "fast" : null; - this.cachedRuntimeInfo = null; - return; + private reconcileServiceTier(): void { + const tiers = this.currentServiceTiers(); + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) { + this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null; } + if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) { + this.serviceTier = null; + } + const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {}; + this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" }; + } + private applyFeatureValue(featureId: "plan_mode", value: boolean): void { + this.config.featureValues = { ...this.config.featureValues, [featureId]: value }; this.planModeEnabled = value; this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; @@ -3722,7 +3911,20 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingPermissionHandlers.set(requestId, { resolve: () => undefined, kind: "plan", - planText, + plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined }, + }); + // Reserve the proposal's place before a later prompt can resolve it. + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: "running", + error: null, + detail: { type: "plan", text: planText }, + }, }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); } @@ -3762,9 +3964,20 @@ export class CodexAppServerAgentSession implements AgentSession { ); } - private async loadPersistedHistory(): Promise { - if (!this.client || !this.currentThreadId) return; - const client = this.client; + private async readArchivedHistory(): Promise { + const child = await this.spawnAppServer(); + const client = new CodexAppServerClient(child, this.logger, () => this.traceContext()); + try { + await client.request("initialize", buildCodexAppServerInitializeParams()); + client.notify("initialized", {}); + await this.loadPersistedHistory(client); + } finally { + await client.dispose(); + } + } + + private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise { + if (!client || !this.currentThreadId) return; const threadId = this.currentThreadId; const history = await loadCodexThreadHistoryTimeline({ @@ -3787,6 +4000,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.resetCodexUserMessageTurns(); for (const entry of timeline) { + if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") { + entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item; + } if (entry.item.type === "user_message") { this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId); } @@ -3799,7 +4015,11 @@ export class CodexAppServerAgentSession implements AgentSession { client: CodexAppServerClientLike, rootRoutes: readonly PersistedSubAgentRoute[], ): Promise { - const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null })); + const queue = rootRoutes.map((route) => ({ + route, + parentCallId: null as string | null, + parentSubagentId: null as string | null, + })); const visitedThreadIds = new Set(this.currentThreadId ? [this.currentThreadId] : []); while (queue.length > 0 && visitedThreadIds.size < 100) { const next = queue.shift(); @@ -3811,6 +4031,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: next.route.toolCall, rawItem: { agentThreadId: next.route.childThreadId }, parentCallId: next.parentCallId, + parentSubagentId: next.parentSubagentId, }); try { const childHistory = await loadCodexThreadHistoryTimeline({ @@ -3822,7 +4043,11 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); } for (const route of childHistory.subAgentRoutes) { - queue.push({ route, parentCallId: next.route.toolCall.callId }); + queue.push({ + route, + parentCallId: next.route.toolCall.callId, + parentSubagentId: next.route.childThreadId, + }); } } catch (error) { this.logger.trace( @@ -3833,9 +4058,7 @@ export class CodexAppServerAgentSession implements AgentSession { } } - private async ensureThreadLoaded( - options: { allowArchivedHistory?: boolean } = {}, - ): Promise { + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; const params: Record = { threadId: this.currentThreadId }; const developerInstructions = composeSystemPromptParts( @@ -3860,16 +4083,6 @@ export class CodexAppServerAgentSession implements AgentSession { } catch (error) { const threadId = this.currentThreadId; const message = error instanceof Error ? error.message : String(error); - if ( - options.allowArchivedHistory === true && - isArchivedCodexThreadResumeError(error, threadId) - ) { - this.logger.info( - { threadId }, - "Loading archived Codex thread history without resuming the native session", - ); - return; - } if (isArchivedCodexThreadResumeError(error, threadId)) { try { await this.client.request("thread/unarchive", { threadId }); @@ -3936,14 +4149,13 @@ export class CodexAppServerAgentSession implements AgentSession { ): Promise { if (commandName.startsWith("prompts:")) { const promptName = commandName.slice("prompts:".length); - const codexHome = resolveCodexHomeDir(); - const promptPath = path.join(codexHome, "prompts", `${promptName}.md`); + const promptPath = path.join(this.codexHome, "prompts", `${promptName}.md`); const raw = await fs.readFile(promptPath, "utf8"); const parsed = parseFrontMatter(raw); return expandCodexCustomPrompt(parsed.body, args); } - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -3989,9 +4201,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (thinkingOptionId) { params.effort = thinkingOptionId; } - if (this.serviceTier) { - params.serviceTier = this.serviceTier; - } + params.serviceTier = this.serviceTier ?? "default"; if (this.resolvedCollaborationMode) { params.collaborationMode = { mode: this.resolvedCollaborationMode.mode, @@ -4050,7 +4260,7 @@ export class CodexAppServerAgentSession implements AgentSession { : toSandboxPolicy(sandboxPolicyType, workspaceWrite); } if (this.hasWorkflowModeOverride) { - applyApprovalsReviewerParam(params, preset); + params.approvalsReviewer = preset.approvalsReviewer; } return { approvalPolicy, sandboxPolicyType }; } @@ -4365,7 +4575,7 @@ export class CodexAppServerAgentSession implements AgentSession { async getRuntimeInfo(): Promise { if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo }; - if (!this.connected) { + if (this.connectionState === "disconnected") { await this.connect(); } if (!this.currentThreadId) { @@ -4409,9 +4619,7 @@ export class CodexAppServerAgentSession implements AgentSession { async setModel(modelId: string | null): Promise { this.config.model = modelId ?? undefined; - if (!codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = null; - } + this.reconcileServiceTier(); this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } @@ -4426,13 +4634,27 @@ export class CodexAppServerAgentSession implements AgentSession { } async setFeature(featureId: string, value: unknown): Promise { + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. if (featureId === "fast_mode") { - if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) { + const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast"); + if (value && !fast) throw new Error( `Codex fast mode is not available for model '${this.config.model ?? "default"}'`, ); + return this.setFeature("service_tier", value ? fast!.id : "default"); + } + if (featureId === "service_tier") { + if ( + typeof value !== "string" || + (value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value)) + ) { + throw new Error( + `Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`, + ); } - this.applyFeatureValue("fast_mode", Boolean(value)); + this.serviceTier = value; + this.reconcileServiceTier(); + this.cachedRuntimeInfo = null; return; } if (featureId === "plan_mode") { @@ -4443,13 +4665,16 @@ export class CodexAppServerAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return Array.from(this.pendingPermissions.values()); + return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()]; } async respondToPermission( requestId: string, response: AgentPermissionResponse, ): Promise { + if (this.asyncQuestions.hasPending(requestId)) { + return this.respondToAsyncQuestion(requestId, response); + } const pending = this.pendingPermissionHandlers.get(requestId); if (!pending) { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); @@ -4543,6 +4768,33 @@ export class CodexAppServerAgentSession implements AgentSession { pending.resolve({ answers: {} }); } + private async respondToAsyncQuestion( + requestId: string, + response: AgentPermissionResponse, + ): Promise { + const prepared = this.asyncQuestions.prepareResponse(requestId, response); + let followUpPrompt = prepared.prompt; + const expectedTurnId = this.activeForegroundTurnId; + if (prepared.prompt && expectedTurnId) { + const result = await this.steerActiveTurn(prepared.prompt, { + expectedTurnId, + clientMessageId: randomUUID(), + }); + if (result.status !== "accepted") { + throw new Error("The active Codex turn changed. Retry sending your answer."); + } + followUpPrompt = undefined; + } + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: response, + }); + return followUpPrompt ? { followUpPrompt } : undefined; + } + private handlePlanPermissionResponse(params: { requestId: string; response: AgentPermissionResponse; @@ -4553,7 +4805,7 @@ export class CodexAppServerAgentSession implements AgentSession { let followUpPrompt: string | undefined; if (response.behavior === "allow") { followUpPrompt = this.preparePlanImplementation({ - planText: pending.planText ?? pendingRequest?.metadata?.planText, + planText: pending.plan?.text ?? pendingRequest?.metadata?.planText, }); } @@ -4585,29 +4837,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void { - if (resolution.behavior === "deny") { - // Every route into a denial lands here — the response handler, a new - // prompt, and an accepted steer — so the transcript record belongs here - // rather than in handlePlanPermissionResponse. - const planText = - this.pendingPermissionHandlers.get(requestId)?.planText ?? - this.pendingPermissions.get(requestId)?.metadata?.planText; - if (typeof planText === "string") { - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { - type: "tool_call", - callId: requestId, - name: "plan_approval", - status: "completed", - error: null, - detail: { type: "plan", text: planText }, - metadata: { approved: false }, - }, - }); - } - } + this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected"); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); this.resolvedPermissionRequests.add(requestId); @@ -4619,6 +4849,29 @@ export class CodexAppServerAgentSession implements AgentSession { }); } + private recordPlanOutcome( + requestId: string, + outcome: "approved" | "rejected" | "canceled", + ): void { + const plan = this.pendingPermissionHandlers.get(requestId)?.plan; + if (plan) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + turnId: plan.turnId, + item: { + type: "tool_call", + callId: requestId, + name: "plan_approval", + status: outcome === "canceled" ? "canceled" : "completed", + error: null, + detail: { type: "plan", text: plan.text }, + metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" }, + }, + }); + } + } + private emitDeniedToolCallTimelineEvent(params: { requestId: string; response: Extract; @@ -4675,10 +4928,10 @@ export class CodexAppServerAgentSession implements AgentSession { modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, - providerOptions: this.config.providerOptions, toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, + asyncQuestions: this.asyncQuestions.serialize(), }, }; } @@ -4701,13 +4954,16 @@ export class CodexAppServerAgentSession implements AgentSession { cwd: this.config.cwd ?? null, model: this.config.model ?? null, serviceTier: this.serviceTier, + config: this.buildCodexInnerConfig(), userMessageTurns: this.codexUserMessageTurns(), + threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; this.cachedRuntimeInfo = null; this.persistedHistory = []; this.historyPending = false; - await this.loadPersistedHistory(); + await this.loadPersistedHistory(this.client); + this.reconcileAsyncQuestionsAfterRewind(); }, }); } @@ -4785,6 +5041,9 @@ export class CodexAppServerAgentSession implements AgentSession { if (options?.preservePlanApprovals && pending.kind === "plan") { continue; } + if (pending.kind === "plan") { + this.recordPlanOutcome(requestId, "canceled"); + } pending.resolve({ decision: "cancel" }); this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); @@ -4795,17 +5054,17 @@ export class CodexAppServerAgentSession implements AgentSession { private async disposeClient(): Promise { const client = this.client; - this.client = null; - this.connected = false; + this.connectionState = "disconnected"; this.currentTurnId = null; if (client) { await client.dispose(); } + this.client = null; } async listCommands(): Promise { - const prompts = await listCodexCustomPrompts(); - if (!this.connected) { + const prompts = await listCodexCustomPrompts(this.codexHome); + if (this.connectionState === "disconnected") { await this.connect(); } else { await this.loadSkills(); @@ -4818,7 +5077,7 @@ export class CodexAppServerAgentSession implements AgentSession { })); const fallbackSkills = this.cachedSkills === null - ? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService) + ? await listCodexSkills(this.config.cwd, this.codexHome, this.deps.workspaceGitService) : []; const builtin: AgentSlashCommand[] = [ { @@ -5011,13 +5270,21 @@ export class CodexAppServerAgentSession implements AgentSession { return { model, thinkingOptionId }; } + // A session without a model or effort runs on Codex's configured defaults, whether its + // thread is new or resumed after a restart. + private async applyDefaultModelAndThinking(): Promise { + const { model, thinkingOptionId } = await this.resolveModelAndThinking(); + this.config.model = model; + this.reconcileServiceTier(); + this.config.thinkingOptionId = thinkingOptionId; + return model; + } + private async ensureThread(): Promise { if (!this.client) return; if (this.currentThreadId) return; - const { model, thinkingOptionId } = await this.resolveModelAndThinking(); - this.config.model = model; - this.config.thinkingOptionId = thinkingOptionId; + const model = await this.applyDefaultModelAndThinking(); const { params, approvalPolicy, sandbox } = this.buildThreadStartRequest(model); const rawResponse = await this.client.request("thread/start", params); @@ -5068,7 +5335,7 @@ export class CodexAppServerAgentSession implements AgentSession { ...(this.ephemeral ? { ephemeral: true } : {}), }; if (this.hasWorkflowModeOverride) { - applyApprovalsReviewerParam(params, preset); + params.approvalsReviewer = preset.approvalsReviewer; } return { params, approvalPolicy, sandbox }; } @@ -5106,7 +5373,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId; + const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId; const tagged = turnId ? { ...event, turnId } : event; this.logger.trace( { @@ -5354,8 +5621,9 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: ToolCallTimelineItem; rawItem: { [key: string]: unknown }; parentCallId: string | null; + parentSubagentId: string | null; }): string[] { - const { timelineItem, rawItem, parentCallId } = params; + const { timelineItem, rawItem, parentCallId, parentSubagentId } = params; if (timelineItem.detail.type !== "sub_agent") { return []; } @@ -5367,6 +5635,7 @@ export class CodexAppServerAgentSession implements AgentSession { callId: timelineItem.callId, toolCall: timelineItem, parentCallId, + parentSubagentId, activityItemIds: new Set(), pendingCommandOutputDeltas: new Map(), pendingFileChangeOutputDeltas: new Map(), @@ -5385,6 +5654,7 @@ export class CodexAppServerAgentSession implements AgentSession { }, }; state.parentCallId ??= parentCallId; + state.parentSubagentId ??= parentSubagentId; const activity = readCodexSubAgentActivity(rawItem); if (activity?.id) { state.activityItemIds.add(activity.id); @@ -5402,6 +5672,10 @@ export class CodexAppServerAgentSession implements AgentSession { new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds), ).filter((threadId) => threadId !== this.currentThreadId); for (const receiverThreadId of childThreadIds) { + const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId); + if (owningCallId && owningCallId !== timelineItem.callId) { + continue; + } this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId); state.childThreadIds.add(receiverThreadId); this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status); @@ -5620,6 +5894,7 @@ export class CodexAppServerAgentSession implements AgentSession { description: detail.description ?? null, status: providerStatus, toolCallId: state.callId, + parentSubagentId: state.parentSubagentId, }, }); } @@ -5874,6 +6149,7 @@ export class CodexAppServerAgentSession implements AgentSession { error: parsed.errorMessage ?? "Codex turn failed", }); } else if (parsed.status === "interrupted") { + this.dismissInterruptedAsyncQuestions(); this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); } else { if (this.planModeEnabled && this.latestPlanResult?.text) { @@ -6226,6 +6502,43 @@ export class CodexAppServerAgentSession implements AgentSession { } } + private receiveAsyncQuestion(threadId: string | null, item: unknown): void { + if (threadId !== this.currentThreadId) return; + const request = this.asyncQuestions.receive(item); + if (request) + this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); + } + + private dismissInterruptedAsyncQuestions(): void { + const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" }; + for (const request of this.asyncQuestions.pending()) { + const prepared = this.asyncQuestions.prepareResponse(request.id, resolution); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId: request.id, + resolution, + }); + } + } + + private reconcileAsyncQuestionsAfterRewind(): void { + const retainedIds = new Set( + this.persistedHistory.flatMap(({ item }) => + item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [], + ), + ); + for (const requestId of this.asyncQuestions.retain(retainedIds)) { + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: { behavior: "deny", message: "Removed by rewind" }, + }); + } + } + private handleItemCompletedNotification( parsed: Extract, ): void { @@ -6236,6 +6549,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } + this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { this.handleUserMessageItem(parsed); return; @@ -6257,6 +6571,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem, rawItem: parsed.item, parentCallId: childSubAgentCallId, + parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) : []; const imageItems = mcpToolResultImagesToTimeline(parsed.item); @@ -6424,6 +6739,7 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem, rawItem: parsed.item, parentCallId: childSubAgentCallId, + parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }); if (childSubAgentCallId) { this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); @@ -6439,6 +6755,7 @@ export class CodexAppServerAgentSession implements AgentSession { ); const itemId = parsed.item.id; if (normalizedItemType === "commandExecution") { + this.rememberTerminalProcessForItem(parsed.item); const callId = timelineItem.callId || itemId; if (callId && this.emittedExecCommandStartedCallIds.has(callId)) { return; @@ -6558,19 +6875,21 @@ export class CodexAppServerAgentSession implements AgentSession { } private rememberTerminalProcessForCommand(command: unknown, output: string | null): void { - const normalizedCommand = normalizeCodexCommandValue(command); - if (!normalizedCommand) { - return; - } - const displayCommand = - typeof normalizedCommand === "string" - ? normalizedCommand - : normalizedCommand.join(" ").trim(); - if (!displayCommand) { - return; - } const processId = extractCodexTerminalSessionId(output ?? undefined); - if (!processId) { + if (processId) { + this.rememberTerminalProcess(processId, command); + } + } + + private rememberTerminalProcessForItem(item: { [key: string]: unknown }): void { + if (typeof item.processId === "string" && item.processId) { + this.rememberTerminalProcess(item.processId, item.command); + } + } + + private rememberTerminalProcess(processId: string, command: unknown): void { + const displayCommand = normalizeCommandExecutionCommand(command); + if (!displayCommand) { return; } this.terminalCommandByProcessId.set(processId, displayCommand); @@ -6642,6 +6961,8 @@ export class CodexAppServerAgentSession implements AgentSession { const parsed = z .object({ itemId: z.string(), + // Set when several approval callbacks belong to one item; it names the callback. + approvalId: z.string().nullable().optional(), threadId: z.string(), turnId: z.string(), command: z.string().nullable().optional(), @@ -6655,7 +6976,7 @@ export class CodexAppServerAgentSession implements AgentSession { cwd: parsed.cwd ?? this.config.cwd ?? null, running: true, }); - const requestId = `permission-${parsed.itemId}`; + const requestId = `permission-${parsed.approvalId ?? parsed.itemId}`; const title = parsed.command ? `Run command: ${parsed.command}` : "Run command"; const request: AgentPermissionRequest = { id: requestId, @@ -6838,16 +7159,18 @@ export class CodexAppServerAgentClient implements AgentClient { private readonly deps: CodexAppServerAgentDeps = {}, ) {} - private sessionDeps(): CodexAppServerAgentDeps { + private sessionDeps(launchEnv: Record | undefined): CodexAppServerAgentDeps { return { ...this.deps, - customCodexConfig: buildCodexCustomProviderConfig( - this.runtimeSettings, - this.deps.customProvider, - ), + codexHome: resolveCodexHomeDir(buildCodexAppServerEnv(this.runtimeSettings, launchEnv)), + customCodexConfig: this.customProviderConfig(), }; } + private customProviderConfig(): CodexCustomProviderConfig | null { + return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider); + } + private resolveGoalsEnabled(): Promise { if (!this.goalsEnabledPromise) { this.goalsEnabledPromise = (async () => { @@ -6902,7 +7225,7 @@ export class CodexAppServerAgentClient implements AgentClient { private async spawnAppServer( launchEnv?: Record, - options?: { goalsEnabled?: boolean; agentId?: string }, + options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record }, ): Promise { const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings); const args = [...launchPrefix.args, "app-server"]; @@ -6921,10 +7244,7 @@ export class CodexAppServerAgentClient implements AgentClient { const child = spawnProcess(launchPrefix.command, args, { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], - ...createProviderEnvSpec({ - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv], - }), + baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv), }); assertChildWithPipes(child); return child; @@ -6945,17 +7265,23 @@ export class CodexAppServerAgentClient implements AgentClient { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( sessionConfig, null, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, options?.persistSession === false, goalsEnabled, autoReviewEnabled, launchContext?.agentId, + "interactive", ); await session.connect(); return session; @@ -6976,13 +7302,18 @@ export class CodexAppServerAgentClient implements AgentClient { }; const goalsEnabled = await this.resolveGoalsEnabled(); const autoReviewEnabled = await this.resolveAutoReviewEnabled(); + const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env); const session = new CodexAppServerAgentSession( merged, handle, this.logger, () => - this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }), - this.sessionDeps(), + this.spawnAppServer(launchContext?.env, { + goalsEnabled, + agentId: launchContext?.agentId, + environment, + }), + { ...this.sessionDeps(launchContext?.env), environment }, false, goalsEnabled, autoReviewEnabled, @@ -7011,13 +7342,15 @@ export class CodexAppServerAgentClient implements AgentClient { // filtering since most threads will be from other cwds, then keep the // local realpath-aware filter for symlink-equivalent workspace paths. const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; - const response = toObjectRecord( - await client.request("thread/list", { - limit: listLimit, - ...(options?.cwd ? { cwd: options.cwd } : {}), - }), - ); - const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : []; + // Codex records each thread under the model provider that ran it and, + // unless told otherwise, lists only the provider its own config selects. + // A custom provider runs under its own id, so ask for exactly that one. + const customProviderConfig = this.customProviderConfig(); + const allThreads = await readCodexThreadWindow(client, this.logger, { + limit: listLimit, + cwd: options?.cwd, + ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), + }); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { const threadId = typeof thread.id === "string" ? thread.id : ""; @@ -7052,6 +7385,11 @@ export class CodexAppServerAgentClient implements AgentClient { }); } + async getCatalogCacheKey(_options: FetchCatalogOptions): Promise { + // This client discovers through host configuration, independent of project cwd. + return "host"; + } + async fetchCatalog( _options: FetchCatalogOptions, context?: ProviderRefreshContext, diff --git a/packages/server/src/server/agent/providers/codex-async-questions.test.ts b/packages/server/src/server/agent/providers/codex-async-questions.test.ts new file mode 100644 index 0000000000..8915688bde --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-async-questions.test.ts @@ -0,0 +1,436 @@ +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + waitForNextEvent, + waitForTimelineToolCall, +} from "./codex/test-utils/fake-app-server.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { AgentManager } from "../agent-manager.js"; + +const questionItem = { + type: "agentMessage", + id: "async-question-1", + text: "Which color?\n- Blue\n- Green", + phase: "final_answer", + delivery: "async", + questions: [{ title: "Which color?", options: ["Blue", "Green"] }], +}; + +async function setup(metadata?: Record, rejectSteer = false) { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": () => ({}), + "turn/steer": () => { + if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } }; + return { turnId: "native-turn" }; + }, + "thread/read": () => ({ thread: { id: "thread-1", turns: [] } }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + metadata ? { sessionId: "thread-1", metadata } : null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.startTurn("Help me choose a color while you inspect the project."); + const started = waitForNextEvent(session, "turn_started"); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" }); + await started; + async function ask() { + const shown = waitForTimelineToolCall(session, questionItem.id); + appServer.child.stdout.write( + JSON.stringify({ + method: "item/completed", + params: { threadId: "thread-1", turnId: "native-turn", item: questionItem }, + }) + "\n", + ); + await shown; + } + async function say(text: string) { + const shown = waitForNextEvent( + session, + "timeline", + (event) => event.item.type === "assistant_message" && event.item.text.includes(text), + ); + appServer.says({ threadId: "thread-1", text }); + await shown; + } + async function finish(status: "completed" | "interrupted" = "completed") { + const finished = waitForNextEvent( + session, + status === "completed" ? "turn_completed" : "turn_canceled", + ); + appServer.completeTurn({ status }); + await finished; + } + function allowAnswers() { + rejectSteer = false; + } + return { session, appServer, events, ask, say, finish, allowAnswers }; +} + +const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } }; + +async function setupRewind(fail = false) { + const records = [ + { item: { ...questionItem, id: "earlier-pending" } }, + { item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] }, + { item: { ...questionItem, id: "removed-pending" } }, + { item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] }, + ]; + function userMessage(id: string) { + return { type: "userMessage", id, content: [{ type: "text", text: id }] }; + } + const turns = [ + { + id: "earlier-turn", + items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)], + }, + { + id: "removed-turn", + items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)], + }, + ]; + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) }, + }; + }, + ...(fail + ? { + "thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }), + } + : {}), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + { sessionId: "thread-1", metadata: { asyncQuestions: records } }, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + return { session, events, records }; +} + +test("rewind removes only questions outside the remaining history, including after resume", async () => { + const { session, events, records } = await setupRewind(); + let metadata: Record | undefined; + try { + await session.revertConversation({ messageId: "rewind-here" }); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + metadata = session.describePersistence()!.metadata; + expect(metadata.asyncQuestions).toEqual( + records.slice(0, 2).map((record) => ({ + resolution: record.resolution, + item: { + type: "agentMessage", + id: record.item.id, + delivery: "async", + questions: questionItem.questions, + }, + })), + ); + expect(events.filter((e) => e.type === "permission_resolved")).toEqual([ + expect.objectContaining({ requestId: "permission-removed-pending" }), + ]); + const history = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.some( + (e) => + e.type === "timeline" && + e.item.type === "tool_call" && + e.item.callId === "removed-pending", + ), + ).toBe(false); + } finally { + await session.close(); + } + const resumed = await setup(metadata); + try { + expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + } finally { + await resumed.session.close(); + } +}); + +test("failed rewind preserves question state", async () => { + const { session, events } = await setupRewind(true); + try { + const before = session.describePersistence(); + await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow( + "Rewind failed", + ); + expect(session.describePersistence()).toEqual(before); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + "permission-removed-pending", + ]); + expect(events.some((e) => e.type === "permission_resolved")).toBe(false); + } finally { + await session.close(); + } +}); + +async function manage(session: CodexAppServerAgentSession) { + const manager = new AgentManager({ + clients: { + codex: { + provider: "codex", + capabilities: session.capabilities, + createSession: async () => session, + resumeSession: async () => session, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + }, + }, + logger: createTestLogger(), + }); + const agent = await manager.createAgent( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4" }, + undefined, + { workspaceId: undefined }, + ); + return { manager, agent }; +} + +test("manager publishes and saves the provider state after rewind", async () => { + const { session } = await setupRewind(); + const { manager, agent } = await manage(session); + try { + await manager.rewind(agent.id, "rewind-here", "conversation"); + const snapshot = manager.getAgent(agent.id)!; + expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]); + expect(snapshot.persistence).toEqual(session.describePersistence()); + expect(snapshot.persistence?.sessionId).toBe("forked-thread"); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("manager snapshots capture pending and answered question state before the turn ends", async () => { + const { session, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + }, + ]); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + resolution: ["Green"], + }, + ]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("concurrent answers deliver only one response to the active Codex turn", async () => { + const { session, appServer, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + const results = await Promise.allSettled([ + manager.respondToPermission(agent.id, permission.id, answer), + manager.respondToPermission(agent.id, permission.id, answer), + ]); + expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength( + 1, + ); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("a failed submission releases the request so the user can retry", async () => { + const { session, ask, allowAnswers } = await setup(undefined, true); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow( + "Delivery failed", + ); + expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]); + allowAnswers(); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => { + const { session, appServer, events, ask, say } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toMatchObject({ + kind: "question", + input: { + questions: [ + { + header: "Question 1", + question: "Which color?", + isOther: true, + options: [{ label: "Blue" }, { label: "Green" }], + }, + ], + }, + }); + await say("I am still inspecting the project."); + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("still inspecting"), + ), + ).toBe(true); + await session.respondToPermission(permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject( + [ + { + params: { + expectedTurnId: "native-turn", + clientUserMessageId: expect.any(String), + input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }], + }, + }, + ], + ); + expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual( + [], + ); + expect( + events.some( + (event) => event.type === "permission_resolved" && event.requestId === permission.id, + ), + ).toBe(true); + } finally { + await session.close(); + } +}); + +test("keeps a failed answer pending for retry", async () => { + const { session, ask } = await setup(undefined, true); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow(); + expect(session.getPendingPermissions()).toHaveLength(1); + } finally { + await session.close(); + } +}); + +test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + first.session.subscribe((event) => { + if (event.type === "turn_canceled") { + metadata = first.session.describePersistence()!.metadata; + } + }); + await first.session.interrupt(); + await first.finish("interrupted"); + expect(first.session.getPendingPermissions()).toEqual([]); + expect(metadata?.asyncQuestions).toEqual([ + expect.objectContaining({ resolution: "dismissed" }), + ]); + expect(first.events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-async-question-1", + resolution: { behavior: "deny", message: "Interrupted" }, + }), + ); + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + } finally { + await resumed.session.close(); + } +}); + +test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => { + const { session, appServer, ask, finish } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await finish(); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(await session.respondToPermission(permission.id, answer)).toEqual({ + followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen", + }); + expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false); + } finally { + await session.close(); + } +}); + +test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + metadata = first.session.describePersistence()!.metadata; + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + const [permission] = resumed.session.getPendingPermissions(); + expect(permission).toBeDefined(); + await resumed.session.respondToPermission(permission.id, { behavior: "deny" }); + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + expect( + resumed.appServer.requests().some((request) => request.method === "turn/interrupt"), + ).toBe(false); + } finally { + await resumed.session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts new file mode 100644 index 0000000000..ebd8de5ab5 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts @@ -0,0 +1,139 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { once } from "node:events"; +import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; + +// Runs the real `codex` binary against a local Responses endpoint standing in +// for a custom provider's OPENAI_BASE_URL, so no login is needed. + +function sse(events: Array>): string { + return events + .map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""); +} + +function finalAnswer(): string { + return sse([ + { type: "response.created", response: { id: "resp-1" } }, + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-1", + content: [{ type: "output_text", text: "done" }], + }, + }, + { + type: "response.completed", + response: { + id: "resp-1", + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }, + ]); +} + +async function startResponsesEndpoint(): Promise<{ + baseUrl: string; + server: Server; + requestCount: () => number; +}> { + let requests = 0; + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.statusCode = 404; + res.end(); + return; + } + requests += 1; + res.setHeader("content-type", "text/event-stream"); + res.end(finalAnswer()); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no server address"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + server, + requestCount: () => requests, + }; +} + +async function closeServer(server: Server): Promise { + server.close(); + await once(server, "close"); +} + +describe("Codex custom provider import list", () => { + const cleanups: Array<() => void | Promise> = []; + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); + }); + + test("lists a custom provider's session under that provider and imports it there", async () => { + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); + cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); + cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); + const { baseUrl, server, requestCount } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); + + const logger = createTestLogger(); + const customCodex = new CodexAppServerAgentClient( + logger, + { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, + { customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, + ); + const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); + + const session = await customCodex.createSession({ + provider: "codex", + cwd, + model: "mock-model", + }); + try { + await session.run("hello from the custom provider"); + } finally { + await session.close(); + } + + const customSessions = await customCodex.listImportableSessions({ cwd }); + const stockSessions = await stockCodex.listImportableSessions({ cwd }); + + expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ + "hello from the custom provider", + ]); + expect(stockSessions).toEqual([]); + + const config = { provider: "codex", cwd, model: "mock-model" }; + const imported = await customCodex.importSession( + { providerHandleId: customSessions[0].providerHandleId, cwd }, + { config, storedConfig: config }, + ); + const requestsBeforeImportedTurn = requestCount(); + try { + await imported.session.run("hello again after import"); + } finally { + await imported.session.close(); + } + expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); + }, 120_000); +}); diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index a850317e56..5eb11d8a0c 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -1,15 +1,33 @@ import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; -const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const; +import { z } from "zod"; -export const CODEX_FAST_MODE_FEATURE: Omit = { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at 2x usage", - tooltip: "Toggle fast mode", - icon: "zap", -}; +export const CodexServiceTierSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), +}); +export type CodexServiceTier = z.infer; + +function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] { + if (tiers.length === 0) return []; + return [ + { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + ...tiers.map((tier) => ({ id: tier.id, label: tier.name })), + ], + }, + ]; +} export const CODEX_PLAN_MODE_FEATURE: Omit = { type: "toggle", @@ -20,42 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit = { icon: "list-todo", }; -function normalizeCodexModelId(modelId: string | null | undefined): string | null { - const normalized = typeof modelId === "string" ? modelId.trim() : ""; - return normalized.length > 0 ? normalized : null; -} - -export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { - const normalizedModelId = normalizeCodexModelId(modelId); - if (!normalizedModelId) { - return false; - } - return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some( - (prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix), - ); -} - export function buildCodexFeatures(input: { - modelId: string | null | undefined; - fastModeEnabled: boolean; + serviceTiers: CodexServiceTier[]; + serviceTier: string; planModeEnabled: boolean; planModeAvailable?: boolean; }): AgentFeature[] { - const features: AgentFeature[] = []; - - if (codexModelSupportsFastMode(input.modelId)) { - features.push({ - ...CODEX_FAST_MODE_FEATURE, - value: input.fastModeEnabled, - }); - } - + const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier); if (input.planModeAvailable !== false) { - features.push({ - ...CODEX_PLAN_MODE_FEATURE, - value: input.planModeEnabled, - }); + features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled }); } - return features; } + +export function readCodexServiceTier(values: Record | undefined): string | null { + const tier = values?.service_tier; + if (typeof tier === "string") return tier; + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + return values?.fast_mode ? "fast" : null; +} diff --git a/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..0d14f29930 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-history-lifecycle.real.e2e.test.ts @@ -0,0 +1,151 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { z } from "zod"; +import { CodexAppServerClient } from "./codex/app-server-transport.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import type { AgentSession } from "../agent-sdk-types.js"; + +async function readNativeThreadPath(threadId: string): Promise { + const client = new CodexAppServerClient( + spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }), + createTestLogger(), + ); + try { + await client.request("initialize", { + clientInfo: { name: "paseo-archive-regression", version: "1.0.0" }, + }); + client.notify("initialized", {}); + const response = await client.request("thread/read", { threadId }); + return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path; + } finally { + await client.dispose(); + } +} + +// Real native processes and a real completion. The second case represents records +// archived by older Paseo versions whose best-effort native archive failed. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])( + "Codex history releases its process and leaves native archive unchanged (native archived: %s)", + async (nativeArchived) => { + const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-")); + const logger = createTestLogger(); + const provider = new CodexAppServerAgentClient(logger); + const spawned: ChildProcessWithoutNullStreams[] = []; + let active: AgentSession | undefined; + let history: CodexAppServerAgentSession | undefined; + try { + const catalog = await provider.fetchCatalog({ scope: "global", force: false }); + const model = catalog.models.find((entry) => entry.isDefault)!; + const config = { + provider: "codex", + cwd, + model: model.id, + modeId: "full-access", + thinkingOptionId: "low", + }; + const initialProcesses: ChildProcessWithoutNullStreams[] = []; + const initial = new CodexAppServerAgentSession(config, null, logger, async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + initialProcesses.push(child); + return child; + }); + active = initial; + await initial.connect(); + const reply = await active.run( + "Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.", + ); + expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + const handle = active.describePersistence()!; + await active.close(); + active = undefined; + expect( + initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null), + ).toBe(true); + if (nativeArchived) await provider.archiveNativeSession(handle); + + const nativePath = await readNativeThreadPath(handle.sessionId); + expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived); + history = new CodexAppServerAgentSession( + config, + handle, + logger, + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + await history.connect(); + const text: string[] = []; + for await (const event of history.streamHistory()) { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + } + expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(spawned).toHaveLength(1); + expect( + spawned.filter((child) => child.exitCode === null && child.signalCode === null), + ).toEqual([]); + + expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath); + + // Native unarchive is possible after reading history; history held no writer. + await provider.unarchiveNativeSession(handle); + active = await provider.resumeSession(handle, config); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + const followUp = await active.run( + "Repeat exactly your previous assistant reply from this conversation. Do not use tools.", + ); + expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY"); + expect(active.describePersistence()?.sessionId).toBe(handle.sessionId); + } finally { + await history?.close(); + await active?.close(); + await rm(cwd, { recursive: true, force: true }); + } + }, + 120_000, +); + +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")( + "a failed Codex history read releases its temporary process", + async () => { + const spawned: ChildProcessWithoutNullStreams[] = []; + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir() }, + { sessionId: "00000000-0000-4000-8000-000000000001" }, + createTestLogger(), + async () => { + const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }); + spawned.push(child); + return child; + }, + {}, + false, + false, + false, + undefined, + "history", + ); + try { + await expect(session.connect()).rejects.toThrow(); + expect(spawned).toHaveLength(1); + expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe( + true, + ); + } finally { + await session.close(); + } + }, + 30_000, +); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index a6c5d2a8b2..5c9dbe956c 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { @@ -22,6 +22,39 @@ describe("Codex app-server transport", () => { child.stdin.end(); }); + test("dispose rejects pending requests instead of leaving them hanging", async () => { + const child = createCodexAppServerChildProcess(); + const client = new CodexAppServerClient(child, createTestLogger()); + + const request = client.request("initialize", {}); + await client.dispose(); + + await expect(request).rejects.toThrow("Codex app-server client is closed"); + }); + + test("dispose rejects until the child has actually exited", async () => { + vi.useFakeTimers(); + const child = createCodexAppServerChildProcess(); + child.kill = () => true; + const client = new CodexAppServerClient(child, createTestLogger()); + try { + for (let i = 0; i < 2; i++) { + const closing = expect(client.dispose()).rejects.toThrow( + "did not report exit after SIGKILL", + ); + await vi.advanceTimersByTimeAsync(3_000); + await closing; + } + child.exitCode = 0; + child.emit("exit", 0, null); + await expect(client.dispose()).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + child.stdout.end(); + child.stderr.end(); + } + }); + test.each([ "item/commandExecution/requestApproval", "item/fileChange/requestApproval", diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index e143cd6863..1720118c63 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -257,10 +257,10 @@ export class CodexAppServerClient { } async dispose(): Promise { - if (this.disposed) return; this.disposed = true; this.unexpectedTerminationHandler = null; this.rl.close(); + this.rejectPending(new Error("Codex app-server client is closed")); try { this.child.stdin.end(); } catch { @@ -277,10 +277,7 @@ export class CodexAppServerClient { }, }); if (result === "kill-timeout") { - this.logger.warn( - { timeoutMs: APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS }, - "Codex app-server did not report exit after SIGKILL", - ); + throw new Error("Codex app-server did not report exit after SIGKILL"); } } @@ -290,11 +287,7 @@ export class CodexAppServerClient { } this.disposed = true; this.rl.close(); - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } - this.pending.clear(); + this.rejectPending(error); const handler = this.unexpectedTerminationHandler; this.unexpectedTerminationHandler = null; if (!handler) { @@ -307,6 +300,14 @@ export class CodexAppServerClient { } } + private rejectPending(error: Error): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + private writeJsonRpcResponse(response: JsonRpcResponse): void { if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) { return; diff --git a/packages/server/src/server/agent/providers/codex/async-questions.ts b/packages/server/src/server/agent/providers/codex/async-questions.ts new file mode 100644 index 0000000000..6fdba7b67e --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/async-questions.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, + ToolCallTimelineItem, +} from "../../agent-sdk-types.js"; + +const QuestionSchema = z.object({ + title: z.string().trim().min(1), + options: z.array(z.string().min(1)).nullish(), +}); +const ItemSchema = z.object({ + type: z.literal("agentMessage"), + id: z.string().min(1), + delivery: z.literal("async"), + questions: z.array(QuestionSchema).min(1), +}); +const RecordSchema = z.object({ + item: ItemSchema, + resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(), +}); +type QuestionRecord = z.infer; + +function requestId(itemId: string): string { + return `permission-${itemId}`; +} + +function toPermission(record: QuestionRecord): AgentPermissionRequest { + return { + id: requestId(record.item.id), + provider: "codex", + name: "request_user_input_async", + kind: "question", + title: "Question", + input: { + questions: record.item.questions.map((question, index) => ({ + id: String(index), + header: `Question ${index + 1}`, + question: question.title, + options: (question.options ?? []).map((label) => ({ label })), + isOther: true, + })), + }, + }; +} + +function toTimeline(record: QuestionRecord): ToolCallTimelineItem { + const answers = Array.isArray(record.resolution) ? record.resolution : undefined; + return { + type: "tool_call", + callId: record.item.id, + name: "request_user_input_async", + status: "completed", + error: null, + detail: { + type: "plain_text", + icon: "brain", + text: + record.item.questions + .map((question, index) => + [question.title, answers ? answers[index] : (question.options ?? []).join(", ")] + .filter(Boolean) + .join("\n"), + ) + .join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""), + }, + }; +} + +export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null { + const parsed = ItemSchema.safeParse(item); + return parsed.success ? toTimeline({ item: parsed.data }) : null; +} + +/** Codex emits these as completed messages; the outstanding answer belongs to the session. */ +export class CodexAsyncQuestions { + private readonly records = new Map(); + + constructor(saved: unknown) { + const parsed = z.array(RecordSchema).safeParse(saved); + if (parsed.success) { + for (const record of parsed.data) this.records.set(requestId(record.item.id), record); + } + } + + receive(item: unknown): AgentPermissionRequest | null { + const parsed = ItemSchema.safeParse(item); + if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null; + const record = { item: parsed.data }; + this.records.set(requestId(parsed.data.id), record); + return toPermission(record); + } + + pending(): AgentPermissionRequest[] { + return Array.from(this.records.values()) + .filter((record) => record.resolution === undefined) + .map(toPermission); + } + + hasPending(id: string): boolean { + const record = this.records.get(id); + return record !== undefined && record.resolution === undefined; + } + + prepareResponse( + id: string, + response: AgentPermissionResponse, + ): { prompt?: string; complete: () => ToolCallTimelineItem } { + const record = this.records.get(id); + if (!record || record.resolution !== undefined) + throw new Error("Question is no longer pending"); + let resolution: QuestionRecord["resolution"] = "dismissed"; + let prompt: string | undefined; + if (response.behavior === "allow") { + const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers); + resolution = record.item.questions.map((_, index) => { + const answer = answers[`Question ${index + 1}`]?.trim(); + if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`); + return answer; + }); + const values = resolution; + prompt = + "Answers to your questions:\n\n" + + record.item.questions + .map((question, index) => `${question.title}\n${values[index]}`) + .join("\n\n"); + } + return { + prompt, + complete: () => { + record.resolution = resolution; + return toTimeline(record); + }, + }; + } + + timeline(itemId: string): ToolCallTimelineItem | null { + const record = this.records.get(requestId(itemId)); + return record ? toTimeline(record) : null; + } + + retain(itemIds: ReadonlySet): string[] { + const removedPendingIds: string[] = []; + for (const [id, record] of this.records) { + if (itemIds.has(record.item.id)) continue; + this.records.delete(id); + if (record.resolution === undefined) removedPendingIds.push(id); + } + return removedPendingIds; + } + + serialize(): unknown { + return Array.from(this.records.values()); + } +} diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts index 923b8074bb..da7fe6a8f5 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.test.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -96,6 +96,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -131,6 +132,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "codex-second", userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -175,6 +177,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -218,6 +221,7 @@ describe("Codex Rewind", () => { model: "gpt-5.4-mini", serviceTier: null, userMessageTurns, + threadRollbackAvailable: true, setThreadId: (threadId) => { reboundThreadId = threadId; }, @@ -239,6 +243,7 @@ describe("Codex Rewind", () => { threadId: "source-thread", messageId: "missing-message", userMessageTurns, + threadRollbackAvailable: true, setThreadId: () => undefined, }), ).rejects.toThrow("Codex could not find user message missing-message"); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts index f3573c58a8..021d57645c 100644 --- a/packages/server/src/server/agent/providers/codex/rewind.ts +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -67,7 +67,9 @@ export async function revertCodexConversation(input: { cwd?: string | null; model?: string | null; serviceTier?: string | null; + config?: Record | null; userMessageTurns: CodexUserMessageTurnIndex; + threadRollbackAvailable: boolean; setThreadId: (threadId: string) => void | Promise; }): Promise { if (!input.threadId) { @@ -85,34 +87,37 @@ export async function revertCodexConversation(input: { throw new Error(`Codex user message ${input.messageId} is outside the current thread`); } - const historyMode = await readCodexThreadHistoryMode(input.client, input.threadId); - if (historyMode === "paginated") { + // Codex does not carry the parent thread's config into a fork; without it the + // forked thread falls back to the default model provider. + const forkParams: CodexThreadForkParams = { + threadId: input.threadId, + cwd: input.cwd ?? null, + model: input.model ?? null, + serviceTier: input.serviceTier ?? null, + ...(input.config ? { config: input.config } : {}), + excludeTurns: false, + persistExtendedHistory: true, + }; + + if ( + !input.threadRollbackAvailable || + (await readCodexThreadHistoryMode(input.client, input.threadId)) === "paginated" + ) { if (!targetTurn.turnId) { throw new Error(`Codex could not find the turn containing user message ${input.messageId}`); } const forked = await forkCodexThread(input.client, { - threadId: input.threadId, + ...forkParams, beforeTurnId: targetTurn.turnId, - cwd: input.cwd ?? null, - model: input.model ?? null, - serviceTier: input.serviceTier ?? null, - excludeTurns: false, - persistExtendedHistory: true, }); await input.setThreadId(forked.thread.id); return; } - // Fork is non-destructive: the old thread file stays on disk and remains + // Legacy threads on Codex before 0.156 fork and then roll back. Fork is + // non-destructive: the old thread file stays on disk and remains // recoverable with `codex resume ` if the rewind target was wrong. - const forked = await forkCodexThread(input.client, { - threadId: input.threadId, - cwd: input.cwd ?? null, - model: input.model ?? null, - serviceTier: input.serviceTier ?? null, - excludeTurns: false, - persistExtendedHistory: true, - }); + const forked = await forkCodexThread(input.client, forkParams); const forkedThreadId = forked.thread.id; // Codex rollback is chat-only by design. File edits from rewound turns stay diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 3f4a7af01c..226eeb9738 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -25,6 +25,13 @@ interface FakeSilentCommand { command: string; cwd: string; } +interface FakeTerminalCommand { + threadId: string; + turnId: string; + itemId: string; + processId: string; + command: string; +} interface FakeTerminalInput { threadId: string; turnId: string; @@ -81,17 +88,20 @@ export interface FakeCodexAppServer { completesCommand(params: FakeLegacyCommand): void; completesSilentCommand(params: FakeSilentCommand): void; completesSilentLegacyCommand(params: FakeSilentCommand): void; + startsTerminalCommand(params: FakeTerminalCommand): void; typesIntoTerminal(params: FakeTerminalInput): void; says(params: { threadId: string; itemId?: string; text: string; chunks?: string[] }): void; requestCommandApproval(params: { itemId: string; + approvalId?: string; threadId: string; turnId: string; command: string; cwd: string; reason: string; }): void; - waitForCommandApprovalDecision(itemId: string): Promise; + /** Keyed by `approvalId` when the request carried one, otherwise by `itemId`. */ + waitForCommandApprovalDecision(callbackId: string): Promise; requestFileChangeApproval(params: { itemId: string; threadId: string; @@ -485,6 +495,19 @@ export function createFakeCodexAppServer( success: true, }); }, + startsTerminalCommand(params) { + writeNotification("item/started", { + threadId: params.threadId, + turnId: params.turnId, + item: { + type: "commandExecution", + id: params.itemId, + status: "inProgress", + command: params.command, + processId: params.processId, + }, + }); + }, typesIntoTerminal(params) { writeNotification("item/commandExecution/terminalInteraction", { threadId: params.threadId, @@ -518,7 +541,7 @@ export function createFakeCodexAppServer( requestCommandApproval(params) { const requestId = nextServerRequestId; nextServerRequestId += 1; - approvalRequestIds.set(params.itemId, requestId); + approvalRequestIds.set(params.approvalId ?? params.itemId, requestId); child.stdout.write( `${JSON.stringify({ jsonrpc: "2.0", @@ -528,8 +551,8 @@ export function createFakeCodexAppServer( })}\n`, ); }, - async waitForCommandApprovalDecision(itemId) { - return await this.waitForApprovalDecision(itemId); + async waitForCommandApprovalDecision(callbackId) { + return await this.waitForApprovalDecision(callbackId); }, requestFileChangeApproval(params) { const requestId = nextServerRequestId; @@ -618,7 +641,7 @@ function toJsonObject(value: unknown): JsonObject { type StreamEventType = AgentStreamEvent["type"]; type StreamEventOfType = Extract; -function waitForNextEvent( +export function waitForNextEvent( session: AgentSession, type: TType, accepts?: (event: StreamEventOfType) => boolean, diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6ce2d4fe13..566fd10083 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -656,6 +656,29 @@ describe("codex tool-call mapper", () => { }); }); + it.each([ + { + type: "commandExecution", + id: "codex-declined-command", + status: "declined", + command: "printf ok > /outside/permission.txt", + cwd: "/tmp/repo", + aggregatedOutput: null, + exitCode: null, + }, + { + type: "fileChange", + id: "codex-declined-patch", + status: "declined", + changes: [], + }, + ])("settles a declined $type as failed instead of running", (threadItem) => { + const item = expectMapped(mapCodexToolCallFromThreadItem(threadItem)); + + expect(item.callId).toBe(threadItem.id); + expect(item.status).toBe("failed"); + }); + it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( mapCodexToolCallEnvelope({ diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 2f274d2d3f..64629bc5d9 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -245,7 +245,7 @@ function isWindowsShellCommand(command: string): boolean { return /(?:^|\\)(?:pwsh|powershell|cmd)(?:\.exe)?$/i.test(normalized); } -function normalizeCommandExecutionCommand(value: unknown): string | undefined { +export function normalizeCommandExecutionCommand(value: unknown): string | undefined { if (typeof value === "string") { const normalized = unwrapShellCommand(value); return normalized.length > 0 ? normalized : undefined; diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index a38fb77128..bd43d913e8 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -1,22 +1,42 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import { describe, expect, test, vi } from "vitest"; +import { ACPAgentSession } from "./acp-agent.js"; import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js"; +import type { AgentSessionConfig } from "../agent-sdk-types.js"; import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +function captureWarnings(): { + logger: ReturnType; + messages: () => string[]; +} { + const logged: string[] = []; + const logger = createTestLogger(); + const child = { + trace: vi.fn(), + warn: (_context: unknown, message: string) => logged.push(message), + }; + vi.spyOn(logger, "child").mockReturnValue( + child as unknown as ReturnType, + ); + return { logger, messages: () => logged }; +} + +function fastConfigOption(currentValue: "false" | "true"): SessionConfigOption { + return { + id: "fast", + name: "Fast", + type: "select", + currentValue, + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "Fast" }, + ], + }; +} + describe("CursorACPAgentClient model discovery", () => { - function fastConfigOption(currentValue: "false" | "true") { - return { - id: "fast", - name: "Fast", - type: "select" as const, - currentValue, - options: [ - { value: "false", name: "Off" }, - { value: "true", name: "Fast" }, - ], - }; - } class TestCursorACPAgentClient extends CursorACPAgentClient { constructor(response: SessionStateResponse) { super({ @@ -33,6 +53,13 @@ describe("CursorACPAgentClient model discovery", () => { child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, connection: { newSession: vi.fn().mockResolvedValue(this.response), + extMethod: async () => ({ + models: (this.response.models?.availableModels ?? []).map((model) => ({ + value: model.modelId, + name: model.name, + configOptions: [], + })), + }), }, initialize: { agentCapabilities: {} }, } as SpawnedACPProcess; @@ -174,3 +201,157 @@ describe("CursorACPAgentClient model discovery", () => { ]); }); }); + +describe("CursorACPAgentClient session start", () => { + // Kimi K3 exposes reasoning but no fast option. + const KIMI_K3_CONFIG_OPTIONS: SessionConfigOption[] = [ + { + id: "reasoning", + name: "Reasoning", + category: "thought_level", + type: "select", + currentValue: "max", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + { value: "max", name: "Max" }, + ], + }, + ]; + + function createCursorSession( + config: Partial, + session: { + currentModelId: string; + configOptions: SessionConfigOption[]; + setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>; + } = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS }, + logger = createTestLogger(), + ): ACPAgentSession { + class StubbedCursorSession extends ACPAgentSession { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + models: { + currentModelId: session.currentModelId, + availableModels: [ + { modelId: "composer-2.5", name: "Composer 2.5", description: null }, + { modelId: "kimi-k3", name: "Kimi K3", description: null }, + ], + }, + configOptions: session.configOptions, + }), + // cursor-agent switches the model without returning refreshed config options. + unstable_setSessionModel: vi.fn().mockResolvedValue(undefined), + setSessionConfigOption: + session.setSessionConfigOption ?? + vi.fn().mockResolvedValue({ configOptions: session.configOptions }), + }, + initialize: { agentCapabilities: {} }, + } as SpawnedACPProcess; + } + } + + return new StubbedCursorSession( + { provider: "acp", cwd: "/tmp/cursor", ...config }, + { + provider: "acp", + logger, + defaultCommand: ["cursor-agent", "acp"], + defaultModes: [], + capabilities: { supportsStreaming: true, supportsSessionPersistence: true }, + configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION], + }, + ); + } + + test("starts on a model without a fast variant while Fast is still stored", async () => { + // The composer stored Fast while a fast-capable model was selected, and it is + // still stored after the draft moved to a model that has no fast variant. + const warn = captureWarnings(); + const session = createCursorSession( + { model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, + { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS }, + warn.logger, + ); + + await session.initializeNewSession(); + + expect(session.id).toBe("session-1"); + expect(warn.messages()).toContain( + "acp cannot apply ACP feature 'fast' to the current model; using the provider default", + ); + }); + + test("starts when the CLI rejects a stored feature the switched-to model dropped", async () => { + // The CLI keeps the pre-switch config options for the session, so Fast is + // still listed after the model moves to one that has no fast variant. + const warn = captureWarnings(); + const session = createCursorSession( + { model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, + { + currentModelId: "composer-2.5", + configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS], + setSessionConfigOption: vi.fn().mockRejectedValue( + Object.assign(new Error("Invalid params"), { + code: -32602, + data: { message: "Unknown model config option: fast" }, + }), + ), + }, + warn.logger, + ); + + await session.initializeNewSession(); + + expect(session.id).toBe("session-1"); + expect(warn.messages()).toContain( + "acp cannot apply ACP feature 'fast' to the current model; using the provider default", + ); + }); + + test("still fails session start when a stored feature write fails for another reason", async () => { + const session = createCursorSession( + { model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, + { + currentModelId: "composer-2.5", + configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS], + setSessionConfigOption: vi.fn().mockRejectedValue(new Error("write EPIPE")), + }, + ); + + await expect(session.initializeNewSession()).rejects.toThrow("write EPIPE"); + }); + + test("reports the failure when the user turns Fast on for a model without it", async () => { + const session = createCursorSession({ model: "kimi-k3" }); + await session.initializeNewSession(); + + await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow( + "acp does not expose ACP feature 'fast'", + ); + }); + + test("still fails session start when the provider rejects a write it should accept", async () => { + // No model switch, so the session's options came straight from session/new and the + // provider disagreeing about a feature it just advertised is a real failure. + const session = createCursorSession( + { model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, + { + currentModelId: "composer-2.5", + configOptions: [fastConfigOption("false")], + setSessionConfigOption: vi.fn().mockRejectedValue( + Object.assign(new Error("Invalid params"), { + code: -32602, + data: { message: "sessionId is required" }, + }), + ), + }, + ); + + await expect(session.initializeNewSession()).rejects.toThrow("Invalid params"); + }); +}); diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.ts index 6dce76c973..0b5b9ac4ec 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.ts @@ -1,6 +1,13 @@ +import { zSessionConfigOption } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js"; import type { Logger } from "pino"; +import { z } from "zod"; -import type { ACPConfigFeatureOption } from "./acp-agent.js"; +import type { AgentModelDefinition } from "../agent-sdk-types.js"; +import { + deriveSelectorOptions, + type ACPCatalogModelResolverContext, + type ACPConfigFeatureOption, +} from "./acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; interface CursorACPAgentClientOptions { @@ -9,7 +16,6 @@ interface CursorACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -26,6 +32,58 @@ export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = { icon: "zap", }; +const CursorModelCatalogSchema = z.object({ + models: z.array( + z.object({ + value: z.string().min(1), + name: z.string(), + configOptions: z.array(zSessionConfigOption), + }), + ), +}); + +// Cursor model switches persist CLI preferences, even in a throwaway probe session. +// Its extension returns each model's parameter definitions without selecting it. +export async function resolveCursorCatalogModels({ + connection, + models, + provider, + runRequest, +}: ACPCatalogModelResolverContext): Promise { + const catalog = await runRequest(() => fetchCursorModelCatalog(connection)); + const currentModelId = models.find((model) => model.isDefault)?.id; + + return catalog.models.map((model) => { + const thinkingOptions = deriveSelectorOptions(model.configOptions, "thought_level"); + const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id; + return { + provider, + id: model.value, + label: model.name, + isDefault: model.value === currentModelId, + thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, + defaultThinkingOptionId, + }; + }); +} + +async function fetchCursorModelCatalog(connection: ACPCatalogModelResolverContext["connection"]) { + try { + const response = await connection.extMethod("cursor/list_available_models", {}); + return CursorModelCatalogSchema.parse(response); + } catch (error) { + const extensionUnavailable = + typeof error === "object" && error !== null && "code" in error && error.code === -32601; + if (extensionUnavailable) { + throw new Error( + "Update Cursor CLI: this version does not support cursor/list_available_models.", + { cause: error }, + ); + } + throw error; + } +} + export class CursorACPAgentClient extends GenericACPAgentClient { constructor(options: CursorACPAgentClientOptions) { super({ @@ -34,12 +92,12 @@ export class CursorACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // cursor-agent publishes slash commands asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META, configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION], + catalogModelResolver: resolveCursorCatalogModels, }); } } diff --git a/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts new file mode 100644 index 0000000000..9facfda374 --- /dev/null +++ b/packages/server/src/server/agent/providers/cursor-acp-catalog.test.ts @@ -0,0 +1,213 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { expect, test } from "vitest"; +import { ZodError } from "zod"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { deriveModelDefinitionsFromACP, type ACPCatalogModelResolverContext } from "./acp-agent.js"; +import { resolveCursorCatalogModels } from "./cursor-acp-agent.js"; + +const modelOption: SessionConfigOption = { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: "claude-haiku-4-5", + options: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5" }, + { value: "grok-4.6", name: "Grok 4.6" }, + ], +}; + +const booleanThinking: SessionConfigOption = { + id: "thinking", + name: "Thinking", + category: "thought_level", + type: "select", + currentValue: "true", + options: [ + { value: "false", name: "Off" }, + { value: "true", name: "On" }, + ], +}; + +const effortThinking: SessionConfigOption = { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "xhigh", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + { value: "xhigh", name: "Extra High" }, + ], +}; + +class CursorCatalogConnection { + selectedModel = "claude-haiku-4-5"; + selectionHistory = ["claude-haiku-4-5"]; + error: unknown = null; + catalog: Record = { + models: [ + { value: "claude-haiku-4-5", name: "Haiku 4.5", configOptions: [booleanThinking] }, + { value: "grok-4.6", name: "Grok 4.6", configOptions: [effortThinking] }, + ], + }; + + async extMethod(method: string, params: Record) { + if (method !== "cursor/list_available_models" || Object.keys(params).length !== 0) { + throw new Error(`Unsupported catalog request: ${method}`); + } + if (this.error) throw this.error; + return this.catalog; + } + + async setSessionConfigOption({ value }: { value: string }) { + this.selectedModel = value; + this.selectionHistory.push(value); + const thinking = value === "grok-4.6" ? effortThinking : booleanThinking; + return { configOptions: [thinking] }; + } +} + +function createCatalogContext( + connection: CursorCatalogConnection, + configOptions: SessionConfigOption[] = [modelOption, booleanThinking], +): ACPCatalogModelResolverContext { + return { + connection, + sessionId: "catalog-session", + models: deriveModelDefinitionsFromACP("acp", null, configOptions), + configOptions, + runRequest: (request) => request(), + transformConfigOptions: (options) => options, + logger: createTestLogger(), + provider: "acp", + }; +} + +test("lists each model's thinking options without changing Cursor preferences", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + + const models = await resolveCursorCatalogModels(context); + + expect(connection.selectedModel).toBe("claude-haiku-4-5"); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); + expect( + models.map(({ id, isDefault, thinkingOptions, defaultThinkingOptionId }) => ({ + id, + isDefault, + thinking: thinkingOptions?.map((option) => option.id), + defaultThinkingOptionId, + })), + ).toEqual([ + { + id: "claude-haiku-4-5", + isDefault: true, + thinking: ["false", "true"], + defaultThinkingOptionId: "true", + }, + { + id: "grok-4.6", + isDefault: false, + thinking: ["low", "medium", "high", "xhigh"], + defaultThinkingOptionId: "xhigh", + }, + ]); +}); + +test("reports that Cursor must be updated when the model extension is unavailable", async () => { + const connection = new CursorCatalogConnection(); + connection.error = { code: -32601, message: "Method not found" }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toThrow( + "Update Cursor CLI", + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("discovers thinking models when the session default has no thinking selector", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection, [modelOption]); + + const models = await resolveCursorCatalogModels(context); + + expect(models[1].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + ]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("uses the extension's complete catalog even when session/new lists only one model", async () => { + const connection = new CursorCatalogConnection(); + const context = createCatalogContext(connection); + context.models = context.models.slice(0, 1); + + const models = await resolveCursorCatalogModels(context); + + expect(models.map((model) => model.id)).toEqual(["claude-haiku-4-5", "grok-4.6"]); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("does not copy session thinking options onto a model without a selector", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { + models: [{ value: "composer-2", name: "Composer", configOptions: [] }], + }; + + const models = await resolveCursorCatalogModels(createCatalogContext(connection)); + + expect(models).toEqual([ + { + provider: "acp", + id: "composer-2", + label: "Composer", + isDefault: false, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }, + ]); +}); + +test("keeps an empty extension catalog empty", async () => { + const connection = new CursorCatalogConnection(); + connection.catalog = { models: [] }; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).resolves.toEqual([]); +}); + +test.each([ + {}, + { models: [{ value: "grok-4.6", name: "Grok 4.6" }] }, + { + models: [ + { + value: "grok-4.6", + name: "Grok 4.6", + configOptions: [{ ...effortThinking, currentValue: 1 }], + }, + ], + }, +])("rejects malformed extension responses: %j", async (catalog) => { + const connection = new CursorCatalogConnection(); + connection.catalog = catalog; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBeInstanceOf( + ZodError, + ); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); + +test("propagates extension failures without changing preferences or returning stale options", async () => { + const connection = new CursorCatalogConnection(); + const error = new Error("Connection closed"); + connection.error = error; + + await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBe(error); + expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]); +}); diff --git a/packages/server/src/server/agent/providers/custom-message.ts b/packages/server/src/server/agent/providers/custom-message.ts new file mode 100644 index 0000000000..3dd15b2d9c --- /dev/null +++ b/packages/server/src/server/agent/providers/custom-message.ts @@ -0,0 +1,26 @@ +import type { AgentTimelineItem } from "../agent-sdk-types.js"; + +/** Preserve extension context as an expandable tool row instead of assistant speech. */ +export function mapCustomMessageToToolCall( + message: { role: "custom"; customType?: unknown; details?: unknown }, + text: string, + callId: string, +): Extract { + const customType = + typeof message.customType === "string" && message.customType.trim() + ? message.customType + : "custom-message"; + return { + type: "tool_call", + callId, + name: customType, + status: "completed", + detail: { type: "plain_text", text }, + metadata: { + synthetic: true, + customType, + ...(message.details === undefined ? {} : { details: message.details }), + }, + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts new file mode 100644 index 0000000000..ec17dd7ecf --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.archived-history.test.ts @@ -0,0 +1,154 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient archived history", () => { + test("replays an archived session whose working directory was removed", async () => { + await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8")); + expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]); + } finally { + await session.close(); + } + }); + }); + + test("replays an archived session whose working directory path is now a file", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + await writeFile(removedCwd, "not a directory", "utf8"); + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const session = await client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "history" }, + ); + try { + expect(await collectTimelineText(session.streamHistory())).toEqual([ + "user_message:What is in the README?", + "assistant_message:It describes the project.", + ]); + } finally { + await session.close(); + } + }); + }); + + test("still requires the working directory to resume a session that will run", async () => { + await withFakeACPAgent(async ({ command, removedCwd }) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + await expect( + client.resumeSession( + { provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } }, + undefined, + undefined, + { purpose: "interactive" }, + ), + ).rejects.toThrow("ENOENT"); + }); + }); +}); + +async function collectTimelineText(events: AsyncGenerator): Promise { + const text: string[] = []; + for await (const event of events) { + if (event.type !== "timeline") { + continue; + } + if (event.item.type === "user_message" || event.item.type === "assistant_message") { + text.push(`${event.item.type}:${event.item.text}`); + } + } + return text; +} + +async function withFakeACPAgent( + run: (input: { + command: [string, ...string[]]; + removedCwd: string; + loadRequestsPath: string; + }) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + const loadRequestsPath = path.join(testDir, "load-requests.json"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run({ + command: [process.execPath, scriptPath, loadRequestsPath], + removedCwd: path.join(testDir, "removed-worktree"), + loadRequestsPath, + }); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const fs = require("node:fs"); +const readline = require("node:readline"); + +const loadRequestsPath = process.argv[2]; +const loadRequests = []; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +function replay(sessionId, sessionUpdate, text) { + write({ + method: "session/update", + params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } }, + }); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/load") { + const { sessionId, cwd } = message.params; + loadRequests.push({ sessionId, cwd }); + fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests)); + replay(sessionId, "user_message_chunk", "What is in the README?"); + replay(sessionId, "agent_message_chunk", "It describes the project."); + write({ id: message.id, result: {} }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts new file mode 100644 index 0000000000..2411773a7e --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts @@ -0,0 +1,112 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient slash commands", () => { + test("lists commands an agent advertises right after session/new", async () => { + await withFakeACPAgent("commands-after-session-new", async (command, cwd) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command, + initialCommandsWaitTimeoutMs: 1_500, + }); + const session = await client.createSession({ provider: "acp", cwd }); + try { + await expect(session.listCommands?.()).resolves.toEqual([ + { name: "review", description: "Review the diff", argumentHint: "", kind: "command" }, + ]); + } finally { + await session.close(); + } + }); + }); + + test("answers with no commands for an agent that never advertises any", async () => { + await withFakeACPAgent("silent", async (command, cwd) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command, + initialCommandsWaitTimeoutMs: 50, + }); + const session = await client.createSession({ provider: "acp", cwd }); + try { + await expect(session.listCommands?.()).resolves.toEqual([]); + } finally { + await session.close(); + } + }); + }); +}); + +async function withFakeACPAgent( + mode: "commands-after-session-new" | "silent", + run: (command: [string, ...string[]], cwd: string) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-commands-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run([process.execPath, scriptPath, mode], testDir); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const readline = require("node:readline"); + +const mode = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/new") { + if (mode === "commands-after-session-new") { + process.stdout.write( + [ + { id: message.id, result: { sessionId: "session-1" } }, + { + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "review", description: "Review the diff" }], + }, + }, + }, + ] + .map((entry) => JSON.stringify({ jsonrpc: "2.0", ...entry }) + "\\n") + .join(""), + ); + } else { + write({ id: message.id, result: { sessionId: "session-1" } }); + } + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts index 5bac343fdc..d70117d57d 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -1,3 +1,4 @@ +import { buildProviderRegistry } from "../provider-registry.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -17,7 +18,132 @@ function parseInitializeTrace(content: string): Array<{ clientCapabilities: unkn .map((line) => JSON.parse(line) as { clientCapabilities: unknown }); } +function historyBudgetClock(): () => number { + let calls = 0; + return () => (calls++ < 2 ? 0 : 60_001); +} + describe("GenericACPAgentClient diagnostics", () => { + test("filters empty ACP sessions by replaying history and returns prompt previews", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath, testDir], + }); + + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([ + { + providerHandleId: "conversation-session", + cwd: testDir, + title: "Conversation", + firstPromptPreview: "First prompt", + lastPromptPreview: "Last prompt", + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + await expect(readFile(closeTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n${JSON.stringify({ sessionId: "conversation-session" })}\n`, + ); + }); + }); + + test("keeps a listed session visible when its ACP history cannot be loaded", async () => { + await withFakeACPAgent("history-load-failure", async (scriptPath, mode, testDir) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir], + }); + + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([ + { + providerHandleId: "stale-session", + cwd: testDir, + title: "Could still be real", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-09-04T20:00:00.000Z"), + }, + ]); + }); + }); + + test("keeps sessions with replayed conversation activity but no user text preview", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir], + }); + + const result = await client.listImportableSessions({ limit: 2 }); + + expect(result).toMatchObject([ + { providerHandleId: "conversation-session" }, + { providerHandleId: "assistant-only-session" }, + ]); + expect(result[1]).toMatchObject({ + firstPromptPreview: null, + lastPromptPreview: null, + }); + }); + }); + + test("reuses ACP prompt previews while the listed session timestamp is unchanged", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const loadTracePath = path.join(testDir, "session-load.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir, loadTracePath], + }); + + const first = await client.listImportableSessions({ limit: 1 }); + const second = await client.listImportableSessions({ limit: 1 }); + + expect(second).toEqual(first); + await expect(readFile(loadTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n${JSON.stringify({ sessionId: "conversation-session" })}\n`, + ); + }); + }); + + test("bounds history loads by scanLimit when every inspected session is empty", async () => { + await withFakeACPAgent("history-list", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath, testDir], + }); + + await expect(client.listImportableSessions({ limit: 1, scanLimit: 1 })).resolves.toEqual([]); + await expect(readFile(closeTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "empty-session" })}\n`, + ); + }); + }); + + test("bounds the total time spent loading inaccessible session histories", async () => { + await withFakeACPAgent("history-load-failures", async (scriptPath, mode, testDir) => { + const loadTracePath = path.join(testDir, "session-load.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", "", testDir, loadTracePath], + now: historyBudgetClock(), + }); + + const sessions = await client.listImportableSessions({ limit: 4 }); + + expect(sessions).toMatchObject([ + { providerHandleId: "failed-session-1" }, + { providerHandleId: "failed-session-2" }, + { providerHandleId: "failed-session-3" }, + { providerHandleId: "failed-session-4" }, + ]); + await expect(readFile(loadTracePath, "utf8")).resolves.toBe( + `${JSON.stringify({ sessionId: "failed-session-1" })}\n`, + ); + }); + }); + test("probes npx-backed agent packages instead of npx itself", () => { expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({ command: "npx", @@ -57,6 +183,25 @@ describe("GenericACPAgentClient diagnostics", () => { }); }); + test("closes the native diagnostic probe session", async () => { + await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { + const closeTracePath = path.join(testDir, "session-close.jsonl"); + const client = new GenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, scriptPath, mode, "", "", closeTracePath], + providerId: "custom-acp", + label: "Custom ACP", + diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS, + }); + + await client.getDiagnostic(); + + await expect(readFile(closeTracePath, "utf8")).resolves.toContain( + JSON.stringify({ sessionId: "session-1" }), + ); + }); + }); + test("reports a hung ACP session/new phase without failing the diagnostic", async () => { await withFakeACPAgent("hang-session", async (scriptPath, mode) => { const client = new GenericACPAgentClient({ @@ -110,22 +255,28 @@ describe("GenericACPAgentClient diagnostics", () => { test("sends configured client capabilities in catalog and live session initialization", async () => { await withFakeACPAgent("success", async (scriptPath, mode, testDir) => { const initializeTracePath = path.join(testDir, "initialize.jsonl"); - const client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: [process.execPath, scriptPath, mode, "", initializeTracePath], - providerParams: { - clientCapabilities: { - fs: { - readTextFile: true, - writeTextFile: true, + const logger = createTestLogger(); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + "configured-acp": { + extends: "acp", + label: "Configured ACP", + command: [process.execPath, scriptPath, mode, "", initializeTracePath], + options: { + supportsMcpServers: false, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, }, - terminal: true, }, }, }); + const client = registry["configured-acp"].createClient(logger); await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true }); - const session = await client.createSession({ provider: "acp", cwd: testDir }); + const session = await client.createSession({ provider: "configured-acp", cwd: testDir }); + expect(session.capabilities.supportsMcpServers).toBe(false); await session.close(); const initializeRequests = parseInitializeTrace(await readFile(initializeTracePath, "utf8")); @@ -181,7 +332,12 @@ describe("GenericACPAgentClient diagnostics", () => { }); async function withFakeACPAgent( - mode: "success" | "hang-session", + mode: + | "success" + | "hang-session" + | "history-list" + | "history-load-failure" + | "history-load-failures", run: (scriptPath: string, mode: string, testDir: string) => Promise, ): Promise { await withTempDir("paseo-acp-diagnostic-", async (testDir) => { @@ -230,6 +386,9 @@ const readline = require("node:readline"); const mode = process.argv[2]; const pidPath = process.argv[3]; const initializeTracePath = process.argv[4]; +const closeTracePath = process.argv[5]; +const sessionCwd = process.argv[6]; +const loadTracePath = process.argv[7]; if (pidPath) { fs.writeFileSync(pidPath, String(process.pid)); } @@ -239,6 +398,10 @@ function send(id, result) { process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n"); } +function sendError(id, code, message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\\n"); +} + rl.on("line", (line) => { const message = JSON.parse(line); if (message.method === "initialize") { @@ -250,7 +413,13 @@ rl.on("line", (line) => { } send(message.id, { protocolVersion: message.params?.protocolVersion ?? 1, - agentCapabilities: {}, + agentCapabilities: { + loadSession: + mode === "history-list" || + mode === "history-load-failure" || + mode === "history-load-failures", + sessionCapabilities: { close: {}, list: {} }, + }, }); return; } @@ -272,6 +441,122 @@ rl.on("line", (line) => { }, configOptions: [], }); + return; + } + + if (message.method === "session/close") { + if (closeTracePath) { + fs.appendFileSync(closeTracePath, JSON.stringify(message.params) + "\\n"); + } + send(message.id, {}); + return; + } + + if (message.method === "session/list" && mode === "history-list") { + send(message.id, { + sessions: [ + { + sessionId: "empty-session", + cwd: sessionCwd, + title: "Empty", + updatedAt: "2026-09-04T21:00:00.000Z", + }, + { + sessionId: "conversation-session", + cwd: sessionCwd, + title: "Conversation", + updatedAt: "2026-09-04T20:00:00.000Z", + }, + { + sessionId: "assistant-only-session", + cwd: sessionCwd, + title: "Recovered conversation", + updatedAt: "2026-09-04T19:00:00.000Z", + }, + ], + nextCursor: null, + }); + return; + } + + if (message.method === "session/list" && mode === "history-load-failure") { + send(message.id, { + sessions: [{ + sessionId: "stale-session", + cwd: sessionCwd, + title: "Could still be real", + updatedAt: "2026-09-04T20:00:00.000Z", + }], + nextCursor: null, + }); + return; + } + + if (message.method === "session/list" && mode === "history-load-failures") { + send(message.id, { + sessions: [1, 2, 3, 4].map((number) => ({ + sessionId: "failed-session-" + number, + cwd: sessionCwd, + title: "Could still be real " + number, + updatedAt: "2026-09-04T20:00:00.000Z", + })), + nextCursor: null, + }); + return; + } + + if (message.method === "session/load" && mode === "history-list") { + if (loadTracePath) { + fs.appendFileSync( + loadTracePath, + JSON.stringify({ sessionId: message.params.sessionId }) + "\\n", + ); + } + if (message.params.sessionId === "conversation-session") { + for (const [messageId, text] of [["user-1", "First prompt"], ["user-2", "Last prompt"]]) { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "user_message_chunk", + messageId, + content: { type: "text", text }, + }, + }, + }) + "\\n"); + } + } + if (message.params.sessionId === "assistant-only-session") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Recovered response" }, + }, + }, + }) + "\\n"); + } + send(message.id, { modes: null, models: null, configOptions: [] }); + return; + } + + if ( + message.method === "session/load" && + (mode === "history-load-failure" || mode === "history-load-failures") + ) { + if (loadTracePath) { + fs.appendFileSync( + loadTracePath, + JSON.stringify({ sessionId: message.params.sessionId }) + "\\n", + ); + } + sendError(message.id, -32602, "stale session"); + return; } }); `; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts new file mode 100644 index 0000000000..aaa6a5110e --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.permission-turn-failure.test.ts @@ -0,0 +1,165 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient permission requests when a turn ends early", () => { + test.each([ + { ending: "fail", terminalEvent: "turn_failed" }, + { ending: "cancel", terminalEvent: "turn_canceled" }, + ] as const)( + "answers a permission request left open by a $terminalEvent turn with cancelled", + async ({ ending, terminalEvent }) => { + await withFakeACPAgent(ending, async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + const session = await client.createSession({ provider: "acp", cwd }); + try { + const events = collectEvents(session); + + await session.startTurn("turn one"); + await waitForEvent(events, terminalEvent); + expect(hasEvent(events, "permission_requested")).toBe(true); + expect(session.getPendingPermissions()).toEqual([]); + + const replyStart = events.length; + await session.startTurn("turn two"); + await waitForEvent(events, "turn_completed", replyStart); + + expect(assistantText(events.slice(replyStart))).toBe( + 'perm-1 answered: {"outcome":{"outcome":"cancelled"}}', + ); + } finally { + await session.close(); + } + }); + }, + ); +}); + +function collectEvents(session: AgentSession): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + return events; +} + +function hasEvent( + events: AgentStreamEvent[], + type: AgentStreamEvent["type"], + fromIndex = 0, +): boolean { + return events.slice(fromIndex).some((event) => event.type === type); +} + +async function waitForEvent( + events: AgentStreamEvent[], + type: AgentStreamEvent["type"], + fromIndex = 0, +): Promise { + const deadline = Date.now() + 5_000; + while (!hasEvent(events, type, fromIndex)) { + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for ${type}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function assistantText(events: AgentStreamEvent[]): string { + return events + .flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ) + .join(""); +} + +async function withFakeACPAgent( + ending: "fail" | "cancel", + run: (command: [string, ...string[]], cwd: string) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run([process.execPath, scriptPath, ending], testDir); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +// Turn one asks for permission, then fails or is cancelled while the request is +// still open. +// Later turns report whether that request was ever answered. +const fakeACPAgentScript = ` +const readline = require("node:readline"); + +const ending = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); +let promptCount = 0; +let permissionAnswer = null; + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === undefined && message.id === "perm-1") { + permissionAnswer = message.result ?? { error: message.error }; + return; + } + if (message.method === "initialize") { + write({ id: message.id, result: { protocolVersion: 1, agentCapabilities: {} } }); + return; + } + if (message.method === "session/new") { + write({ id: message.id, result: { sessionId: "session-1" } }); + return; + } + if (message.method === "session/prompt") { + const sessionId = message.params.sessionId; + promptCount += 1; + if (promptCount === 1) { + const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" }; + write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } }); + write({ + id: "perm-1", + method: "session/request_permission", + params: { + sessionId, + toolCall, + options: [ + { optionId: "allow", name: "Allow once", kind: "allow_once" }, + { optionId: "reject", name: "Reject", kind: "reject_once" }, + ], + }, + }); + setTimeout(() => { + if (ending === "cancel") { + write({ id: message.id, result: { stopReason: "cancelled" } }); + } else { + write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } }); + } + }, 50); + return; + } + const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer); + write({ + method: "session/update", + params: { + sessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } }, + }, + }); + write({ id: message.id, result: { stopReason: "end_turn" } }); + return; + } + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts new file mode 100644 index 0000000000..9e652a37d5 --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.request-errors.test.ts @@ -0,0 +1,121 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient request errors", () => { + test("reports the agent's message when it rejects the configured mode", async () => { + await withFakeACPAgent("reject-set-mode", async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const created = client.createSession({ provider: "acp", cwd, modeId: "plan" }); + + await expect(created).rejects.toBeInstanceOf(Error); + await expect(created).rejects.toThrow("Internal error: Mode plan is not allowed"); + }); + }); + + test("reports the agent's message when it rejects the configured thinking option", async () => { + await withFakeACPAgent("reject-set-config-option", async (command, cwd) => { + const client = new GenericACPAgentClient({ logger: createTestLogger(), command }); + + const created = client.createSession({ provider: "acp", cwd, thinkingOptionId: "high" }); + + await expect(created).rejects.toBeInstanceOf(Error); + await expect(created).rejects.toThrow("Internal error: Effort high is not allowed"); + }); + }); +}); + +async function withFakeACPAgent( + mode: "reject-set-mode" | "reject-set-config-option", + run: (command: [string, ...string[]], cwd: string) => Promise, +): Promise { + const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-request-errors-")); + try { + const scriptPath = path.join(testDir, "fake-acp-agent.cjs"); + await writeFile(scriptPath, fakeACPAgentScript, "utf8"); + await run([process.execPath, scriptPath, mode], testDir); + } finally { + await rm(testDir, { recursive: true, force: true }); + } +} + +const fakeACPAgentScript = ` +const readline = require("node:readline"); + +const mode = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin }); + +function write(message) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + write({ + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? 1, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }, + }); + return; + } + + if (message.method === "session/new") { + write({ + id: message.id, + result: { + sessionId: "session-1", + modes: { + currentModeId: "agent", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "plan", name: "Plan" }, + ], + }, + configOptions: [ + { + id: "effort", + name: "Effort", + category: "thought_level", + type: "select", + currentValue: "low", + options: [ + { value: "low", name: "Low" }, + { value: "high", name: "High" }, + ], + }, + ], + }, + }); + return; + } + + if (message.method === "session/set_mode" && mode === "reject-set-mode") { + write({ + id: message.id, + error: { code: -32603, message: "Internal error", data: { details: "Mode plan is not allowed" } }, + }); + return; + } + + if (message.method === "session/set_config_option" && mode === "reject-set-config-option") { + write({ + id: message.id, + error: { code: -32603, message: "Internal error", data: { details: "Effort high is not allowed" } }, + }); + return; + } + + if (message.id !== undefined) { + write({ id: message.id, result: {} }); + } +}); +`; diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts index 19a1e9f8ee..a1f5c497ec 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts @@ -62,24 +62,8 @@ describe("GenericACPAgentClient", () => { supportsRewindFiles: false, supportsRewindBoth: false, }, + waitForInitialCommands: true, }, ]); }); - - test("uses provider params to report MCP support", () => { - const _client = new GenericACPAgentClient({ - logger: createTestLogger(), - command: ["no-mcp-acp", "serve"], - providerParams: { - supportsMcpServers: false, - }, - }); - void _client; - - expect(mockState.superConstructorOptions.at(-1)).toMatchObject({ - capabilities: { - supportsMcpServers: false, - }, - }); - }); }); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 095d4e10a8..5a14d99137 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,7 +1,5 @@ import type { Logger } from "pino"; -import { z } from "zod"; -import type { AgentCapabilityFlags } from "../agent-sdk-types.js"; import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js"; import { ACPAgentClient, @@ -18,32 +16,12 @@ import { toDiagnosticErrorMessage, } from "./diagnostic-utils.js"; -export const GenericACPProviderParamsSchema = z - .object({ - supportsMcpServers: z.boolean().optional(), - clientCapabilities: z - .object({ - fs: z - .object({ - readTextFile: z.boolean().optional(), - writeTextFile: z.boolean().optional(), - }) - .optional(), - terminal: z.boolean().optional(), - }) - .optional(), - }) - .passthrough(); - -type GenericACPProviderParams = z.infer; - interface GenericACPAgentClientOptions { logger: Logger; command: [string, ...string[]]; env?: Record; providerId?: string; label?: string; - providerParams?: unknown; waitForInitialCommands?: boolean; initialCommandsWaitTimeoutMs?: number; diagnosticPhaseTimeoutMs?: number; @@ -51,6 +29,7 @@ interface GenericACPAgentClientOptions { configFeatureOptions?: ACPConfigFeatureOption[]; extensionCommandsParser?: ACPExtensionCommandsParser; catalogModelResolver?: ACPCatalogModelResolver; + now?: () => number; } export class GenericACPAgentClient extends ACPAgentClient { @@ -60,7 +39,6 @@ export class GenericACPAgentClient extends ACPAgentClient { private readonly diagnosticPhaseTimeoutMs?: number; constructor(options: GenericACPAgentClientOptions) { - const providerParams = parseGenericACPProviderParams(options.providerParams); super({ provider: "acp", logger: options.logger, @@ -68,14 +46,16 @@ export class GenericACPAgentClient extends ACPAgentClient { env: options.env, }, defaultCommand: options.command, - capabilities: buildGenericACPCapabilities(providerParams), - waitForInitialCommands: options.waitForInitialCommands, + capabilities: DEFAULT_ACP_CAPABILITIES, + // ACP agents advertise slash commands with available_commands_update after + // session/new, so the first listCommands() waits for that batch. + waitForInitialCommands: options.waitForInitialCommands ?? true, initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs, - clientCapabilities: providerParams.clientCapabilities, clientCapabilityMeta: options.clientCapabilityMeta, configFeatureOptions: options.configFeatureOptions, extensionCommandsParser: options.extensionCommandsParser, catalogModelResolver: options.catalogModelResolver, + now: options.now, }); this.command = options.command; @@ -161,17 +141,6 @@ export class GenericACPAgentClient extends ACPAgentClient { } } -function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags { - return { - ...DEFAULT_ACP_CAPABILITIES, - supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers, - }; -} - -function parseGenericACPProviderParams(params: unknown): GenericACPProviderParams { - return GenericACPProviderParamsSchema.parse(params ?? {}); -} - export interface CommandInvocation { command: string; args: string[]; diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 2b3e88659a..7dfbb6abb8 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -154,6 +154,28 @@ describe("JsonlRpcProcess", () => { } }); + test("publishes unmatched responses without publishing the response that settles a request", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + const messages: Record[] = []; + transport.onMessage((message) => messages.push(message)); + + try { + const { id, promise } = transport.startRequest({ type: "prompt" }); + const response = { id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "rejected after acknowledgement" }; + const unknownResponse = { ...response, id: "unknown-request", success: true }; + child.stdout.write(`${JSON.stringify({ ...response, success: true })}\n`); + child.stdout.write(`${JSON.stringify(rejection)}\n`); + child.stdout.write(`${JSON.stringify(unknownResponse)}\n`); + + await expect(promise).resolves.toBeUndefined(); + expect(messages).toEqual([rejection, unknownResponse]); + } finally { + await transport.close(); + } + }); + test("rejects unsuccessful responses", async () => { const transport = startProcess(); @@ -268,6 +290,41 @@ describe("JsonlRpcProcess", () => { }, ); + test("requestStopWork resolves once the child has exited", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + + child.emit("exit", 1, null); + + await expect(transport.requestStopWork({ type: "abort" })).resolves.toBeUndefined(); + }); + + // A dying child's stdin pipe breaks a fraction before its exit event, so deciding at the + // moment of the write failure would refuse a stop the runtime did honor. + test("requestStopWork resolves when stdin fails just before the child exits", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + + const stopped = transport.requestStopWork({ type: "abort" }); + child.stdin.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" })); + + await expect(stopped).resolves.toBeUndefined(); + }); + + // `close()` and a spawn error dispose the transport before the child is known to be + // gone, and a child can outlive SIGKILL, so a disposed transport is not proof that the + // requested work stopped. + test("requestStopWork keeps failing while the transport is disposed but the child has not exited", async () => { + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); + + child.emit("error", new Error("spawn failed")); + + await expect(transport.requestStopWork({ type: "abort" })).rejects.toThrow( + "JSONL RPC process is closed", + ); + }); + test("stdin error events close the transport instead of becoming uncaught exceptions", async () => { const child = createInMemoryChildProcess(); const transport = startProcess({ child }); diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts index 791df2500a..9d8b9af623 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.ts @@ -81,6 +81,8 @@ export class JsonlRpcProcess { private stderrBuffer = ""; private nextRequestId = 1; private disposed = false; + private exited = false; + private closing: Promise | null = null; private readonly frameDecoder: JsonlFrameDecoder; constructor(private readonly options: JsonlRpcProcessOptions) { @@ -111,6 +113,7 @@ export class JsonlRpcProcess { this.failAll(error instanceof Error ? error : new Error(String(error))); }); this.child.on("exit", (code, signal) => { + this.exited = true; const error = new Error( `${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(), ); @@ -175,6 +178,36 @@ export class JsonlRpcProcess { return this.startRequest(command, timeoutMs).promise; } + /** + * Send a command whose goal an exited process has already met — stopping a turn, + * clearing a queue. Resolves once the child has exited instead of rejecting, + * because nothing is queued and nothing is running, which is what the caller + * asked for. Use `request` when the caller needs an answer from a live process. + * + * Keyed on an observed exit rather than on `disposed`: `close()` disposes the + * transport before termination completes, and a child that outlives SIGKILL can + * still be working, so a disposed transport is not proof the work stopped. + */ + async requestStopWork( + command: { type: string; [key: string]: unknown }, + timeoutMs?: number | null, + ): Promise { + if (this.exited) { + return; + } + try { + await this.request(command, timeoutMs); + } catch (error) { + // A dying child breaks its stdin pipe a fraction before it emits `exit`, which + // fails this request and starts a termination. Wait for that termination to reach + // an answer rather than refusing a stop the runtime went on to honor. + await this.closing?.catch(() => undefined); + if (!this.exited) { + throw error; + } + } + } + send(message: Record): void { if (this.disposed) { return; @@ -193,6 +226,11 @@ export class JsonlRpcProcess { async close(error = new Error(`${this.diagnosticName} process is closed`)): Promise { if (this.disposed) return; this.failAll(error); + this.closing = this.terminate(); + await this.closing; + } + + private async terminate(): Promise { try { this.child.stdin.end(); } catch { @@ -220,25 +258,27 @@ export class JsonlRpcProcess { this.frameDecoder.write(chunk); } - /** Route a complete logical frame to its consumer (response or subscriber). */ + /** Route a complete logical frame to its consumer (pending request or subscriber). */ private dispatchFrame(message: Record): void { - if (message.type === "response") { - this.handleResponse(message as unknown as JsonlRpcResponse); + const pending = + message.type === "response" && typeof message.id === "string" + ? this.pending.get(message.id) + : undefined; + if (pending) { + this.settleRequest(pending, message as unknown as JsonlRpcResponse & { id: string }); return; } + // A response nothing is waiting for is published like any other frame, so a provider + // can read it as an event, such as OMP 18.3 rejecting a prompt after acknowledging it. for (const subscriber of this.messageSubscribers) { subscriber(message); } } - private handleResponse(response: JsonlRpcResponse): void { - if (!response.id) { - return; - } - const pending = this.pending.get(response.id); - if (!pending) { - return; - } + private settleRequest( + pending: PendingRequest, + response: JsonlRpcResponse & { id: string }, + ): void { if (pending.timer) { clearTimeout(pending.timer); } diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts index ce296726a1..b388fdb01a 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.test.ts @@ -156,8 +156,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { expect(setSessionConfigOption).not.toHaveBeenCalled(); }); - test("skips per-model probing when the provider has no thinking picker", async () => { - const setSessionConfigOption = vi.fn(); + test("probes each model when session/new has no thought_level so a later model can still expose thinking options", async () => { + const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => ({ + configOptions: + value === "kimi-k3" + ? [modelConfigOption(value), effortThinkingConfigOption()] + : [modelConfigOption(value)], + })); const client = createKimiClient( async () => @@ -174,16 +179,28 @@ describe("KimiACPAgentClient per-model thinking options", () => { }) as unknown as SpawnedACPProcess, ); - await client.fetchCatalog({ + const catalog = await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-kimi-no-thinking", force: false, }); - expect(setSessionConfigOption).not.toHaveBeenCalled(); + expect(setSessionConfigOption).toHaveBeenCalledTimes(2); + + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); + const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); + expect(kimiForCoding?.thinkingOptions).toBeUndefined(); + expect(kimiForCoding?.defaultThinkingOptionId).toBeUndefined(); + expect(kimiK3?.thinkingOptions).toEqual([ + expect.objectContaining({ id: "off", isDefault: false }), + expect.objectContaining({ id: "low", isDefault: false }), + expect.objectContaining({ id: "medium", isDefault: true }), + expect.objectContaining({ id: "high", isDefault: false }), + ]); + expect(kimiK3?.defaultThinkingOptionId).toBe("medium"); }); - test("keeps a model's default thinking options when its probe fails", async () => { + test("omits thinking options when a model's probe fails instead of keeping another model's list", async () => { const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => { if (value === "kimi-k3") { throw new Error("probe rejected model switch"); @@ -212,10 +229,13 @@ describe("KimiACPAgentClient per-model thinking options", () => { force: false, }); + const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding"); const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3"); - expect(kimiK3?.thinkingOptions).toEqual([ + expect(kimiForCoding?.thinkingOptions).toEqual([ expect.objectContaining({ id: "off", isDefault: true }), expect.objectContaining({ id: "on", isDefault: false }), ]); + expect(kimiK3?.thinkingOptions).toBeUndefined(); + expect(kimiK3?.defaultThinkingOptionId).toBeUndefined(); }); }); diff --git a/packages/server/src/server/agent/providers/kimi-acp-agent.ts b/packages/server/src/server/agent/providers/kimi-acp-agent.ts index e8ff7ce252..7ae144f653 100644 --- a/packages/server/src/server/agent/providers/kimi-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kimi-acp-agent.ts @@ -15,26 +15,10 @@ interface KimiACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } -/** - * Kimi reports different thinking-effort levels per model (a boolean on/off toggle for one - * model, a multi-level select for another), but only exposes the *currently selected* - * model's levels through `configOptions` — the model list itself carries no per-model - * effort metadata. `deriveModelDefinitionsFromACP` can only see whichever model the probe - * session defaulted to, so every other model would otherwise inherit that one model's - * thinking options. - * - * Reuse the single catalog probe session to switch through each candidate model in turn and - * read back its real thinking options, rather than spawning a probe per model. Skipped - * entirely when the provider reports one model or no thinking picker, so a misbehaving ACP - * that only advertises a single model pays no extra round trips. - * - * This lives on the Kimi client, not the base ACP adapter: only Kimi needs the extra - * `setSessionConfigOption` round trips, so no other ACP provider risks a slow or - * nonconforming agent stalling its catalog probe on model switching. - */ +// Kimi exposes thinking options only for the selected model. Keep its model-switching +// discovery here: other providers can supply a read-only catalog instead. export async function resolveKimiCatalogModels({ connection, sessionId, @@ -49,7 +33,7 @@ export async function resolveKimiCatalogModels({ return models; } const modelOption = findSelectConfigOption({ configOptions, category: "model" }); - if (!modelOption || !findSelectConfigOption({ configOptions, category: "thought_level" })) { + if (!modelOption) { return models; } @@ -72,11 +56,24 @@ export async function resolveKimiCatalogModels({ thinkingOptions.find((option) => option.isDefault)?.id ?? undefined, }); } catch (error) { + const errorMessage = toDiagnosticErrorMessage(error); + if (model.isDefault) { + logger.warn( + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not refresh thinking options for current model "${model.id}"; keeping session options`, + ); + resolved.push(model); + continue; + } logger.warn( - { modelId: model.id, error: toDiagnosticErrorMessage(error) }, - `${provider} catalog probe could not resolve thinking options for model "${model.id}"; keeping its default options`, + { modelId: model.id, error: errorMessage }, + `${provider} catalog probe could not resolve thinking options for model "${model.id}"; omitting thinking options`, ); - resolved.push(model); + resolved.push({ + ...model, + thinkingOptions: undefined, + defaultThinkingOptionId: undefined, + }); } } return resolved; @@ -90,7 +87,6 @@ export class KimiACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, catalogModelResolver: resolveKimiCatalogModels, }); } diff --git a/packages/server/src/server/agent/providers/kiro-acp-agent.ts b/packages/server/src/server/agent/providers/kiro-acp-agent.ts index 53fd7f2408..41bbd97647 100644 --- a/packages/server/src/server/agent/providers/kiro-acp-agent.ts +++ b/packages/server/src/server/agent/providers/kiro-acp-agent.ts @@ -10,7 +10,6 @@ interface KiroACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } // Kiro CLI publishes its slash commands and skills asynchronously through the @@ -92,7 +91,6 @@ export class KiroACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, waitForInitialCommands: true, initialCommandsWaitTimeoutMs: KIRO_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, extensionCommandsParser: parseKiroExtensionCommands, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 27afed7b9a..c9c0f07161 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -449,6 +449,18 @@ describe("MockLoadTestAgentClient", () => { unsubscribe(); }); + test("emits distinct configured responses across consecutive fixture turns", async () => { + const session = await new MockLoadTestAgentClient().createSession({ + provider: "mock", + cwd: process.cwd(), + model: "e2e-fast-stream", + featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, + }); + await session.run("Emit a synthetic turn failure."); + expect((await session.run("First prompt")).finalText).toBe("First response"); + expect((await session.run("Second prompt")).finalText).toBe("![Second](second.png)"); + }); + test("emits a settled assistant Markdown image path selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 315bc728c1..14733e0601 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -145,6 +145,18 @@ const MODELS: AgentModelDefinition[] = [ intervalMs: 5, }, }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "max-only-thinking-stream", + label: "Max-only thinking stream", + description: "Fast realistic stream with one supported thinking level.", + thinkingOptions: [{ id: "max", label: "Max", isDefault: true }], + defaultThinkingOptionId: "max", + metadata: { + durationMs: 10_000, + intervalMs: 5, + }, + }, { provider: MOCK_LOAD_TEST_PROVIDER_ID, id: "e2e-fast-stream", @@ -714,11 +726,19 @@ export class MockLoadTestAgentClient implements AgentClient { } } +function getConfiguredAssistantResponses(value: unknown): string[] { + return Array.isArray(value) && value.every((response) => typeof response === "string") + ? [...value] + : []; +} + export class MockLoadTestAgentSession implements AgentSession { readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; readonly capabilities = CAPABILITIES; readonly features: AgentFeature[] = []; readonly id: string; + private readonly usageSessionKey = randomUUID(); + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); private readonly history: AgentStreamEvent[] = []; private readonly logger?: Logger; @@ -727,6 +747,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly assistantResponse: string | null; + private readonly assistantResponses: string[]; private readonly streamingAssistantResponse: string | null; private readonly streamingAssistantIntervalMs: number; private readonly rewindError: string | null; @@ -742,6 +763,9 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockAssistantResponse === "string" ? options.config.featureValues.mockAssistantResponse : null; + this.assistantResponses = getConfiguredAssistantResponses( + options.config.featureValues?.mockAssistantResponses, + ); this.streamingAssistantResponse = typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" ? options.config.featureValues.mockStreamingAssistantResponse @@ -770,6 +794,15 @@ export class MockLoadTestAgentSession implements AgentSession { ); } + usageSession() { + return { + provider: this.provider, + model: this.modelId ?? undefined, + env: {}, + sessionKey: this.usageSessionKey, + }; + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { const { turnId } = await this.startTurn(prompt, options); const turn = this.activeTurn; @@ -829,8 +862,11 @@ export class MockLoadTestAgentSession implements AgentSession { this.scheduleSteeringReplayTurn(turn, steeringReplayShape); } else if (this.streamingAssistantResponse !== null) { this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse); - } else if (this.assistantResponse !== null) { - this.scheduleSettledAssistantTurn(turn, this.assistantResponse); + } else if (this.assistantResponses.length > 0 || this.assistantResponse !== null) { + this.scheduleSettledAssistantTurn( + turn, + (this.assistantResponses.shift() ?? this.assistantResponse)!, + ); } else if (structuredBranchName) { this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); } else if (settledAssistantImageMarkdown) { diff --git a/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts new file mode 100644 index 0000000000..ddd30a2590 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/agent.history-purpose.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentPersistenceHandle } from "../../agent-sdk-types.js"; +import { OmpAgentClient } from "./agent.js"; +import { FakeOmp } from "./test-utils/fake-omp.js"; + +test("archived OMP history replays messages and todos without leaving a runtime", async () => { + const directory = await mkdtemp(join(tmpdir(), "paseo-omp-history-purpose-")); + const sessionFile = join(directory, "session.jsonl"); + const entries = [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "user-1", + parentId: "root", + message: { role: "user", content: "make a plan" }, + }, + { + type: "message", + id: "todo-1", + parentId: "user-1", + message: { + role: "toolResult", + toolName: "todo", + toolCallId: "call-1", + content: [{ type: "text", text: "one task" }], + details: { + phases: [{ name: "Tasks", tasks: [{ content: "Check history", status: "pending" }] }], + }, + }, + }, + ]; + await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n")); + const runtime = new FakeOmp(); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + const handle: AgentPersistenceHandle = { + provider: "omp", + sessionId: "history-1", + nativeHandle: sessionFile, + metadata: { cwd: directory }, + }; + + const session = await client.resumeSession(handle, undefined, undefined, { purpose: "history" }); + const events = []; + try { + for await (const event of session.streamHistory()) events.push(event); + } finally { + await session.close(); + } + + expect(runtime.recordedLaunches).toHaveLength(0); + expect(events).toContainEqual({ + type: "timeline", + provider: "omp", + item: { + type: "todo", + items: [{ text: "Check history", status: "pending", completed: false }], + }, + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 79fc4d8d11..c0e6193bb5 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -1,23 +1,119 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import { setImmediate as waitForImmediate } from "node:timers/promises"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; +import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js"; import type { OmpUsagePollScheduler } from "./usage-poller.js"; -import { resolveOmpProviderParams } from "./provider-config.js"; +import { resolveOmpProviderOptions } from "./provider-config.js"; +import { OmpRuntimeEventSchema } from "./rpc-types.js"; import { OmpHarness } from "./test-utils/omp-harness.js"; +import { OmpAgentClient } from "./agent.js"; +import { FakeOmp } from "./test-utils/fake-omp.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; + +const TURN_LIFECYCLE_EVENTS = new Set([ + "turn_started", + "turn_completed", + "turn_failed", + "turn_canceled", +]); + +function isTurnLifecycle(type: AgentStreamEvent["type"]): boolean { + return TURN_LIFECYCLE_EVENTS.has(type); +} + +// What OMP reports for a turn the user stopped: an error message on a terminal +// response whose stop reason says the request was aborted. +const ABORTED_TERMINAL_RESPONSE: OmpAgentMessage = { + role: "assistant", + content: [], + provider: "ai-harness-omp", + model: "glm-5.3-flash-high", + responseId: "chatcmpl-aborted", + stopReason: "aborted", + errorMessage: "Interrupted by user", +}; test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both", () => { - expect(resolveOmpProviderParams({}).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({}).runtimeOptions).toMatchObject({ readyTimeoutMs: 20_000, rpcTimeoutMs: 60_000, }); - expect(resolveOmpProviderParams({ rpcTimeoutMs: 90_000 }).runtimeProviderParams).toMatchObject({ + expect(resolveOmpProviderOptions({ rpcTimeoutMs: 90_000 }).runtimeOptions).toMatchObject({ readyTimeoutMs: 90_000, rpcTimeoutMs: 90_000, }); }); +test("OMP import uses the runtime's custom agent directory without a configured sessionDir", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-import-dir-")); + const agentDir = path.join(root, "agent"); + const sessionFile = path.join(agentDir, "sessions", "project", "session.jsonl"); + await mkdir(path.dirname(sessionFile), { recursive: true }); + await writeFile( + sessionFile, + JSON.stringify({ type: "session", id: "custom-dir", cwd: root, timestamp: "2026-09-28" }), + ); + const client = new OmpAgentClient({ + logger: createTestLogger(), + runtime: new FakeOmp(), + runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } }, + }); + + expect(await client.listImportableSessions({ cwd: root })).toEqual([ + expect.objectContaining({ providerHandleId: sessionFile }), + ]); +}); +test("OMP resumes a session whose model was removed on the model OMP falls back to", async () => { + const runtime = new FakeOmp(); + runtime.removeModel("9router/deepseek-v4-flash"); + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "fallback" } }; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/fallback" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/fallback"); +}); + +test("OMP resumes a session on the requested model when it differs from the session's", async () => { + const runtime = new FakeOmp(); + const requestedModel = { provider: "openrouter", id: "requested" }; + runtime.queueSessionSetup((session) => { + session.state = { ...session.state, model: { provider: "openrouter", id: "recorded" } }; + session.models = [requestedModel]; + session.setModelResult = requestedModel; + }); + const client = new OmpAgentClient({ logger: createTestLogger(), runtime }); + + const session = await client.resumeSession({ + provider: "omp", + sessionId: "omp-session-1", + nativeHandle: "/tmp/omp-session.jsonl", + metadata: { cwd: "/workspace/project", model: "openrouter/requested" }, + }); + onTestFinished(() => session.close()); + + expect(runtime.latestSession().setModelRequests).toEqual([ + { provider: "openrouter", modelId: "requested" }, + ]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/requested" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/requested"); +}); + class ManualIdleScheduler implements OmpProviderIdleScheduler { private readonly retries: Array<() => void> = []; private readonly waiters: Array<{ count: number; resolve: () => void }> = []; @@ -133,7 +229,7 @@ describe("OMP agent client and session", () => { [expect.objectContaining({ name: "create_agent" })], ]); expect(omp.capabilities()).toMatchObject({ - supportsMcpServers: false, + supportsMcpServers: true, supportsNativePaseoTools: true, }); }); @@ -145,6 +241,26 @@ describe("OMP agent client and session", () => { expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "max"])); }); + test("launches with auto thinking when auto is selected", async () => { + const omp = new OmpHarness(); + await omp.start({ thinkingOptionId: "auto" }); + + expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"])); + }); + + test("keeps auto thinking when resuming a session", async () => { + const omp = new OmpHarness(); + await omp.resume( + { + user: { id: "user-auto", text: "continue" }, + assistant: { id: "assistant-auto", text: "ready" }, + }, + { thinkingOptionId: "auto" }, + ); + + expect(omp.launchConfiguration().argv).toEqual(expect.arrayContaining(["--thinking", "auto"])); + }); + test("launches with write approval mode", async () => { const omp = new OmpHarness(); await omp.start({ modeId: "write" }); @@ -341,6 +457,184 @@ describe("OMP agent client and session", () => { await expect(completion).resolves.toMatchObject({ finalText: "first done" }); }); + test("fails a turn when the provider idle gate passes its deadline", async () => { + const scheduler = new ManualIdleScheduler(); + // Long enough that the gate's first check, made right after the turn ends, cannot already + // be past it on a slow runner. + const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 }); + await omp.start(); + const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", { + isStreaming: true, + isCompacting: false, + }); + await scheduler.waitForWaits(1); + omp.runtime().emit({ + type: "tool_execution_start", + toolCallId: "tool-at-deadline", + toolName: "bash", + args: { command: "sleep 30" }, + }); + expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]); + await new Promise((resolve) => setTimeout(resolve, 60)); + scheduler.retry(); + await expect(completion).rejects.toThrow(/provider idle/i); + expect(omp.runningToolCallIds()).toEqual([]); + }); + + test("steers a running turn and correlates a template-expanded echo exactly once", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + const { turnId } = await session.startTurn("first", { clientMessageId: "client-first" }); + const runtime = omp.runtime(); + runtime.beginTurn(); + runtime.acceptPrompt("first", "native-first"); + await expect( + session.steerActiveTurn?.("expand template", { + expectedTurnId: turnId, + clientMessageId: "client-steer", + }), + ).resolves.toEqual({ status: "accepted" }); + runtime.acceptPrompt("expanded prompt", "native-steer"); + runtime.acceptPrompt("expanded prompt", "native-steer"); + expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([ + expect.objectContaining({ messageId: "native-first", clientMessageId: "client-first" }), + expect.objectContaining({ messageId: "native-steer", clientMessageId: "client-steer" }), + ]); + }); + + test("reports a rejected steer as unavailable", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + const { turnId } = await session.startTurn("first"); + omp.runtime().steerError = new Error("extension command cannot be steered"); + await expect( + session.steerActiveTurn?.("extension input", { expectedTurnId: turnId }), + ).resolves.toEqual({ status: "unavailable" }); + }); + + test("propagates an OMP steer transport timeout", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + const { turnId } = await session.startTurn("first"); + const timeout = new Error( + "OMP RPC request timed out phase=steer elapsedMs=60000 timeoutMs=60000", + ); + omp.runtime().steerError = timeout; + await expect(session.steerActiveTurn?.("second", { expectedTurnId: turnId })).rejects.toBe( + timeout, + ); + }); + + test("shows OMP's fallback model without persisting it as the selected model", async () => { + const omp = new OmpHarness(); + await omp.start({ model: "openrouter/google/gemini-3.8-flash" }); + const runtime = omp.runtime(); + runtime.state = { + ...runtime.state, + model: { provider: "openrouter", id: "google/gemini-3.8-flash" }, + }; + runtime.emit({ + type: "retry_fallback_applied", + from: "openrouter/google/gemini-3.8-flash", + to: "openrouter/other/model", + role: "primary", + }); + runtime.state = { ...runtime.state, model: { provider: "openrouter", id: "other/model" } }; + runtime.state = { ...runtime.state, fastModeEnabled: true, fastModeActive: false }; + runtime.emit({ type: "model_changed" }); + await waitForImmediate(); + expect(omp.eventTypes()).toContain("model_changed"); + expect((await omp.requireSession().getRuntimeInfo()).model).toBe("openrouter/other/model"); + expect(omp.requireSession().describePersistence()?.metadata?.model).toBe( + "openrouter/google/gemini-3.8-flash", + ); + expect(omp.requireSession().features).toEqual([ + expect.objectContaining({ + value: true, + description: expect.stringMatching(/does not apply/i), + }), + ]); + }); + + test("Fast stays selected when OMP says it is inactive for the current model", async () => { + const omp = new OmpHarness(); + await omp.start({ model: "openrouter/google/gemini-3.8-flash" }); + const session = omp.requireSession(); + omp.runtime().fastModeResult = { enabled: true, active: false }; + await session.setFeature?.("fast_mode", true); + expect(omp.runtime().setFastModeRequests).toEqual([true]); + expect(session.features).toEqual([ + expect.objectContaining({ + id: "fast_mode", + value: true, + description: expect.stringMatching(/does not apply to this model/i), + tooltip: expect.stringMatching(/does not apply to this model/i), + }), + ]); + }); + + test("shows Fast only when OMP reports its state fields", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + const { + fastModeEnabled: _enabled, + fastModeActive: _active, + ...olderState + } = omp.runtime().state; + omp.runtime().state = olderState; + await session.getRuntimeInfo(); + expect(session.features).toEqual([]); + await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i); + omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false }; + await session.getRuntimeInfo(); + expect(session.features).toHaveLength(1); + }); + + test("switching model refreshes whether Fast applies", async () => { + const omp = new OmpHarness(); + await omp.start({ model: "openrouter/google/gemini-3.8-flash" }); + const session = omp.requireSession(); + omp.runtime().fastModeResult = { enabled: true, active: true }; + await session.setFeature?.("fast_mode", true); + expect(session.features).toEqual([expect.objectContaining({ value: true })]); + + omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" }; + omp.runtime().queueStateReports([ + { + ...omp.runtime().state, + model: omp.runtime().setModelResult, + fastModeEnabled: true, + fastModeActive: false, + }, + ]); + await session.setModel?.("openrouter/other/model"); + expect(session.features).toEqual([ + expect.objectContaining({ + value: true, + description: expect.stringMatching(/does not apply/i), + }), + ]); + }); + + test("restores Fast from the initial OMP state on create and resume", async () => { + const created = new OmpHarness(); + await created.start({ featureValues: { fast_mode: true } }); + expect(created.runtime().setFastModeRequests).toEqual([true]); + expect(created.runtime().getStateRequestCount).toBe(1); + + const resumed = new OmpHarness(); + await resumed.resume( + { user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } }, + { featureValues: { fast_mode: true } }, + ); + expect(resumed.runtime().setFastModeRequests).toEqual([true]); + expect(resumed.runtime().getStateRequestCount).toBe(1); + }); + test("does not complete on OMP's extension-notice agent_end", async () => { const omp = new OmpHarness(); await omp.start(); @@ -351,6 +645,65 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("does not complete a turn when a custom message arrives before its user message", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.requireStartTurn("hello OMP"); + + const runtime = omp.runtime(); + runtime.beginTurn(); + runtime.acceptCustomMessage("startup notice"); + + expect(omp.completedTurnCount()).toBe(0); + + runtime.acceptPrompt("hello OMP", "user-1"); + runtime.streamAssistantText("model turn completed"); + runtime.finishTurn(); + await waitForImmediate(); + + expect(omp.completedTurnCount()).toBe(1); + }); + + test("keeps custom context in separate tools while a turn continues", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.requireStartTurn("Explain the project"); + omp.runtime().beginTurn(); + for (const display of [true, false, true]) { + omp.emit({ + type: "message_end", + message: { + role: "custom", + customType: "project-context", + content: [{ type: "text", text: "Project instructions" }], + details: { project: "example" }, + display, + }, + }); + } + const items = omp.timeline(); + expect(items).toEqual( + [1, 2].map(() => ({ + type: "tool_call", + callId: expect.stringMatching(/^omp-custom-/), + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { + synthetic: true, + customType: "project-context", + details: { project: "example" }, + }, + error: null, + })), + ); + expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2); + expect(omp.completedTurnCount()).toBe(0); + omp.runtime().finishTurn(); + await waitForImmediate(); + expect(omp.completedTurnCount()).toBe(1); + }); + test("omits live custom messages when display is false", async () => { const omp = new OmpHarness(); await omp.start(); @@ -368,7 +721,7 @@ describe("OMP agent client and session", () => { ]); }); - test("renders a live system-notice custom message as a synthetic tool call", async () => { + test("renders a live system-notice custom message as a notification", async () => { const omp = new OmpHarness(); await omp.start(); @@ -387,16 +740,177 @@ describe("OMP agent client and session", () => { ); omp.runtime().acceptCustomMessage("plain custom status text"); - expect(omp.timeline().filter((item) => item.type === "tool_call")).toMatchObject([ - { callId: "omp-notice:DocsSmokeTwo", name: "task_notification", status: "completed" }, + expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([ + { + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", + }, ]); - // Non-notice custom messages still fall through as assistant messages. - expect(omp.timeline().filter((item) => item.type === "assistant_message")).toMatchObject([ - { text: "done" }, - { text: "plain custom status text" }, + expect( + omp.timeline().filter((item) => item.type !== "notification" && item.type !== "user_message"), + ).toEqual([ + { type: "assistant_message", text: "done", messageId: "omp-assistant-1" }, + { + type: "tool_call", + callId: expect.stringMatching(/^omp-custom-/), + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "plain custom status text" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, ]); }); + test("shows a concise error for a failed tool while keeping shell output", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "tool_execution_start", + toolCallId: "failed-shell", + toolName: "bash", + args: { command: "false" }, + }); + omp.emit({ + type: "tool_execution_end", + toolCallId: "failed-shell", + toolName: "bash", + result: { + content: [{ type: "text", text: "Command exited with code 1" }], + details: {}, + isError: true, + exitCode: 1, + }, + isError: true, + }); + expect(omp.timeline().at(-1)).toMatchObject({ + type: "tool_call", + status: "failed", + error: "Command exited with code 1", + detail: { type: "shell", command: "false", exitCode: 1 }, + }); + }); + + test("uses the exit message when a failed shell has no output", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "tool_execution_start", + toolCallId: "empty-shell", + toolName: "bash", + args: { command: "false" }, + }); + omp.emit({ + type: "tool_execution_end", + toolCallId: "empty-shell", + toolName: "bash", + result: { + content: [ + { + type: "text", + text: "(no output)\n\nWall time: 0.02 seconds\n\nCommand exited with code 1", + }, + ], + isError: true, + }, + isError: true, + }); + expect(omp.timeline().at(-1)).toMatchObject({ error: "Command exited with code 1" }); + }); + + test("does not duplicate the typed invocation for a live skill expansion", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.runPromptWithCustomMessage( + "hello", + { + role: "custom", + content: "[IMPORTANT] Full skill body", + customType: "skill-prompt", + attribution: "user", + details: { name: "commit" }, + display: true, + id: "skill-1", + }, + "done", + ); + expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([ + { type: "user_message", text: "hello", messageId: "user-1" }, + ]); + expect(omp.timeline()).not.toContainEqual( + expect.objectContaining({ text: "[IMPORTANT] Full skill body" }), + ); + }); + + test.each([ + { kind: "initial", priorPrompt: false }, + { kind: "follow-up", priorPrompt: true }, + ])("correlates a $kind skill invocation without a user echo", async ({ priorPrompt }) => { + const omp = new OmpHarness(); + await omp.start(); + if (priorPrompt) { + await omp.runPrompt("Reply OK", "OK"); + } + + const typed = "/skill:tldr Summarize: hi."; + const runtime = omp.runtime(); + const promptStarted = runtime.nextPrompt(); + const run = omp.requireSession().run(typed, { clientMessageId: "client-skill" }); + await promptStarted; + runtime.beginTurn(); + runtime.emit({ + type: "message_end", + message: { + role: "custom", + content: "[IMPORTANT] Full skill body", + customType: "skill-prompt", + attribution: "user", + details: { name: "tldr", args: "Summarize: hi." }, + display: true, + id: "skill-1", + }, + }); + runtime.streamAssistantText("done"); + runtime.finishTurn(); + await run; + + expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([ + ...(priorPrompt ? [{ type: "user_message", text: "Reply OK", messageId: "user-1" }] : []), + { type: "user_message", text: typed, clientMessageId: "client-skill" }, + ]); + }); + + test("marks an OMP web search details error as failed even without isError", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "tool_execution_start", + toolCallId: "search-failed", + toolName: "web_search", + args: { query: "Paseo" }, + }); + omp.emit({ + type: "tool_execution_end", + toolCallId: "search-failed", + toolName: "web_search", + result: { + content: [{ type: "text", text: "Error: All web search providers failed" }], + details: { + response: { provider: "mojeek", sources: [] }, + error: "All web search providers failed", + }, + }, + isError: false, + }); + expect(omp.timeline().at(-1)).toMatchObject({ + type: "tool_call", + status: "failed", + error: "All web search providers failed", + detail: { type: "search", query: "Paseo" }, + }); + }); + test("does not complete a queued model turn from OMP's local-only hint", async () => { const omp = new OmpHarness(); await omp.start(); @@ -415,6 +929,46 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test.each(["result after ack", "result before ack"] as const)( + "fails a prompt OMP rejects before its agent runs (%s)", + async (order) => { + const omp = new OmpHarness(); + await omp.start(); + + await expect( + omp.runPromptRejectedBeforeAgentRuns( + "Reply with ok.", + "No API key found for anthropic.", + order, + ), + ).rejects.toThrow("No API key found for anthropic."); + expect(omp.turnFailures()).toEqual(["No API key found for anthropic."]); + expect(omp.completedTurnCount()).toBe(0); + }, + ); + + test("completes a no-turn notify with one notification and no assistant text", async () => { + const scheduler = new ManualNoTurnScheduler(); + const omp = new OmpHarness({ noTurnScheduler: scheduler }); + await omp.start(); + const prompt = await omp.startPromptWithFalseLocalOnlyResult("/autoresearch off"); + omp.emit({ + type: "extension_ui_request", + id: "local-notify", + method: "notify", + message: "Autoresearch mode disabled", + notifyType: "info", + }); + + scheduler.settle(); + await expect(prompt.completion).resolves.toMatchObject({ finalText: "" }); + expect(omp.completedTurnCount()).toBe(1); + expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([ + { type: "notification", level: "info", message: "Autoresearch mode disabled" }, + ]); + expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]); + }); + test("waits for a delayed queued model turn after OMP's local-only result", async () => { const omp = new OmpHarness(); await omp.start(); @@ -517,6 +1071,9 @@ describe("OMP agent client and session", () => { messageId: "assistant-history", }, ]); + expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) }); + await omp.close(); + expect(omp.usageSession()).toBeNull(); }); test("maps permissions and sends the selected OMP response", async () => { @@ -534,6 +1091,98 @@ describe("OMP agent client and session", () => { ]); }); + test("shows OMP notifications during a turn and while idle with their levels", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.startTurn("work"); + omp.runtime().beginTurn(); + omp.emit({ + type: "extension_ui_request", + id: "n1", + method: "notify", + message: "Working", + notifyType: "warning", + }); + omp.runtime().finishTurn(); + omp.emit({ + type: "extension_ui_request", + id: "n2", + method: "notify", + message: "Done", + notifyType: "info", + }); + expect(omp.timeline().filter((item) => item.type === "notification")).toEqual([ + { type: "notification", level: "warning", message: "Working" }, + { type: "notification", level: "info", message: "Done" }, + ]); + expect(omp.timeline().filter((item) => item.type === "assistant_message")).toEqual([]); + }); + + test("maps legacy select options without descriptions and preserves ordinary responses", async () => { + const omp = new OmpHarness(); + await omp.start(); + + omp.emit({ + type: "extension_ui_request", + id: "select-legacy", + method: "select", + title: "Choose", + options: ["Approve", "Deny"], + }); + + expect(omp.pendingPermissions()[0]?.input).toMatchObject({ + questions: [{ options: [{ label: "Approve" }, { label: "Deny" }] }], + }); + await omp.respondToPermission("select-legacy", { + behavior: "allow", + updatedInput: { answers: { Response: "Deny" } }, + }); + expect(omp.extensionUiResponses()).toContainEqual({ + id: "select-legacy", + response: { value: "Deny" }, + }); + }); + + test("maps described and mixed select metadata by option index", async () => { + const omp = new OmpHarness(); + await omp.start(); + + omp.emit({ + type: "extension_ui_request", + id: "select-described", + method: "select", + title: "Choose", + options: ["First", "Second", "Third"], + optionDetails: [{ description: "First detail" }, {}, { description: " \t" }], + }); + + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([ + { label: "First", description: "First detail" }, + { label: "Second" }, + { label: "Third" }, + ]); + }); + + test("accepts malformed or misaligned optional metadata and falls back to labels", async () => { + const omp = new OmpHarness(); + await omp.start(); + const parsed = OmpRuntimeEventSchema.safeParse({ + type: "extension_ui_request", + id: "select-malformed", + method: "select", + options: ["First", "Second"], + optionDetails: [{ description: 42 }, { description: "\n\t" }, { description: "extra" }], + }); + expect(parsed.success).toBe(true); + if (!parsed.success) throw new Error("Expected malformed metadata event to parse"); + + omp.emit(parsed.data); + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([ + { label: "First" }, + { label: "Second" }, + ]); + }); + test("exposes OMP modes and commands through the domain session", async () => { const omp = new OmpHarness(); omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]); @@ -550,10 +1199,118 @@ describe("OMP agent client and session", () => { expect.objectContaining({ name: "review", kind: "skill" }), ]), ); + await expect(omp.setMode("ask")).resolves.toBeUndefined(); + await expect(omp.currentMode()).resolves.toBe("ask"); + expect(omp.runtimeLaunches()[1]?.argv).toContain("--approval-mode"); + expect(omp.runtimeLaunches()[1]?.argv).toContain("always-ask"); + }); + + test("restarts the same conversation after an idle process exit", async () => { + const omp = new OmpHarness(); + await omp.start({ modeId: "full" }); + const previousId = omp.runtime().state.sessionId; + omp.processExit("OMP RPC process exited with code null and signal SIGKILL\nBun crashed"); + expect(omp.turnFailures()).toEqual([ + "OMP RPC process exited with code null and signal SIGKILL\nBun crashed", + ]); + + await omp.startTurn("remember the conversation"); + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.runtimeLaunches()[1]?.session).toBe("/tmp/omp-session"); + expect(omp.runtime().state.sessionId).toBe(previousId); + expect(omp.threadStartedSessionIds()).toEqual([]); + expect(omp.runtime().prompts).toEqual([ + { message: "remember the conversation", imageCount: 0 }, + ]); + }); + + test("reports a mid-turn process exit and resumes on the following prompt", async () => { + const omp = new OmpHarness(); + await omp.start(); + await omp.requireStartTurn("sleep 30"); + omp.runtime().beginTurn(); + omp.processExit("OMP RPC process exited with code 137 and signal null\nOOM"); + expect(omp.turnFailures()).toEqual([ + "OMP RPC process exited with code 137 and signal null\nOOM", + ]); + await omp.startTurn("continue"); + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.runtime().prompts).toEqual([{ message: "continue", imageCount: 0 }]); + }); + + test("ignores a prompt rejection a previous OMP process left for a reused request id", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + omp.processExit("OMP RPC process exited with code 1 and signal null"); + + await omp.runPromptWithoutTurnOnNextRuntime("/session", "req_1"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.turnFailures()).toEqual(["OMP RPC process exited with code 1 and signal null"]); + expect(omp.completedTurnCount()).toBe(1); + }); + + test("reports an immediate relaunch failure without retrying in a loop", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.processExit("OMP RPC process exited with code null and signal SIGKILL"); + omp.failNextStart(new Error("Bun failed during startup")); + await omp.startTurn("continue"); + expect(omp.turnFailures()).toEqual([ + "OMP RPC process exited with code null and signal SIGKILL", + "Bun failed during startup", + ]); + expect(omp.runtimeLaunches()).toHaveLength(1); + }); + + test("closes a replacement process if the session closes during relaunch", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.processExit("OMP RPC process exited with code 1 and signal null"); + const turn = omp.startTurnDetached("continue"); + await omp.close(); + await turn; + await waitForImmediate(); + expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true); + }); + + test("announces a fresh native session when an internal agent recovers", async () => { + const omp = new OmpHarness(); + await omp.start({ internal: true }); + const previousId = omp.runtime().state.sessionId; + omp.processExit("OMP RPC process exited with code 1 and signal null"); + await omp.startTurn("continue"); + expect(omp.runtimeLaunches()[1]?.argv).toContain("--no-session"); + expect(omp.runtime().state.sessionId).not.toBe(previousId); + expect(omp.threadStartedSessionIds()).toEqual([omp.runtime().state.sessionId]); + }); + + test("leaves the current approval mode in place when relaunch fails", async () => { + const omp = new OmpHarness(); + await omp.start({ modeId: "full" }); + omp.failNextStart(new Error("OMP launch failed")); + await expect(omp.setMode("ask")).rejects.toThrow("OMP launch failed"); + expect(await omp.currentMode()).toBe("full"); + expect(omp.runtimeLaunches()).toHaveLength(1); + }); + + test("rejects a running approval-mode change until the turn ends", async () => { + const omp = new OmpHarness(); + await omp.start({ modeId: "full" }); + await omp.requireStartTurn("work"); await expect(omp.setMode("ask")).resolves.toEqual({ type: "warning", - message: "Start a new OMP session to change approval mode", + message: "Change approval mode once the current turn ends", }); + expect(omp.runtimeLaunches()).toHaveLength(1); + expect(await omp.currentMode()).toBe("full"); }); test("rewinds natively, interrupts, and shuts down", async () => { @@ -620,6 +1377,74 @@ describe("OMP agent client and session", () => { expect(omp.runningToolCallIds()).toEqual([]); }); + test("an interrupt that OMP reports as an aborted turn cancels instead of failing", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.requireStartTurn("do something long"); + const runtime = omp.runtime(); + runtime.beginTurn(); + runtime.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "sleep 30" }, + }); + runtime.streamAssistantText("working on it"); + // OMP ends the turn with an aborted terminal response before answering the abort. + runtime.onAbort = () => { + runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE }); + runtime.finishTurn(ABORTED_TERMINAL_RESPONSE); + }; + + await omp.interrupt(); + await waitForImmediate(); + await waitForImmediate(); + + expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]); + expect(omp.runningToolCallIds()).toEqual([]); + }); + + test("an aborted turn that settles after the interrupt cancels exactly once", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.requireStartTurn("do something long"); + const runtime = omp.runtime(); + runtime.beginTurn(); + runtime.streamAssistantText("working on it"); + // The provider-idle check is still in flight when the abort is acknowledged. + const releaseState = runtime.holdStateRequests(); + runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE }); + runtime.finishTurn(ABORTED_TERMINAL_RESPONSE); + + await omp.interrupt(); + releaseState(); + await waitForImmediate(); + await waitForImmediate(); + + expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]); + }); + + test("an autonomous OMP turn aborted with no client turn id cancels", async () => { + const omp = new OmpHarness(); + await omp.start(); + + const runtime = omp.runtime(); + runtime.beginTurn(); + runtime.streamAssistantText("autonomous work"); + runtime.onAbort = () => { + runtime.emit({ type: "message_end", message: ABORTED_TERMINAL_RESPONSE }); + runtime.finishTurn(ABORTED_TERMINAL_RESPONSE); + }; + + await omp.interrupt(); + await waitForImmediate(); + await waitForImmediate(); + + expect(omp.eventTypes().filter(isTurnLifecycle)).toEqual(["turn_started", "turn_canceled"]); + }); + test("a resumed session does not re-emit replayed events as live timeline items", async () => { const omp = new OmpHarness(); await omp.resume({ diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 78b79fe9b1..2dc6c58dea 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; @@ -19,6 +20,7 @@ import { type AgentPersistenceHandle, type AgentPromptInput, type AgentProvider, + type AgentResumeSessionOptions, type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, @@ -34,6 +36,8 @@ import { type ListImportableSessionsOptions, type ProviderCatalog, type ProviderRefreshContext, + type SteerActiveTurnOptions, + type SteerResult, type ToolCallDetail, } from "../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; @@ -60,20 +64,20 @@ import { mergeOmpRuntimeSettings, resolveOmpDiagnosticPaths, resolveOmpLaunchMode, - resolveOmpProviderParams, + resolveOmpProviderOptions, OMP_MODES, - type OmpModelRoleParams, - type OmpRuntimeProviderParams, + type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js"; -import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; +import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; -import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { OmpCliRuntime } from "./cli-runtime.js"; import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js"; +import { OmpMcpBridge, type OmpBridgedToolIdentity } from "./mcp-bridge.js"; import type { OmpAgentSessionEvent, OmpAgentMessage, @@ -86,21 +90,20 @@ import type { import { parseToolArgs, parseToolResult, + toolFailureMessage, + isOmpToolFailure, resolveToolCallName, type OmpToolResult, type OmpTrackedToolCall, } from "./tool-call-detail.js"; import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./commands.js"; -import { streamOmpHistory } from "./history.js"; +import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; -import { - clearOmpHostToolState, - handleOmpHostToolRuntimeEvent, - setOmpHostTools, -} from "./host-tools.js"; +import { handleOmpHostToolRuntimeEvent, OmpHostToolRouter } from "./host-tools.js"; import { OmpSubagentIndex } from "./subagent-index.js"; +import { OmpQuestionUi } from "./question-ui.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { OmpUsagePoller, type OmpUsagePollScheduler } from "./usage-poller.js"; import { @@ -110,17 +113,12 @@ import { import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js"; const OMP_PROVIDER = "omp"; -const QUESTION_RESPONSE_HEADER = "Response"; -const QUESTION_COMMENT_HEADER = "Comment"; -const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; -const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; - const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, supportsSessionListing: true, supportsDynamicModes: true, - supportsMcpServers: false, + supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, supportsRewindConversation: true, @@ -131,12 +129,12 @@ const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = { export interface OmpAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: OmpRuntime; subagentCardScheduler?: OmpSubagentCardScheduler; providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; + providerIdleDeadlineMs?: number; } export interface OmpProviderIdleScheduler { @@ -152,6 +150,8 @@ export interface OmpNoTurnScheduler { // v0.2.0-beta.1; remove after January 20, 2027 once the minimum OMP version // guarantees prompt_result waits for queued extension work. const OMP_NO_TURN_SETTLE_MS = 5_000; +const OMP_PROVIDER_IDLE_DEADLINE_MS = 600_000; +const OMP_FAST_INACTIVE_MESSAGE = "Fast is enabled but does not apply to this model."; interface OmpPromptPayload { text: string; @@ -169,6 +169,22 @@ interface OmpPersistenceMetadata { thinkingOptionId?: string; modeId?: string; systemPrompt?: string; + bridgedTools: Map; +} + +/** OMP's outcome for one prompt, correlated by the prompt's request id. */ +interface OmpPromptResult { + agentInvoked: boolean; + /** Why OMP rejected the prompt before its agent ran, such as a missing API key. */ + error: string | null; +} + +function promptResultError(result: { + status?: string; + error?: { message: string }; +}): string | null { + if (result.status !== "error") return null; + return result.error?.message || "OMP rejected the prompt"; } interface StartTurnResult { @@ -177,6 +193,14 @@ interface StartTurnResult { interface OmpAgentSessionOptions { runtimeSession: OmpRuntimeSession; + hostTools?: OmpHostToolRouter; + restartRuntime: ( + sessionFile: string | null, + modeId: string, + ) => Promise<{ + runtimeSession: OmpRuntimeSession; + hostTools?: OmpHostToolRouter; + }>; config: AgentSessionConfig; initialState: OmpSessionState; currentModeId?: string | null; @@ -185,7 +209,7 @@ interface OmpAgentSessionOptions { providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; - paseoTools?: PaseoToolCatalog; + providerIdleDeadlineMs?: number; /** * When false (resumed sessions), replayed session events are dropped until * the first prompt or agent_start so history is not re-emitted as live @@ -218,24 +242,6 @@ interface OmpResumeConfig { config: AgentSessionConfig; } -interface ActiveAskUserDialog { - allowComment: boolean; - allowFreeform: boolean; - allowMultiple: boolean; -} - -interface PendingCombinedAskUserResponse { - comment: string; - freeform: string | null; -} - -interface ExtensionUiMappingOptions { - provider?: AgentProvider; - label?: string; - combineOptionalComment?: boolean; - allowFreeform?: boolean; -} - interface OmpSlashCommandInvocation { commandName: string; args?: string; @@ -281,7 +287,8 @@ function isOmpThinkingLevel(value: string | null | undefined): value is OmpThink value === "medium" || value === "high" || value === "xhigh" || - value === "max" + value === "max" || + value === "auto" ); } @@ -388,9 +395,20 @@ function parseModelReference(modelId: string | null): OmpModelReference | null { function parsePersistenceMetadata(metadata: AgentMetadata | undefined): OmpPersistenceMetadata { if (!metadata) { - return {}; + return { bridgedTools: new Map() }; + } + const bridgedTools = new Map(); + if (Array.isArray(metadata.bridgedTools)) { + for (const entry of metadata.bridgedTools) { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue; + const identity = entry[1]; + if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue; + if (typeof identity.server !== "string" || typeof identity.tool !== "string") continue; + bridgedTools.set(entry[0], { server: identity.server, tool: identity.tool }); + } } return { + bridgedTools, ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), ...(typeof metadata.thinkingOptionId === "string" @@ -439,7 +457,6 @@ function buildResumeStartInput(input: { protocolMode: "rpc-ui", env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizeOmpThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, ...(input.launchMode.modeId ? { modeId: input.launchMode.modeId } : {}), ...(input.launchMode.extraArgs ? { extraArgs: input.launchMode.extraArgs } : {}), @@ -462,7 +479,7 @@ function readNativeMessageId( function withOmpCapabilities(): AgentCapabilityFlags { return { ...OMP_CORE_CAPABILITIES, - supportsMcpServers: false, + supportsMcpServers: true, supportsNativePaseoTools: true, }; } @@ -475,6 +492,20 @@ function isOmpRequestAbortError(error: unknown): boolean { return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error)); } +function isOmpSteerTransportFailure(error: unknown): boolean { + // JsonlRpcProcess currently uses plain Error for both RPC rejections and transport failures. + // Only its own timeout/closed-process messages are transport failures; OMP rejections are unavailable. + const message = toDiagnosticErrorMessage(error); + return ( + /^OMP RPC request timed out phase=steer\b/.test(message) || + /^OMP RPC process (?:is closed|exited\b)/.test(message) + ); +} + +function hasOmpFastMode(state: OmpSessionState): boolean { + return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean"; +} + function resolveThinkingOptionId( cachedThinkingOptionId: string | null, sessionThinkingLevel: OmpThinkingLevel | undefined, @@ -526,116 +557,15 @@ function latestOmpErrorMessage(messages: OmpAgentMessage[]): string | null { return formatOmpErrorMessage(latestAssistant); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +function isOmpAbortedTerminalResponse(messages: OmpAgentMessage[]): boolean { + const latestAssistant = messages.findLast((message) => message.role === "assistant"); + return latestAssistant?.stopReason?.toLowerCase() === "aborted"; } function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null { - if (toolName !== "ask_user" || !isRecord(args)) { - return null; - } - return { - allowComment: optionalBoolean(args.allowComment) ?? false, - allowFreeform: optionalBoolean(args.allowFreeform) ?? true, - allowMultiple: optionalBoolean(args.allowMultiple) ?? false, - }; -} - -function isOptionalInputPlaceholder(placeholder: string | undefined): boolean { - return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); -} - -function getInputQuestionTitle(title: string | undefined, placeholder: string | undefined): string { - if (!isOptionalInputPlaceholder(placeholder)) { - return title ?? "Enter a value"; - } - if (/\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)) { - return "Optional comment"; - } - return "Optional response"; -} - -function readStringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : []; -} - -function isOmpAskUserFreeformOption(option: string): boolean { - return option === OMP_ASK_USER_FREEFORM_SENTINEL; -} - -function mapExtensionUiRequestToPermission( - event: Extract, - options: ExtensionUiMappingOptions = {}, -): AgentPermissionRequest | null { - const provider = options.provider ?? OMP_PROVIDER; - const label = options.label ?? "OMP"; - switch (event.method) { - case "select": { - const selectOptions = readStringArray(event.options); - if (options.combineOptionalComment) { - return buildCombinedAskUserQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - allowFreeform: options.allowFreeform === true, - }); - } - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - multiSelect: false, - }); - } - case "input": { - const placeholder = optionalString(event.placeholder); - const title = optionalString(event.title); - const allowEmpty = isOptionalInputPlaceholder(placeholder); - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: getInputQuestionTitle(title, placeholder), - options: [], - multiSelect: false, - ...(placeholder ? { placeholder } : {}), - ...(allowEmpty ? { allowEmpty: true, dismissLabel: "Skip" } : {}), - }); - } - case "editor": - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Edit text", - options: [], - multiSelect: false, - }); - case "confirm": - return buildExtensionUiQuestionPermission(event, { - provider, - label, - question: [optionalString(event.title), optionalString(event.message)] - .filter(Boolean) - .join("\n\n"), - options: ["Yes", "No"], - multiSelect: false, - }); - default: - return null; - } -} - function isExtensionUiRequestEvent( event: OmpRuntimeEvent, ): event is Extract { @@ -667,179 +597,100 @@ function isOmpAgentSessionEvent(event: OmpRuntimeEvent): event is OmpAgentSessio } } -function buildExtensionUiQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: string[]; - multiSelect: boolean; - placeholder?: string; - allowEmpty?: boolean; - dismissLabel?: string; - }, -): AgentPermissionRequest { - return { - id: event.id, - provider: input.provider, - name: `${input.label} ${event.method}`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: input.options.map((label) => ({ label })), - multiSelect: input.multiSelect, - ...(input.placeholder ? { placeholder: input.placeholder } : {}), - ...(input.allowEmpty ? { allowEmpty: true } : {}), - ...(input.dismissLabel ? { dismissLabel: input.dismissLabel } : {}), - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - }, - }; -} - -function buildCombinedAskUserQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: string[]; - allowFreeform: boolean; - }, -): AgentPermissionRequest { - const visibleOptions = input.options.filter((option) => !isOmpAskUserFreeformOption(option)); - const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length; - return { - id: event.id, - provider: input.provider, - name: `${input.label} ask_user`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: visibleOptions.map((label) => ({ label })), - multiSelect: false, - ...(allowOther ? { allowOther: true } : {}), - }, - { - question: "Optional comment", - header: QUESTION_COMMENT_HEADER, - options: [], - multiSelect: false, - placeholder: "Optional comment (press Enter to skip)...", - allowEmpty: true, - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - commentHeader: QUESTION_COMMENT_HEADER, - combinedAskUser: COMBINED_ASK_USER_METADATA, - selectOptions: visibleOptions, - ...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}), - }, - }; -} - -function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const answer = answers[header]; - return typeof answer === "string" ? answer : null; -} - -function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const first = Object.values(answers).find((value) => typeof value === "string"); - return typeof first === "string" ? first : null; -} - -function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean { - return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA; -} - -function buildCombinedAskUserSelectionResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { - uiResponse: { value?: string; cancelled?: boolean }; - pendingResponse: PendingCombinedAskUserResponse | null; -} { - if (response.behavior === "deny") { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER); - if (answer === null) { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const selectOptions = readStringArray(request.metadata?.selectOptions); - const freeformSentinel = optionalString(request.metadata?.freeformSentinel); - const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer); - const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? ""; - return { - uiResponse: { value: isFreeform ? freeformSentinel : answer }, - pendingResponse: { - comment, - freeform: isFreeform ? answer : null, - }, - }; -} - -function buildExtensionUiResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { value?: string; confirmed?: boolean; cancelled?: boolean } { - if (response.behavior === "deny") { - return { cancelled: true }; - } - - const method = optionalString(request.metadata?.extensionUiMethod); - const answer = firstPermissionAnswer(response.updatedInput); - if (answer === null) { - return { cancelled: true }; - } - - if (method === "confirm") { - return { confirmed: /^yes$/i.test(answer.trim()) }; - } - return { value: answer }; -} - function createRuntime( logger: Logger, runtimeSettings: ProviderRuntimeSettings | undefined, - providerParams: OmpRuntimeProviderParams, + providerOptions: OmpRuntimeOptions, ): OmpRuntime { return new OmpCliRuntime({ logger, runtimeSettings, command: ["omp"], commandsRpcName: "get_available_commands", - readyTimeoutMs: providerParams.readyTimeoutMs, - requestTimeoutMs: providerParams.rpcTimeoutMs, + readyTimeoutMs: providerOptions.readyTimeoutMs, + requestTimeoutMs: providerOptions.rpcTimeoutMs, }); } +class OmpHistorySession implements AgentSession { + readonly provider: AgentProvider; + readonly capabilities: AgentCapabilityFlags = withOmpCapabilities(); + + constructor( + private readonly handle: AgentPersistenceHandle, + private readonly config: OmpResumeConfig, + private readonly sessionFile: string, + provider: AgentProvider, + private readonly bridgedTools: ReadonlyMap, + ) { + this.provider = provider; + } + + get id(): string | null { + return this.handle.sessionId; + } + + async run(): Promise { + throw new Error("OMP history session cannot start a turn"); + } + + async startTurn(): Promise<{ turnId: string }> { + throw new Error("OMP history session cannot start a turn"); + } + + subscribe(): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + yield* streamOmpHistory({ + sessionFile: this.sessionFile, + provider: this.provider, + bridgedTools: this.bridgedTools, + }); + const todo = await readOmpHistoryTodoState(this.sessionFile); + if (todo) yield { type: "timeline", provider: this.provider, item: todo }; + } + + async getRuntimeInfo(): Promise { + return { + provider: this.provider, + sessionId: this.handle.sessionId, + model: this.config.model ?? null, + thinkingOptionId: this.config.thinkingOptionId ?? null, + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return [...OMP_MODES]; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise { + throw new Error("OMP history session cannot change mode"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(): Promise { + throw new Error("OMP history session has no pending permissions"); + } + + describePersistence(): AgentPersistenceHandle { + return this.handle; + } + + async interrupt(): Promise {} + + async close(): Promise {} +} + export class OmpAgentSession implements AgentSession { readonly provider: AgentProvider = OMP_PROVIDER; readonly capabilities: AgentCapabilityFlags = withOmpCapabilities(); @@ -847,10 +698,10 @@ export class OmpAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); - private activeAskUserDialog: ActiveAskUserDialog | null = null; - private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; + private readonly questionUi = new OmpQuestionUi(); private activeTurnId: string | null = null; - private activeClientMessageId: string | null = null; + private readonly pendingClientMessages: Array<{ clientMessageId: string | null; text: string }> = + []; private activeAssistantMessageId: string | null = null; private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null; private activeTurnStarted = false; @@ -859,7 +710,8 @@ export class OmpAgentSession implements AgentSession { private readonly pendingNoTurnOutputs: Array<{ turnId: string; message: string }> = []; private activePromptRequestId: string | null = null; private activePromptAgentInvoked: boolean | null = null; - private readonly pendingPromptResults = new Map(); + private activePromptError: string | null = null; + private readonly pendingPromptResults = new Map(); private pendingNoTurnCompletionAbort: AbortController | null = null; private lastKnownThinkingOptionId: string | null; private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; @@ -870,23 +722,42 @@ export class OmpAgentSession implements AgentSession { private readonly subagentCardTracker: OmpSubagentCardTracker; private lastTodoItem: Extract | null = null; private state: OmpSessionState; - private readonly currentModeId: string | null; + private currentModeId: string | null; + private runtimeDead = false; + private replacingRuntime: Promise | null = null; + private unsubscribeRuntime: (() => void) | null = null; private readonly providerIdleScheduler: OmpProviderIdleScheduler; + private readonly providerIdleDeadlineMs: number; private readonly noTurnScheduler: OmpNoTurnScheduler; private readonly usagePoller: OmpUsagePoller; private closed = false; private live: boolean; private readonly emittedUserMessageIds = new Set(); + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "omp", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } + constructor(options: OmpAgentSessionOptions) { this.runtimeSession = options.runtimeSession; + this.hostTools = options.hostTools; + this.restartRuntime = options.restartRuntime; this.config = options.config; this.state = options.initialState; this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; - this.paseoTools = options.paseoTools; this.live = options.live ?? true; this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler(); + this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS; this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler(); this.usagePoller = new OmpUsagePoller({ scheduler: options.usagePollScheduler, @@ -910,34 +781,63 @@ export class OmpAgentSession implements AgentSession { normalizeOmpThinkingOption(options.config.thinkingOptionId) ?? this.state.thinkingLevel ?? null; - this.runtimeSession.onEvent((event) => { - this.handleRuntimeEvent(event); + this.attachRuntime(this.runtimeSession); + } + + private attachRuntime(runtime: OmpRuntimeSession): void { + this.unsubscribeRuntime = runtime.onEvent((event) => { + if (!this.closed && runtime === this.runtimeSession) this.handleRuntimeEvent(event); }); - void this.runtimeSession.setSubagentSubscription("events").catch((eventsError: unknown) => { + void runtime.setSubagentSubscription("events").catch((eventsError: unknown) => { this.logger.debug( { err: eventsError }, "OMP subagent event subscription unavailable; falling back to progress", ); - void this.runtimeSession - .setSubagentSubscription("progress") - .catch((progressError: unknown) => { - this.logger.debug( - { err: progressError }, - "OMP subagent progress subscription unavailable", - ); - }); + void runtime.setSubagentSubscription("progress").catch((progressError: unknown) => { + this.logger.debug({ err: progressError }, "OMP subagent progress subscription unavailable"); + }); }); } - private readonly runtimeSession: OmpRuntimeSession; + private runtimeSession: OmpRuntimeSession; + private hostTools?: OmpHostToolRouter; + private readonly restartRuntime: OmpAgentSessionOptions["restartRuntime"]; private readonly config: AgentSessionConfig; private readonly logger: Logger; - private readonly paseoTools?: PaseoToolCatalog; get id(): string | null { return this.state.sessionId; } + get features(): AgentFeature[] { + if (!hasOmpFastMode(this.state)) return []; + return [ + { + type: "toggle", + id: "fast_mode", + label: "Fast", + icon: "zap", + value: this.state.fastModeEnabled === true, + ...(this.state.fastModeEnabled && !this.state.fastModeActive + ? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE } + : {}), + }, + ]; + } + + async setFeature(featureId: string, value: unknown): Promise { + if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`); + if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean"); + if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); + const result = await this.runtimeSession.setFastMode(value); + this.state = { + ...this.state, + fastModeEnabled: result.enabled, + fastModeActive: result.active, + }; + this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled }; + } + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { return runProviderTurn({ prompt, @@ -959,7 +859,7 @@ export class OmpAgentSession implements AgentSession { const turnId = randomUUID(); this.live = true; this.activeTurnId = turnId; - this.activeClientMessageId = options?.clientMessageId ?? null; + this.rememberClientMessage(options?.clientMessageId ?? null, payload.text); this.activeAssistantMessageId = null; this.activeTurnTerminalAssistantMessage = null; this.activeTurnStarted = false; @@ -971,6 +871,8 @@ export class OmpAgentSession implements AgentSession { void (async () => { try { + if (this.runtimeDead) await this.replaceRuntime(this.currentModeId ?? "full"); + if (this.closed) throw new Error("OMP session is closed"); const ack = await this.runtimeSession.prompt(payload.text, payload.images); this.activePromptRequestId = ack.requestId ?? null; const correlatedResult = ack.requestId @@ -979,12 +881,13 @@ export class OmpAgentSession implements AgentSession { if (ack.requestId) { this.pendingPromptResults.delete(ack.requestId); } - this.activePromptAgentInvoked = correlatedResult ?? ack.agentInvoked ?? null; - if (correlatedResult === false) { + this.activePromptAgentInvoked = correlatedResult?.agentInvoked ?? ack.agentInvoked ?? null; + this.activePromptError = correlatedResult?.error ?? null; + if (correlatedResult?.agentInvoked === false) { this.scheduleNoTurnPromptCompletion(turnId); return; } - if (correlatedResult !== true && ack.agentInvoked === false) { + if (correlatedResult?.agentInvoked !== true && ack.agentInvoked === false) { await this.completeNoTurnPrompt(turnId); return; } @@ -993,13 +896,7 @@ export class OmpAgentSession implements AgentSession { return; } this.usagePoller.stopTurn(); - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); if (isOmpRequestAbortError(error)) { this.emit({ type: "turn_canceled", @@ -1021,6 +918,57 @@ export class OmpAgentSession implements AgentSession { return { turnId }; } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; + const payload = convertPromptInput(prompt, { model: this.state.model }); + if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" }; + const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text); + try { + await this.runtimeSession.steer(payload.text, payload.images); + } catch (error) { + this.forgetClientMessage(submission); + if (isOmpSteerTransportFailure(error)) throw error; + return { status: "unavailable" }; + } + if (this.closed || this.activeTurnId !== options.expectedTurnId) + return { status: "unavailable" }; + if (options.clearPendingPermissions) { + for (const requestId of this.pendingExtensionUiRequests.keys()) { + await this.respondToPermission(requestId, { + behavior: "deny", + message: "The user sent a message instead of approving.", + }); + } + } + return { status: "accepted" }; + } + + private rememberClientMessage( + clientMessageId: string | null, + text: string, + ): { + clientMessageId: string | null; + text: string; + } { + const submission = { clientMessageId, text }; + this.pendingClientMessages.push(submission); + if (this.pendingClientMessages.length > 16) this.pendingClientMessages.shift(); + return submission; + } + + private forgetClientMessage(submission: { clientMessageId: string | null; text: string }): void { + const index = this.pendingClientMessages.indexOf(submission); + if (index >= 0) this.pendingClientMessages.splice(index, 1); + } + + private takeClientMessage(): { clientMessageId: string | null; text: string } | undefined { + return this.pendingClientMessages.shift(); + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -1033,6 +981,7 @@ export class OmpAgentSession implements AgentSession { sessionFile: this.state.sessionFile, runtimeSession: this.runtimeSession, provider: this.provider, + bridgedTools: this.hostTools?.bridge?.toolIdentities(), }); for (const item of mapOmpTodoState(this.state)) { yield { @@ -1045,6 +994,10 @@ export class OmpAgentSession implements AgentSession { async getRuntimeInfo(): Promise { await this.refreshState(); + return this.runtimeInfoFromState(); + } + + private runtimeInfoFromState(): AgentRuntimeInfo { return { provider: this.provider, sessionId: this.state.sessionId, @@ -1069,10 +1022,59 @@ export class OmpAgentSession implements AgentSession { if (!OMP_MODES.some((mode) => mode.id === modeId)) { throw new Error(`Invalid OMP mode '${modeId}'`); } - return { - type: "warning", - message: "Start a new OMP session to change approval mode", + if (modeId === this.currentModeId) return; + if (this.activeTurnId || this.state.isStreaming || this.state.isCompacting) { + return { type: "warning", message: "Change approval mode once the current turn ends" }; + } + await this.replaceRuntime(modeId); + } + + private async replaceRuntime(modeId: string): Promise { + if (this.replacingRuntime) return this.replacingRuntime; + const replace = async () => { + const old = this.runtimeSession; + const oldHostTools = this.hostTools; + const previousSessionId = this.state.sessionId; + const restarted = await this.restartRuntime( + this.config.internal ? null : (this.state.sessionFile ?? null), + modeId, + ); + const next = restarted.runtimeSession; + try { + if (this.closed) throw new Error("OMP session is closed"); + const state = await next.getState(); + if (this.closed) throw new Error("OMP session is closed"); + this.unsubscribeRuntime?.(); + // Request ids restart with each OMP process, so results held for the old one cannot + // correlate with prompts sent to the new one. + this.pendingPromptResults.clear(); + this.runtimeSession = next; + this.hostTools = restarted.hostTools; + this.state = state; + this.currentModeId = modeId; + this.config.modeId = modeId; + this.runtimeDead = false; + this.attachRuntime(next); + this.subagentIndex.clear(old); + await oldHostTools?.close(); + await old.close().catch(() => undefined); + if (state.sessionId !== previousSessionId) { + this.emit({ + type: "thread_started", + provider: this.provider, + sessionId: state.sessionId, + }); + } + } catch (error) { + await restarted.hostTools?.close(); + await next.close().catch(() => undefined); + throw error; + } }; + this.replacingRuntime = replace().finally(() => { + this.replacingRuntime = null; + }); + return this.replacingRuntime; } getPendingPermissions(): AgentPermissionRequest[] { @@ -1086,15 +1088,12 @@ export class OmpAgentSession implements AgentSession { } this.pendingExtensionUiRequests.delete(requestId); - if (isCombinedAskUserPermission(request)) { - const combined = buildCombinedAskUserSelectionResponse(request, response); - this.pendingCombinedAskUserResponse = combined.pendingResponse; - this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse); + const approvalResponse = buildOmpRpcUiPermissionResponse(request, response); + if (approvalResponse) { + this.runtimeSession.respondToExtensionUiRequest(requestId, approvalResponse); } else { - this.runtimeSession.respondToExtensionUiRequest( - requestId, - buildOmpRpcUiPermissionResponse(request, response) ?? - buildExtensionUiResponse(request, response), + this.questionUi.respond(request, response, (id, uiResponse) => + this.runtimeSession.respondToExtensionUiRequest(id, uiResponse), ); } this.emit({ @@ -1113,6 +1112,9 @@ export class OmpAgentSession implements AgentSession { nativeHandle: this.state.sessionFile, metadata: { cwd: this.config.cwd, + ...(this.hostTools?.bridge + ? { bridgedTools: [...this.hostTools.bridge.toolIdentities()] } + : {}), ...(this.config.model ? { model: this.config.model } : {}), ...(this.config.thinkingOptionId ? { thinkingOptionId: this.config.thinkingOptionId } : {}), ...(this.currentModeId ? { modeId: this.currentModeId } : {}), @@ -1124,15 +1126,8 @@ export class OmpAgentSession implements AgentSession { const turnId = this.activeTurnId; await this.runtimeSession.abort(); if (turnId && this.activeTurnId === turnId) { - this.terminalizeActiveWork(); this.usagePoller.stopTurn(); - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); this.emit({ type: "turn_canceled", provider: this.provider, @@ -1160,11 +1155,13 @@ export class OmpAgentSession implements AgentSession { return; } this.closed = true; + this.unsubscribeRuntime?.(); this.usagePoller.close(); this.cancelNoTurnPromptCompletion(); try { await this.runtimeSession.close(); } finally { + await this.hostTools?.close(); this.clearOmpSessionState(); } } @@ -1175,7 +1172,7 @@ export class OmpAgentSession implements AgentSession { } private clearOmpTurnState(): void { - clearOmpHostToolState(this.runtimeSession); + this.hostTools?.clear(); this.subagentCardTracker.clear(); } @@ -1190,6 +1187,17 @@ export class OmpAgentSession implements AgentSession { this.clearOmpTurnState(); } + private resetActiveTurn({ terminalizeWork }: { terminalizeWork: boolean }): void { + if (terminalizeWork) this.terminalizeActiveWork(); + this.activeTurnId = null; + this.activeAssistantMessageId = null; + this.activeTurnTerminalAssistantMessage = null; + this.activeTurnStarted = false; + this.activeTurnHasUserMessage = false; + this.pendingClientMessages.length = 0; + this.clearNoTurnBuffers(); + } + async listCommands(): Promise { if (this.commandCache) { return this.commandCache; @@ -1259,11 +1267,8 @@ export class OmpAgentSession implements AgentSession { } const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); - this.state = { - ...this.state, - model, - }; this.config.model = `${model.provider}/${model.id}`; + await this.refreshState(); } async setThinkingOption(thinkingOptionId: string | null): Promise { @@ -1324,7 +1329,14 @@ export class OmpAgentSession implements AgentSession { ) { return; } + const error = this.activePromptError; this.emitBufferedNoTurnOutputs(turnId); + if (error) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ type: "turn_failed", provider: this.provider, turnId, error }); + return; + } this.completeTurn(turnId, []); } @@ -1333,6 +1345,7 @@ export class OmpAgentSession implements AgentSession { this.activeNoTurnPromptText = null; this.activePromptRequestId = null; this.activePromptAgentInvoked = null; + this.activePromptError = null; this.pendingNoTurnOutputs.splice(0, this.pendingNoTurnOutputs.length); } @@ -1341,6 +1354,7 @@ export class OmpAgentSession implements AgentSession { const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId); this.clearNoTurnBuffers(); if (promptText) { + const clientMessageId = this.takeClientMessage()?.clientMessageId; this.emit({ type: "timeline", provider: this.provider, @@ -1348,7 +1362,7 @@ export class OmpAgentSession implements AgentSession { item: { type: "user_message", text: promptText, - ...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}), + ...(clientMessageId ? { clientMessageId } : {}), }, }); } @@ -1518,7 +1532,20 @@ export class OmpAgentSession implements AgentSession { ): void { const message = optionalString(event.message); if (event.method === "notify" && message) { - this.bufferNoTurnOutput(message); + this.emit({ + type: "timeline", + provider: this.provider, + turnId: this.currentTurnIdForEvent(), + item: { + type: "notification", + level: + event.notifyType === "warning" || event.notifyType === "error" + ? event.notifyType + : "info", + message, + }, + }); + return; } const sideEffectItem = this.mapExtensionUiSideEffect(event); @@ -1532,22 +1559,11 @@ export class OmpAgentSession implements AgentSession { return; } - if (this.respondToCombinedAskUserFollowUp(event)) { - return; - } - - const shouldCombineOptionalComment = - event.method === "select" && - this.activeAskUserDialog?.allowComment === true && - this.activeAskUserDialog.allowMultiple === false; const request = mapOmpRpcUiPermissionRequest(event, { provider: this.provider }) ?? - mapExtensionUiRequestToPermission(event, { - provider: this.provider, - label: "OMP", - combineOptionalComment: shouldCombineOptionalComment, - allowFreeform: this.activeAskUserDialog?.allowFreeform, - }); + this.questionUi.handleRequest(event, this.provider, (id, response) => + this.runtimeSession.respondToExtensionUiRequest(id, response), + ); if (!request) { return; } @@ -1577,33 +1593,6 @@ export class OmpAgentSession implements AgentSession { return { type: "assistant_message", text: lines.join("\n") }; } - private respondToCombinedAskUserFollowUp( - event: Extract, - ): boolean { - const pending = this.pendingCombinedAskUserResponse; - if (!pending || event.method !== "input") { - return false; - } - - const placeholder = optionalString(event.placeholder); - if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = { - ...pending, - freeform: null, - }; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform }); - return true; - } - - if (isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = null; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment }); - return true; - } - - return false; - } - private handleCommandOutput(textValue: unknown): void { if (!this.activeTurnId) { return; @@ -1625,13 +1614,21 @@ export class OmpAgentSession implements AgentSession { } private handleExtraRuntimeEvent(event: OmpRuntimeEvent): boolean { - if ( - handleOmpHostToolRuntimeEvent(event, { - runtimeSession: this.runtimeSession, - paseoTools: this.paseoTools, - logger: this.logger, - }) - ) { + if (event.type === "model_changed") { + void this.refreshState() + .then(() => + this.emit({ + type: "model_changed", + provider: this.provider, + runtimeInfo: this.runtimeInfoFromState(), + }), + ) + .catch((error: unknown) => + this.logger.debug({ err: error }, "OMP state unavailable after model change"), + ); + return true; + } + if (handleOmpHostToolRuntimeEvent(event, this.hostTools, this.runtimeSession, this.logger)) { return true; } if (event.type === "subagent_lifecycle") { @@ -1746,15 +1743,17 @@ export class OmpAgentSession implements AgentSession { ? event.agentInvoked : undefined; if (requestId && agentInvoked !== undefined) { + const result = { agentInvoked, error: promptResultError(event) }; if (requestId === this.activePromptRequestId && this.activeTurnId) { this.activePromptAgentInvoked = agentInvoked; + this.activePromptError = result.error; if (agentInvoked === false) { this.scheduleNoTurnPromptCompletion(this.activeTurnId); } else { this.cancelNoTurnPromptCompletion(); } } else if (this.activePromptRequestId === null) { - this.pendingPromptResults.set(requestId, agentInvoked); + this.pendingPromptResults.set(requestId, result); } } return; @@ -1778,19 +1777,18 @@ export class OmpAgentSession implements AgentSession { } private handleProcessExit(error: string): void { + this.runtimeDead = true; + void this.hostTools?.close(); this.usagePoller.stopTurn(); - this.terminalizeActiveWork(); - this.subagentIndex.clear(this.runtimeSession); if (!this.activeTurnId) { + this.terminalizeActiveWork(); + this.subagentIndex.clear(this.runtimeSession); + this.emit({ type: "turn_failed", provider: this.provider, error }); return; } const turnId = this.activeTurnId; - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.activeTurnTerminalAssistantMessage = null; - this.clearNoTurnBuffers(); + this.resetActiveTurn({ terminalizeWork: true }); + this.subagentIndex.clear(this.runtimeSession); this.emit({ type: "turn_failed", provider: this.provider, @@ -1836,7 +1834,7 @@ export class OmpAgentSession implements AgentSession { case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); - this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args); + this.questionUi.start(event.toolName, event.args); this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); return; } @@ -1906,14 +1904,12 @@ export class OmpAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - if (event.toolName === "ask_user") { - this.activeAskUserDialog = null; - this.pendingCombinedAskUserResponse = null; - } + this.questionUi.finish(event.toolName); const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; - const status = event.isError ? "failed" : "completed"; + const failed = isOmpToolFailure(toolCall, result, Boolean(event.isError)); + const error = failed ? toolFailureMessage(result) : null; + const status = failed ? "failed" : "completed"; this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); if (event.toolName === "task") { this.subagentCardTracker.delete(event.toolCallId); @@ -2008,6 +2004,7 @@ export class OmpAgentSession implements AgentSession { ): void { if (event.message.role === "assistant") { this.activeAssistantMessageId = null; + this.questionUi.observeMessage(event.message); if (turnId) { this.activeTurnTerminalAssistantMessage = event.message; } @@ -2017,20 +2014,23 @@ export class OmpAgentSession implements AgentSession { if (shouldDisplayOmpCustomMessage(event.message)) { const text = getUserMessageText(event.message.content); if (text) { + const skillPrompt = ompSkillPromptUserText(event.message); + if (skillPrompt) { + this.emitSkillPromptEcho(turnId); + return; + } const item = mapOmpAdvisorMessageToToolCall(event.message, text) ?? - mapOmpSystemNoticeToToolCall(text); + mapOmpSystemNoticeToNotification(text); this.emit({ type: "timeline", provider: this.provider, turnId, - item: item ?? { type: "assistant_message", text }, + item: + item ?? mapCustomMessageToToolCall(event.message, text, `omp-custom-${randomUUID()}`), }); } } - if (!this.activeTurnHasUserMessage) { - this.completeTurn(turnId, []); - } return; } @@ -2043,7 +2043,6 @@ export class OmpAgentSession implements AgentSession { } const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown }; const messageId = readNativeMessageId(nativeMessage); - const clientMessageId = this.activeClientMessageId; const emitUserMessage = (resolvedMessageId?: string): void => { if (resolvedMessageId) { // OMP re-emits user message_end frames for entries it has already @@ -2054,6 +2053,7 @@ export class OmpAgentSession implements AgentSession { } this.emittedUserMessageIds.add(resolvedMessageId); } + const clientMessageId = this.takeClientMessage()?.clientMessageId; this.emit({ type: "timeline", provider: this.provider, @@ -2084,6 +2084,22 @@ export class OmpAgentSession implements AgentSession { }); } + private emitSkillPromptEcho(turnId: string | undefined): void { + const pending = this.takeClientMessage(); + if (!pending) return; + this.activeTurnHasUserMessage = true; + this.emit({ + type: "timeline", + provider: this.provider, + turnId, + item: { + type: "user_message", + text: pending.text, + ...(pending.clientMessageId ? { clientMessageId: pending.clientMessageId } : {}), + }, + }); + } + private emitToolCallEvent( toolCallId: string, toolCall: OmpTrackedToolCall, @@ -2096,10 +2112,13 @@ export class OmpAgentSession implements AgentSession { if (!detail) { return false; } + const bridgedTool = this.hostTools?.bridge?.tool(toolCall.toolName); const baseItem = { type: "tool_call" as const, callId: toolCallId, - name: resolveToolCallName(toolCall, result), + name: bridgedTool + ? `${bridgedTool.server} / ${bridgedTool.tool}` + : resolveToolCallName(toolCall, result), detail, }; const item = @@ -2120,22 +2139,30 @@ export class OmpAgentSession implements AgentSession { ): ToolCallDetail | null { return mapOmpToolDetail(toolCall, result, { toolCallId, + bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName), mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail, }); } private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void { - this.activeTurnId = null; - this.activeClientMessageId = null; - this.activeAssistantMessageId = null; - this.activeTurnTerminalAssistantMessage = null; - this.activeTurnStarted = false; - this.activeTurnHasUserMessage = false; - this.clearNoTurnBuffers(); + // OMP reports a stopped turn as a terminal response carrying its interrupt + // text as an error. That is the user's own Stop, not a failed turn. + if (isOmpAbortedTerminalResponse(messages)) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ + type: "turn_canceled", + provider: this.provider, + turnId, + reason: "interrupted", + }); + return; + } const errorMessage = latestOmpErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); this.emit({ type: "turn_failed", provider: this.provider, @@ -2145,6 +2172,7 @@ export class OmpAgentSession implements AgentSession { return; } const finalUsage = this.usagePoller.completeTurn(turnId); + this.resetActiveTurn({ terminalizeWork: false }); this.emit({ type: "turn_completed", provider: this.provider, @@ -2157,10 +2185,26 @@ export class OmpAgentSession implements AgentSession { turnId: string | undefined, messages: OmpAgentMessage[], ): Promise { + const deadline = Date.now() + this.providerIdleDeadlineMs; while (!this.closed && this.activeTurnStarted && this.currentTurnIdForEvent() === turnId) { + if (Date.now() >= deadline) { + this.usagePoller.stopTurn(); + this.resetActiveTurn({ terminalizeWork: true }); + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId, + error: "OMP provider idle deadline exceeded", + }); + return; + } try { const state = await this.runtimeSession.getState(); this.state = state; + if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) { + // An interrupt settled this turn while the state check was in flight. + return; + } if (!state.isStreaming && !state.isCompacting) { this.completeTurn(turnId, messages); return; @@ -2187,18 +2231,14 @@ export class OmpAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: OmpRuntimeProviderParams; - private readonly modelRoleParams: OmpModelRoleParams; private readonly subagentCardScheduler?: OmpSubagentCardScheduler; private readonly providerIdleScheduler?: OmpProviderIdleScheduler; private readonly noTurnScheduler?: OmpNoTurnScheduler; private readonly usagePollScheduler?: OmpUsagePollScheduler; - private readonly runtime: OmpRuntime; + private readonly providerIdleDeadlineMs?: number; + private readonly runtime?: OmpRuntime; constructor(options: OmpAgentClientOptions) { - const { runtimeProviderParams, modelRoleParams } = resolveOmpProviderParams( - options.providerParams, - ); const runtimeSettings = mergeOmpRuntimeSettings( { command: { @@ -2210,32 +2250,56 @@ export class OmpAgentClient implements AgentClient { ); this.logger = options.logger; this.runtimeSettings = runtimeSettings; - this.providerParams = runtimeProviderParams; - this.modelRoleParams = modelRoleParams; this.subagentCardScheduler = options.subagentCardScheduler; this.providerIdleScheduler = options.providerIdleScheduler; this.noTurnScheduler = options.noTurnScheduler; this.usagePollScheduler = options.usagePollScheduler; - this.runtime = - options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams); + this.providerIdleDeadlineMs = options.providerIdleDeadlineMs; + this.runtime = options.runtime; } private async configureNativePaseoTools( runtimeSession: OmpRuntimeSession, catalog: PaseoToolCatalog | undefined, - ): Promise { - if (!catalog) { + config: AgentSessionConfig, + launchEnv?: NodeJS.ProcessEnv, + ): Promise { + if (!catalog && !config.mcpServers) { return; } - await setOmpHostTools(runtimeSession, catalog); + const bridge = await OmpMcpBridge.connect(config.mcpServers, config.cwd, this.logger, { + ...process.env, + ...this.runtimeSettings?.env, + ...launchEnv, + }); + const router = new OmpHostToolRouter({ runtimeSession, catalog, bridge, logger: this.logger }); + try { + await router.register(); + return router; + } catch (error) { + await router.close(); + throw error; + } + } + + private async restoreFastMode( + runtimeSession: OmpRuntimeSession, + config: AgentSessionConfig, + state: OmpSessionState, + ): Promise { + const value = config.featureValues?.fast_mode; + if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled) + return state; + const result = await runtimeSession.setFastMode(value); + return { ...state, fastModeEnabled: result.enabled, fastModeActive: result.active }; } async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { - const launchMode = this.resolveLaunchMode(config.modeId); - const runtimeSession = await this.runtime.startSession({ + const launchMode = this.resolveLaunchMode(config.modeId, config.providerOptions); + const startInput: OmpStartSessionInput = { cwd: config.cwd, protocolMode: "rpc-ui", model: config.model, @@ -2245,22 +2309,39 @@ export class OmpAgentClient implements AgentClient { extraArgs: launchMode.extraArgs, systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), env: launchContext?.env, - }); + }; + const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession( + startInput, + ); + let hostTools: OmpHostToolRouter | undefined; try { - await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools); - return new OmpAgentSession({ + hostTools = await this.configureNativePaseoTools( + runtimeSession, + launchContext?.paseoTools, + config, + startInput.env, + ); + const initialState = await this.restoreFastMode( runtimeSession, config, - initialState: await runtimeSession.getState(), + await runtimeSession.getState(), + ); + return new OmpAgentSession({ + runtimeSession, + hostTools, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, + initialState, currentModeId: launchMode.modeId, logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, - paseoTools: launchContext?.paseoTools, + providerIdleDeadlineMs: this.providerIdleDeadlineMs, }); } catch (error) { + await hostTools?.close(); await runtimeSession.close().catch(() => undefined); throw error; } @@ -2270,6 +2351,7 @@ export class OmpAgentClient implements AgentClient { handle: AgentPersistenceHandle, overrides?: Partial, launchContext?: AgentLaunchContext, + options?: AgentResumeSessionOptions, ): Promise { const sessionFile = handle.nativeHandle; if (!sessionFile) { @@ -2279,41 +2361,133 @@ export class OmpAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); - const launchMode = this.resolveLaunchMode(resumeConfig.modeId); - const runtimeSession = await this.runtime.startSession( - buildResumeStartInput({ + if (options?.purpose === "history") { + return new OmpHistorySession( + handle, resumeConfig, sessionFile, - launchContext, - launchMode, - }), + this.provider, + persistenceMetadata.bridgedTools, + ); + } + + const launchMode = this.resolveLaunchMode( + resumeConfig.modeId, + resumeConfig.config.providerOptions, ); + const startInput = buildResumeStartInput({ + resumeConfig, + sessionFile, + launchContext, + launchMode, + }); + const runtimeSession = await this.resolveRuntime( + resumeConfig.config.providerOptions, + ).startSession(startInput); + let hostTools: OmpHostToolRouter | undefined; try { - await this.configureNativePaseoTools(runtimeSession, launchContext?.paseoTools); + hostTools = await this.configureNativePaseoTools( + runtimeSession, + launchContext?.paseoTools, + resumeConfig.config, + startInput.env, + ); + const resumedState = await this.applyResumeModel(runtimeSession, resumeConfig.model); + const config = { + ...resumeConfig.config, + model: modelToId(resumedState.model) ?? resumeConfig.config.model, + }; + const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ runtimeSession, - config: resumeConfig.config, - initialState: await runtimeSession.getState(), + hostTools, + restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), + config, + initialState, currentModeId: launchMode.modeId, logger: this.logger, subagentCardScheduler: this.subagentCardScheduler, providerIdleScheduler: this.providerIdleScheduler, noTurnScheduler: this.noTurnScheduler, usagePollScheduler: this.usagePollScheduler, - paseoTools: launchContext?.paseoTools, + providerIdleDeadlineMs: this.providerIdleDeadlineMs, live: false, }); } catch (error) { + await hostTools?.close(); await runtimeSession.close().catch(() => undefined); throw error; } } + // OMP resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: OmpRuntimeSession, + requestedModel: string | undefined, + ): Promise { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: OmpModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "OMP resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + + private buildRestartRuntime( + startInput: OmpStartSessionInput, + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): OmpAgentSessionOptions["restartRuntime"] { + return async (sessionFile, modeId) => { + const launchMode = this.resolveLaunchMode(modeId, config.providerOptions); + const next = await this.resolveRuntime(config.providerOptions).startSession({ + ...startInput, + model: config.model, + thinkingOptionId: normalizeOmpThinkingOption(config.thinkingOptionId) ?? undefined, + modeId: launchMode.modeId, + extraArgs: launchMode.extraArgs, + ...(!startInput.noSession && sessionFile ? { session: sessionFile } : {}), + }); + let hostTools: OmpHostToolRouter | undefined; + try { + hostTools = await this.configureNativePaseoTools( + next, + launchContext?.paseoTools, + config, + startInput.env, + ); + const state = await next.getState(); + await this.restoreFastMode(next, config, state); + return { runtimeSession: next, hostTools }; + } catch (error) { + await hostTools?.close(); + await next.close().catch(() => undefined); + throw error; + } + }; + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { - const launchMode = this.resolveLaunchMode(undefined); + const launchMode = this.resolveLaunchMode(undefined, options.providerOptions); let runtimeSession: OmpRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2325,7 +2499,7 @@ export class OmpAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await this.resolveRuntime(options.providerOptions).startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, protocolMode: "rpc-ui", modeId: launchMode.modeId, @@ -2359,13 +2533,21 @@ export class OmpAgentClient implements AgentClient { ): Promise { return await listOmpImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: resolveOmpProviderOptions(options?.providerOptions).runtimeOptions.sessionDir, runtimeSettings: this.runtimeSettings, }); } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { - const importConfig = await readOmpImportSessionConfig(input.providerHandleId); + const descriptorOptions = { + sessionDir: resolveOmpProviderOptions(context.config.providerOptions).runtimeOptions + .sessionDir, + runtimeSettings: this.runtimeSettings, + }; + const importConfig = await readOmpImportSessionConfig( + input.providerHandleId, + descriptorOptions, + ); return importSessionFromPersistence({ provider: this.provider, request: input, @@ -2434,11 +2616,19 @@ export class OmpAgentClient implements AgentClient { } } - private resolveLaunchMode(modeId: string | undefined): { + private resolveLaunchMode( + modeId: string | undefined, + providerOptions?: Record, + ): { modeId: string; extraArgs: string[]; } { - return resolveOmpLaunchMode(modeId, this.modelRoleParams); + return resolveOmpLaunchMode(modeId, resolveOmpProviderOptions(providerOptions).modelRoles); + } + + private resolveRuntime(providerOptions: Record | undefined): OmpRuntime { + const { runtimeOptions } = resolveOmpProviderOptions(providerOptions); + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, runtimeOptions); } private async resolveOmpLaunch(): Promise { diff --git a/packages/server/src/server/agent/providers/omp/ask.test.ts b/packages/server/src/server/agent/providers/omp/ask.test.ts new file mode 100644 index 0000000000..7c72004e4a --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/ask.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, test } from "vitest"; + +import { OmpHarness } from "./test-utils/omp-harness.js"; + +function startAsk(omp: OmpHarness, multi: boolean): void { + omp.emit({ + type: "tool_execution_start", + toolCallId: "ask-1", + toolName: "ask", + args: { + questions: [ + { + id: "colors", + question: "Which colors?", + multi, + options: [ + { label: "Red", description: "Warm" }, + { label: "Blue", description: "Cool" }, + ], + }, + ], + }, + }); +} + +function select(omp: OmpHarness, id: string, title: string, options: string[]): void { + omp.emit({ type: "extension_ui_request", id, method: "select", title, options }); +} + +describe("OMP ask RPC UI", () => { + test("recognizes ask arguments from the assistant message before the UI request", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "message_end", + message: { + role: "assistant", + content: [ + { + type: "toolCall", + id: "ask-1", + name: "ask", + arguments: { + questions: [ + { + id: "colors", + question: "Which colors?", + multi: true, + options: [ + { label: "Red", description: "Warm" }, + { label: "Blue", description: "Cool" }, + ], + }, + ], + }, + }, + ], + }, + }); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ + multiSelect: true, + allowOther: true, + }); + startAsk(omp, true); + }); + test("answers the multi-select loop from one Paseo question", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, true); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + + expect(omp.pendingPermissions()).toHaveLength(1); + expect(omp.pendingPermissions()[0]).toMatchObject({ + kind: "question", + input: { + questions: [ + { + question: "Which colors?", + multiSelect: true, + allowOther: true, + options: [{ label: "Red" }, { label: "Blue" }], + }, + ], + }, + }); + await omp.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Blue, Red" } }, + }); + select(omp, "select-2", "(1 selected) Which colors?", [ + "Red", + "Blue", + "✓ Done selecting", + "Other (type your own)", + ]); + select(omp, "select-3", "(2 selected) Which colors?", [ + "Red", + "Blue", + "✔ Done selecting", + "Other (type your own)", + ]); + + expect(omp.pendingPermissions()).toHaveLength(0); + expect(omp.extensionUiResponses()).toEqual([ + { id: "select-1", response: { value: "Blue" } }, + { id: "select-2", response: { value: "Red" } }, + { id: "select-3", response: { value: "✔ Done selecting" } }, + ]); + }); + + test("passes a single-select Other answer through OMP's editor", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, false); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ allowOther: true }); + await omp.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Violet" } }, + }); + omp.emit({ + type: "extension_ui_request", + id: "editor-1", + method: "editor", + title: "Which colors? ○ Red ○ Blue ◉ Other (type your own) Enter your response:", + }); + + expect(omp.pendingPermissions()).toHaveLength(0); + expect(omp.extensionUiResponses()).toEqual([ + { id: "select-1", response: { value: "Other (type your own)" } }, + { id: "editor-1", response: { value: "Violet" } }, + ]); + }); + + test("passes multi-select choices and Other through the same editor flow", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, true); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + await omp.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Red, Violet" } }, + }); + select(omp, "select-2", "(1 selected) Which colors?", [ + "Red", + "Blue", + "✓ Done selecting", + "Other (type your own)", + ]); + omp.emit({ + type: "extension_ui_request", + id: "editor-1", + method: "editor", + title: "(1 selected) Which colors?", + }); + + expect(omp.pendingPermissions()).toHaveLength(0); + expect(omp.extensionUiResponses()).toEqual([ + { id: "select-1", response: { value: "Red" } }, + { id: "select-2", response: { value: "Other (type your own)" } }, + { id: "editor-1", response: { value: "Violet" } }, + ]); + }); + + test("cancels an ask without leaving replay state behind", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, true); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + await omp.respondToPermission("select-1", { behavior: "deny" }); + select(omp, "select-after-cancel", "Another question", ["One", "Two"]); + + expect(omp.extensionUiResponses()).toEqual([{ id: "select-1", response: { cancelled: true } }]); + expect(omp.pendingPermissions()).toHaveLength(1); + expect(omp.pendingPermissions()[0]?.id).toBe("select-after-cancel"); + }); + + test("keeps single choices, tool approvals, and free-form input independent", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, false); + select(omp, "select-1", "Which colors?", ["Red", "Blue", "Other (type your own)"]); + await omp.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Blue" } }, + }); + expect(omp.extensionUiResponses()).toContainEqual({ + id: "select-1", + response: { value: "Blue" }, + }); + + omp.emit({ + type: "tool_execution_end", + toolCallId: "ask-1", + toolName: "ask", + result: "Blue", + isError: false, + }); + omp.requestToolApproval({ id: "approval", tool: "bash", detail: "echo hi" }); + expect(omp.pendingPermissions()[0]).toMatchObject({ id: "approval", kind: "tool" }); + await omp.respondToPermission("approval", { behavior: "allow" }); + expect(omp.extensionUiResponses()).toContainEqual({ + id: "approval", + response: { value: "Approve" }, + }); + + omp.emit({ type: "extension_ui_request", id: "input", method: "input", title: "Type a value" }); + expect(omp.pendingPermissions()[0]).toMatchObject({ id: "input", kind: "question" }); + await omp.respondToPermission("input", { + behavior: "allow", + updatedInput: { answers: { Response: "typed value" } }, + }); + expect(omp.extensionUiResponses()).toContainEqual({ + id: "input", + response: { value: "typed value" }, + }); + }); + + test("recognizes OMP's numbered question title", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, true); + select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]); + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]).toMatchObject({ multiSelect: true }); + }); + + test("keeps descriptions with their labels when a select event reorders rows", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, true); + select(omp, "select-1", "Which colors?", ["Blue", "Red", "Other (type your own)"]); + + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([ + { label: "Blue", description: "Cool" }, + { label: "Red", description: "Warm" }, + ]); + }); + + test("keeps a recommended option's description with its display label", async () => { + const omp = new OmpHarness(); + await omp.start(); + startAsk(omp, false); + select(omp, "select-1", "Which colors?", [ + "Red (Recommended)", + "Blue", + "Other (type your own)", + ]); + expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toEqual([ + { label: "Red (Recommended)", description: "Warm" }, + { label: "Blue", description: "Cool" }, + ]); + }); + + test("cancels cleanly when OMP omits Done selecting in a multi-question ask", async () => { + const omp = new OmpHarness(); + await omp.start(); + omp.emit({ + type: "tool_execution_start", + toolCallId: "ask-1", + toolName: "ask", + args: { + questions: [ + { + id: "colors", + question: "Which colors?", + multi: true, + options: [{ label: "Red" }, { label: "Blue" }], + }, + { + id: "shape", + question: "Which shape?", + options: [{ label: "Round" }, { label: "Square" }], + }, + ], + }, + }); + select(omp, "select-1", "Which colors? (1/2)", ["Red", "Blue", "Other (type your own)"]); + await omp.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "Red" } }, + }); + // OMP's RPC select omits the right-arrow navigation callback and, with + // allowForward true, also omits its Done row on every follow-up request. + select(omp, "select-2", "(1 selected) Which colors? (1/2)", [ + "Red", + "Blue", + "Other (type your own)", + ]); + + expect(omp.extensionUiResponses()).toEqual([ + { id: "select-1", response: { value: "Red" } }, + { id: "select-2", response: { cancelled: true } }, + ]); + expect(omp.pendingPermissions()).toHaveLength(0); + omp.emit({ + type: "tool_execution_end", + toolCallId: "ask-1", + toolName: "ask", + result: "Ask tool was cancelled by the user", + isError: true, + }); + expect(omp.timeline()).toContainEqual( + expect.objectContaining({ + type: "tool_call", + name: "ask", + status: "failed", + }), + ); + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index 9df9da68dd..2deae45850 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -66,10 +66,7 @@ function createRuntime( }); } -function replyToCommands( - child: OmpChild, - handler: (command: Record) => unknown, -): void { +function onOmpCommand(child: OmpChild, handler: (command: Record) => void): void { let buffer = ""; child.stdin.on("data", (chunk) => { buffer += chunk.toString(); @@ -78,17 +75,42 @@ function replyToCommands( if (newlineIndex === -1) break; const line = buffer.slice(0, newlineIndex); buffer = buffer.slice(newlineIndex + 1); - const command = JSON.parse(line) as Record; - const result = handler(command); - child.stdout.write( - `${JSON.stringify({ - id: command.id, - type: "response", - command: command.type, - success: true, - data: result, - })}\n`, - ); + handler(JSON.parse(line) as Record); + } + }); +} + +function replyToCommands( + child: OmpChild, + handler: (command: Record) => unknown, +): void { + onOmpCommand(child, (command) => { + const result = handler(command); + child.stdout.write( + `${JSON.stringify({ + id: command.id, + type: "response", + command: command.type, + success: true, + data: result, + })}\n`, + ); + }); +} + +function capturePendingCommand(child: OmpChild, type: string): Promise> { + return new Promise((resolve) => { + onOmpCommand(child, (command) => { + if (command.type === type) resolve(command); + }); + }); +} + +/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */ +function exitOnCommand(child: OmpChild, type: string): void { + onOmpCommand(child, (command) => { + if (command.type === type) { + child.emit("exit", 1, null); } }); } @@ -99,6 +121,28 @@ function withoutRequestId(command: Record): Record { + test("steer waits for OMP's response and surfaces rejection", async () => { + const child = createOmpChild(); + let pending: Record | null = null; + onOmpCommand(child, (command) => { + if (command.type === "steer") pending = command; + }); + const launches: OmpRuntimeLaunch[] = []; + const session = await createRuntime(child, launches).startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/omp-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/omp-home"); + const result = session.steer("change direction"); + await new Promise((resolve) => setImmediate(resolve)); + expect(pending).toMatchObject({ type: "steer", message: "change direction" }); + child.stdout.write( + `${JSON.stringify({ id: pending!.id, type: "response", command: "steer", success: false, error: "rejected" })}\n`, + ); + await expect(result).rejects.toThrow("rejected"); + await session.close(); + }); test("uses the configured RPC timeout and attributes the pending phase", async () => { vi.useFakeTimers(); const child = createOmpChild(); @@ -183,6 +227,95 @@ describe("OMP CLI runtime", () => { expect(eventTypes).toEqual(["notice"]); }); + test("emits agent_end for a run that contains an OMP developer message", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule. + const reminder = { + role: "developer", + content: [ + { type: "text", text: '…' }, + ], + attribution: "agent", + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 }, + reminder, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 3, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + + test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const eventTypes: string[] = []; + session.onEvent((event) => eventTypes.push(event.type)); + + // A prompt that @-mentions a file makes OMP add a fileMention message to the run. + const fileMention = { + role: "fileMention", + files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }], + timestamp: 2, + }; + child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`); + child.stdout.write( + `${JSON.stringify({ + type: "agent_end", + messages: [ + { role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 }, + fileMention, + { + role: "pythonExecution", + code: "print(1)", + output: "1\n", + cancelled: false, + truncated: false, + timestamp: 3, + }, + { + role: "hookMessage", + customType: "hook", + content: "hook output", + display: false, + timestamp: 4, + }, + { role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 }, + { + role: "compactionSummary", + summary: "Earlier turns", + tokensBefore: 1000, + timestamp: 6, + }, + { + role: "assistant", + content: [{ type: "text", text: "Done" }], + stopReason: "stop", + timestamp: 7, + }, + ], + })}\n`, + ); + + expect(eventTypes).toEqual(["message_end", "agent_end"]); + }); + test("lists commands through get_available_commands", async () => { const child = createOmpChild(); const commandTypes: string[] = []; @@ -279,6 +412,27 @@ describe("OMP CLI runtime", () => { ]); }); + test("compact waits beyond the control-plane timeout for a late response", async () => { + vi.useFakeTimers(); + const child = createOmpChild(); + const pending = capturePendingCommand(child, "compact"); + const session = await createRuntime(child, [], { requestTimeoutMs: 100 }).startSession({ + cwd: "/workspace/project", + }); + try { + const compact = session.compact("focus on tests"); + const command = await pending; + await vi.advanceTimersByTimeAsync(101); + child.stdout.write( + `${JSON.stringify({ id: command.id, type: "response", command: "compact", success: true, data: {} })}\n`, + ); + await expect(compact).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + await session.close(); + } + }); + test("accepts the empty prompt acknowledgement emitted by OMP 17", async () => { const child = createOmpChild(); replyToCommands(child, () => undefined); @@ -287,6 +441,31 @@ describe("OMP CLI runtime", () => { await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); }); + test("reports OMP 18.3's late prompt rejection as a failed prompt result", async () => { + const child = createOmpChild(); + onOmpCommand(child, (command) => { + if (command.type !== "prompt") return; + const response = { id: command.id, type: "response", command: "prompt" }; + const rejection = { ...response, success: false, error: "No API key found for anthropic." }; + child.stdout.write( + `${JSON.stringify({ ...response, success: true })}\n${JSON.stringify(rejection)}\n`, + ); + }); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + const events: unknown[] = []; + session.onEvent((event) => events.push(event)); + + await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" }); + + expect(events).toContainEqual({ + type: "prompt_result", + id: "req_1", + agentInvoked: false, + status: "error", + error: { message: "No API key found for anthropic." }, + }); + }); + test("negotiates RPC protocol v2 when OMP advertises it", async () => { const child = createOmpChild({ supportedProtocolVersions: [1, 2] }); const commands: Record[] = []; @@ -418,4 +597,27 @@ describe("OMP CLI runtime", () => { expect(result).toHaveLength(2_000); expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" })); }); + + // A dead runtime owns no turn, so aborting it is already satisfied. Rejecting here + // makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which + // pins the agent at `running` until the daemon restarts. See issue #3749. + test("abort resolves once the OMP process has exited", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + const environment = session.environment; + child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); + + await expect(session.abort()).resolves.toBeUndefined(); + }); + + test("abort resolves when the OMP process exits while the abort is in flight", async () => { + const child = createOmpChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + exitOnCommand(child, "abort"); + + await expect(session.abort()).resolves.toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.ts index 2ec98fb9b6..75bd79f5f0 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.ts @@ -1,8 +1,14 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; +import { z } from "zod"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; -import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js"; +import { + JSONL_RPC_NO_TIMEOUT, + JsonlRpcProcess, + type JsonlRpcLaunch, +} from "../jsonl-rpc-process.js"; import { establishOmpProtocol } from "./protocol-session.js"; import { buildOmpLaunch, @@ -21,7 +27,7 @@ import { OmpModelsResultSchema, OmpPromptAckSchema, OmpRpcCommandSchema, - OmpRuntimeEventSchema, + parseOmpRuntimeEvent, OmpSessionStateSchema, OmpSessionStatsSchema, type OmpThinkingLevel, @@ -69,6 +75,7 @@ export class OmpCliRuntime implements OmpRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -93,7 +100,7 @@ export class OmpCliRuntime implements OmpRuntime { requestTimeoutMs: this.options.requestTimeoutMs, }); input.signal?.throwIfAborted(); - return new OmpCliRuntimeSession(process, this.commandsRpcName); + return new OmpCliRuntimeSession(process, this.commandsRpcName, launch.env); } catch (error) { const startupError = error instanceof Error ? error : new Error(String(error)); await process.close(startupError); @@ -111,11 +118,12 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: "get_available_commands", + private readonly launchEnvironment: Record, ) { process.onMessage((message) => { - const event = OmpRuntimeEventSchema.safeParse(message); - if (event.success) { - this.emit(event.data); + const event = parseOmpRuntimeEvent(message); + if (event) { + this.emit(event); } }); process.onExit(({ error }) => { @@ -123,6 +131,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { }); } + get environment(): Record { + return this.launchEnvironment; + } + onEvent(callback: (event: OmpRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -144,10 +156,13 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { } async compact(customInstructions?: string): Promise { - await this.request({ - type: "compact", - ...(customInstructions ? { customInstructions } : {}), - }); + await this.request( + { + type: "compact", + ...(customInstructions ? { customInstructions } : {}), + }, + JSONL_RPC_NO_TIMEOUT, + ); } async setAutoCompaction(enabled: boolean): Promise { @@ -155,13 +170,18 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { } async abort(): Promise { - await this.request({ type: "abort" }); + await this.requestStopWork({ type: "abort" }); } async getState(): Promise { return OmpSessionStateSchema.parse(await this.request({ type: "get_state" })); } + async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> { + const result = await this.request({ type: "set_fast_mode", enabled }); + return z.object({ enabled: z.boolean(), active: z.boolean() }).parse(result); + } + async getMessages(): Promise { const data = OmpMessagesResultSchema.parse(await this.request({ type: "get_messages" })); return data.messages ?? []; @@ -255,8 +275,11 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { return data.messages ?? []; } - steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void { - this.process.send({ type: "steer", message, ...(images?.length ? { images } : {}) }); + async steer( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise { + await this.request({ type: "steer", message, ...(images?.length ? { images } : {}) }); } followUp( @@ -292,6 +315,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession { return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs); } + private requestStopWork(command: OmpRpcCommand): Promise { + return this.process.requestStopWork(OmpRpcCommandSchema.parse(command)); + } + private emit(event: OmpRuntimeEvent): void { for (const subscriber of this.subscribers) { subscriber(event); diff --git a/packages/server/src/server/agent/providers/omp/custom-message.ts b/packages/server/src/server/agent/providers/omp/custom-message.ts index e84095b4f0..4a85b198e7 100644 --- a/packages/server/src/server/agent/providers/omp/custom-message.ts +++ b/packages/server/src/server/agent/providers/omp/custom-message.ts @@ -5,3 +5,30 @@ type OmpCustomMessage = Extract; export function shouldDisplayOmpCustomMessage(message: OmpCustomMessage): boolean { return Reflect.get(message, "display") !== false; } + +// injected rows need their own message id so the stream coalescer never glues them +// onto the assistant reply they arrive next to +export function ompCustomMessageId(message: OmpCustomMessage, nextIndex: () => number): string { + const id = Reflect.get(message, "id"); + return `omp-custom-${typeof id === "string" && id ? id : nextIndex()}`; +} + +// omp does not persist the typed /skill: as a user message, only the expansion +// it injected on the user's behalf; rebuild the bubble from the expansion's details +export function ompSkillPromptUserText(message: OmpCustomMessage): string | null { + if ( + Reflect.get(message, "customType") !== "skill-prompt" || + Reflect.get(message, "attribution") !== "user" + ) { + return null; + } + const details = Reflect.get(message, "details"); + const name = details && typeof details === "object" ? Reflect.get(details, "name") : undefined; + if (typeof name !== "string" || !name) { + return null; + } + const args = Reflect.get(details as object, "args"); + return typeof args === "string" && args.trim() + ? `/skill:${name} ${args.trim()}` + : `/skill:${name}`; +} diff --git a/packages/server/src/server/agent/providers/omp/event-mapper.test.ts b/packages/server/src/server/agent/providers/omp/event-mapper.test.ts index 4c53ee9c0d..2959969e15 100644 --- a/packages/server/src/server/agent/providers/omp/event-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/event-mapper.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; describe("OMP runtime event mapper", () => { - test("maps notice events to timeline status lines", () => { + test("maps notice events to timeline notifications", () => { const event = { type: "notice", level: "warning", @@ -14,23 +14,9 @@ describe("OMP runtime event mapper", () => { expect(mapOmpRuntimeEventToTimelineItem(event)).toMatchObject({ handled: true, item: { - type: "tool_call", - callId: expect.stringMatching(/^omp-notice:/), - name: "omp_notice", - status: "completed", - error: null, - detail: { - type: "plain_text", - label: "OMP warning notice from anthropic", - text: "Provider quota is getting low", - icon: "sparkles", - }, - metadata: { - synthetic: true, - source: "omp_notice", - level: "warning", - eventSource: "anthropic", - }, + type: "notification", + level: "warning", + message: "Provider quota is getting low", }, }); }); diff --git a/packages/server/src/server/agent/providers/omp/event-mapper.ts b/packages/server/src/server/agent/providers/omp/event-mapper.ts index 83fbf95937..cec883dc69 100644 --- a/packages/server/src/server/agent/providers/omp/event-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/event-mapper.ts @@ -30,7 +30,7 @@ export type OmpRuntimeEventMapping = logReason: string; }; -interface StatusLineNoticeInput { +interface StatusLineInput { callId: string; name: string; label: string; @@ -70,26 +70,13 @@ function mapNoticeEvent(event: unknown): OmpRuntimeEventMapping { if (!parsed.success) { return { handled: true, item: null, logReason: "malformed_omp_notice" }; } - const label = parsed.data.source - ? `OMP ${parsed.data.level} notice from ${parsed.data.source}` - : `OMP ${parsed.data.level} notice`; return { handled: true, - item: buildStatusLineNotice({ - callId: `omp-notice:${hashParts(parsed.data.level, parsed.data.source ?? "", parsed.data.message)}`, - name: "omp_notice", - label, - text: parsed.data.message, - icon: "sparkles", - status: parsed.data.level === "error" ? "failed" : "completed", - error: parsed.data.level === "error" ? parsed.data.message : undefined, - metadata: { - synthetic: true, - source: "omp_notice", - level: parsed.data.level, - ...(parsed.data.source ? { eventSource: parsed.data.source } : {}), - }, - }), + item: { + type: "notification", + level: parsed.data.level, + message: parsed.data.message, + }, }; } @@ -103,7 +90,7 @@ function mapGoalUpdatedEvent(event: unknown): OmpRuntimeEventMapping { const text = formatGoalText(parsed.data, goal); return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-goal:${goal?.id ?? hashParts(text)}`, name: "omp_goal_updated", label, @@ -127,7 +114,7 @@ function mapAutoRetryStartEvent(event: unknown): OmpRuntimeEventMapping { const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-auto-retry:${data.attempt}`, name: "omp_auto_retry", label: `OMP retry ${data.attempt}/${data.maxAttempts}`, @@ -155,7 +142,7 @@ function mapAutoRetryEndEvent(event: unknown): OmpRuntimeEventMapping { const status = data.success ? "completed" : "failed"; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-auto-retry:${data.attempt}`, name: "omp_auto_retry", label: data.success @@ -183,7 +170,7 @@ function mapRetryFallbackAppliedEvent(event: unknown): OmpRuntimeEventMapping { const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-retry-fallback:${hashParts(data.role, data.from, data.to)}`, name: "omp_retry_fallback", label: `OMP fallback applied for ${data.role}`, @@ -208,7 +195,7 @@ function mapRetryFallbackSucceededEvent(event: unknown): OmpRuntimeEventMapping const data = parsed.data; return { handled: true, - item: buildStatusLineNotice({ + item: buildStatusLineItem({ callId: `omp-retry-fallback-succeeded:${hashParts(data.role, data.model)}`, name: "omp_retry_fallback", label: `OMP fallback succeeded for ${data.role}`, @@ -257,7 +244,7 @@ function mapAutoCompactionEndEvent(event: unknown): OmpRuntimeEventMapping { }; } -function buildStatusLineNotice(input: StatusLineNoticeInput): OmpTelemetryToolCallItem { +function buildStatusLineItem(input: StatusLineInput): OmpTelemetryToolCallItem { const base = { type: "tool_call" as const, callId: input.callId, diff --git a/packages/server/src/server/agent/providers/omp/history-hooks.ts b/packages/server/src/server/agent/providers/omp/history-hooks.ts index 75b9505c3f..860d64443b 100644 --- a/packages/server/src/server/agent/providers/omp/history-hooks.ts +++ b/packages/server/src/server/agent/providers/omp/history-hooks.ts @@ -1,6 +1,6 @@ import type { OmpHistoryMapperHooks } from "./message-history.js"; import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; -import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { mapOmpSystemNoticeToNotification } from "./system-notice.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { resolveOmpEmittedToolCallId } from "./tool-call-id.js"; @@ -8,7 +8,7 @@ export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = { mapToolDetail: mapOmpToolDetail, mapCustomMessage: (message, text, provider) => { const item = - mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToToolCall(text); + mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToNotification(text); return item ? { type: "timeline", provider, item } : null; }, resolveToolCallId: resolveOmpEmittedToolCallId, diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index db67f0cbe3..e64a93fd64 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -8,7 +8,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag import type { OmpAgentMessage } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js"; -import { streamOmpHistory } from "./history.js"; +import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js"; async function collectHistory( messages: OmpAgentMessage[], @@ -27,6 +27,315 @@ async function collectHistory( } describe("OMP history mapper", () => { + test("renders visible custom messages as completed tools with their type and content", async () => { + const events = await collectHistory([ + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + { + role: "custom", + customType: "private-context", + content: "Hidden instructions", + display: false, + }, + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + ]); + expect(events.map((event) => event.item)).toEqual( + [1, 2].map((index) => ({ + type: "tool_call", + callId: `omp-custom-${index}`, + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { synthetic: true, customType: "project-context" }, + error: null, + })), + ); + }); + + test("replays a web search details error as failed when OMP sets isError false", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [ + { type: "toolCall", id: "web-1", name: "web_search", arguments: { query: "Paseo" } }, + ], + }, + { + role: "toolResult", + toolCallId: "web-1", + toolName: "web_search", + content: [{ type: "text", text: "Error: All web search providers failed" }], + details: { + error: "All web search providers failed", + response: { provider: "none", sources: [] }, + }, + isError: false, + }, + ]); + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + status: "failed", + error: "All web search providers failed", + }); + }); + + test("replays a Paseo browser tool result whose details error is an object", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "shot-1", name: "browser_screenshot", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "shot-1", + toolName: "browser_screenshot", + content: [{ type: "text", text: "The tab has not painted yet. Retry the screenshot." }], + details: { + ok: false, + error: { + code: "screenshot_no_frame", + message: "The tab has not painted yet. Retry the screenshot.", + retryable: true, + }, + }, + isError: false, + }, + { role: "assistant", content: [{ type: "text", text: "Retrying later." }] }, + ]); + + expect(events.map((event) => event.item)).toEqual([ + expect.objectContaining({ type: "tool_call", callId: "shot-1", status: "running" }), + expect.objectContaining({ + type: "tool_call", + callId: "shot-1", + name: "browser_screenshot", + detail: expect.objectContaining({ + type: "unknown", + output: expect.objectContaining({ + details: expect.objectContaining({ + error: expect.objectContaining({ code: "screenshot_no_frame", retryable: true }), + }), + }), + }), + }), + expect.objectContaining({ type: "assistant_message", text: "Retrying later." }), + ]); + }); + + test("reports a failed tool's structured details error by its message", async () => { + const events = await collectHistory([ + { + role: "assistant", + content: [{ type: "toolCall", id: "tabs-1", name: "browser_list_tabs", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "tabs-1", + toolName: "browser_list_tabs", + content: [], + details: { + ok: false, + error: { + code: "browser_no_host", + message: "No browser automation host is connected.", + retryable: true, + }, + }, + isError: true, + }, + ]); + + expect(events.at(-1)?.item).toMatchObject({ + type: "tool_call", + status: "failed", + error: "No browser automation host is connected.", + }); + }); + + test("restores blocked and abandoned todo state from a session file", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-todo-state-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "todos", + parentId: "root", + message: { + role: "toolResult", + toolName: "todo", + details: { + phases: [ + { + name: "Tasks", + tasks: [ + { content: "Wait for approval", status: "blocked", blocker: "review" }, + { content: "Old route", status: "abandoned" }, + ], + }, + ], + }, + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + expect(await readOmpHistoryTodoState(sessionFile)).toEqual({ + type: "todo", + items: [{ text: "Wait for approval (blocked: review)", status: "pending", completed: false }], + }); + }); + + test("hides persisted developer reminders and shows other developer messages", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-developer-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "reminder", + parentId: "root", + message: { + role: "developer", + attribution: "agent", + content: [ + { + type: "text", + text: "\nContinue unfinished tasks\n", + }, + ], + }, + }, + { + type: "message", + id: "other", + parentId: "reminder", + message: { + role: "developer", + content: [{ type: "text", text: "External instruction" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) + events.push(event); + expect(events.map((event) => event.item)).toEqual([ + { + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "[developer] External instruction", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, + ]); + }); + + test("replays a rule-violation run with the same rows the live timeline showed", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-rule-reminder-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root" }, + { + type: "message", + id: "prompt", + parentId: "root", + message: { + role: "user", + content: "Read @notes.md, then write tiny.ts", + }, + }, + { + type: "message", + id: "mention", + parentId: "prompt", + message: { + role: "fileMention", + files: [ + { + path: "notes.md", + content: "[notes.md#468D]\n1:# notes", + lineCount: 1, + }, + ], + }, + }, + { + type: "ttsr_injection", + id: "injection", + parentId: "mention", + injectedRules: ["ts-no-tiny-functions"], + }, + { + type: "message", + id: "rule-reminder", + parentId: "injection", + message: { + role: "developer", + content: [ + { + type: "text", + text: '\nAvoid tiny functions.\n', + }, + ], + }, + }, + { + type: "message", + id: "answer", + parentId: "rule-reminder", + message: { + role: "assistant", + content: [{ type: "text", text: "FINAL_ANSWER" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ + sessionFile, + provider: "omp", + })) + events.push(event); + expect(events.map((event) => event.item)).toEqual([ + { + type: "user_message", + text: "Read @notes.md, then write tiny.ts", + messageId: "prompt", + }, + { + type: "assistant_message", + text: "FINAL_ANSWER", + messageId: "omp-history-assistant-1", + }, + ]); + }); + test("coalesces replayed subagent poll calls by target set", async () => { const events = await collectHistory([ { @@ -88,7 +397,7 @@ describe("OMP history mapper", () => { ]); }); - test("absorbs replayed omp system-notice custom messages as synthetic tool calls", async () => { + test("maps replayed OMP system-notice custom messages to notifications", async () => { const notice = [ "", "Background job DocsSmokeTwo has completed. Resume your work using the result below.", @@ -124,24 +433,9 @@ describe("OMP history mapper", () => { type: "timeline", provider: "omp", item: { - type: "tool_call", - callId: "omp-notice:DocsSmokeTwo", - name: "task_notification", - status: "completed", - detail: { - type: "plain_text", - label: "Background job DocsSmokeTwo completed", - text: notice, - icon: "wrench", - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - taskId: "DocsSmokeTwo", - subagentType: "explore", - status: "completed", - }, - error: null, + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }, }, { @@ -238,12 +532,28 @@ describe("OMP history mapper", () => { { type: "timeline", provider: "omp", - item: { type: "assistant_message", text: "visible explicit custom" }, + item: { + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "visible explicit custom" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, { type: "timeline", provider: "omp", - item: { type: "assistant_message", text: "visible legacy custom" }, + item: { + type: "tool_call", + callId: "omp-custom-2", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "visible legacy custom" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, { type: "timeline", @@ -381,6 +691,17 @@ describe("OMP history mapper", () => { }, }, }); + // The running row must share the completed row's detail type, or the timeline merge keeps + // the running detail and drops the result. + expect( + events.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.callId === "xd-write-call" + ? [event.item.detail.type] + : [], + ), + ).toEqual(["plain_text", "unknown"]); }); test("maps only the active JSONL chain with native user ids and visible unknown roles", async () => { @@ -462,8 +783,27 @@ describe("OMP history mapper", () => { } expect(events.map((event) => event.item)).toEqual([ { type: "user_message", text: "active branch", messageId: "user-active" }, - { type: "assistant_message", text: "[future_control] Unsupported history record" }, - { type: "assistant_message", text: "[developer] developer note" }, + { + type: "tool_call", + callId: "omp-custom-1", + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "[future_control] Unsupported history record", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, + { + type: "tool_call", + callId: "omp-custom-2", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "[developer] developer note" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, ]); const omp = new FakeOmp(); @@ -489,6 +829,266 @@ describe("OMP history mapper", () => { ]); }); + test("replays a compaction entry as a completed compaction row", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-compaction-history-")); + const sessionFile = join(dir, "session.jsonl"); + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Before." }], + responseId: "resp-1", + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "answer-1", + timestamp: "2026-10-05T04:31:19.537Z", + summary: "## Goal\nSummary", + shortSummary: "Summary", + firstKeptEntryId: "answer-1", + tokensBefore: 77611, + tokensAfter: 37526, + method: "snapcompact", + details: { readFiles: [], modifiedFiles: [] }, + fromExtension: false, + }, + { + type: "message", + id: "answer-2", + parentId: "compaction-1", + message: { + role: "assistant", + content: [{ type: "text", text: "After." }], + responseId: "resp-2", + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Before.", messageId: "resp-1" }, + { type: "compaction", status: "completed", preTokens: 77611 }, + { type: "assistant_message", text: "After.", messageId: "resp-2" }, + ]); + expect(events[1]).toMatchObject({ type: "timeline", timestamp: "2026-10-05T04:31:19.537Z" }); + }); + + test("maps omp 18.1 custom_message entries like live custom messages", async () => { + const dir = mkdtempSync(join(tmpdir(), "omp-custom-message-history-")); + const sessionFile = join(dir, "session.jsonl"); + const skillPrompt = + '[IMPORTANT: User invoked the "commit" skill; follow its instructions. Full skill below.]\n\n# Commit'; + const ircMessage = "\nworker-1\nready for review\n"; + writeFileSync( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "answer-1", + parentId: "root", + message: { + role: "assistant", + content: [{ type: "text", text: "Done." }], + responseId: "resp-1", + }, + }, + { + type: "custom_message", + customType: "skill-prompt", + content: skillPrompt, + display: true, + details: { + name: "commit", + path: "/home/me/.agents/skills/commit/SKILL.md", + lineCount: 12, + }, + attribution: "user", + id: "skill-1", + parentId: "answer-1", + timestamp: "2026-09-13T13:11:35.811Z", + }, + { + type: "custom_message", + customType: "irc:incoming", + content: ircMessage, + display: true, + details: { from: "worker-1", message: "ready for review" }, + attribution: "user", + id: "irc-1", + parentId: "skill-1", + timestamp: "2026-09-13T13:11:36.811Z", + }, + { + type: "custom_message", + customType: "hidden-reminder", + content: "must stay hidden", + display: false, + attribution: "user", + id: "hidden-1", + parentId: "irc-1", + timestamp: "2026-09-13T13:11:37.811Z", + }, + { + type: "custom_message", + customType: "legacy-no-display", + content: "visible without display flag", + attribution: "user", + id: "legacy-1", + parentId: "hidden-1", + timestamp: "2026-09-13T13:11:38.811Z", + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + + const events: AgentStreamEvent[] = []; + for await (const event of streamOmpHistory({ sessionFile, provider: "omp" })) { + events.push(event); + } + expect(events.map((event) => event.item)).toEqual([ + { type: "assistant_message", text: "Done.", messageId: "resp-1" }, + { type: "user_message", text: "/skill:commit", messageId: "omp-custom-skill-1-user" }, + { + type: "tool_call", + callId: "omp-custom-irc-1", + name: "irc:incoming", + status: "completed", + detail: { type: "plain_text", text: ircMessage }, + metadata: { + synthetic: true, + customType: "irc:incoming", + details: { from: "worker-1", message: "ready for review" }, + }, + error: null, + }, + { + type: "tool_call", + callId: "omp-custom-legacy-1", + name: "legacy-no-display", + status: "completed", + detail: { + type: "plain_text", + text: "visible without display flag", + }, + metadata: { synthetic: true, customType: "legacy-no-display" }, + error: null, + }, + ]); + }); + + test("synthesises the typed /skill bubble only for user-attributed skill prompts", async () => { + await expect( + collectHistory([ + { + role: "custom", + content: '[IMPORTANT: User invoked the "improve" skill; follow its instructions.]', + customType: "skill-prompt", + display: true, + details: { + name: "improve", + path: "/home/me/.agents/skills/improve/SKILL.md", + lineCount: 9, + args: "tests", + }, + attribution: "user", + id: "skill-args", + }, + { + role: "custom", + content: '[IMPORTANT: Agent invoked the "improve" skill.]', + customType: "skill-prompt", + display: true, + details: { + name: "improve", + path: "/home/me/.agents/skills/improve/SKILL.md", + lineCount: 9, + }, + attribution: "agent", + id: "skill-agent", + }, + { + role: "custom", + content: "\nworker-1\nhi\n", + customType: "irc:incoming", + display: true, + details: { from: "worker-1", message: "hi" }, + attribution: "user", + id: "irc-user", + }, + ]), + ).resolves.toEqual([ + { + type: "timeline", + provider: "omp", + item: { + type: "user_message", + text: "/skill:improve tests", + messageId: "omp-custom-skill-args-user", + }, + }, + { + type: "timeline", + provider: "omp", + item: { + type: "tool_call", + callId: "omp-custom-skill-agent", + name: "skill-prompt", + status: "completed", + detail: { + type: "plain_text", + text: '[IMPORTANT: Agent invoked the "improve" skill.]', + }, + metadata: { + synthetic: true, + customType: "skill-prompt", + details: { + name: "improve", + path: "/home/me/.agents/skills/improve/SKILL.md", + lineCount: 9, + }, + }, + error: null, + }, + }, + { + type: "timeline", + provider: "omp", + item: { + type: "tool_call", + callId: "omp-custom-irc-user", + name: "irc:incoming", + status: "completed", + detail: { + type: "plain_text", + text: "\nworker-1\nhi\n", + }, + metadata: { + synthetic: true, + customType: "irc:incoming", + details: { from: "worker-1", message: "hi" }, + }, + error: null, + }, + }, + ]); + }); + test("rehydrates structured batch and nested task transcripts with stable status and time", async () => { const dir = mkdtempSync(join(tmpdir(), "omp-subagent-history-")); const parentFile = join(dir, "parent.jsonl"); diff --git a/packages/server/src/server/agent/providers/omp/history.ts b/packages/server/src/server/agent/providers/omp/history.ts index 7147e35e59..2c818dfc5a 100644 --- a/packages/server/src/server/agent/providers/omp/history.ts +++ b/packages/server/src/server/agent/providers/omp/history.ts @@ -1,12 +1,15 @@ import { readFile } from "node:fs/promises"; import { basename, extname, join } from "node:path"; -import type { AgentProvider, AgentStreamEvent } from "../../agent-sdk-types.js"; +import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js"; import { OmpHistoryMapper, type OmpCapturedUserMessageEntry } from "./message-history.js"; import type { OmpAgentMessage } from "./rpc-types.js"; import type { OmpRuntimeSession } from "./runtime.js"; import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js"; +import type { OmpBridgedToolIdentity } from "./mcp-bridge.js"; import { formatOmpSubagentTitle } from "./subagent-title.js"; +import { mapOmpTodoPhases } from "./todo-mapper.js"; +import { OmpTodoPhaseSchema } from "./rpc-types.js"; interface OmpSessionEntry { type?: string; @@ -17,6 +20,28 @@ interface OmpSessionEntry { [key: string]: unknown; } +type OmpReplayEntry = + | { entry: OmpSessionEntry; message: OmpAgentMessage } + | { entry: OmpSessionEntry; item: AgentTimelineItem }; + +export async function readOmpHistoryTodoState( + sessionFile: string, +): Promise { + const entries = await readActiveOmpEntryChain(sessionFile).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + }); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const message = entries[index]?.message; + if (message?.role !== "toolResult" || message.toolName !== "todo") continue; + const details = message.details; + if (!details || typeof details !== "object") return null; + const phases = OmpTodoPhaseSchema.array().safeParse(Reflect.get(details, "phases")); + return phases.success ? mapOmpTodoPhases(phases.data) : null; + } + return null; +} + function extractOmpSubagentModel(entries: readonly OmpSessionEntry[]): string | null { let resolvedModel: string | null = null; for (const entry of entries) { @@ -45,6 +70,7 @@ export async function* streamOmpHistory(input: { sessionFile?: string; runtimeSession?: OmpRuntimeSession; provider: AgentProvider; + bridgedTools?: ReadonlyMap; visitedSessionFiles?: Set; }): AsyncGenerator { if (!input.sessionFile) { @@ -68,21 +94,34 @@ export async function* streamOmpHistory(input: { throw error; } const messages: OmpAgentMessage[] = []; - const messageEntries: OmpSessionEntry[] = []; + const replayEntries: OmpReplayEntry[] = []; const userEntries: OmpCapturedUserMessageEntry[] = []; for (const entry of entries) { + if (entry.type === "compaction") { + replayEntries.push({ entry, item: mapCompactionEntry(entry) }); + continue; + } const mapped = mapEntryMessage(entry); if (!mapped) continue; messages.push(mapped); - messageEntries.push(entry); + replayEntries.push({ entry, message: mapped }); if (mapped.role === "user" && entry.id) { userEntries.push({ id: entry.id, text: textOf(mapped.content) }); } } - const mapper = new OmpHistoryMapper(input.provider, userEntries, OMP_HISTORY_MAPPER_HOOKS); - for (let index = 0; index < messages.length; index += 1) { - const timestamp = normalizeProviderReplayTimestamp(messageEntries[index]?.timestamp); - for (const event of mapper.mapMessages([messages[index]!])) { + const mapper = new OmpHistoryMapper( + input.provider, + userEntries, + OMP_HISTORY_MAPPER_HOOKS, + input.bridgedTools, + ); + for (const replayEntry of replayEntries) { + const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp); + const events: AgentStreamEvent[] = + "item" in replayEntry + ? [{ type: "timeline", provider: input.provider, item: replayEntry.item }] + : mapper.mapMessages([replayEntry.message]); + for (const event of events) { yield timestamp && event.type === "timeline" ? { ...event, timestamp } : event; } } @@ -326,17 +365,81 @@ function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null { if (message.role === "system") { return null; } + if (message.role === "developer") { + const content = message.content; + if ( + Array.isArray(content) && + content.length === 1 && + content[0]?.type === "text" && + typeof content[0].text === "string" && + isSystemReminder(content[0].text) + ) { + return null; + } + return visibleFallback(message.role, message); + } if (["user", "assistant", "toolResult", "custom", "bashExecution"].includes(message.role)) { return message as unknown as OmpAgentMessage; } + // OMP roles the live timeline does not render either, such as the fileMention that carries + // an @-mentioned file's content. + if ( + [ + "pythonExecution", + "hookMessage", + "branchSummary", + "compactionSummary", + "fileMention", + ].includes(message.role) + ) { + return null; + } return visibleFallback(message.role, message); } + if (entry.type === "custom_message") { + return mapCustomMessageEntry(entry); + } if (!entry.type || isControlEntryType(entry.type)) { return null; } return visibleFallback(entry.type, entry); } +// OMP persists a compaction as a top-level entry; the live timeline shows it as a compaction row. +// The entry does not record whether a manual /compact or auto-compaction produced it. +function mapCompactionEntry(entry: OmpSessionEntry): AgentTimelineItem { + return { + type: "compaction", + status: "completed", + ...(typeof entry.tokensBefore === "number" ? { preTokens: entry.tokensBefore } : {}), + }; +} + +// omp 18.1+ persists injected rows (skill prompts, hub messages, job notices) as top-level +// custom_message entries without a message object; replay them as live custom messages +function mapCustomMessageEntry(entry: OmpSessionEntry): OmpAgentMessage | null { + const content = entry.content; + if (typeof content !== "string" && !Array.isArray(content)) { + return null; + } + return { + role: "custom", + content, + id: entry.id, + customType: entry.customType, + display: entry.display, + details: entry.details, + attribution: entry.attribution, + } as OmpAgentMessage; +} + +// OMP's rule-violation reminder carries attributes: +function isSystemReminder(text: string): boolean { + return ( + /^]/.test(text.trimStart()) && text.trimEnd().endsWith("") + ); +} + function isControlEntryType(type: string): boolean { return ( type === "session" || @@ -348,6 +451,7 @@ function isControlEntryType(type: string): boolean { type === "system_prompt" || type === "model_change" || type === "thinking_level_change" || + type === "ttsr_injection" || type === "tool_execution" || type.startsWith("tool_execution_") ); @@ -357,6 +461,8 @@ function visibleFallback(role: string, value: Record): OmpAgent let text = "Unsupported history record"; if (typeof value.content === "string") { text = value.content; + } else if (Array.isArray(value.content)) { + text = textOf(value.content) || text; } else if (typeof value.text === "string") { text = value.text; } diff --git a/packages/server/src/server/agent/providers/omp/host-tools.test.ts b/packages/server/src/server/agent/providers/omp/host-tools.test.ts index 70a795a5ee..61be9887c8 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.test.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.test.ts @@ -4,10 +4,9 @@ import { z } from "zod"; import type { PaseoToolCatalog, PaseoToolDefinition, PaseoToolResult } from "../../tools/types.js"; import { - clearOmpHostToolState, handleOmpHostToolRuntimeEvent, + OmpHostToolRouter, serializeOmpHostTools, - waitForOmpHostToolsIdle, } from "./host-tools.js"; import type { OmpRpcHostToolResult } from "./rpc-types.js"; import { FakeOmp } from "./test-utils/fake-omp.js"; @@ -33,11 +32,14 @@ class OmpHostToolHarness { private readonly controlledStart = new Promise((resolve) => { this.resolveControlledStart = resolve; }); + private readonly router: OmpHostToolRouter; private constructor( private readonly catalog: PaseoToolCatalog, private readonly runtimeSession: Awaited>, - ) {} + ) { + this.router = new OmpHostToolRouter({ runtimeSession, catalog, logger: this.logger }); + } static async withTools(tools: PaseoToolDefinition[]): Promise { const omp = new FakeOmp(); @@ -69,7 +71,12 @@ class OmpHostToolHarness { arguments: Record; }): Promise { const result = this.runtimeSession.nextHostToolResult(); - handleOmpHostToolRuntimeEvent({ type: "host_tool_call", ...input }, this.routerInput()); + handleOmpHostToolRuntimeEvent( + { type: "host_tool_call", ...input }, + this.router, + this.runtimeSession, + this.logger, + ); return await result; } @@ -82,7 +89,9 @@ class OmpHostToolHarness { toolName: "wait_for_agent", arguments: { agentId: "child-1" }, }, - this.routerInput(), + this.router, + this.runtimeSession, + this.logger, ); } @@ -93,7 +102,9 @@ class OmpHostToolHarness { cancelControlledCall(): void { handleOmpHostToolRuntimeEvent( { type: "host_tool_cancel", id: "cancel-1", targetId: "host-cancel" }, - this.routerInput(), + this.router, + this.runtimeSession, + this.logger, ); } @@ -103,7 +114,7 @@ class OmpHostToolHarness { } async waitForIdle(): Promise { - await waitForOmpHostToolsIdle(this.runtimeSession); + await this.router.waitForIdle(); } wasControlledCallAborted(): boolean { @@ -119,11 +130,7 @@ class OmpHostToolHarness { } close(): void { - clearOmpHostToolState(this.runtimeSession); - } - - private routerInput() { - return { runtimeSession: this.runtimeSession, paseoTools: this.catalog, logger: this.logger }; + this.router.clear(); } } diff --git a/packages/server/src/server/agent/providers/omp/host-tools.ts b/packages/server/src/server/agent/providers/omp/host-tools.ts index 9850c654dd..1b0f7a24a7 100644 --- a/packages/server/src/server/agent/providers/omp/host-tools.ts +++ b/packages/server/src/server/agent/providers/omp/host-tools.ts @@ -6,6 +6,7 @@ import { } from "../../tools/paseo-tool-serialization.js"; import type { PaseoToolCatalog, PaseoToolResult } from "../../tools/types.js"; import type { OmpRuntimeSession } from "./runtime.js"; +import type { OmpMcpBridge } from "./mcp-bridge.js"; import { OmpRpcHostToolCallRequestSchema, OmpRpcHostToolCancelRequestSchema, @@ -24,12 +25,11 @@ interface PendingOmpHostToolCall { interface OmpHostToolRouterInput { runtimeSession: OmpRuntimeSession; - catalog: PaseoToolCatalog; + catalog?: PaseoToolCatalog; + bridge?: OmpMcpBridge; logger: Logger; } -const routersByRuntimeSession = new WeakMap(); - export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostToolDefinition[] { return [...catalog.tools.values()].map((tool) => { const definition: OmpRpcHostToolDefinition = { @@ -45,20 +45,11 @@ export function serializeOmpHostTools(catalog: PaseoToolCatalog): OmpRpcHostTool }); } -export async function setOmpHostTools( - runtimeSession: OmpRuntimeSession, - catalog: PaseoToolCatalog, -): Promise { - return await runtimeSession.setHostTools(serializeOmpHostTools(catalog)); -} - export function handleOmpHostToolRuntimeEvent( event: unknown, - input: { - runtimeSession: OmpRuntimeSession; - paseoTools?: PaseoToolCatalog; - logger: Logger; - }, + router: OmpHostToolRouter | undefined, + runtimeSession: OmpRuntimeSession, + logger: Logger, ): boolean { if (!isRecord(event) || typeof event.type !== "string" || !isOmpHostToolEventType(event.type)) { return false; @@ -66,9 +57,8 @@ export function handleOmpHostToolRuntimeEvent( const call = OmpRpcHostToolCallRequestSchema.safeParse(event); if (call.success) { - const router = getRouter(input); if (!router) { - sendMissingCatalogResult(input.runtimeSession, call.data); + sendMissingCatalogResult(runtimeSession, call.data); return true; } router.handleCall(call.data); @@ -77,17 +67,17 @@ export function handleOmpHostToolRuntimeEvent( const cancel = OmpRpcHostToolCancelRequestSchema.safeParse(event); if (cancel.success) { - getRouter(input)?.handleCancel(cancel.data.targetId); + router?.handleCancel(cancel.data.targetId); return true; } const update = OmpRpcHostToolUpdateSchema.safeParse(event); if (update.success) { - input.logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update"); + logger.debug({ id: update.data.id }, "Ignoring unexpected inbound OMP host tool update"); return true; } - input.logger.debug({ event }, "Dropped malformed OMP host tool frame"); + logger.debug({ event }, "Dropped malformed OMP host tool frame"); return true; } @@ -95,36 +85,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -export function clearOmpHostToolState(runtimeSession: OmpRuntimeSession): void { - routersByRuntimeSession.get(runtimeSession)?.clear(); - routersByRuntimeSession.delete(runtimeSession); -} - -export async function waitForOmpHostToolsIdle(runtimeSession: OmpRuntimeSession): Promise { - await routersByRuntimeSession.get(runtimeSession)?.waitForIdle(); -} - -function getRouter(input: { - runtimeSession: OmpRuntimeSession; - paseoTools?: PaseoToolCatalog; - logger: Logger; -}): OmpHostToolRouter | null { - if (!input.paseoTools) { - return null; - } - const existing = routersByRuntimeSession.get(input.runtimeSession); - if (existing) { - return existing; - } - const router = new OmpHostToolRouter({ - runtimeSession: input.runtimeSession, - catalog: input.paseoTools, - logger: input.logger, - }); - routersByRuntimeSession.set(input.runtimeSession, router); - return router; -} - function sendMissingCatalogResult( runtimeSession: OmpRuntimeSession, request: OmpRpcHostToolCallRequest, @@ -141,9 +101,10 @@ function isOmpHostToolEventType(type: string): boolean { return type === "host_tool_call" || type === "host_tool_cancel" || type === "host_tool_update"; } -class OmpHostToolRouter { +export class OmpHostToolRouter { private readonly runtimeSession: OmpRuntimeSession; - private readonly catalog: PaseoToolCatalog; + private readonly catalog?: PaseoToolCatalog; + readonly bridge?: OmpMcpBridge; private readonly logger: Logger; private readonly pendingCalls = new Map(); private readonly idleWaiters = new Set<() => void>(); @@ -151,9 +112,17 @@ class OmpHostToolRouter { constructor(input: OmpHostToolRouterInput) { this.runtimeSession = input.runtimeSession; this.catalog = input.catalog; + this.bridge = input.bridge; this.logger = input.logger; } + async register(): Promise { + return await this.runtimeSession.setHostTools([ + ...(this.catalog ? serializeOmpHostTools(this.catalog) : []), + ...(this.bridge?.definitions ?? []), + ]); + } + handleCall(request: OmpRpcHostToolCallRequest): void { const entry: PendingOmpHostToolCall = { controller: new AbortController(), @@ -183,6 +152,11 @@ class OmpHostToolRouter { this.resolveIdleWaiters(); } + async close(): Promise { + this.clear(); + await this.bridge?.close(); + } + waitForIdle(): Promise { if (this.pendingCalls.size === 0) return Promise.resolve(); return new Promise((resolve) => this.idleWaiters.add(resolve)); @@ -193,15 +167,20 @@ class OmpHostToolRouter { entry: PendingOmpHostToolCall, ): Promise { try { - const result = await this.catalog.executeTool(request.toolName, request.arguments, { - signal: entry.controller.signal, - sendUpdate: (update) => { - if (entry.canceled || entry.controller.signal.aborted) { - return; - } - this.sendUpdate(request.id, update); - }, - }); + const result = this.bridge?.tool(request.toolName) + ? await this.bridge.execute(request.toolName, request.arguments, entry.controller.signal) + : ((await this.catalog?.executeTool(request.toolName, request.arguments, { + signal: entry.controller.signal, + sendUpdate: (update) => { + if (entry.canceled || entry.controller.signal.aborted) { + return; + } + this.sendUpdate(request.id, update); + }, + })) ?? { + content: [{ type: "text", text: `Unknown host tool: ${request.toolName}` }], + isError: true, + }); if (entry.canceled || entry.controller.signal.aborted) { return; } diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts index 5a380e553d..da86a38ba7 100644 --- a/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.test.ts @@ -26,7 +26,7 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); - expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["high", "xhigh"]); + expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "high", "xhigh"]); expect(result.defaultThinkingOptionId).toBe("xhigh"); expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("xhigh"); }); @@ -37,6 +37,7 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "auto", "off", "minimal", "low", @@ -57,6 +58,7 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "auto", "off", "minimal", "low", @@ -93,7 +95,7 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); - expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "high"]); + expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["auto", "low", "high"]); expect(result.defaultThinkingOptionId).toBe("low"); expect(result.thinkingOptions?.find((option) => option.isDefault)?.id).toBe("low"); }); @@ -106,7 +108,12 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); - expect(result.thinkingOptions?.map((option) => option.id)).toEqual(["low", "medium", "high"]); + expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "auto", + "low", + "medium", + "high", + ]); expect(result.defaultThinkingOptionId).toBe("low"); }); @@ -119,6 +126,7 @@ describe("mapOmpModel thinking options", () => { const result = mapOmpModel(model, "omp"); expect(result.thinkingOptions?.map((option) => option.id)).toEqual([ + "auto", "off", "minimal", "low", @@ -130,6 +138,22 @@ describe("mapOmpModel thinking options", () => { expect(result.defaultThinkingOptionId).toBe("medium"); }); + test("offers auto alongside reported efforts without making it the default", () => { + const model = baseModel({ + reasoning: true, + thinking: { mode: "effort", efforts: ["low", "high"], defaultLevel: "high" }, + }); + + const result = mapOmpModel(model, "omp"); + + expect(result.thinkingOptions?.at(0)).toEqual({ + id: "auto", + label: "Auto", + description: "OMP picks the effort each turn", + }); + expect(result.defaultThinkingOptionId).toBe("high"); + }); + test("preserves provider and model id in the mapped definition", () => { const model = baseModel({ reasoning: true, diff --git a/packages/server/src/server/agent/providers/omp/map-omp-model.ts b/packages/server/src/server/agent/providers/omp/map-omp-model.ts index 6cffc77949..094b1adf35 100644 --- a/packages/server/src/server/agent/providers/omp/map-omp-model.ts +++ b/packages/server/src/server/agent/providers/omp/map-omp-model.ts @@ -13,6 +13,7 @@ export const OMP_THINKING_OPTIONS: ReadonlyArray<{ description: string; isDefault?: boolean; }> = [ + { id: "auto", label: "Auto", description: "OMP picks the effort each turn" }, { id: "off", label: "Off", description: "No extra reasoning" }, { id: "minimal", label: "Minimal", description: "Light reasoning" }, { id: "low", label: "Low", description: "Faster reasoning" }, @@ -82,8 +83,10 @@ function resolveOmpThinkingConfig(model: OmpModel): { reportedDefault && filtered.some((option) => option.id === reportedDefault) ? reportedDefault : (filtered[0]?.id ?? DEFAULT_OMP_THINKING_LEVEL); + // `auto` is a session-level OMP mode, never a per-model effort, so models do not list it. + const options = [OMP_THINKING_OPTIONS[0], ...filtered.filter((option) => option.id !== "auto")]; return { - thinkingOptions: filtered.map((option) => + thinkingOptions: options.map((option) => mapThinkingOption(option, option.id === defaultThinkingOptionId), ), defaultThinkingOptionId, diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts new file mode 100644 index 0000000000..20cab26fd5 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts @@ -0,0 +1,340 @@ +import { fileURLToPath } from "node:url"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test, vi } from "vitest"; +import { OmpHarness } from "./test-utils/omp-harness.js"; +import type { PaseoToolCatalog } from "../../tools/types.js"; + +const fixture = fileURLToPath(new URL("./test-utils/echo-mcp-server.mjs", import.meta.url)); +const healthy = { type: "stdio" as const, command: process.execPath, args: [fixture] }; + +describe("OMP MCP host tools", () => { + test("registers a stdio MCP tool and proxies its call", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness(); + await omp.start({ mcpServers: { local: healthy } }); + try { + const tool = omp.registeredHostTools().at(-1)?.[0]; + expect(tool?.name).toMatch(/^mcp_local__echo_secret/); + const runtime = omp.runtime(); + const result = runtime.nextHostToolResult(); + runtime.emit({ + type: "host_tool_call", + id: "call-1", + toolCallId: "tool-1", + toolName: tool!.name, + arguments: { word: "hello" }, + }); + expect(await result).toMatchObject({ + id: "call-1", + result: { content: [{ type: "text", text: "MANGO:hello" }] }, + }); + const failed = runtime.nextHostToolResult(); + runtime.emit({ + type: "host_tool_call", + id: "call-2", + toolCallId: "tool-2", + toolName: tool!.name, + arguments: { word: "FAIL" }, + }); + expect(await failed).toMatchObject({ + id: "call-2", + isError: true, + result: { isError: true, content: [{ type: "text", text: "fixture failure" }] }, + }); + runtime.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: tool!.name, + args: { word: "hello" }, + }); + runtime.emit({ + type: "tool_execution_end", + toolCallId: "tool-1", + toolName: tool!.name, + result: { content: [{ type: "text", text: "MANGO:hello" }] }, + }); + expect(omp.timeline().at(-1)).toMatchObject({ + type: "tool_call", + name: "local / echo_secret", + status: "completed", + detail: { + type: "unknown", + input: { word: "hello" }, + output: { content: [{ type: "text", text: "MANGO:hello" }] }, + }, + }); + runtime.emit({ + type: "tool_execution_start", + toolCallId: "native-1", + toolName: "mcp__native_echo_secret", + args: { word: "native" }, + }); + runtime.emit({ + type: "tool_execution_end", + toolCallId: "native-1", + toolName: "mcp__native_echo_secret", + result: { content: [{ type: "text", text: "native result" }] }, + }); + expect(omp.timeline().at(-1)).toMatchObject({ + type: "tool_call", + name: "mcp__native_echo_secret", + detail: { type: "unknown", input: { word: "native" } }, + }); + } finally { + await omp.close(); + } + }); + + test("skips a failed server and registers a healthy one", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness(); + await omp.start({ + mcpServers: { broken: { type: "stdio", command: "/nonexistent/omp-mcp" }, local: healthy }, + }); + try { + expect( + omp + .registeredHostTools() + .at(-1) + ?.map((tool) => tool.name), + ).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]); + const runtime = omp.runtime(); + const result = runtime.nextHostToolResult(); + runtime.emit({ + type: "host_tool_call", + id: "healthy-call", + toolCallId: "healthy-tool", + toolName: omp.registeredHostTools()[0]![0]!.name, + arguments: { word: "healthy" }, + }); + expect(await result).toMatchObject({ + result: { content: [{ type: "text", text: "MANGO:healthy" }] }, + }); + } finally { + await omp.close(); + } + }); + + test("starts with a healthy server when another never answers initialize", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness(); + const started = Date.now(); + await omp.start({ + mcpServers: { + silent: { + type: "stdio", + command: process.execPath, + args: ["-e", "process.stdin.resume();setInterval(()=>{},1<<30)"], + }, + local: healthy, + }, + }); + try { + expect(Date.now() - started).toBeLessThan(15_000); + expect( + omp + .registeredHostTools() + .at(-1) + ?.map((tool) => tool.name), + ).toEqual([expect.stringMatching(/^mcp_local__echo_secret/)]); + } finally { + await omp.close(); + } + }, 80_000); + + test("passes runtime and launch env to stdio, then applies server env", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness({ + runtimeEnv: { OMP_MCP_RUNTIME_ENV: "runtime", OMP_MCP_PRECEDENCE: "runtime" }, + }); + await omp.start( + { + mcpServers: { + local: { ...healthy, env: { OMP_MCP_PRECEDENCE: "server" } }, + }, + }, + undefined, + { PASEO_AGENT_ID: "agent-123", OMP_MCP_PRECEDENCE: "launch" }, + ); + try { + const runtime = omp.runtime(); + const result = runtime.nextHostToolResult(); + runtime.emit({ + type: "host_tool_call", + id: "env-call", + toolCallId: "env-tool", + toolName: omp.registeredHostTools()[0]![0]!.name, + arguments: { word: "ENV" }, + }); + expect(await result).toMatchObject({ + result: { + content: [ + { + type: "text", + text: '{"runtime":"runtime","agent":"agent-123","precedence":"server"}', + }, + ], + }, + }); + } finally { + await omp.close(); + } + }); + + test("replays the bridge label and Codex-style detail after archive", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness(); + await omp.start({ mcpServers: { local: healthy } }); + try { + const exposedName = omp.registeredHostTools()[0]![0]!.name; + const persisted = omp.persistence(); + expect(persisted?.metadata?.bridgedTools).toEqual([ + [exposedName, { server: "local", tool: "echo_secret" }], + ]); + const directory = await mkdtemp(join(tmpdir(), "omp-mcp-history-")); + const sessionFile = join(directory, "session.jsonl"); + await writeFile( + sessionFile, + [ + { type: "session", id: "root", parentId: null }, + { + type: "message", + id: "call", + parentId: "root", + message: { + role: "assistant", + content: [ + { + type: "toolCall", + id: "tool-1", + name: exposedName, + arguments: { word: "REVIEW" }, + }, + ], + }, + }, + { + type: "message", + id: "result", + parentId: "call", + message: { + role: "toolResult", + toolCallId: "tool-1", + toolName: exposedName, + content: [{ type: "text", text: "MANGO:REVIEW" }], + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + const events = await omp.replayHistory({ ...persisted!, nativeHandle: sessionFile }); + expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({ + type: "tool_call", + name: "local / echo_secret", + detail: { + type: "unknown", + input: { word: "REVIEW" }, + output: { content: [{ type: "text", text: "MANGO:REVIEW" }] }, + }, + }); + } finally { + await omp.close(); + } + }); + + test("registers Paseo and MCP tools in the same replacement set", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const paseoTools: PaseoToolCatalog = { + tools: new Map([ + [ + "create_agent", + { + name: "create_agent", + description: "Create an agent", + handler: async () => ({ content: [] }), + }, + ], + ]), + getTool: () => undefined, + executeTool: async () => ({ content: [] }), + }; + const omp = new OmpHarness(); + await omp.start({ mcpServers: { local: healthy } }, paseoTools); + try { + expect(omp.registeredHostTools()).toHaveLength(1); + expect(omp.registeredHostTools()[0]?.map((tool) => tool.name)).toEqual([ + "create_agent", + expect.stringMatching(/^mcp_local__echo_secret_/), + ]); + } finally { + await omp.close(); + } + }); + + test("registers MCP tools after resume and crash relaunch", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const omp = new OmpHarness(); + await omp.resume( + { user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } }, + { mcpServers: { local: healthy } }, + ); + try { + expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/); + omp.processExit("crashed"); + await omp.startTurn("again"); + await vi.waitFor(() => { + expect(omp.runtimeSessions()).toHaveLength(2); + expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/); + }); + } finally { + await omp.close(); + } + }); + + test("closes the stdio MCP child with the session", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const directory = await mkdtemp(join(tmpdir(), "omp-mcp-pid-")); + const pidFile = join(directory, "pid"); + const omp = new OmpHarness(); + await omp.start({ mcpServers: { local: { ...healthy, env: { OMP_MCP_PID_FILE: pidFile } } } }); + const pid = Number(await readFile(pidFile, "utf8")); + expect(() => process.kill(pid, 0)).not.toThrow(); + await omp.close(); + expect(() => process.kill(pid, 0)).toThrow(); + }); + + test("cancels an in-flight MCP call when OMP cancels its host tool", async () => { + await mkdir("/tmp/paseo-omp-agent-test", { recursive: true }); + const directory = await mkdtemp(join(tmpdir(), "omp-mcp-cancel-")); + const waitFile = join(directory, "waiting"); + const cancelFile = join(directory, "cancelled"); + const omp = new OmpHarness(); + await omp.start({ + mcpServers: { + local: { + ...healthy, + env: { OMP_MCP_WAIT_FILE: waitFile, OMP_MCP_CANCEL_FILE: cancelFile }, + }, + }, + }); + try { + const runtime = omp.runtime(); + runtime.emit({ + type: "host_tool_call", + id: "wait-call", + toolCallId: "wait-tool", + toolName: omp.registeredHostTools()[0]![0]!.name, + arguments: { word: "WAIT" }, + }); + await vi.waitFor(async () => expect(await readFile(waitFile, "utf8")).toBe("waiting")); + runtime.emit({ type: "host_tool_cancel", id: "cancel-1", targetId: "wait-call" }); + await vi.waitFor(async () => expect(await readFile(cancelFile, "utf8")).toBe("cancelled")); + expect(runtime.hostToolResults).toEqual([]); + } finally { + await omp.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts new file mode 100644 index 0000000000..72f7a093f4 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { Logger } from "pino"; +import type { McpServerConfig } from "../../agent-sdk-types.js"; +import type { PaseoToolResult } from "../../tools/types.js"; +import type { OmpRpcHostToolDefinition } from "./rpc-types.js"; + +interface BridgedTool { + client: Client; + server: string; + tool: string; +} + +export interface OmpBridgedToolIdentity { + server: string; + tool: string; +} + +const SERVER_TIMEOUT_MS = 10_000; + +export class OmpMcpBridge { + readonly definitions: OmpRpcHostToolDefinition[] = []; + private readonly tools = new Map(); + private readonly clients: Client[] = []; + private closed = false; + + static async connect( + servers: Record | undefined, + cwd: string, + logger: Logger, + env: NodeJS.ProcessEnv, + ): Promise { + const bridge = new OmpMcpBridge(); + const connections = await Promise.all( + Object.entries(servers ?? {}).map(async ([serverName, config]) => { + const client = new Client({ name: "paseo-omp-mcp", version: "1.0.0" }); + try { + const transport = makeTransport(config, cwd, env); + await client.connect(transport, { timeout: SERVER_TIMEOUT_MS }); + const listed = await client.listTools(undefined, { timeout: SERVER_TIMEOUT_MS }); + return { serverName, client, tools: listed.tools }; + } catch (error) { + logger.warn({ err: error, server: serverName }, "OMP MCP server unavailable"); + await client.close().catch(() => undefined); + return null; + } + }), + ); + for (const connection of connections) { + if (!connection) continue; + const { serverName, client, tools } = connection; + bridge.clients.push(client); + for (const tool of tools) { + const name = bridgeName(serverName, tool.name); + bridge.tools.set(name, { client, server: serverName, tool: tool.name }); + bridge.definitions.push({ + name, + label: `${serverName} / ${tool.name}`, + description: tool.description || `${tool.name} from ${serverName}`, + loadMode: "essential", + parameters: tool.inputSchema, + }); + } + } + return bridge; + } + + tool(name: string): OmpBridgedToolIdentity | undefined { + const entry = this.tools.get(name); + return entry ? { server: entry.server, tool: entry.tool } : undefined; + } + + toolIdentities(): Map { + return new Map( + [...this.tools].map(([name, entry]) => [name, { server: entry.server, tool: entry.tool }]), + ); + } + + async execute( + name: string, + args: Record, + signal: AbortSignal, + ): Promise { + const entry = this.tools.get(name); + if (!entry || this.closed) throw new Error(`MCP tool ${name} is unavailable`); + const raw = await entry.client.callTool({ name: entry.tool, arguments: args }, undefined, { + signal, + }); + const result = CallToolResultSchema.parse(raw); + return { + content: result.content.map((item) => ({ ...item })), + ...(result.structuredContent !== undefined + ? { structuredContent: result.structuredContent } + : {}), + ...(result.isError !== undefined ? { isError: result.isError } : {}), + }; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + await Promise.all(this.clients.map((client) => client.close().catch(() => undefined))); + this.clients.length = 0; + } +} + +function makeTransport(config: McpServerConfig, cwd: string, env: NodeJS.ProcessEnv) { + if (config.type === "stdio") { + return new StdioClientTransport({ + command: config.command, + args: config.args, + cwd, + env: { ...env, ...config.env } as Record, + stderr: "ignore", + }); + } + if (config.type === "http") { + return new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: { headers: config.headers }, + }); + } + return new SSEClientTransport(new URL(config.url), { + requestInit: { headers: config.headers }, + eventSourceInit: { + fetch: (url, init) => + fetch(url, { ...init, headers: { ...init?.headers, ...config.headers } }), + }, + }); +} + +function bridgeName(server: string, tool: string): string { + const key = `${server}\0${tool}`; + const hash = createHash("sha256").update(key).digest("hex").slice(0, 12); + const clean = (name: string, length: number) => + name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, length); + // 64 characters also fits the stricter model tool-name limits OMP can reach. + return `mcp_${clean(server, 20)}__${clean(tool, 25)}_${hash}`; +} diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index b030ec513c..1950502c89 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -1,12 +1,19 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; import type { OmpAgentMessage, OmpImageContent, OmpTextContent } from "./rpc-types.js"; -import { shouldDisplayOmpCustomMessage } from "./custom-message.js"; +import type { OmpBridgedToolIdentity } from "./mcp-bridge.js"; +import { + ompCustomMessageId, + ompSkillPromptUserText, + shouldDisplayOmpCustomMessage, +} from "./custom-message.js"; import { - extractTextFromToolResult, mapToolDetail, parseToolArgs, parseToolResult, resolveToolCallName, + toolFailureMessage, + isOmpToolFailure, type OmpToolResult, type OmpTrackedToolCall, } from "./tool-call-detail.js"; @@ -26,7 +33,7 @@ export interface OmpHistoryMapperHooks { mapToolDetail?: ( toolCall: OmpTrackedToolCall, result: OmpToolResult, - context: { toolCallId: string }, + context: { toolCallId: string; bridgedTool?: OmpBridgedToolIdentity }, ) => ToolCallDetail | null; } @@ -58,11 +65,13 @@ export class OmpHistoryMapper { private readonly pendingToolCalls = new Map(); private userIndex = 0; private assistantIndex = 0; + private customIndex = 0; constructor( private readonly provider: string, private readonly userEntries: readonly OmpCapturedUserMessageEntry[] = [], private readonly hooks: OmpHistoryMapperHooks = {}, + private readonly bridgedTools: ReadonlyMap = new Map(), ) {} mapMessages(messages: readonly OmpAgentMessage[]): AgentStreamEvent[] { @@ -126,15 +135,30 @@ export class OmpHistoryMapper { if (mappedEvent) { return [mappedEvent]; } - return text - ? [ - { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text }, - }, - ] - : []; + if (!text) { + return []; + } + const messageId = ompCustomMessageId(message, () => { + this.customIndex += 1; + return this.customIndex; + }); + const skillPrompt = ompSkillPromptUserText(message); + if (skillPrompt) { + return [ + { + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: skillPrompt, messageId: `${messageId}-user` }, + }, + ]; + } + return [ + { + type: "timeline", + provider: this.provider, + item: mapCustomMessageToToolCall(message, text, messageId), + }, + ]; } private mapAssistantMessage( @@ -174,7 +198,7 @@ export class OmpHistoryMapper { item: { type: "tool_call", callId: this.resolveToolCallId(content.id, tracked), - name: tracked.toolName, + name: this.toolName(tracked, null), status: "running", detail, error: null, @@ -201,10 +225,10 @@ export class OmpHistoryMapper { provider: this.provider, item: toToolResultTimelineItem({ callId: this.resolveToolCallId(message.toolCallId, tracked), - name: resolveToolCallName(tracked, result), - isError: Boolean(message.isError), + name: this.toolName(tracked, result), + isError: isOmpToolFailure(tracked, result, Boolean(message.isError)), detail, - errorText: extractTextFromToolResult(result) ?? "Tool call failed", + errorText: toolFailureMessage(result), }), }; } @@ -242,7 +266,17 @@ export class OmpHistoryMapper { result: OmpToolResult, ): ToolCallDetail | null { const hook = this.hooks.mapToolDetail; - return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result); + return hook + ? hook(toolCall, result, { + toolCallId, + bridgedTool: this.bridgedTools.get(toolCall.toolName), + }) + : mapToolDetail(toolCall, result); + } + + private toolName(toolCall: OmpTrackedToolCall, result: OmpToolResult): string { + const bridged = this.bridgedTools.get(toolCall.toolName); + return bridged ? `${bridged.server} / ${bridged.tool}` : resolveToolCallName(toolCall, result); } } diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 6c360cbc60..0bcd369a1c 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -6,7 +6,6 @@ import { z } from "zod"; import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; -const OMP_SESSION_DIR = "~/.omp/agent/sessions"; const DEFAULT_OMP_MODE_ID = "full"; const DEFAULT_OMP_READY_TIMEOUT_MS = 20_000; const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000; @@ -14,7 +13,7 @@ const DEFAULT_OMP_RPC_TIMEOUT_MS = 60_000; export const MIN_SUPPORTED_OMP_VERSION = "16.3.9"; export { OMP_MODES }; -export const OmpProviderParamsSchema = z +export const OmpProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().optional(), @@ -24,13 +23,13 @@ export const OmpProviderParamsSchema = z }) .strict(); -export interface OmpRuntimeProviderParams { - sessionDir: string; +export interface OmpRuntimeOptions { + sessionDir?: string; readyTimeoutMs: number; rpcTimeoutMs: number; } -export interface OmpModelRoleParams { +export interface OmpModelRoles { smolModel?: string; slowModel?: string; planModel?: string; @@ -38,9 +37,9 @@ export interface OmpModelRoleParams { export function resolveOmpLaunchMode( modeId: string | undefined, - modelRoleParams: OmpModelRoleParams = {}, + modelRoles: OmpModelRoles = {}, ): { modeId: string; extraArgs: string[] } { - const modelRoleArgs = resolveOmpModelRoleArgs(modelRoleParams); + const modelRoleArgs = resolveOmpModelRoleArgs(modelRoles); switch (modeId ?? DEFAULT_OMP_MODE_ID) { case "full": return { modeId: "full", extraArgs: ["--approval-mode", "yolo", ...modelRoleArgs] }; @@ -59,11 +58,11 @@ export function resolveOmpLaunchMode( } } -function resolveOmpModelRoleArgs(modelRoleParams: OmpModelRoleParams): string[] { +function resolveOmpModelRoleArgs(modelRoles: OmpModelRoles): string[] { const args: string[] = []; - if (modelRoleParams.smolModel) args.push("--smol", modelRoleParams.smolModel); - if (modelRoleParams.slowModel) args.push("--slow", modelRoleParams.slowModel); - if (modelRoleParams.planModel) args.push("--plan", modelRoleParams.planModel); + if (modelRoles.smolModel) args.push("--smol", modelRoles.smolModel); + if (modelRoles.slowModel) args.push("--slow", modelRoles.slowModel); + if (modelRoles.planModel) args.push("--plan", modelRoles.planModel); return args; } @@ -129,22 +128,22 @@ export function formatOmpVersionSupport(versionOutput: string): string { return `${match[1]}.${match[2]}.${match[3]} (${supported ? "supported" : "unsupported"}; minimum ${MIN_SUPPORTED_OMP_VERSION})`; } -export function resolveOmpProviderParams(providerParams: unknown): { - runtimeProviderParams: OmpRuntimeProviderParams; - modelRoleParams: OmpModelRoleParams; +export function resolveOmpProviderOptions(providerOptions: unknown): { + runtimeOptions: OmpRuntimeOptions; + modelRoles: OmpModelRoles; } { - const params = OmpProviderParamsSchema.parse(providerParams ?? {}); - const configuredRpcTimeoutMs = params.rpcTimeoutMs; + const options = OmpProviderOptionsSchema.parse(providerOptions ?? {}); + const configuredRpcTimeoutMs = options.rpcTimeoutMs; return { - runtimeProviderParams: { - sessionDir: params.sessionDir ?? OMP_SESSION_DIR, + runtimeOptions: { + sessionDir: options.sessionDir, readyTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_READY_TIMEOUT_MS, rpcTimeoutMs: configuredRpcTimeoutMs ?? DEFAULT_OMP_RPC_TIMEOUT_MS, }, - modelRoleParams: { - ...(params.smolModel ? { smolModel: params.smolModel } : {}), - ...(params.slowModel ? { slowModel: params.slowModel } : {}), - ...(params.planModel ? { planModel: params.planModel } : {}), + modelRoles: { + ...(options.smolModel ? { smolModel: options.smolModel } : {}), + ...(options.slowModel ? { slowModel: options.slowModel } : {}), + ...(options.planModel ? { planModel: options.planModel } : {}), }, }; } diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts new file mode 100644 index 0000000000..4fdb37f61c --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -0,0 +1,355 @@ +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentProvider, +} from "../../agent-sdk-types.js"; +import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js"; + +type UiRequest = Extract; +interface UiResponse { + value?: string; + confirmed?: boolean; + cancelled?: boolean; +} +type SendResponse = (id: string, response: UiResponse) => void; + +const OTHER = "Other (type your own)"; +const ANSWER_HEADER = "Response"; + +interface SelectOption { + label: string; + description?: string; +} + +interface AskQuestion { + title: string; + multi: boolean; + options: SelectOption[]; +} + +interface PendingAnswer { + question: AskQuestion; + selected: string[]; + custom: string | null; + next: "select" | "editor"; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function string(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readSelectOptions(event: UiRequest): SelectOption[] { + const details = Array.isArray(event.optionDetails) ? event.optionDetails : []; + return (event.options ?? []).map((label, index) => { + const description = string(record(details[index])?.description); + return description?.trim() ? { label, description } : { label }; + }); +} + +function isOptionalInput(placeholder: string | undefined): boolean { + return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); +} + +function inputTitle(title: string | undefined, placeholder: string | undefined): string { + if (!isOptionalInput(placeholder)) return title ?? "Enter a value"; + return /\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`) + ? "Optional comment" + : "Optional response"; +} + +function genericQuestion( + event: UiRequest, + provider: AgentProvider, + title: string, + options: SelectOption[], + input: { placeholder?: string; allowEmpty?: boolean; dismissLabel?: string } = {}, +): AgentPermissionRequest { + return { + id: event.id, + provider, + name: `OMP ${event.method}`, + kind: "question", + title, + input: { + questions: [ + { question: title, header: ANSWER_HEADER, options, multiSelect: false, ...input }, + ], + }, + metadata: { extensionUiMethod: event.method, answerHeader: ANSWER_HEADER }, + }; +} + +function mapGenericQuestion( + event: UiRequest, + provider: AgentProvider, +): AgentPermissionRequest | null { + switch (event.method) { + case "select": + return genericQuestion( + event, + provider, + event.title ?? "Select an option", + readSelectOptions(event), + ); + case "input": { + const placeholder = event.placeholder; + const optional = isOptionalInput(placeholder); + return genericQuestion(event, provider, inputTitle(event.title, placeholder), [], { + ...(placeholder ? { placeholder } : {}), + ...(optional ? { allowEmpty: true, dismissLabel: "Skip" } : {}), + }); + } + case "editor": + return genericQuestion(event, provider, event.title ?? "Edit text", []); + case "confirm": + return genericQuestion( + event, + provider, + [event.title, event.message].filter(Boolean).join("\n\n"), + [{ label: "Yes" }, { label: "No" }], + ); + default: + return null; + } +} + +function genericResponse( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): UiResponse { + if (response.behavior === "deny") return { cancelled: true }; + const answers = record(response.updatedInput?.answers); + const answer = Object.values(answers ?? {}).find( + (value): value is string => typeof value === "string", + ); + if (answer === undefined) return { cancelled: true }; + return request.metadata?.extensionUiMethod === "confirm" + ? { confirmed: /^yes$/i.test(answer.trim()) } + : { value: answer }; +} + +function questionTitle(title: string): string { + return title.replace(/^\(\d+ selected\) /, ""); +} + +function matchesQuestion(title: string, question: AskQuestion): boolean { + const plainTitle = questionTitle(title); + if (plainTitle === question.title) return true; + return ( + plainTitle.startsWith(question.title) && + /^ \(\d+\/\d+\)$/.test(plainTitle.slice(question.title.length)) + ); +} + +function readQuestions(args: unknown): AskQuestion[] { + const questions = record(args)?.questions; + if (!Array.isArray(questions)) return []; + return questions.flatMap((item) => { + const question = record(item); + if (typeof question?.question !== "string" || !Array.isArray(question.options)) return []; + const options = question.options.flatMap((optionValue) => { + const option = record(optionValue); + if (typeof option?.label !== "string") return []; + return [ + { + label: option.label, + ...(typeof option.description === "string" ? { description: option.description } : {}), + }, + ]; + }); + return [{ title: question.question, multi: question.multi === true, options }]; + }); +} + +function selectedAnswer( + answer: string, + labels: string[], + multi: boolean, +): { selected: string[]; custom: string | null } { + if (!multi) + return labels.includes(answer) + ? { selected: [answer], custom: null } + : { selected: [], custom: answer }; + + // The shared question UI sends a comma-joined answer in click order. Consume + // exact option labels from the front; any remaining text is the Other answer. + let remaining = answer; + const selected: string[] = []; + while (remaining.length > 0) { + const label = labels + .filter((candidate) => !selected.includes(candidate)) + .sort((left, right) => right.length - left.length) + .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); + if (!label) break; + selected.push(label); + remaining = remaining === label ? "" : remaining.slice(label.length + 2); + } + return { selected, custom: remaining || null }; +} + +/** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */ +export class OmpQuestionUi { + private questions: AskQuestion[] = []; + private pending: PendingAnswer | null = null; + + observeMessage(message: OmpAgentMessage): void { + if (message.role !== "assistant") return; + for (const item of message.content) { + if (item.type === "toolCall" && item.name === "ask") this.start(item.name, item.arguments); + } + } + + start(toolName: string, args: unknown): void { + if (toolName === "ask") { + this.questions = readQuestions(args); + this.pending = null; + } + } + + finish(toolName: string): void { + if (toolName === "ask") { + this.questions = []; + this.pending = null; + } + } + + handleRequest( + event: UiRequest, + provider: AgentProvider, + send: SendResponse, + ): AgentPermissionRequest | null { + if (this.consume(event, send)) return null; + return this.mapAsk(event, provider) ?? mapGenericQuestion(event, provider); + } + + private mapAsk(event: UiRequest, provider: AgentProvider): AgentPermissionRequest | null { + if (event.method !== "select" || typeof event.title !== "string") return null; + const title = event.title; + const question = this.questions.find((item) => matchesQuestion(title, item)); + if (!question || !Array.isArray(event.options) || !event.options.includes(OTHER)) return null; + + const descriptions = new Map( + question.options.map((option) => [option.label, option.description]), + ); + const options = readSelectOptions(event) + .filter((option) => option.label !== OTHER && !option.label.endsWith(" Done selecting")) + .map((option) => { + const description = + descriptions.get(option.label) ?? + descriptions.get(option.label.replace(/ \(Recommended\)$/, "")) ?? + option.description; + return description ? { label: option.label, description } : { label: option.label }; + }); + return { + id: event.id, + provider, + name: "OMP ask", + kind: "question", + title: question.title, + input: { + questions: [ + { + question: question.title, + header: ANSWER_HEADER, + options, + multiSelect: question.multi, + allowOther: true, + }, + ], + }, + metadata: { + ompAsk: true, + answerHeader: ANSWER_HEADER, + optionLabels: options.map((option) => option.label), + }, + }; + } + + respond( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + send: SendResponse, + ): void { + if (request.metadata?.ompAsk !== true) { + send(request.id, genericResponse(request, response)); + return; + } + const question = this.questions.find((item) => item.title === request.title); + const answers = record( + response.behavior === "allow" ? response.updatedInput?.answers : undefined, + ); + const answer = answers?.[ANSWER_HEADER]; + if (response.behavior === "deny" || typeof answer !== "string" || !question) { + this.pending = null; + send(request.id, { cancelled: true }); + return; + } + + const labels = Array.isArray(request.metadata.optionLabels) + ? request.metadata.optionLabels.filter((item): item is string => typeof item === "string") + : []; + const { selected, custom } = selectedAnswer(answer, labels, question.multi); + if (selected.length === 0 && custom === null) { + this.pending = null; + send(request.id, { cancelled: true }); + return; + } + if (question.multi) { + this.pending = { question, selected: selected.slice(1), custom, next: "select" }; + send(request.id, { value: selected[0] ?? OTHER }); + if (selected.length === 0) this.pending.next = "editor"; + } else if (custom !== null) { + this.pending = { question, selected: [], custom, next: "editor" }; + send(request.id, { value: OTHER }); + } else { + this.pending = null; + send(request.id, { value: selected[0] }); + } + } + + consume(event: UiRequest, send: SendResponse): boolean { + const pending = this.pending; + if (pending?.next === "editor" && event.method === "editor" && pending.custom !== null) { + this.pending = null; + send(event.id, { value: pending.custom }); + return true; + } + if ( + !pending || + typeof event.title !== "string" || + !matchesQuestion(event.title, pending.question) + ) + return false; + if (pending.next !== "select" || event.method !== "select" || !Array.isArray(event.options)) + return false; + + const choice = pending.selected[0]; + if (choice && event.options.includes(choice)) { + pending.selected.shift(); + send(event.id, { value: choice }); + return true; + } + if (pending.custom !== null && event.options.includes(OTHER)) { + pending.next = "editor"; + send(event.id, { value: OTHER }); + return true; + } + const done = event.options.find((option) => option.endsWith(" Done selecting")); + if (!done) { + // OMP omits Done for multi-question asks and expects a right-arrow + // navigation callback, which its RPC response cannot express. + this.pending = null; + send(event.id, { cancelled: true }); + return true; + } + this.pending = null; + send(event.id, { value: done }); + return true; + } +} diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index 63aaa7d265..b0514c7863 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -8,6 +8,7 @@ export const OmpThinkingLevelSchema = z.enum([ "high", "xhigh", "max", + "auto", ]); export const OmpImageContentSchema = z @@ -40,6 +41,13 @@ const OmpUserMessageSchema = z content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), }) .passthrough(); +// OMP injects hidden developer messages, such as rule-violation reminders, into a run. +const OmpDeveloperMessageSchema = z + .object({ + role: z.literal("developer"), + content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]), + }) + .passthrough(); const OmpCustomMessageSchema = z .object({ role: z.literal("custom"), @@ -78,13 +86,30 @@ const OmpBashExecutionMessageSchema = z timestamp: z.number(), }) .passthrough(); +// Roles Paseo does not render. They are listed so that a frame carrying one, such as +// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union. +const OmpPythonExecutionMessageSchema = z + .object({ role: z.literal("pythonExecution") }) + .passthrough(); +const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough(); +const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough(); +const OmpCompactionSummaryMessageSchema = z + .object({ role: z.literal("compactionSummary") }) + .passthrough(); +const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough(); export const OmpAgentMessageSchema = z.discriminatedUnion("role", [ OmpUserMessageSchema, + OmpDeveloperMessageSchema, OmpCustomMessageSchema, OmpAssistantMessageSchema, OmpToolResultMessageSchema, OmpBashExecutionMessageSchema, + OmpPythonExecutionMessageSchema, + OmpHookMessageSchema, + OmpBranchSummaryMessageSchema, + OmpCompactionSummaryMessageSchema, + OmpFileMentionMessageSchema, ]); export const OmpModelThinkingSchema = z @@ -128,6 +153,8 @@ export const OmpSessionStateSchema = z isStreaming: z.boolean(), isCompacting: z.boolean(), autoCompactionEnabled: z.boolean().optional(), + fastModeEnabled: z.boolean().optional(), + fastModeActive: z.boolean().optional(), sessionFile: z.string().optional(), sessionId: z.string(), sessionName: z.string().optional(), @@ -333,7 +360,8 @@ export const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [ export const OmpTodoItemSchema = z .object({ content: z.string(), - status: z.enum(["pending", "in_progress", "completed", "abandoned"]), + status: z.enum(["pending", "in_progress", "completed", "abandoned", "blocked"]), + blocker: z.string().optional(), }) .passthrough(); export const OmpTodoPhaseSchema = z @@ -444,6 +472,7 @@ const OmpExtensionUiRequestSchema = z title: z.string().optional(), message: z.string().optional(), options: z.array(z.string()).optional(), + optionDetails: z.unknown().optional(), placeholder: z.string().optional(), url: z.string().optional(), launchUrl: z.string().optional(), @@ -475,6 +504,8 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [ type: z.literal("prompt_result"), id: z.string().optional(), agentInvoked: z.boolean().optional(), + status: z.string().optional(), + error: z.object({ message: z.string() }).passthrough().optional(), }) .passthrough(), z.object({ type: z.literal("process_exit"), error: z.string() }).passthrough(), @@ -494,6 +525,7 @@ export const OmpRuntimeEventSchema = z.discriminatedUnion("type", [ OmpRpcHostToolCallRequestSchema, OmpRpcHostToolCancelRequestSchema, OmpRpcHostToolUpdateSchema, + z.object({ type: z.literal("model_changed") }).passthrough(), ]); const OmpCommandBase = { id: z.string().optional() }; @@ -512,6 +544,13 @@ export const OmpRpcCommandSchema = z.discriminatedUnion("type", [ z.object({ ...OmpCommandBase, type: z.literal("set_auto_compaction"), enabled: z.boolean() }), z.object({ ...OmpCommandBase, type: z.literal("abort") }), z.object({ ...OmpCommandBase, type: z.literal("get_state") }), + z.object({ + ...OmpCommandBase, + type: z.literal("steer"), + message: z.string(), + images: z.array(OmpImageContentSchema).optional(), + }), + z.object({ ...OmpCommandBase, type: z.literal("set_fast_mode"), enabled: z.boolean() }), z.object({ ...OmpCommandBase, type: z.literal("get_messages") }), z.object({ ...OmpCommandBase, type: z.literal("get_available_models") }), z.object({ @@ -596,6 +635,36 @@ export type OmpSubagentEventPayload = z.infer; export type OmpAgentSessionEvent = z.infer; export type OmpRuntimeEvent = z.infer; + +/** + * COMPAT(ompLatePromptRejection): added in v0.11.0, remove after 2027-04-05 once the + * minimum supported OMP version emits prompt_result errors. + * OMP 18.3 reports a prompt it rejects after acknowledging it, such as one whose model has + * no API key, only as a second failed `response` frame for the prompt id. Later releases + * report the same outcome as a `prompt_result` with `status: "error"`. + */ +const OmpLatePromptRejectionSchema = z.object({ + type: z.literal("response"), + command: z.literal("prompt"), + id: z.string(), + success: z.literal(false), + error: z.string(), +}); + +export function parseOmpRuntimeEvent(message: unknown): OmpRuntimeEvent | null { + const rejection = OmpLatePromptRejectionSchema.safeParse(message); + if (rejection.success) { + return { + type: "prompt_result", + id: rejection.data.id, + agentInvoked: false, + status: "error", + error: { message: rejection.data.error }, + }; + } + const event = OmpRuntimeEventSchema.safeParse(message); + return event.success ? event.data : null; +} export type OmpTodoItem = z.infer; export type OmpTodoPhase = z.infer; export type OmpTodoReminderEvent = z.infer; diff --git a/packages/server/src/server/agent/providers/omp/runtime.ts b/packages/server/src/server/agent/providers/omp/runtime.ts index dc8001e3ec..691e907139 100644 --- a/packages/server/src/server/agent/providers/omp/runtime.ts +++ b/packages/server/src/server/agent/providers/omp/runtime.ts @@ -43,6 +43,7 @@ export interface OmpStartSessionInput { } export interface OmpRuntimeSession { + readonly environment: Record; onEvent(callback: (event: OmpRuntimeEvent) => void): () => void; prompt( message: string, @@ -52,6 +53,7 @@ export interface OmpRuntimeSession { setAutoCompaction(enabled: boolean): Promise; abort(): Promise; getState(): Promise; + setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }>; getMessages(): Promise; getAvailableModels(timeoutMs?: number | null): Promise; setModel(provider: string, modelId: string): Promise; @@ -65,7 +67,10 @@ export interface OmpRuntimeSession { branch(entryId: string): Promise<{ text: string }>; getBranchMessages(): Promise>; activeBranchEntryId?: string; - steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void; + steer( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise; followUp( message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>, diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts index 9dc4ba876c..916c61e042 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; -import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js"; +import { + listOmpImportableSessions, + readOmpImportSessionConfig, + resolveOmpSessionFile, +} from "./session-descriptor.js"; async function writeSession(root: string, relativePath: string, lines: unknown[]): Promise { const filePath = path.join(root, "sessions", relativePath); @@ -95,6 +99,50 @@ describe("OMP session descriptor", () => { }); }); + test("resolves a bare session id to its file and reads the same import config", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-by-id-")); + const sessionDir = path.join(root, "sessions"); + const cwd = path.join(root, "repo"); + const sessionId = "01a03dc8-77bb-7000-b0a3-bb7d25477e81"; + const sessionFile = await writeSession( + root, + `project/2026-08-26T11-15-43-163Z_${sessionId}.jsonl`, + [ + { type: "title", v: 1, title: "", updatedAt: "2026-08-26T11:15:43.163Z" }, + { type: "session", version: 3, id: sessionId, timestamp: "2026-08-26T11:15:43.163Z", cwd }, + { + type: "model_change", + id: "model-1", + timestamp: "2026-08-26T11:15:43.200Z", + model: "anthropic/claude-opus-5", + }, + ], + ); + // A decoy whose filename does not follow the `_` convention + // but whose header carries the id must still be found. + const oddlyNamedId = "renamed-session"; + const oddlyNamed = await writeSession(root, "project/notes.jsonl", [ + { type: "session", id: oddlyNamedId, timestamp: "2026-08-27T00:00:00.000Z", cwd }, + { + type: "model_change", + id: "model-2", + timestamp: "2026-08-27T00:00:00.100Z", + model: "mimorouter/claude-fable-5-1", + }, + ]); + + await expect(resolveOmpSessionFile(sessionId, { sessionDir })).resolves.toBe(sessionFile); + await expect(resolveOmpSessionFile(sessionFile, { sessionDir })).resolves.toBe(sessionFile); + await expect(resolveOmpSessionFile(oddlyNamedId, { sessionDir })).resolves.toBe(oddlyNamed); + await expect(resolveOmpSessionFile("does-not-exist", { sessionDir })).resolves.toBeNull(); + + const byId = await readOmpImportSessionConfig(sessionId, { sessionDir }); + const byPath = await readOmpImportSessionConfig(sessionFile, { sessionDir }); + expect(byId).toEqual({ model: "anthropic/claude-opus-5" }); + expect(byId).toEqual(byPath); + await expect(readOmpImportSessionConfig("does-not-exist", { sessionDir })).resolves.toEqual({}); + }); + test("keeps recent nested OMP subagent sessions importable", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-omp-session-nested-")); const cwd = path.join(root, "repo"); @@ -145,4 +193,99 @@ describe("OMP session descriptor", () => { expect.objectContaining({ providerHandleId: sessionFile, cwd }), ]); }); + + test("uses a named OMP profile's session directory", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-profile-home-")); + const cwd = path.join(home, "repo"); + const sessionFile = path.join( + home, + ".omp", + "profiles", + "qa", + "agent", + "sessions", + "project", + "profile.jsonl", + ); + await mkdir(path.dirname(sessionFile), { recursive: true }); + await writeFile(sessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd })); + + await expect( + listOmpImportableSessions({ + homeDir: home, + env: {}, + runtimeSettings: { env: { OMP_PROFILE: "qa" } }, + }), + ).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]); + }); + + test.skipIf(process.platform === "win32")( + "uses a profile's XDG data directory after OMP has created it", + async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-xdg-home-")); + const cwd = path.join(home, "repo"); + const xdgDataHome = path.join(home, "xdg-data"); + const sessionFile = path.join( + xdgDataHome, + "omp", + "profiles", + "qa", + "sessions", + "project", + "session.jsonl", + ); + await mkdir(path.dirname(sessionFile), { recursive: true }); + await writeFile(sessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd })); + + await expect( + listOmpImportableSessions({ + homeDir: home, + env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome }, + }), + ).resolves.toEqual([expect.objectContaining({ providerHandleId: sessionFile })]); + }, + ); + + test.runIf(process.platform === "win32")( + "ignores a profile's XDG data directory on Windows", + async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-omp-windows-xdg-home-")); + const cwd = path.join(home, "repo"); + const xdgDataHome = path.join(home, "xdg-data"); + const xdgSessionFile = path.join( + xdgDataHome, + "omp", + "profiles", + "qa", + "sessions", + "project", + "xdg.jsonl", + ); + const profileSessionFile = path.join( + home, + ".omp", + "profiles", + "qa", + "agent", + "sessions", + "project", + "profile.jsonl", + ); + await Promise.all([ + mkdir(path.dirname(xdgSessionFile), { recursive: true }), + mkdir(path.dirname(profileSessionFile), { recursive: true }), + ]); + await Promise.all([ + writeFile(xdgSessionFile, JSON.stringify({ type: "session", id: "xdg-profile", cwd })), + writeFile(profileSessionFile, JSON.stringify({ type: "session", id: "qa-profile", cwd })), + ]); + + await expect( + listOmpImportableSessions({ + homeDir: home, + env: { OMP_PROFILE: "qa", XDG_DATA_HOME: xdgDataHome }, + }), + ).resolves.toEqual([expect.objectContaining({ providerHandleId: profileSessionFile })]); + }, + ); }); diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.ts index 5b471cb1ff..a97afe861d 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.ts @@ -2,6 +2,7 @@ import type { Dirent } from "node:fs"; import { open, readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; +import { existsSync } from "node:fs"; import type { ImportableProviderSession, @@ -11,8 +12,7 @@ import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; import { createRealpathAwarePathMatcher } from "../../../../utils/path.js"; const OMP_CONFIG_DIR_NAME = ".omp"; -const OMP_AGENT_DIR_ENV = "OMP_AGENT_DIR"; -const OMP_SESSION_DIR_ENV = "OMP_SESSION_DIR"; +const OMP_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR"; // Import listing intentionally bounds header parsing to this window. Sessions // with unusually large preambles may omit their first-prompt preview. const HEAD_BYTES = 64 * 1024; @@ -24,7 +24,7 @@ const FULL_SCAN_LINE_LIMIT = 2_000; const IMPORT_CANDIDATE_OVERSCAN = 40; const IMPORT_CANDIDATE_MIN = 400; -interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions { +export interface OmpSessionDescriptorOptions extends ListImportableSessionsOptions { sessionDir?: string; runtimeSettings?: ProviderRuntimeSettings; env?: NodeJS.ProcessEnv; @@ -102,16 +102,70 @@ export async function listOmpImportableSessions( ); } +/** + * Reads the model/thinking config of an OMP session for import. + * + * `handle` is either an absolute path to the session `.jsonl` (what the + * importable-sessions list hands back) or a bare OMP session id (what + * `paseo import ` supplies). Ids are resolved against the configured + * sessions directory; an unresolvable handle yields `{}`. + */ export async function readOmpImportSessionConfig( - filePath: string, + handle: string, + options: OmpSessionDescriptorOptions = {}, ): Promise { + const filePath = await resolveOmpSessionFile(handle, options); + if (!filePath) return {}; const descriptor = await readOmpSessionDescriptor(filePath); if (!descriptor) return {}; return toOmpImportSessionConfig(descriptor); } +/** + * Resolves an import handle to a session file path. Returns the handle itself + * when it already names a readable file; otherwise searches the sessions + * directory for a file whose header `id` matches. + */ +export async function resolveOmpSessionFile( + handle: string, + options: OmpSessionDescriptorOptions = {}, +): Promise { + const trimmed = handle.trim(); + if (!trimmed) return null; + if (await isReadableFile(trimmed)) return trimmed; + + const sessionsDir = await resolveOmpSessionsDir(options); + const files = await walkJsonlFiles(sessionsDir); + // OMP names session files `_.jsonl`; try those first so the + // common case opens a single file instead of scanning the directory. + const nameSuffix = `_${trimmed}.jsonl`; + const byName = files.filter((file) => path.basename(file).endsWith(nameSuffix)); + for (const file of byName) { + if (await headerSessionIdMatches(file, trimmed)) return file; + } + const rest = byName.length > 0 ? files.filter((file) => !byName.includes(file)) : files; + for (const file of rest) { + if (await headerSessionIdMatches(file, trimmed)) return file; + } + return null; +} + +async function isReadableFile(filePath: string): Promise { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function headerSessionIdMatches(filePath: string, sessionId: string): Promise { + const headChunk = await readHeadChunk(filePath); + if (!headChunk) return false; + return parseSessionHeaderFromChunk(headChunk)?.sessionId === sessionId; +} + async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Promise { - const env = options.env ?? process.env; + const env = { ...(options.env ?? process.env), ...options.runtimeSettings?.env }; const homeDir = options.homeDir ?? homedir(); const baseDir = options.cwd ?? process.cwd(); @@ -119,10 +173,9 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom return resolveConfigPath(options.sessionDir, { baseDir, homeDir }); } - const agentDir = resolveOmpAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir }); + const agentDir = resolveOmpAgentDir({ env, homeDir }); - const envSessionDir = - options.runtimeSettings?.env?.[OMP_SESSION_DIR_ENV] ?? env[OMP_SESSION_DIR_ENV]; + const envSessionDir = env[OMP_SESSION_DIR_ENV]; if (envSessionDir?.trim()) { return resolveConfigPath(envSessionDir, { baseDir, homeDir }); } @@ -135,20 +188,50 @@ async function resolveOmpSessionsDir(options: OmpSessionDescriptorOptions): Prom return resolveConfigPath(settingsSessionDir, { baseDir, homeDir }); } + return resolveOmpDefaultSessionsDir(env, agentDir); +} + +function resolveOmpDefaultSessionsDir(env: NodeJS.ProcessEnv, agentDir: string): string { + const profile = resolveOmpProfile(env); + const xdgDataHome = env.XDG_DATA_HOME; + if ( + xdgDataHome && + (process.platform === "linux" || process.platform === "darwin") && + (profile || !env.PI_CODING_AGENT_DIR?.trim()) + ) { + const xdgRoot = path.join(xdgDataHome, "omp", ...(profile ? ["profiles", profile] : [])); + if (existsSync(xdgRoot)) return path.join(xdgRoot, "sessions"); + } return path.join(agentDir, "sessions"); } -function resolveOmpAgentDir(input: { - runtimeSettings?: ProviderRuntimeSettings; - env: NodeJS.ProcessEnv; - homeDir: string; -}): string { - const configured = - input.runtimeSettings?.env?.[OMP_AGENT_DIR_ENV] ?? input.env[OMP_AGENT_DIR_ENV]; - if (configured?.trim()) { - return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir }); +function resolveOmpAgentDir(input: { env: NodeJS.ProcessEnv; homeDir: string }): string { + const env = input.env; + const profile = resolveOmpProfile(env); + if (!profile) { + const configured = env.PI_CODING_AGENT_DIR; + if (configured?.trim()) { + return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir }); + } } - return path.join(input.homeDir, OMP_CONFIG_DIR_NAME, "agent"); + const configRoot = path.join(input.homeDir, env.PI_CONFIG_DIR || OMP_CONFIG_DIR_NAME); + const profileRoot = profile ? path.join(configRoot, "profiles", profile) : configRoot; + return path.join(profileRoot, "agent"); +} + +function resolveOmpProfile(env: NodeJS.ProcessEnv): string | undefined { + const profile = (env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE)?.trim(); + if (!profile || profile === "default") return undefined; + if ( + profile === "." || + profile === ".." || + profile.endsWith(".") || + !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(profile) || + /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/i.test(profile) + ) { + throw new Error(`Invalid OMP profile "${profile}"`); + } + return profile; } async function readConfiguredSessionDir(input: { diff --git a/packages/server/src/server/agent/providers/omp/system-notice.test.ts b/packages/server/src/server/agent/providers/omp/system-notice.test.ts index 0125ec08d4..bac0ec5508 100644 --- a/packages/server/src/server/agent/providers/omp/system-notice.test.ts +++ b/packages/server/src/server/agent/providers/omp/system-notice.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { isOmpSystemNotice, mapOmpSystemNoticeToToolCall } from "./system-notice.js"; +import { isOmpSystemNotice, mapOmpSystemNoticeToNotification } from "./system-notice.js"; const COMPLETED_NOTICE = [ "", @@ -24,31 +24,16 @@ describe("omp system notice detection", () => { test("ignores regular prompts, including ones that mention the tag mid-message", () => { expect(isOmpSystemNotice("please fix the bug")).toBe(false); expect(isOmpSystemNotice("what does mean in omp?")).toBe(false); - expect(mapOmpSystemNoticeToToolCall("what does mean in omp?")).toBeNull(); + expect(mapOmpSystemNoticeToNotification("what does mean in omp?")).toBeNull(); }); }); -describe("omp system notice tool call mapping", () => { - test("maps a completed task-result notice to a synthetic completed tool call", () => { - expect(mapOmpSystemNoticeToToolCall(COMPLETED_NOTICE)).toEqual({ - type: "tool_call", - callId: "omp-notice:DocsSmokeTwo", - name: "task_notification", - status: "completed", - detail: { - type: "plain_text", - label: "Background job DocsSmokeTwo completed", - text: COMPLETED_NOTICE, - icon: "wrench", - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - taskId: "DocsSmokeTwo", - subagentType: "explore", - status: "completed", - }, - error: null, +describe("omp system notice notification mapping", () => { + test("maps a completed task-result notice to an info notification", () => { + expect(mapOmpSystemNoticeToNotification(COMPLETED_NOTICE)).toEqual({ + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }); }); @@ -62,11 +47,11 @@ describe("omp system notice tool call mapping", () => { "", ].join("\n"); - const item = mapOmpSystemNoticeToToolCall(notice); - expect(item).toMatchObject({ - callId: "omp-notice:RepoSmokeOne", - status: "failed", - error: "Background job RepoSmokeOne failed", + const item = mapOmpSystemNoticeToNotification(notice); + expect(item).toEqual({ + type: "notification", + level: "error", + message: "Background job RepoSmokeOne failed", }); }); @@ -80,30 +65,23 @@ describe("omp system notice tool call mapping", () => { "", ].join("\n"); - expect(mapOmpSystemNoticeToToolCall(notice)).toMatchObject({ - callId: "omp-notice:DocsSmokeTwo", - status: "completed", - metadata: { - taskId: "DocsSmokeTwo", - subagentType: "explore", - }, + expect(mapOmpSystemNoticeToNotification(notice)).toEqual({ + type: "notification", + level: "info", + message: "Background job DocsSmokeTwo completed", }); }); - test("maps a notice without a task-result using its first line and a stable hash id", () => { + test("maps a notice without a task-result using its first line", () => { const notice = "\nThe daemon rotated its logs.\n"; - const first = mapOmpSystemNoticeToToolCall(notice); - const second = mapOmpSystemNoticeToToolCall(notice); + const first = mapOmpSystemNoticeToNotification(notice); + const second = mapOmpSystemNoticeToNotification(notice); expect(first).toEqual(second); - expect(first).toMatchObject({ - status: "completed", - detail: { - type: "plain_text", - label: "The daemon rotated its logs.", - text: notice, - }, + expect(first).toEqual({ + type: "notification", + level: "info", + message: "The daemon rotated its logs.", }); - expect(first?.callId).toMatch(/^omp-notice:[0-9a-f]{12}$/); }); }); diff --git a/packages/server/src/server/agent/providers/omp/system-notice.ts b/packages/server/src/server/agent/providers/omp/system-notice.ts index 73dd5e3e55..141ee68afe 100644 --- a/packages/server/src/server/agent/providers/omp/system-notice.ts +++ b/packages/server/src/server/agent/providers/omp/system-notice.ts @@ -1,5 +1,3 @@ -import { createHash } from "node:crypto"; - import type { AgentTimelineItem } from "../../agent-sdk-types.js"; const SYSTEM_NOTICE_OPEN_TAG = ""; @@ -9,7 +7,7 @@ const TASK_RESULT_TAG_PATTERN = /]*)>/i; // with typographic quotes after copy/paste round-trips; accept both. const TASK_RESULT_ATTRIBUTE_PATTERN = /([\w-]+)=["'“‘]([^"'“”‘’]*)["'”’]/g; -type OmpSystemNoticeToolCallItem = Extract; +type OmpSystemNotificationItem = Extract; interface OmpTaskResultSummary { id: string | null; @@ -17,11 +15,6 @@ interface OmpTaskResultSummary { status: string | null; } -type OmpNoticeLifecycle = - | { status: "completed"; error: null } - | { status: "failed"; error: string } - | { status: "canceled"; error: null }; - export function isOmpSystemNotice(text: string): boolean { return text.trimStart().startsWith(SYSTEM_NOTICE_OPEN_TAG); } @@ -62,18 +55,17 @@ function readNoticeFirstLine(text: string): string | null { return null; } -function buildLifecycle( +function notificationLevel( taskResult: OmpTaskResultSummary | null, - label: string, -): OmpNoticeLifecycle { +): OmpSystemNotificationItem["level"] { const status = taskResult?.status?.toLowerCase() ?? null; if (status === "failed" || status === "error") { - return { status: "failed", error: label }; + return "error"; } if (status === "canceled" || status === "cancelled" || status === "stopped") { - return { status: "canceled", error: null }; + return "warning"; } - return { status: "completed", error: null }; + return "info"; } function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): string { @@ -83,46 +75,15 @@ function buildLabel(taskResult: OmpTaskResultSummary | null, text: string): stri return readNoticeFirstLine(text) ?? "System notice"; } -function buildCallId(taskResult: OmpTaskResultSummary | null, text: string): string { - if (taskResult?.id) { - return `omp-notice:${taskResult.id}`; - } - const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12); - return `omp-notice:${digest}`; -} - -export function mapOmpSystemNoticeToToolCall(text: string): OmpSystemNoticeToolCallItem | null { +export function mapOmpSystemNoticeToNotification(text: string): OmpSystemNotificationItem | null { if (!isOmpSystemNotice(text)) { return null; } const taskResult = readTaskResult(text); - const label = buildLabel(taskResult, text); - const lifecycle = buildLifecycle(taskResult, label); - const base = { - type: "tool_call" as const, - callId: buildCallId(taskResult, text), - name: "task_notification", - detail: { - type: "plain_text" as const, - label, - text, - icon: "wrench" as const, - }, - metadata: { - synthetic: true, - source: "omp_system_notice", - ...(taskResult?.id ? { taskId: taskResult.id } : {}), - ...(taskResult?.agent ? { subagentType: taskResult.agent } : {}), - ...(taskResult?.status ? { status: taskResult.status } : {}), - }, + return { + type: "notification", + level: notificationLevel(taskResult), + message: buildLabel(taskResult, text), }; - - if (lifecycle.status === "failed") { - return { ...base, status: "failed", error: lifecycle.error }; - } - if (lifecycle.status === "canceled") { - return { ...base, status: "canceled", error: null }; - } - return { ...base, status: "completed", error: null }; } diff --git a/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs new file mode 100644 index 0000000000..1821afb47d --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/test-utils/echo-mcp-server.mjs @@ -0,0 +1,47 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { writeFileSync } from "node:fs"; + +if (process.env.OMP_MCP_PID_FILE) writeFileSync(process.env.OMP_MCP_PID_FILE, String(process.pid)); + +const server = new McpServer({ name: "omp-echo-fixture", version: "1.0.0" }); +server.registerTool( + "echo_secret", + { description: "Returns the fixture word", inputSchema: { word: z.string() } }, + async ({ word }, extra) => { + if (word === "ENV") { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + runtime: process.env.OMP_MCP_RUNTIME_ENV, + agent: process.env.PASEO_AGENT_ID, + precedence: process.env.OMP_MCP_PRECEDENCE, + }), + }, + ], + }; + } + if (word === "WAIT") { + if (process.env.OMP_MCP_WAIT_FILE) writeFileSync(process.env.OMP_MCP_WAIT_FILE, "waiting"); + return await new Promise((resolve) => { + extra.signal.addEventListener( + "abort", + () => { + if (process.env.OMP_MCP_CANCEL_FILE) { + writeFileSync(process.env.OMP_MCP_CANCEL_FILE, "cancelled"); + } + resolve({ content: [{ type: "text", text: "cancelled" }] }); + }, + { once: true }, + ); + }); + } + return word === "FAIL" + ? { content: [{ type: "text", text: "fixture failure" }], isError: true } + : { content: [{ type: "text", text: `MANGO:${word}` }] }; + }, +); +await server.connect(new StdioServerTransport()); diff --git a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts index 0b17fe8c5b..403a389647 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/fake-omp.ts @@ -54,6 +54,11 @@ export class FakeOmp implements OmpRuntime { private readonly sessions: FakeOmpSession[] = []; private readonly command: [string, ...string[]]; private readonly queuedCommands: OmpRpcSlashCommand[][] = []; + private readonly queuedSessionSetups: Array<(session: FakeOmpSession) => void> = []; + private readonly removedModels = new Set(); + private nextStartError: Error | null = null; + private nextSessionId = 0; + private readonly sessionIdsByFile = new Map(); private readonly queuedSubagentSubscriptionErrors = new Map< FakeOmpSubagentSubscriptionLevel, Error @@ -64,17 +69,28 @@ export class FakeOmp implements OmpRuntime { } async startSession(input: OmpStartSessionInput): Promise { + const startError = this.nextStartError; + this.nextStartError = null; + if (startError) throw startError; const launch = buildOmpLaunch({ command: this.command, session: input, }); this.recordedLaunches.push(launch); - const session = new FakeOmpSession(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } + const sessionId = + (launch.session ? this.sessionIdsByFile.get(launch.session) : undefined) ?? + `omp-session-${++this.nextSessionId}`; + const session = new FakeOmpSession(launch, sessionId, this.removedModels); + if (!launch.noSession) this.sessionIdsByFile.set(session.state.sessionFile, sessionId); session.commands = this.queuedCommands.shift() ?? []; for (const [level, error] of this.queuedSubagentSubscriptionErrors) { session.subagentSubscriptionErrors.set(level, error); } this.queuedSubagentSubscriptionErrors.clear(); + this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); return session; } @@ -83,6 +99,19 @@ export class FakeOmp implements OmpRuntime { this.queuedCommands.push(commands); } + queueSessionSetup(setup: (session: FakeOmpSession) => void): void { + this.queuedSessionSetups.push(setup); + } + + // OMP refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + + failNextStart(error: Error): void { + this.nextStartError = error; + } + failNextSubagentSubscription(level: FakeOmpSubagentSubscriptionLevel, error: Error): void { this.queuedSubagentSubscriptionErrors.set(level, error); } @@ -94,9 +123,16 @@ export class FakeOmp implements OmpRuntime { } return session; } + + allSessions(): FakeOmpSession[] { + return [...this.sessions]; + } } export class FakeOmpSession implements OmpRuntimeSession { + readonly environment: Record; + fastModeResult = { enabled: false, active: false }; + readonly setFastModeRequests: boolean[] = []; readonly prompts: Array<{ message: string; imageCount: number }> = []; readonly compactRequests: Array<{ customInstructions?: string }> = []; readonly setAutoCompactionRequests: boolean[] = []; @@ -106,6 +142,7 @@ export class FakeOmpSession implements OmpRuntimeSession { readonly setThinkingLevelRequests: OmpThinkingLevel[] = []; readonly handoffRequests: Array<{ customInstructions?: string }> = []; readonly steerRequests: Array<{ message: string; imageCount: number }> = []; + steerError: Error | null = null; readonly followUpRequests: Array<{ message: string; imageCount: number }> = []; readonly hostToolSetRequests: OmpRpcHostToolDefinition[][] = []; readonly hostToolResults: OmpRpcHostToolResult[] = []; @@ -113,6 +150,8 @@ export class FakeOmpSession implements OmpRuntimeSession { getStateRequestCount = 0; abortRequested = false; abortError: Error | null = null; + onAbort: (() => void) | null = null; + private heldStateRequests: Array<() => void> | null = null; readonly canceledExtensionUiRequests: string[] = []; readonly extensionUiResponses: Array<{ id: string; @@ -150,15 +189,22 @@ export class FakeOmpSession implements OmpRuntimeSession { private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = null; - constructor(launch: OmpRuntimeLaunch) { + constructor( + launch: OmpRuntimeLaunch, + sessionId = "omp-session-1", + private readonly removedModels: ReadonlySet = new Set(), + ) { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", isStreaming: false, isCompacting: false, autoCompactionEnabled: true, + fastModeEnabled: false, + fastModeActive: false, sessionFile: launch.session ?? "/tmp/omp-session", - sessionId: "omp-session-1", + sessionId, messageCount: 0, queuedMessageCount: 0, }; @@ -243,6 +289,8 @@ export class FakeOmpSession implements OmpRuntimeSession { throw this.abortError; } this.abortRequested = true; + // OMP can stream a turn's teardown before it answers the abort request. + this.onAbort?.(); } async getState(): Promise { @@ -251,6 +299,10 @@ export class FakeOmpSession implements OmpRuntimeSession { if (this.getStateRequestCount >= waiter.count) waiter.resolve(); else this.stateRequestWaiters.push(waiter); } + if (this.heldStateRequests) { + const held = this.heldStateRequests; + await new Promise((resolve) => held.push(resolve)); + } if (this.getStateError) { throw this.getStateError; } @@ -261,6 +313,26 @@ export class FakeOmpSession implements OmpRuntimeSession { return this.state; } + async setFastMode(enabled: boolean): Promise<{ enabled: boolean; active: boolean }> { + this.setFastModeRequests.push(enabled); + this.state = { + ...this.state, + fastModeEnabled: this.fastModeResult.enabled, + fastModeActive: this.fastModeResult.active, + }; + return this.fastModeResult; + } + + /** Holds every state request until the returned function releases them. */ + holdStateRequests(): () => void { + const held: Array<() => void> = []; + this.heldStateRequests = held; + return () => { + this.heldStateRequests = null; + for (const resolve of held.splice(0)) resolve(); + }; + } + queueStateReports(states: OmpSessionState[]): void { this.stateReports.push(...states); } @@ -280,9 +352,13 @@ export class FakeOmpSession implements OmpRuntimeSession { async setModel(provider: string, modelId: string): Promise { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakeOmp setModel requires setModelResult to be scripted"); } + this.state = { ...this.state, model: this.setModelResult }; return this.setModelResult; } @@ -332,8 +408,12 @@ export class FakeOmpSession implements OmpRuntimeSession { return this.branchMessages; } - steer(message: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): void { + async steer( + message: string, + images?: Array<{ type: "image"; data: string; mimeType: string }>, + ): Promise { this.steerRequests.push({ message, imageCount: images?.length ?? 0 }); + if (this.steerError) throw this.steerError; } followUp( diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 30d75bb8f4..1820c187c9 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -19,7 +19,7 @@ import { type OmpProviderIdleScheduler, } from "../agent.js"; import type { OmpUsagePollScheduler } from "../usage-poller.js"; -import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js"; +import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; const CWD = "/tmp/paseo-omp-agent-test"; @@ -71,14 +71,18 @@ export class OmpHarness { providerIdleScheduler?: OmpProviderIdleScheduler; noTurnScheduler?: OmpNoTurnScheduler; usagePollScheduler?: OmpUsagePollScheduler; + providerIdleDeadlineMs?: number; + runtimeEnv?: Record; } = {}, ) { this.client = new OmpAgentClient({ logger: pino({ level: "silent" }), + runtimeSettings: { env: options.runtimeEnv }, runtime: this.omp, providerIdleScheduler: options.providerIdleScheduler, noTurnScheduler: options.noTurnScheduler, usagePollScheduler: options.usagePollScheduler, + providerIdleDeadlineMs: options.providerIdleDeadlineMs, }); } @@ -93,10 +97,11 @@ export class OmpHarness { async start( config: Partial = {}, paseoTools?: PaseoToolCatalog, + env?: Record, ): Promise { const session = await this.client.createSession( { provider: "omp", cwd: CWD, ...config }, - paseoTools ? { paseoTools } : undefined, + paseoTools || env ? { paseoTools, env } : undefined, ); if (!(session instanceof OmpAgentSession)) { throw new Error("OMP client returned a non-OMP session"); @@ -150,6 +155,20 @@ export class OmpHarness { return this.client.capabilities; } + persistence() { + return this.requireSession().describePersistence(); + } + + async replayHistory(handle: AgentPersistenceHandle): Promise { + const session = await this.client.resumeSession(handle, undefined, undefined, { + purpose: "history", + }); + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + await session.close(); + return events; + } + async runPrompt( input: string, output: string, @@ -275,6 +294,14 @@ export class OmpHarness { return await run; } + async runPromptWithoutTurnOnNextRuntime(input: string, requestId: string): Promise { + const session = this.requireSession(); + this.omp.queueSessionSetup((runtime) => { + runtime.promptAck = { requestId, agentInvoked: false }; + }); + return await session.run(input); + } + async runPromptWithoutTurn(input: string): Promise { const session = this.requireSession(); this.omp.latestSession().promptAck = { agentInvoked: false }; @@ -310,6 +337,31 @@ export class OmpHarness { return { completed: () => isCompleted, completion }; } + async runPromptRejectedBeforeAgentRuns( + input: string, + error: string, + order: "result after ack" | "result before ack", + ): Promise { + const session = this.requireSession(); + const runtime = this.omp.latestSession(); + runtime.promptAck = { requestId: "prompt-rejected" }; + const rejection = { + type: "prompt_result", + id: "prompt-rejected", + agentInvoked: false, + status: "error", + sessionSettled: true, + error: { message: error, retryable: false }, + } as const; + const promptStarted = runtime.nextPrompt(); + const run = session.run(input); + if (order === "result before ack") runtime.emit(rejection); + await promptStarted; + await waitForImmediate(); + if (order === "result after ack") runtime.emit(rejection); + return await run; + } + async runPromptAfterCorrelatedTrueResult( input: string, output: string, @@ -422,6 +474,10 @@ export class OmpHarness { this.omp.latestSession().requestToolApproval(input); } + emit(event: OmpRuntimeEvent): void { + this.omp.latestSession().emit(event); + } + pendingPermissions() { return this.requireSession().getPendingPermissions(); } @@ -446,6 +502,36 @@ export class OmpHarness { return await this.requireSession().setMode(modeId); } + currentMode() { + return this.requireSession().getCurrentMode(); + } + + runtimeLaunches() { + return this.omp.recordedLaunches; + } + + runtimeSessions() { + return this.omp.allSessions(); + } + + processExit(error: string): void { + this.omp.latestSession().emit({ type: "process_exit", error }); + } + + failNextStart(error: Error): void { + this.omp.failNextStart(error); + } + + turnFailures(): string[] { + return this.events.flatMap((event) => (event.type === "turn_failed" ? [event.error] : [])); + } + + threadStartedSessionIds(): string[] { + return this.events.flatMap((event) => + event.type === "thread_started" ? [event.sessionId] : [], + ); + } + async rewind(messageId: string, restoredPrompt: string): Promise { this.omp.latestSession().branchResponse = { text: restoredPrompt }; await this.requireSession().revertConversation({ messageId }); @@ -466,6 +552,15 @@ export class OmpHarness { await promptStarted; } + async startTurn(message: string): Promise { + await this.requireSession().startTurn(message); + await waitForImmediate(); + } + + startTurnDetached(message: string) { + return this.requireSession().startTurn(message); + } + async interrupt(): Promise { await this.requireSession().interrupt(); } @@ -504,6 +599,10 @@ export class OmpHarness { return this.events.filter((event) => event.type === "turn_canceled").length; } + usageSession() { + return this.requireSession().usageSession(); + } + async close(): Promise { await this.requireSession().close(); await waitForImmediate(); diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts index e04ddee2bc..85e4b9a3a9 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.test.ts @@ -103,4 +103,41 @@ describe("OMP todo mapper", () => { mapOmpTodoToolResult({ details: { phases: [{ name: "Bad", tasks: [{}] }] } }), ).toBeNull(); }); + + test("keeps blocked work visible and excludes abandoned work from active todos", () => { + const phases = [ + { + name: "Tasks", + tasks: [ + { content: "Wait for access", status: "blocked", blocker: "approval" }, + { content: "Old approach", status: "abandoned" }, + ], + }, + ]; + const expected = { + type: "todo", + items: [{ text: "Wait for access (blocked: approval)", status: "pending", completed: false }], + }; + expect(mapOmpTodoToolResult(parseToolResult({ content: [], details: { phases } }))).toEqual( + expected, + ); + expect( + mapOmpTodoState({ + isStreaming: false, + isCompacting: false, + sessionId: "s", + todoPhases: phases, + }), + ).toEqual([expected]); + expect( + mapOmpTodoToolResult( + parseToolResult({ + content: [], + details: { + phases: [{ name: "Tasks", tasks: [{ content: "Old approach", status: "abandoned" }] }], + }, + }), + ), + ).toEqual({ type: "todo", items: [] }); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/todo-mapper.ts b/packages/server/src/server/agent/providers/omp/todo-mapper.ts index 0b41d5a43f..699a40d3aa 100644 --- a/packages/server/src/server/agent/providers/omp/todo-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/todo-mapper.ts @@ -39,11 +39,16 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul } return { type: "todo", - items: items.map((item) => ({ - text: item.content, - status: normalizeOmpTodoStatus(item.status), - completed: item.status === "completed", - })), + items: items + .filter((item) => item.status !== "abandoned") + .map((item) => ({ + text: + item.status === "blocked" + ? `${item.content} (${item.blocker ? `blocked: ${item.blocker}` : "blocked"})` + : item.content, + status: normalizeOmpTodoStatus(item.status), + completed: item.status === "completed", + })), }; } diff --git a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts index 7df6efb676..cece7f0398 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-detail.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-detail.ts @@ -57,8 +57,13 @@ interface OmpToolResultObject { details?: OmpToolResultDetails; } +interface OmpToolResultStructuredError { + message: string; +} + interface OmpToolResultDetails { diff?: string; + error?: string | OmpToolResultStructuredError; mode?: string; xdev?: unknown; } @@ -154,9 +159,17 @@ const OmpToolResultContentSchema = z.union([ OmpToolResultUnknownContentSchema, ]); +// OMP's own tools report a string error; Paseo host tools (browser_*) report +// a structured `{ code, message, retryable }` error. +const OmpToolResultErrorSchema = z.union([ + z.string(), + z.object({ message: z.string() }).passthrough(), +]); + const OmpToolResultDetailsSchema = z .object({ diff: z.string().optional(), + error: OmpToolResultErrorSchema.optional(), }) .passthrough(); @@ -270,6 +283,41 @@ export function extractTextFromToolResult(result: OmpToolResult): string | undef return textParts.length > 0 ? textParts.join("\n") : undefined; } +export function toolFailureMessage(result: OmpToolResult): string { + const error = result && typeof result !== "string" ? result.details?.error : undefined; + const errorMessage = typeof error === "string" ? error : error?.message; + if (errorMessage) { + return errorMessage.split("\n", 1)[0].slice(0, 240); + } + const output = extractTextFromToolResult(result); + const exitMessage = output?.match(/(?:Command|Process) exited with code \d+/i)?.[0]; + if (exitMessage) return exitMessage; + const firstLine = output + ?.split("\n") + .find((line) => line.trim()) + ?.trim(); + if (firstLine) return firstLine.slice(0, 240); + if (result && typeof result !== "string") { + const code = result.exitCode ?? result.code; + if (typeof code === "number") return `Tool exited with code ${code}`; + } + return "Tool call failed"; +} + +export function isOmpToolFailure( + toolCall: OmpTrackedToolCall, + result: OmpToolResult, + isError: boolean, +): boolean { + return ( + isError || + (toolCall.toolName === "web_search" && + result !== null && + typeof result !== "string" && + typeof result.details?.error === "string") + ); +} + export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToolCall { if (toolName === "edit") { return parseEditToolArgs(rawArgs); @@ -289,6 +337,12 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): OmpTrackedToo } export function resolveToolCallName(toolCall: OmpTrackedToolCall, result?: OmpToolResult): string { + if ( + (toolCall.kind === "read" || toolCall.kind === "write") && + toolCall.args.path.startsWith("xd://") + ) { + return toolCall.args.path.slice("xd://".length).split(/[/?#]/, 1)[0] || toolCall.toolName; + } if (toolCall.kind === "write" && result && typeof result !== "string") { const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev); if (xdev.success) { @@ -316,6 +370,20 @@ export function mapToolDetail( }; } case "read": + if (toolCall.args.path.startsWith("xd://")) { + return { + type: "plain_text", + label: resolveToolCallName(toolCall), + text: extractTextFromToolResult(parsedResult), + }; + } + if (/^https?:\/\//.test(toolCall.args.path)) { + return { + type: "fetch", + url: toolCall.args.path, + result: extractTextFromToolResult(parsedResult), + }; + } return { type: "read", filePath: toolCall.args.path, @@ -374,6 +442,15 @@ function mapWriteToolDetail(args: WriteToolInput, result: OmpToolResult): ToolCa }; } + // A write to `xd://` runs a tool, not a file write. Name the invoked tool while it runs. + if (args.path.startsWith("xd://")) { + return { + type: "plain_text", + label: args.path.slice("xd://".length).split(/[/?#]/, 1)[0], + text: extractTextFromToolResult(result), + }; + } + return { type: "write", filePath: args.path, diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts index dcccea3b60..ae4c0d0d77 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.test.ts @@ -117,4 +117,218 @@ describe("OMP tool call mapper", () => { output: null, }); }); + + test("uses current OMP task arguments and wait results for readable rows", () => { + expect( + mapOmpToolDetail( + parseToolArgs("task", { + name: "Docs", + agent: "explore", + task: "Read src/a.ts", + solutionSpace: "Report findings", + }), + null, + ), + ).toMatchObject({ type: "sub_agent", subAgentType: "Docs", description: "Read src/a.ts" }); + expect( + mapOmpToolDetail( + parseToolArgs("wait", {}), + parseToolResult({ content: [{ type: "text", text: "Waiting for Docs to finish" }] }), + ), + ).toMatchObject({ type: "plain_text", label: "Waiting for Docs to finish" }); + }); + + test("names a task from OMP's tasks array while it runs and after it completes", () => { + const call = parseToolArgs("task", { + context: "Read a scratch file", + tasks: [{ name: "ReadNotes", task: "Read src/notes.ts and report its content" }], + i: "Delegating the read", + }); + expect(mapOmpToolDetail(call, null)).toMatchObject({ + type: "sub_agent", + subAgentType: "ReadNotes", + description: "Delegating the read", + }); + expect( + mapOmpToolDetail( + call, + parseToolResult({ content: [{ type: "text", text: "Spawned agent `ReadNotes`" }] }), + ), + ).toMatchObject({ type: "sub_agent", subAgentType: "ReadNotes" }); + }); + + test("uses the first task instruction when OMP omits its short intent", () => { + expect( + mapOmpToolDetail( + parseToolArgs("task", { + tasks: [ + { + name: "WaitThenRead", + task: "# Target src/notes.ts in the scratch repository.\n# Change\nRun bash sleep 8.", + }, + ], + }), + null, + ), + ).toMatchObject({ + type: "sub_agent", + subAgentType: "WaitThenRead", + description: "src/notes.ts in the scratch repository.", + }); + }); + + test("skips a standalone target heading in a running task", () => { + expect( + mapOmpToolDetail( + parseToolArgs("task", { + tasks: [ + { + name: "SlowRead", + task: "# Target\nsrc/notes.ts in the scratch repository.\n# Change\nRead the file.", + }, + ], + }), + null, + ), + ).toMatchObject({ description: "src/notes.ts in the scratch repository." }); + }); + + test("names a structured subagent yield by its action", () => { + expect( + mapOmpToolDetail( + parseToolArgs("yield", { data: { content: "done" } }), + parseToolResult({ content: [{ type: "text", text: "Result submitted." }] }), + ), + ).toEqual({ + type: "plain_text", + label: "Submitted subagent result", + text: "Result submitted.", + }); + }); + + test("connects a steered background bash call with its wait result", () => { + const command = "sleep 2; echo finished"; + expect( + mapOmpToolDetail( + parseToolArgs("bash", { command }), + parseToolResult({ + content: [ + { + type: "text", + text: "Backgrounded early to handle an incoming message; the command keeps running.\nJob: bg_1", + }, + ], + }), + ), + ).toMatchObject({ type: "shell", command }); + const resultText = + "## Completed (1)\n\n### bg_1 [bash] — completed\nLabel: sleep 2; echo finished\nDelivery: not auto-delivered; recovered by this snapshot.\n```\nfinished\n```"; + expect( + mapOmpToolDetail( + parseToolArgs("wait", {}), + parseToolResult({ + content: [{ type: "text", text: resultText }], + details: { + op: "wait", + jobs: [ + { + id: "bg_1", + type: "bash", + status: "completed", + label: command, + resultText: "finished", + }, + ], + }, + }), + ), + ).toEqual({ type: "plain_text", label: `bash completed: ${command}`, text: "finished" }); + }); + + test("maps web search and URL reads to search and fetch", () => { + expect(mapOmpToolDetail(parseToolArgs("web_search", { query: "Paseo" }), null)).toMatchObject({ + type: "search", + query: "Paseo", + toolName: "web_search", + }); + expect( + mapOmpToolDetail(parseToolArgs("read", { path: "https://example.com/" }), null), + ).toMatchObject({ type: "fetch", url: "https://example.com/" }); + }); + + test("names virtual device calls from their path on failure and while running", () => { + expect(mapOmpToolDetail(parseToolArgs("read", { path: "xd://ast_grep" }), null)).toMatchObject({ + type: "plain_text", + label: "ast_grep", + }); + expect( + mapOmpToolDetail(parseToolArgs("write", { path: "xd://lsp", content: "{}" }), null), + ).toMatchObject({ type: "plain_text", label: "lsp" }); + }); + + test("renders executed OMP device tools with readable action and output", () => { + expect( + mapOmpToolDetail( + parseToolArgs("write", { path: "xd://github", content: '{"op":"status"}' }), + parseToolResult({ + content: [{ type: "text", text: "Working tree clean" }], + details: { + xdev: { tool: "github", mode: "execute", args: { op: "status" }, inner: {} }, + }, + }), + ), + ).toEqual({ type: "plain_text", label: "status", text: "Working tree clean" }); + }); + + test("shows the first question in an ask row", () => { + expect( + mapOmpToolDetail(parseToolArgs("ask", { questions: [{ question: "Which file?" }] }), null), + ).toMatchObject({ type: "plain_text", label: "Which file?" }); + }); + + test("shows the question and selected answers after ask completes", () => { + const detail = mapOmpToolDetail( + parseToolArgs("ask", { questions: [{ question: "Which colors?" }] }), + parseToolResult({ + content: [{ type: "text", text: "User selected: Red, Blue" }], + details: { question: "Which colors?", selectedOptions: ["Red", "Blue"] }, + }), + ); + expect(detail).toEqual({ + type: "plain_text", + label: "Which colors?", + text: "Which colors?\nRed, Blue", + }); + }); + + test("shows meaningful labels for OMP's remaining built-in tools", () => { + expect(mapOmpToolDetail(parseToolArgs("glob", { pattern: "src/**/*.ts" }), null)).toMatchObject( + { type: "search", query: "src/**/*.ts", toolName: "glob" }, + ); + expect( + mapOmpToolDetail(parseToolArgs("ast_grep", { pattern: "console.log($X)" }), null), + ).toMatchObject({ type: "search", query: "console.log($X)" }); + expect( + mapOmpToolDetail(parseToolArgs("checkpoint", { goal: "Inspect parser" }), null), + ).toMatchObject({ type: "plain_text", label: "Inspect parser" }); + expect( + mapOmpToolDetail(parseToolArgs("manage_skill", { action: "create", name: "demo" }), null), + ).toMatchObject({ type: "plain_text", label: "create demo" }); + expect( + mapOmpToolDetail(parseToolArgs("think", { thoughts: "Check the next step" }), null), + ).toEqual({ + type: "plain_text", + label: "Thinking", + text: "Check the next step", + }); + }); + + test("uses OMP ast_grep pat as the search query", () => { + expect( + mapOmpToolDetail(parseToolArgs("ast_grep", { pat: "console.log($A)" }), null), + ).toMatchObject({ + type: "search", + query: "console.log($A)", + }); + }); }); diff --git a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts index 180790ab3a..86b1ee53ef 100644 --- a/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/omp/tool-call-mapper.ts @@ -1,7 +1,9 @@ import type { ToolCallDetail } from "../../agent-sdk-types.js"; +import type { OmpBridgedToolIdentity } from "./mcp-bridge.js"; import { extractTextFromToolResult, mapToolDetail as mapOmpCoreToolDetail, + resolveToolCallName, type OmpToolResult, type OmpTrackedToolCall, } from "./tool-call-detail.js"; @@ -11,9 +13,13 @@ export function mapOmpToolDetail( result: OmpToolResult, context?: { toolCallId: string; + bridgedTool?: OmpBridgedToolIdentity; mapSubagentDetail?: (baseDetail: ToolCallDetail) => ToolCallDetail; }, ): ToolCallDetail | null { + if (context?.bridgedTool) { + return { type: "unknown", input: toolCall.args, output: result }; + } if (toolCall.toolName === "todo") { return null; } @@ -21,6 +27,10 @@ export function mapOmpToolDetail( const detail = mapOmpTaskDetail(toolCall.args, result); return context?.mapSubagentDetail?.(detail) ?? detail; } + const deviceDetail = mapOmpDeviceToolDetail(toolCall, result); + if (deviceDetail) return deviceDetail; + const extended = mapOmpExtendedToolDetail(toolCall, result); + if (extended) return extended; if (toolCall.toolName === "edit") { return mapOmpEditDetail(toolCall, result); } @@ -30,19 +40,160 @@ export function mapOmpToolDetail( return mapOmpCoreToolDetail(toolCall, result); } +function mapOmpDeviceToolDetail( + toolCall: OmpTrackedToolCall, + result: OmpToolResult, +): ToolCallDetail | null { + if (toolCall.kind !== "write" || !toolCall.args.path.startsWith("xd://")) return null; + const toolName = resolveToolCallName(toolCall, result); + if (!OPAQUE_TEXT_TOOLS.has(toolName) && toolName !== "ast_grep") return null; + let args: unknown; + try { + args = JSON.parse(toolCall.args.content); + } catch { + args = null; + } + return mapOmpExtendedToolDetail({ kind: "unknown", toolName, args }, result); +} + +function mapOmpExtendedToolDetail( + toolCall: OmpTrackedToolCall, + result: OmpToolResult, +): ToolCallDetail | null { + if (toolCall.toolName === "yield") { + return { + type: "plain_text", + label: "Submitted subagent result", + text: extractTextFromToolResult(result), + }; + } + if (toolCall.toolName === "wait") { + return mapOmpWaitDetail(result); + } + if (toolCall.toolName === "web_search") { + const args = isRecord(toolCall.args) ? toolCall.args : {}; + return { + type: "search", + query: firstString(args.query) ?? "Web search", + toolName: "web_search", + content: extractTextFromToolResult(result), + }; + } + if (toolCall.toolName === "ask" || toolCall.toolName === "ask_user") { + return mapOmpAskDetail(toolCall.args, result); + } + if (toolCall.toolName === "glob" || toolCall.toolName === "ast_grep") { + const args = isRecord(toolCall.args) ? toolCall.args : {}; + return { + type: "search", + query: firstString(args.pattern, args.pat, args.query) ?? toolCall.toolName, + ...(toolCall.toolName === "glob" ? { toolName: "glob" as const } : {}), + content: extractTextFromToolResult(result), + }; + } + if (toolCall.toolName === "think") { + const args = isRecord(toolCall.args) ? toolCall.args : {}; + return { type: "plain_text", label: "Thinking", text: firstString(args.thoughts) }; + } + return mapOmpOpaqueToolDetail(toolCall, result); +} + +function mapOmpWaitDetail(result: OmpToolResult): ToolCallDetail { + const text = extractTextFromToolResult(result)?.trim(); + const jobs = resultDetails(result)?.jobs; + const firstJob = Array.isArray(jobs) ? jobs.find(isRecord) : undefined; + const jobType = firstString(firstJob?.type); + const jobStatus = firstString(firstJob?.status); + const jobLabel = firstString(firstJob?.label, firstJob?.id); + const label = + jobType && jobStatus && jobLabel + ? `${jobType} ${jobStatus}: ${jobLabel}` + : (text?.split("\n", 1)[0] ?? "Waiting for background work"); + return { + type: "plain_text", + label, + text: firstString(firstJob?.resultText, firstJob?.errorText) ?? text, + }; +} + +function mapOmpAskDetail(rawArgs: unknown, result: OmpToolResult): ToolCallDetail { + const args = isRecord(rawArgs) ? rawArgs : {}; + const firstQuestion = Array.isArray(args.questions) ? args.questions[0] : undefined; + const askedQuestion = isRecord(firstQuestion) ? firstString(firstQuestion.question) : undefined; + const details = + result && typeof result !== "string" && isRecord(result.details) ? result.details : {}; + const answers = Array.isArray(details.results) ? details.results : [details]; + const lines = answers.flatMap((answer) => { + if (!isRecord(answer)) return []; + const question = firstString(answer.question); + const selected = Array.isArray(answer.selectedOptions) + ? answer.selectedOptions.filter((option): option is string => typeof option === "string") + : []; + const response = + [...selected, firstString(answer.customInput)].filter(Boolean).join(", ") || "No selection"; + return question ? [`${question}\n${response}`] : []; + }); + const text = lines.length ? lines.join("\n\n") : extractTextFromToolResult(result); + return { + type: "plain_text", + label: firstString(details.question, askedQuestion, args.question, args.title) ?? "Question", + text, + }; +} + +function mapOmpOpaqueToolDetail( + toolCall: OmpTrackedToolCall, + result: OmpToolResult, +): ToolCallDetail | null { + if (OPAQUE_TEXT_TOOLS.has(toolCall.toolName)) { + const args = isRecord(toolCall.args) ? toolCall.args : {}; + const action = firstString(args.action, args.op, args.operation, args.command); + const target = firstString(args.name, args.goal, args.path, args.query, args.id, args.report); + const label = + [action, target].filter(Boolean).join(" ") || toolCall.toolName.replaceAll("_", " "); + return { type: "plain_text", label, text: extractTextFromToolResult(result) }; + } + return null; +} + +const OPAQUE_TEXT_TOOLS = new Set([ + "ast_edit", + "debug", + "eval", + "github", + "checkpoint", + "rewind", + "context_notes", + "new_context", + "security_scan", + "memory_edit", + "retain", + "recall", + "reflect", + "learn", + "manage_skill", + "yield", + "goal", +]); + function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail { const argRecord = isRecord(args) ? args : {}; + const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined; const resultText = extractTextFromToolResult(result); const childSessionId = readChildSessionId(result); return { type: "sub_agent", subAgentType: firstString( + firstTask?.name, + argRecord.name, argRecord.agent, argRecord.subAgentType, argRecord.agentType, argRecord.type, ), description: firstString( + argRecord.i, + taskInstructionSummary(firstTask?.task), argRecord.description, argRecord.task, argRecord.prompt, @@ -53,6 +204,15 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail }; } +function taskInstructionSummary(value: unknown): string | undefined { + const instruction = firstString(value); + if (!instruction) return undefined; + return instruction + .split("\n") + .map((line) => line.replace(/^#+\s*(?:Target|Goal|Task)\s*/i, "").trim()) + .find(Boolean); +} + function mapOmpEditDetail( toolCall: OmpTrackedToolCall, result: OmpToolResult, diff --git a/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts b/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts index 49993202dd..8a4f43426e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.recovery.test.ts @@ -15,29 +15,63 @@ import { OpenCodeServerManager } from "./opencode/server-manager.js"; test("dispatches ascending OpenCode message identifiers through the public provider path", async () => { const upstream = await createRecoveryUpstream(); - const fixture = await createPublicRecoverySession(upstream, new RecoveryTiming()); - const observed: AgentStreamEvent[] = []; - fixture.session.subscribe((event) => observed.push(event)); - await upstream.connected(1); - upstream.send(0, connectedRecord()); - - await fixture.session.startTurn("first"); - await upstream.dispatched(1); - upstream.send(0, idleRecord()); - await eventually(() => - expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1), + const client = new OpenCodeAgentClient( + createTestLogger(), + { + command: { + mode: "replace", + argv: [ + process.execPath, + "-e", + `setInterval(() => {}, 1000); fetch(${JSON.stringify( + `${upstream.url}/test/listening`, + )}).then(() => console.log("listening on test server"));`, + "--", + ], + }, + }, + { + resolveHomeDir: () => process.cwd(), + createClient: ({ directory }) => createOpencodeClient({ baseUrl: upstream.url, directory }), + }, ); - await fixture.session.startTurn("second"); - const ids = await upstream.dispatched(2); + try { + const creating = client.createSession({ + provider: "opencode", + cwd: "/workspace", + }); + void creating.catch(() => undefined); + await upstream.awaitingListening(); + // The process is alive but has not reported listening, so even a reachable endpoint must stay untouched. + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(upstream.connectionCount()).toBe(0); + upstream.reportListening(); + const session = await creating; + const observed: AgentStreamEvent[] = []; + session.subscribe((event) => observed.push(event)); + await upstream.connected(1); + upstream.send(0, connectedRecord()); - expect(ids).toHaveLength(2); - expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); - expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); - expect((ids[0] as string) < (ids[1] as string)).toBe(true); + await session.startTurn("first"); + await upstream.dispatched(1); + upstream.send(0, idleRecord()); + await eventually(() => + expect(observed.filter((event) => event.type === "turn_completed")).toHaveLength(1), + ); + await session.startTurn("second"); + const ids = await upstream.dispatched(2); - await fixture.session.close(); - await fixture.manager.shutdown(); - await upstream.close(); + expect(ids).toHaveLength(2); + expect(ids[0]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + expect(ids[1]).toMatch(/^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/); + expect((ids[0] as string) < (ids[1] as string)).toBe(true); + + await session.close(); + } finally { + upstream.reportListening(); + await client.shutdown(); + await upstream.close(); + } }); test("recovers missed output after EOF without failing the active turn", async () => { @@ -169,6 +203,105 @@ test("orders queued live deltas behind an incomplete recovery snapshot and suppr await upstream.close(); }); +test("sends the next turn to the new OpenCode server after the old one exits", async () => { + const exited = await createRecoveryUpstream(); + const current = await createRecoveryUpstream(); + const ports = [exited.port, current.port]; + const processes: RecoveryServerProcess[] = []; + const manager = new OpenCodeServerManager({ + logger: createTestLogger(), + portAllocator: async () => ports.shift() as number, + resolveCommandPrefix: async () => ({ command: "opencode", args: [] }), + resolveHomeDir: () => process.cwd(), + spawnServerProcess: () => { + const serverProcess = new RecoveryServerProcess(); + processes.push(serverProcess); + return serverProcess as unknown as ChildProcess; + }, + terminateProcess: async (serverProcess) => { + (serverProcess as unknown as RecoveryServerProcess).exit(); + return "terminated"; + }, + }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: manager, + createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }), + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace" }); + const observed: AgentStreamEvent[] = []; + session.subscribe((event) => observed.push(event)); + await exited.connected(1); + exited.send(0, connectedRecord()); + processes[0]?.exit(); + await exited.close(); + + const turn = session.startTurn("after restart"); + await current.connected(1); + current.send(0, connectedRecord()); + await turn; + await current.dispatched(1); + current.send(0, idleRecord()); + + await eventually(() => expect(observed.map((event) => event.type)).toContain("turn_completed")); + expect(observed.filter((event) => event.type === "turn_failed")).toHaveLength(0); + + await session.close(); + await manager.shutdown(); + await current.close(); +}); + +test("adds the session's MCP servers to the new OpenCode server after the old one exits", async () => { + const exited = await createRecoveryUpstream(); + const current = await createRecoveryUpstream(); + const ports = [exited.port, current.port]; + const processes: RecoveryServerProcess[] = []; + const manager = new OpenCodeServerManager({ + logger: createTestLogger(), + portAllocator: async () => ports.shift() as number, + resolveCommandPrefix: async () => ({ command: "opencode", args: [] }), + resolveHomeDir: () => process.cwd(), + spawnServerProcess: () => { + const serverProcess = new RecoveryServerProcess(); + processes.push(serverProcess); + return serverProcess as unknown as ChildProcess; + }, + terminateProcess: async (serverProcess) => { + (serverProcess as unknown as RecoveryServerProcess).exit(); + return "terminated"; + }, + }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: manager, + createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }), + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/workspace", + mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:1/mcp" } }, + }); + await exited.connected(1); + exited.send(0, connectedRecord()); + const firstTurn = session.startTurn("before restart"); + await exited.dispatched(1); + exited.send(0, idleRecord()); + await firstTurn; + expect(exited.mcpAdds()).toEqual(["paseo"]); + processes[0]?.exit(); + await exited.close(); + + const turn = session.startTurn("after restart"); + await current.connected(1); + current.send(0, connectedRecord()); + await turn; + await current.dispatched(1); + + expect(current.mcpAdds()).toEqual(["paseo"]); + + await session.close(); + await manager.shutdown(); + await current.close(); +}); + class RecoveryTiming implements OpenCodeEventConsumerTiming { private resolveWait: (() => void) | null = null; arm(): () => void { @@ -207,7 +340,10 @@ async function createPublicRecoverySession( serverProcess?.exit(); return "terminated"; }, - createEventSource: (options) => new OpenCodeEventConsumer({ ...options, timing }), + createEventSource: (options) => { + expect(options.listening).toBeInstanceOf(Promise); + return new OpenCodeEventConsumer({ ...options, timing }); + }, }); const client = new OpenCodeAgentClient(createTestLogger(), undefined, { serverManager: manager, @@ -239,12 +375,18 @@ class RecoveryServerProcess extends EventEmitter { async function createRecoveryUpstream() { const streams: ServerResponse[] = []; + let listeningResponse: ServerResponse | null = null; const dispatchIds: string[] = []; + const mcpAddNames: string[] = []; let messages: unknown[] = []; let messageReadCount = 0; let status: "busy" | "idle" = "idle"; const server = createServer(async (request, response) => { const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + if (pathname === "/test/listening") { + listeningResponse = response; + return; + } if (request.url?.startsWith("/global/event")) { response.writeHead(200, { "content-type": "text/event-stream" }); response.flushHeaders(); @@ -254,6 +396,11 @@ async function createRecoveryUpstream() { if (request.method === "POST" && pathname === "/session") { return json(response, { id: "session-1", directory: "/workspace" }); } + if (request.method === "POST" && pathname === "/mcp") { + const body = JSON.parse(await readBody(request)) as { name: string }; + mcpAddNames.push(body.name); + return json(response, {}); + } if (request.url?.includes("/prompt_async")) { const body = JSON.parse(await readBody(request)) as { messageID: string }; dispatchIds.push(body.messageID); @@ -273,6 +420,10 @@ async function createRecoveryUpstream() { return { port: address.port, url: `http://127.0.0.1:${address.port}`, + awaitingListening: async () => + expect.poll(() => listeningResponse, { timeout: 3_000 }).not.toBeNull(), + reportListening: () => listeningResponse?.end(), + connectionCount: () => streams.length, connected: async (count: number) => eventually(() => expect(streams).toHaveLength(count)), send(index: number, value: unknown) { streams[index]?.write(`data: ${JSON.stringify(value)}\n\n`); @@ -285,6 +436,7 @@ async function createRecoveryUpstream() { return [...dispatchIds]; }, messageReads: () => messageReadCount, + mcpAdds: () => [...mcpAddNames], setMessages(nextMessages: unknown[]) { messages = nextMessages; }, diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 1f63c4d08e..d6b6c5513a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -333,6 +333,76 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { model: TEST_MODEL, }); + test.each([ + { variants: { medium: {}, custom: {} }, selected: "medium", expected: "medium" }, + { variants: { custom: {} }, selected: "custom", expected: "custom" }, + { variants: { high: {} }, selected: "medium", expected: undefined }, + { variants: {}, selected: "medium", expected: undefined }, + ])( + "model switches resolve the retained variant: $selected / $variants", + async ({ variants, selected, expected }) => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const upstream = new TestOpenCodeClient(); + upstream.providerListResponse = { + data: { + connected: ["test"], + all: [ + { + id: "test", + name: "Test", + source: "api", + models: { target: { name: "Target", variants } }, + }, + ], + }, + }; + runtime.enqueueClient(upstream); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "test/source", + thinkingOptionId: selected, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.setModel!("test/target"); + expect((await session.getRuntimeInfo()).thinkingOptionId).toBe(expected ?? null); + expect(events).toContainEqual({ + type: "thinking_option_changed", + provider: "opencode", + thinkingOptionId: expected ?? null, + }); + upstream.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "session-1" } }, + ]; + await collectTurnEvents(streamSession(session, "Use the selected model")); + expect(upstream.calls.sessionPromptAsync).toEqual([ + expect.objectContaining({ + model: { providerID: "test", modelID: "target" }, + ...(expected ? { variant: expected } : {}), + }), + ]); + if (!expected) expect(upstream.calls.sessionPromptAsync[0]).not.toHaveProperty("variant"); + await session.setThinkingOption!("medium"); + await expect(session.setModel!("test/missing")).rejects.toThrow( + "OpenCode model unavailable", + ); + upstream.providerListResponse = { error: "Catalog unavailable" }; + await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable"); + expect((await session.getRuntimeInfo()).model).toBe("test/target"); + } finally { + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + test("creates a session with valid id and provider", async () => { const cwd = tmpCwd(); const runtime = new TestOpenCodeHarness(); @@ -612,6 +682,115 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test.each([ + { name: "absent", variants: {} }, + { name: "empty", variants: { default: {} } }, + { name: "configured", variants: { default: { reasoningEffort: "low" } } }, + ])("catalog exposes one Default when upstream default is $name", async ({ variants }) => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.providerListResponse = { + data: { + connected: ["catalog-provider"], + all: [ + { + id: "catalog-provider", + name: "Catalog provider", + source: "api", + models: { + model: { + name: "Variant model", + variants: { ...variants, high: {}, "variant:default": {} }, + }, + }, + }, + ], + }, + }; + runtime.enqueueClient(openCodeClient); + const cwd = tmpCwd(); + try { + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + resolveHomeDir: () => cwd, + }); + const catalog = await client.fetchCatalog({ scope: "global", force: false }); + const options = catalog.models[0].thinkingOptions ?? []; + expect(options).toEqual([ + { id: "default", label: "Default", isDefault: true }, + { id: "high", label: "high" }, + { id: "variant:default", label: "variant:default" }, + ]); + expect(catalog.models[0].defaultThinkingOptionId).toBe("default"); + const execution = new TestOpenCodeClient(); + execution.sessionCreateResponse = { data: { id: "ses_catalog_variants" } }; + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_catalog_variants" } }, + ]; + runtime.enqueueClient(execution); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "catalog-provider/model", + thinkingOptionId: "default", + }); + try { + await collectTurnEvents(streamSession(session, "Use OpenCode's default")); + await session.setThinkingOption!("high"); + await collectTurnEvents(streamSession(session, "Use high")); + await session.setThinkingOption!("variant:default"); + await collectTurnEvents(streamSession(session, "Use the literal variant name")); + await session.setThinkingOption!("default"); + await collectTurnEvents(streamSession(session, "Return to OpenCode's default")); + expect(execution.calls.sessionPromptAsync).toEqual([ + expect.not.objectContaining({ variant: expect.anything() }), + expect.objectContaining({ variant: "high" }), + expect.objectContaining({ variant: "variant:default" }), + expect.not.objectContaining({ variant: expect.anything() }), + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { saved: "default", variant: undefined }, + { saved: "high", variant: "high" }, + { saved: "variant:default", variant: "variant:default" }, + ])("resume preserves saved thinking choice $saved", async ({ saved, variant }) => { + const runtime = new TestOpenCodeHarness(); + const execution = new TestOpenCodeClient(); + execution.sessionPromptAsyncEvents = [ + { type: "session.idle", properties: { sessionID: "ses_saved_variant" } }, + ]; + runtime.enqueueClient(execution); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const cwd = tmpCwd(); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_saved_variant", metadata: { cwd } }, + { thinkingOptionId: saved }, + ); + try { + await collectTurnEvents(streamSession(session, "Keep my saved choice")); + expect(execution.calls.sessionPromptAsync.map((request) => request.variant)).toEqual([ + variant, + ]); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("fetchCatalog returns models with required fields", async () => { const runtime = new TestOpenCodeHarness(); const openCodeClient = new TestOpenCodeClient(); @@ -1439,6 +1618,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1549,6 +1729,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1633,6 +1814,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -1674,6 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), undefined, undefined, @@ -1702,6 +1885,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); await session.close(); @@ -1726,6 +1910,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, new Map(), events, ); @@ -1798,6 +1983,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -1835,6 +2021,76 @@ describe("OpenCode adapter startTurn error handling", () => { ]); }); + test("streamHistory hides user text that OpenCode marks synthetic", async () => { + const fakeClient = { + session: { + get: vi.fn().mockResolvedValue({ + data: { revert: undefined }, + error: undefined, + }), + messages: vi.fn().mockResolvedValue({ + data: [ + { + info: { id: "msg_continue", sessionID: "ses_unit_test", role: "user" }, + parts: [ + { + id: "prt_continue", + sessionID: "ses_unit_test", + messageID: "msg_continue", + type: "text", + text: "Summarize the task tool output above and continue with your task.", + synthetic: true, + }, + ], + }, + { + info: { id: "msg_user", sessionID: "ses_unit_test", role: "user" }, + parts: [ + { + id: "prt_user", + sessionID: "ses_unit_test", + messageID: "msg_user", + type: "text", + text: "Read the notes", + }, + { + id: "prt_resource", + sessionID: "ses_unit_test", + messageID: "msg_user", + type: "text", + text: "Reading MCP resource: notes.md", + synthetic: true, + }, + ], + }, + ], + error: undefined, + }), + }, + } as never; + + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + {}, + ); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: "Read the notes", messageId: "msg_user" }, + }, + ]); + }); + test("streamHistory omits replay timestamps when OpenCode omits times", async () => { const fakeClient = { session: { @@ -1871,6 +2127,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -1984,6 +2241,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const history: AgentStreamEvent[] = []; @@ -2099,6 +2357,7 @@ describe("OpenCode adapter startTurn error handling", () => { fakeClient, "ses_unit_test", createTestLogger(), + {}, ); const events: AgentStreamEvent[] = []; @@ -2276,49 +2535,41 @@ describe("OpenCode adapter startTurn error handling", () => { }); test("sends exact Hub MCP permission grants without approving unrelated tools", async () => { - const promptAsync = vi.fn(async () => ({ data: {}, error: undefined })); - const fakeClient = { - global: { - event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({ - stream: { - async *[Symbol.asyncIterator](): AsyncGenerator { - yield { type: "server.connected", properties: {} } as OpenCodeEvent; - await waitForAbort(signal); - }, - }, - })), - }, - session: { promptAsync }, - } as never; - const session = new __openCodeInternals.OpenCodeAgentSession( - { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.createSession({ provider: "opencode", - cwd: "/tmp/test", - providerOptions: { permission: { bash: "ask", hub_reply: "deny" } }, + cwd, + providerOptions: { permission: { bash: "ask", question: "deny" } }, toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }], }, - }, - fakeClient, - "ses_unit_test", - createTestLogger(), - ); - - await session.startTurn("finish"); - - expect(promptAsync).toHaveBeenCalledWith( - expect.objectContaining({ - permission: [ + }); + try { + const created = openCode.calls.sessionCreate[0] as { + permission?: Array<{ permission: string; pattern: string; action: string }>; + }; + expect(created.permission).toEqual([ { permission: "hub_finish_execution", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "ask" }, - { permission: "hub_reply", pattern: "*", action: "deny" }, - ], - }), - ); - expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual( - expect.objectContaining({ permission: "bash", action: "allow" }), - ); - await session.close(); + { permission: "question", pattern: "*", action: "deny" }, + ]); + expect(created.permission).not.toContainEqual( + expect.objectContaining({ permission: "bash", action: "allow" }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); test("waits for the stop abort and provider idle before starting the next prompt", async () => { @@ -2906,6 +3157,7 @@ describe("OpenCode adapter startTurn error handling", () => { sdkClient, "ses_sync_abort_failure", createTestLogger(), + {}, ); try { @@ -3504,7 +3756,10 @@ describe("OpenCode adapter startTurn error handling", () => { try { await session.startTurn("first"); await session.interrupt(); + const descriptor = session.usageSession?.(); openCode.emitEvent({ type: "server-exited", error: new Error("OpenCode exited") }); + expect(session.usageSession?.()).toEqual(descriptor); + expect(descriptor).not.toBeNull(); await vi.advanceTimersByTimeAsync(0); openCode.sessionPromptAsyncEvents = [ @@ -3559,6 +3814,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_timeout", createTestLogger(), + {}, new Map(), { ready: () => new Promise(() => undefined), @@ -3612,6 +3868,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_slow_stream", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -3644,6 +3901,7 @@ describe("OpenCode adapter startTurn error handling", () => { openCode.asSdkClient(), "ses_readiness_retry", createTestLogger(), + {}, new Map(), { ready: () => streamReady.promise, @@ -4414,6 +4672,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_registry", + parentSubagentId: null, description: "Live child", status: "running", }, @@ -5321,6 +5580,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -5338,6 +5598,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_background", + parentSubagentId: null, description: "Plugin child", status: "running", }, @@ -5386,6 +5647,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_plugin", + parentSubagentId: null, description: "Background plugin child", status: "running", }, @@ -5477,6 +5739,7 @@ describe("OpenCode provider subagent contract", () => { fakeClient, "ses_parent", createTestLogger(), + {}, new Map(), createDirectEventSource(fakeClient), ); @@ -5629,6 +5892,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_rich", + parentSubagentId: null, title: "explore", description: "Investigate flaky test", status: "running", @@ -5655,7 +5919,12 @@ describe("OpenCode provider subagent contract", () => { { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_bare", status: "running" }, + event: { + type: "upsert", + id: "ses_child_bare", + parentSubagentId: null, + status: "running", + }, }, ]); }); @@ -5980,12 +6249,24 @@ describe("OpenCode provider subagent contract", () => { { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" }, + event: { + type: "upsert", + id: "ses_child_a", + parentSubagentId: null, + description: "Child A", + status: "completed", + }, }, { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" }, + event: { + type: "upsert", + id: "ses_child_b", + parentSubagentId: null, + description: "Child B", + status: "completed", + }, }, { type: "provider_subagent", @@ -5993,6 +6274,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_grandchild_a", + parentSubagentId: "ses_child_a", description: "Grandchild A", status: "completed", }, @@ -6055,6 +6337,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_with_history", + parentSubagentId: null, description: "Historical child", status: "completed", cwd: "/workspace/child", @@ -6142,6 +6425,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_hydrated_facts", + parentSubagentId: null, description: "Chase the regression", status: "completed", subtitle: "claude-sonnet-5 · Max", @@ -6600,3 +6884,129 @@ describe("OpenCode snapshot summary false-idle regression", () => { await session.close(); }); }); + +describe("OpenCode session permission rules", () => { + const logger = createTestLogger(); + function permissionConfig(cwd: string): AgentSessionConfig { + return { + provider: "opencode", + cwd, + providerOptions: { permission: { bash: "ask", external_directory: "allow" } }, + toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] }, + }; + } + // OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and + // drops an unknown `permission` field on prompt_async, so rules sent with a prompt never + // reach the server. + const expectedRules = [ + { permission: "hub_finish_execution", pattern: "*", action: "allow" }, + { permission: "bash", pattern: "*", action: "ask" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; + + test("creates the session with the agent's permission rules", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.createSession(permissionConfig(cwd)); + try { + expect(openCode.calls.sessionCreate[0]).toEqual( + expect.objectContaining({ permission: expectedRules }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("applies the agent's permission rules to a resumed session", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.resumeSession( + { provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } }, + permissionConfig(cwd), + ); + try { + expect(openCode.calls.sessionUpdate[0]).toEqual( + expect.objectContaining({ + sessionID: "ses_resumed_rules", + permission: expectedRules, + }), + ); + } finally { + await session.close(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("fails the resume when the rules cannot be applied", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } }; + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + // Resuming without the rules would run the agent under a more permissive policy than + // the one it was configured with, which is the failure this guards. + await expect( + client.resumeSession( + { provider: "opencode", sessionId: "ses_archived", metadata: { cwd } }, + permissionConfig(cwd), + ), + ).rejects.toThrow("Failed to apply OpenCode session permission rules"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("leaves a session without rules untouched", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeHarness(); + const openCode = new TestOpenCodeClient(); + runtime.enqueueClient(openCode); + const client = new OpenCodeAgentClient(logger, undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + try { + const session = await client.resumeSession({ + provider: "opencode", + sessionId: "ses_resumed_no_rules", + metadata: { cwd }, + }); + try { + expect(session.usageSession?.()).toMatchObject({ + provider: "opencode", + sessionKey: expect.any(String), + }); + expect(openCode.calls.sessionUpdate).toEqual([]); + } finally { + await session.close(); + expect(session.usageSession?.()).toBeNull(); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ad22023ec0..7e49bc50d3 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,3 +1,4 @@ +import { validateProviderOptions } from "../provider-options.js"; import { createOpencodeClient, type AssistantMessage as OpenCodeAssistantMessage, @@ -12,7 +13,7 @@ import { type TextPartInput as OpenCodeTextPartInput, } from "@opencode-ai/sdk/v2/client"; import fs from "node:fs/promises"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import pLimit from "p-limit"; @@ -806,11 +807,14 @@ function buildOpenCodeModelDefinition( }, ): AgentModelDefinition { const rawVariants = model.variants ? Object.keys(model.variants) : []; - // OpenCode lists only overrides; its base model behavior is selected by omitting `variant`. + // Like OpenCode's web UI, Default omits `variant` and lets OpenCode resolve it. + // Reserve that choice instead of exposing a second upstream `default` entry. const thinkingOptions = rawVariants.length ? [ { id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true }, - ...rawVariants.map((id) => ({ id, label: id })), + ...rawVariants + .filter((id) => id !== OPENCODE_DEFAULT_VARIANT_ID) + .map((id) => ({ id, label: id })), ] : []; @@ -1315,7 +1319,10 @@ function buildOpenCodeReplayTimelineEvents( } if (info.role === "user") { const text = parts - .filter((part): part is Extract => part.type === "text") + .filter( + (part): part is Extract => + part.type === "text" && isUserAuthoredOpenCodeText(part), + ) .map((part) => part.text) .join(""); @@ -1422,10 +1429,11 @@ export class OpenCodeAgentClient implements AgentClient { OpenCodeServerManager.getInstance(this.logger, runtimeSettings, { managedProcesses: deps.managedProcesses, resolveHomeDir: deps.resolveHomeDir, - createEventSource: ({ serverUrl, processExit, logger: eventLogger }) => + createEventSource: ({ serverUrl, processExit, listening, logger: eventLogger }) => new OpenCodeEventConsumer({ serverUrl, processExit, + listening, logger: eventLogger, createClient: (baseUrl) => this.createOpenCodeClient({ baseUrl, directory: "" }), }), @@ -1442,19 +1450,26 @@ export class OpenCodeAgentClient implements AgentClient { options?: AgentCreateSessionOptions, ): Promise { const openCodeConfig = this.assertConfig(config); - const acquisition = await this.acquireServer(openCodeConfig, launchContext); - const { url } = acquisition.server; - const client = this.createOpenCodeClient({ - baseUrl: url, - directory: openCodeConfig.cwd, - }); + const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext); + const connection = await connectServer(); + const { client } = connection; + + // OpenCode stores permission rules on the session, so they are set here and on resume + // rather than sent with each prompt, which drops them. + const permission = buildOpenCodePermissionRules( + openCodeConfig.providerOptions, + openCodeConfig.toolPolicy, + ); try { // Creating the first session for a directory is part of OpenCode coming up, so it // shares the server startup budget instead of a shorter one that fails agent // creation on contended cold starts. const response = await withTimeout( - client.session.create({ directory: openCodeConfig.cwd }), + client.session.create({ + directory: openCodeConfig.cwd, + ...(permission ? { permission } : {}), + }), OPENCODE_SERVER_STARTUP_TIMEOUT_MS, `OpenCode session.create timed out after ${Math.round( OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000, @@ -1478,17 +1493,19 @@ export class OpenCodeAgentClient implements AgentClient { client, session.id, this.logger, + connection.environment, new Map(this.modelContextWindows), - acquisition.events, - acquisition.release, + connection.events, + connection.release, options?.persistSession, launchContext?.agentId, - url, + connection.url, false, unbindBridge, + connectServer, ); } catch (error) { - await acquisition.release(); + await connection.release(); throw error; } } @@ -1515,15 +1532,14 @@ export class OpenCodeAgentClient implements AgentClient { const registeredAcquisition = registeredServerUrl ? this.serverManager.acquireExisting(registeredServerUrl) : null; - const acquisition = - registeredAcquisition ?? (await this.acquireServer(openCodeConfig, launchContext)); - const { url } = acquisition.server; - const client = this.createOpenCodeClient({ - baseUrl: url, - directory: openCodeConfig.cwd, - }); + const connectServer = this.connectServer.bind(this, openCodeConfig, launchContext); + const connection = registeredAcquisition + ? this.toServerConnection(registeredAcquisition, openCodeConfig.cwd) + : await connectServer(); + const { client } = connection; try { + await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId); await this.populateModelContextWindowCache(client, openCodeConfig.cwd); const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext); @@ -1532,21 +1548,61 @@ export class OpenCodeAgentClient implements AgentClient { client, handle.sessionId, this.logger, + connection.environment, new Map(this.modelContextWindows), - acquisition.events, - acquisition.release, + connection.events, + connection.release, undefined, launchContext?.agentId, - url, + connection.url, registeredAcquisition !== null, unbindBridge, + connectServer, ); } catch (error) { - await acquisition.release(); + await connection.release(); throw error; } } + private async applySessionPermissionRules( + client: OpencodeClient, + config: OpenCodeAgentConfig, + sessionId: string, + ): Promise { + const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy); + if (!permission) return; + const response = readOpenCodeRecord( + await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }), + ); + if (response?.error) { + throw new Error( + `Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`, + ); + } + } + + private async connectServer( + config: OpenCodeAgentConfig, + launchContext?: AgentLaunchContext, + ): Promise { + const acquisition = await this.acquireServer(config, launchContext); + return this.toServerConnection(acquisition, config.cwd); + } + + private toServerConnection( + acquisition: OpenCodeServerAcquisition, + directory: string, + ): OpenCodeServerConnection { + return { + client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }), + events: acquisition.events, + environment: acquisition.environment, + url: acquisition.server.url, + release: acquisition.release, + }; + } + private acquireServer( config: OpenCodeAgentConfig, launchContext?: AgentLaunchContext, @@ -1879,7 +1935,9 @@ export class OpenCodeAgentClient implements AgentClient { if (config.provider !== "opencode") { throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`); } - const providerOptions = OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}); + const providerOptions = + validateProviderOptions("opencode", OpenCodeProviderOptionsSchema, config.providerOptions) ?? + {}; return normalizeOpenCodeConfig({ ...config, provider: "opencode", providerOptions }); } @@ -2423,6 +2481,7 @@ function appendOpenCodeChildSessionDetected( event: { type: "upsert", id: child.id, + parentSubagentId: child.parentSessionId === state.sessionId ? null : child.parentSessionId, ...(title ? { title } : {}), ...(child.title && !presentation.descriptionFromLink ? { description: child.title } : {}), ...(status ? { status } : {}), @@ -2805,6 +2864,11 @@ function shouldSuppressOpenCodeAssistantPart( ); } +// OpenCode marks text it adds to a user message itself with `synthetic` and hides it from its own UI. +function isUserAuthoredOpenCodeText(part: { synthetic?: boolean }): boolean { + return part.synthetic !== true; +} + function appendOpenCodeTextPart( part: Extract< Extract["properties"]["part"], @@ -2815,7 +2879,11 @@ function appendOpenCodeTextPart( events: AgentStreamEvent[], ): void { if (messageRole === "user") { - if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) { + if ( + !part.text || + !isUserAuthoredOpenCodeText(part) || + state.emittedUserMessageIds?.has(part.messageID) + ) { return; } state.emittedUserMessageIds?.add(part.messageID); @@ -3218,7 +3286,7 @@ function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boole } function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { - return event.type === "provider_subagent"; + return event.type === "provider_subagent" || event.type === "model_changed"; } function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null { @@ -3291,12 +3359,25 @@ async function listOpenCodeChildSessions( return readOpenCodeChildSessionInfosFromResponse(sessionIdResponse) ?? []; } +/** One OpenCode server generation a session talks to. */ +interface OpenCodeServerConnection { + environment: Record; + client: OpencodeClient; + events: OpenCodeEventSource; + url: string; + release: () => Promise; +} + class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; private readonly config: OpenCodeAgentConfig; - private readonly client: OpencodeClient; + private server: OpenCodeServerConnection; + /** Connects to the server generation that is current now. */ + private readonly connectServer: (() => Promise) | null; + private serverExited = false; + private reconnection: Promise | null = null; private readonly sessionId: string; private readonly logger: Logger; private readonly modelContextWindowsByModelKey: ReadonlyMap; @@ -3306,8 +3387,7 @@ class OpenCodeAgentSession implements AgentSession { private abortController: AbortController | null = null; private accumulatedUsage: AgentUsage = {}; private sessionTotalCostUsd: number | undefined; - private mcpConfigured = false; - private mcpSetupPromise: Promise | null = null; + private mcpSetup: Promise | null = null; private messageRoles = new Map(); private pendingUserMessageText: string | null = null; private pendingClientMessageId: string | null = null; @@ -3354,7 +3434,6 @@ class OpenCodeAgentSession implements AgentSession { private childHydrationCompleted = false; private readonly unrelatedSessionIds = new Set(); private selectedModelContextWindowMaxTokens: number | undefined; - private releaseServer: (() => Promise) | null; private releaseBridge: (() => void) | null; private ingress = Promise.resolve(); private gapRepairRevision = 0; @@ -3363,36 +3442,69 @@ class OpenCodeAgentSession implements AgentSession { private closed = false; private readonly persistSession: boolean; private deletedFromProvider = false; + private readonly usageSessionKey = randomUUID(); constructor( config: OpenCodeAgentConfig, client: OpencodeClient, sessionId: string, logger: Logger, + private readonly harnessEnvironment: Record, modelContextWindowsByModelKey: ReadonlyMap = new Map(), - private readonly events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE, - releaseServer?: () => Promise, + events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE, + releaseServer: () => Promise = async () => undefined, persistSession = true, private readonly agentId?: string, - private readonly serverUrl?: string, + serverUrl?: string, private readonly externallyDriven = false, releaseBridge?: () => void, + connectServer?: () => Promise, ) { this.config = config; - this.client = client; + this.server = { + client, + events, + url: serverUrl ?? "", + release: releaseServer, + environment: this.harnessEnvironment, + }; + this.connectServer = connectServer ?? null; this.sessionId = sessionId; this.logger = logger.child({ agentId: this.agentId }); this.modelContextWindowsByModelKey = modelContextWindowsByModelKey; this.currentMode = normalizeOpenCodeModeId(config.modeId); this.autoAcceptEnabled = !config.toolPolicy && isOpenCodeAutoAcceptEnabled(config); - this.releaseServer = releaseServer ?? null; this.releaseBridge = releaseBridge ?? null; this.persistSession = persistSession; this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( config.model, ); + this.subscribeServerEvents(); + } + + usageSession() { + if (this.closed) return null; + return { + provider: "opencode", + model: this.config.model, + env: this.harnessEnvironment, + sessionKey: this.usageSessionKey, + }; + } + + private get client(): OpencodeClient { + return this.server.client; + } + + private get events(): OpenCodeEventSource { + return this.server.events; + } + + private subscribeServerEvents(): void { this.unsubscribeEvents = this.events.subscribe((input) => { - if ("type" in input && input.type === "server-exited") + if ("type" in input && input.type === "server-exited") { this.recoveryAbortController.abort(input.error); + this.serverExited = true; + } this.ingress = this.ingress .then(() => this.consumeEventSourceInput(input)) .catch((error) => { @@ -3404,6 +3516,47 @@ class OpenCodeAgentSession implements AgentSession { }); } + /** + * The OpenCode session outlives the server process that served it, and Paseo starts the + * next server on a new port. Move to the current server before talking to OpenCode again, + * so the session does not keep calling a port nothing listens on. + */ + private async reconnectIfServerExited(): Promise { + if (!this.serverExited || !this.connectServer) return; + this.reconnection ??= this.reconnect(this.connectServer).finally(() => { + this.reconnection = null; + }); + await this.reconnection; + } + + private async reconnect(connectServer: () => Promise): Promise { + const exited = this.server; + const next = await connectServer(); + if (this.closed) { + await next.release(); + return; + } + this.unsubscribeEvents?.(); + this.unsubscribeEvents = null; + // Let the exited server's queued events, including its exit, settle while no turn is + // running, so none of them can fail a turn started on the new server. + await this.ingress; + if (this.closed) { + await next.release(); + return; + } + this.server = next; + this.serverExited = false; + this.recoveryAbortController = new AbortController(); + this.mcpSetup = null; + this.subscribeServerEvents(); + this.logger.info( + { sessionId: this.sessionId, previousUrl: exited.url, url: next.url }, + "OpenCode session moved to the current server after its server exited", + ); + await exited.release(); + } + get id(): string | null { return this.sessionId; } @@ -3422,16 +3575,37 @@ class OpenCodeAgentSession implements AgentSession { sessionId: this.sessionId, model: this.config.model ?? null, modeId: this.currentMode, + thinkingOptionId: this.config.thinkingOptionId ?? null, }; } async setModel(modelId: string | null): Promise { + await this.reconnectIfServerExited(); const normalizedModelId = typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + let variant = this.config.thinkingOptionId; + if (!normalizedModelId) { + variant = undefined; + } else if (variant) { + const model = this.parseModel(normalizedModelId); + const response = await this.client.provider.list({ directory: this.config.cwd }); + if (response.error) + throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`); + const provider = response.data?.all.find((entry) => entry.id === model?.providerID); + const target = model && provider?.models[model.modelID]; + if (!target) throw new Error(`OpenCode model unavailable: ${normalizedModelId}`); + if (!Object.hasOwn(target.variants ?? {}, variant)) variant = undefined; + } this.config.model = normalizedModelId ?? undefined; + this.config.thinkingOptionId = variant; this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens( this.config.model, ); + this.notifySubscribers({ + type: "thinking_option_changed", + provider: "opencode", + thinkingOptionId: variant ?? null, + }); } async setThinkingOption(thinkingOptionId: string | null): Promise { @@ -3506,10 +3680,6 @@ class OpenCodeAgentSession implements AgentSession { this.config.systemPrompt, this.config.daemonAppendSystemPrompt, ); - const permission = buildOpenCodePermissionRules( - this.config.providerOptions, - this.config.toolPolicy, - ); const model = this.parseModel(this.config.model); const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode); const effectiveVariant = this.config.thinkingOptionId ?? undefined; @@ -3521,7 +3691,6 @@ class OpenCodeAgentSession implements AgentSession { messageID: promptId, parts, ...(systemPrompt ? { system: systemPrompt } : {}), - ...(permission ? { permission } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -3574,6 +3743,7 @@ class OpenCodeAgentSession implements AgentSession { } async revertBoth(input: { messageId: string }): Promise { + await this.reconnectIfServerExited(); await revertOpenCodeConversationAndFiles({ client: this.client, sessionId: this.sessionId, @@ -3698,6 +3868,7 @@ class OpenCodeAgentSession implements AgentSession { if (this.turnState.status === "running") { throw new Error("A foreground turn is already active"); } + await this.reconnectIfServerExited(); try { await this.awaitRunnerQuiescence(); } catch (error) { @@ -3846,10 +4017,6 @@ class OpenCodeAgentSession implements AgentSession { this.config.systemPrompt, this.config.daemonAppendSystemPrompt, ); - const permission = buildOpenCodePermissionRules( - this.config.providerOptions, - this.config.toolPolicy, - ); const promptResponse = await this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, @@ -3864,7 +4031,6 @@ class OpenCodeAgentSession implements AgentSession { } : {}), ...(systemPrompt ? { system: systemPrompt } : {}), - ...(permission ? { permission } : {}), ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -4209,8 +4375,8 @@ class OpenCodeAgentSession implements AgentSession { if (event.event.cwd) { this.childSessionCwds.set(event.event.id, event.event.cwd); } - if (this.serverUrl) { - registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl); + if (this.server.url) { + registerOpenCodeChildSessionServerUrl(event.event.id, this.server.url); } } else if (event.event.type === "remove") { unregisterOpenCodeChildSessionServerUrl(event.event.id); @@ -4702,8 +4868,13 @@ class OpenCodeAgentSession implements AgentSession { return; } if (event.type === "provider_subagent" && event.event.type === "upsert" && event.event.status) { - if (isDeepStrictEqual(this.childStatuses.get(event.event.id), event.event)) return; - this.childStatuses.set(event.event.id, structuredClone(event.event)); + const previous = this.childStatuses.get(event.event.id); + const current = + event.event.parentSubagentId === undefined && previous?.parentSubagentId !== undefined + ? { ...event.event, parentSubagentId: previous.parentSubagentId } + : event.event; + if (isDeepStrictEqual(previous, current)) return; + this.childStatuses.set(event.event.id, structuredClone(current)); } const turnId = turnIdOverride === null ? null : (turnIdOverride ?? this.activeForegroundTurnId); const tagged = turnId ? { ...event, turnId } : event; @@ -4737,6 +4908,7 @@ class OpenCodeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + await this.reconnectIfServerExited(); const sessionResponse = await this.client.session.get({ sessionID: this.sessionId, directory: this.config.cwd, @@ -4766,6 +4938,7 @@ class OpenCodeAgentSession implements AgentSession { return this.availableModesCache; } + await this.reconnectIfServerExited(); const response = await openCodeMetadataLimit(() => this.client.app.agents({ directory: this.config.cwd, @@ -4784,6 +4957,7 @@ class OpenCodeAgentSession implements AgentSession { } async listCommands(): Promise { + await this.reconnectIfServerExited(); return await listOpenCodeCommandsFromSdk(this.client, this.config.cwd); } @@ -4901,8 +5075,7 @@ class OpenCodeAgentSession implements AgentSession { } finally { this.releaseBridge?.(); this.releaseBridge = null; - await this.releaseServer?.(); - this.releaseServer = null; + await this.server.release(); } } @@ -4978,25 +5151,18 @@ class OpenCodeAgentSession implements AgentSession { } private async ensureMcpServersConfigured(): Promise { - if (this.mcpConfigured) { - return; - } - const mcpServers = this.config.mcpServers; if (!mcpServers || Object.keys(mcpServers).length === 0) { - this.mcpConfigured = true; return; } - if (!this.mcpSetupPromise) { - this.mcpSetupPromise = this.configureMcpServers(mcpServers); - } - + const setup = (this.mcpSetup ??= this.configureMcpServers(mcpServers)); try { - await this.mcpSetupPromise; - this.mcpConfigured = true; + await setup; } catch (error) { - this.mcpSetupPromise = null; + if (this.mcpSetup === setup) { + this.mcpSetup = null; + } throw error; } } @@ -5227,6 +5393,7 @@ class OpenCodeAgentSession implements AgentSession { private async translateEvent(event: OpenCodeEvent): Promise { const eventSessionId = getOpenCodeEventSessionId(event); + const runtimeModelChanged = this.syncRuntimeModel(event, eventSessionId); if ( event.type !== "session.created" && eventSessionId && @@ -5243,6 +5410,12 @@ class OpenCodeAgentSession implements AgentSession { } } const translated = translateOpenCodeEvent(event, this.createTranslationState()); + if (runtimeModelChanged) + translated.push({ + type: "model_changed", + provider: this.provider, + runtimeInfo: await this.getRuntimeInfo(), + }); this.appendProviderSubagentEvents(event, translated); const events: AgentStreamEvent[] = []; @@ -5283,6 +5456,20 @@ class OpenCodeAgentSession implements AgentSession { return events; } + private syncRuntimeModel(event: OpenCodeEvent, eventSessionId: string | null): boolean { + if (event.type !== "message.updated" || eventSessionId !== this.sessionId) return false; + const info = event.properties.info; + let model: string | undefined; + if (info.role === "assistant") model = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); + if (info.role === "user" && info.model) + model = buildOpenCodeModelLookupKey(info.model.providerID, info.model.modelID); + if (!model || model === this.config.model) return false; + this.config.model = model; + this.selectedModelContextWindowMaxTokens = + this.resolveConfiguredModelContextWindowMaxTokens(model); + return true; + } + private async tryAutoApproveToolPermission( request: AgentPermissionRequest, directory: string, diff --git a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts index af67c2604a..d3d295cc8e 100644 --- a/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts @@ -1,3 +1,4 @@ +import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -5,11 +6,68 @@ import path from "node:path"; import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"; import { expect, test } from "vitest"; +import { execCommand } from "../../../utils/spawn.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + getRealProviderConfig, + getRealProviderRuntimeSettings, +} from "../../daemon-e2e/real-provider-test-config.js"; import type { PaseoToolCatalog } from "../tools/types.js"; import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; import { OpenCodeBridge } from "./opencode/bridge.js"; import { OpenCodeServerManager } from "./opencode/server-manager.js"; +import { + drainPersistedTimeline, + readAssistantText, + requireSessionId, +} from "./opencode/test-utils/v2-local-e2e-helpers.js"; + +test("real OpenCode server persists provider permissions across creation and resume", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-")); + const cwd = path.join(root, "repo"); + const logger = createTestLogger(); + const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root }); + const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager }); + let session: Awaited> | undefined; + let inspection: Awaited> | undefined; + + try { + await mkdir(cwd); + session = await client.createSession({ + provider: "opencode", + cwd, + providerOptions: { permission: { external_directory: "allow" } }, + }); + inspection = await manager.acquireCurrent(); + const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd }); + async function readPermission(sessionId: string) { + const response = await sdk.session.get({ sessionID: sessionId, directory: cwd }); + if (response.error) throw new Error(JSON.stringify(response.error)); + return response.data?.permission; + } + const handle = session.describePersistence()!; + expect(await readPermission(handle.sessionId)).toEqual([ + { permission: "external_directory", pattern: "*", action: "allow" }, + ]); + + await session.close(); + // OpenCode appends session-update rules and the last matching rule wins, so a resume + // with a different policy leaves both entries in order. + session = await client.resumeSession(handle, { + providerOptions: { permission: { external_directory: "deny" } }, + }); + expect(await readPermission(handle.sessionId)).toEqual([ + { permission: "external_directory", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "deny" }, + ]); + } finally { + await inspection?.release(); + await session?.close(); + await manager.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); test("real OpenCode server shares one process while shell.env stays session-scoped", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-")); @@ -40,7 +98,12 @@ test("real OpenCode server shares one process while shell.env stays session-scop mkdir(secondCwd, { recursive: true }), ]); first = await client.createSession( - { provider: "opencode", cwd: firstCwd, model: "opencode/big-pickle", modeId: "build" }, + { + provider: "opencode", + cwd: firstCwd, + model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle", + modeId: "build", + }, { agentId: "real-agent-one", env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd }, @@ -81,6 +144,22 @@ test("real OpenCode server shares one process while shell.env stays session-scop expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`); expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/); + // Keep the prompt assertions independent of asynchronous events from the direct shell API. + await first.close(); + first = await client.createSession( + { + provider: "opencode", + cwd: firstCwd, + model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle", + modeId: "build", + }, + { + agentId: "real-agent-one", + env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd }, + paseoTools: firstTools, + }, + { persistSession: false }, + ); const agentResult = await first.run( [ "Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='", @@ -124,10 +203,71 @@ test("real OpenCode server shares one process while shell.env stays session-scop } }, 240_000); -function requireSessionId(session: { id: string | null }): string { - if (!session.id) throw new Error("OpenCode session has no id"); - return session.id; -} +test("real OpenCode server asks before running a Paseo tool with an ask rule", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-ask-")); + const cwd = path.join(root, "repo"); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + await bridge.start(); + const catalog = createCallerCatalog("ask-rule-agent"); + bridge.setManifestCatalog(catalog); + const realSettings = getRealProviderRuntimeSettings("opencode"); + const runtimeSettings = { + ...realSettings, + env: { + ...realSettings.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: { "paseo_*": "ask" } }), + }, + }; + const manager = new OpenCodeServerManager({ + logger, + runtimeSettings, + resolveHomeDir: () => root, + decorateServerEnv: (env) => bridge.decorateServerEnv(env), + }); + const client = new OpenCodeAgentClient(logger, runtimeSettings, { + serverManager: manager, + bridge, + }); + let session: Awaited> | undefined; + + try { + await mkdir(cwd); + session = await client.createSession( + { ...getRealProviderConfig("opencode"), cwd }, + { agentId: "ask-rule-agent", paseoTools: catalog }, + { persistSession: false }, + ); + const asked: string[] = []; + const completedTools: string[] = []; + const activeSession = session; + session.subscribe((event) => { + if (event.type === "permission_requested") { + asked.push(event.request.name); + void activeSession.respondToPermission(event.request.id, { behavior: "allow" }); + } + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed" + ) { + completedTools.push(event.item.name); + } + }); + + await session.run( + "Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.", + ); + + expect(completedTools).toContain("paseo_report_caller_agent_id"); + expect(asked).toContain("paseo_report_caller_agent_id"); + } finally { + await session?.close(); + await manager.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { const tool = { @@ -151,8 +291,143 @@ function createCallerCatalog(callerAgentId: string): PaseoToolCatalog { }; } -function readAssistantText(timeline: ReadonlyArray<{ type: string; text?: string }>): string { - return timeline - .flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : [])) - .join(""); -} +test("v2 native tool bridge preserves caller identity", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-")); + const logger = createTestLogger(); + const bridge = new OpenCodeBridge({ paseoHome: root, logger }); + const catalog = createCallerCatalog("paseo-v2-caller"); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const client = new OpenCodeV2AgentClient({ logger, bridge }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + }, + { agentId: "paseo-v2-caller", paseoTools: catalog, env: { PASEO_TEST_SCOPE: "V2_ENV_OK" } }, + { persistSession: false }, + ); + const result = await session.run( + "Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.", + ); + expect(readAssistantText(result.timeline)).toContain("paseo-v2-caller"); + const environment = await session.run( + "Use the shell tool to run printf '%s' \"$PASEO_TEST_SCOPE\", then reply with its output.", + ); + expect(environment.finalText).toContain("V2_ENV_OK"); + expect(result.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "paseo_report_caller_agent_id", + status: "completed", + }), + ]), + ); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await bridge.close(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test.each([ + { + major: 1, + package: "opencode-ai@1.14.46", + windowsPackage: "opencode-windows", + expectedTimeline: [], + expectedInitialTimeline: undefined, + expectedNotices: undefined, + }, + { + major: 2, + package: "@opencode/cli@2.0.10", + windowsPackage: "@opencode/cli-windows", + expectedTimeline: [ + { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + ], + expectedInitialTimeline: [ + expect.objectContaining({ + item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + }), + ], + expectedNotices: [expect.objectContaining({ major: 2 })], + }, +])( + "versioned runtime v$major discovers models and preserves a native session handle", + async ({ + package: cliPackage, + windowsPackage, + expectedTimeline, + expectedInitialTimeline, + expectedNotices, + }) => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-versioned-")); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { + mode: "replace", + argv: [ + process.platform === "win32" + ? path.join( + root, + "node_modules", + `${windowsPackage}-${process.arch}`, + "bin", + "opencode.exe", + ) + : path.join(root, "node_modules", ".bin", "opencode"), + ], + }, + env: { + XDG_CONFIG_HOME: path.join(root, "config"), + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + }, + }); + let original: Awaited> | undefined; + let resumed: Awaited> | undefined; + try { + await execCommand( + "npm", + ["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage], + { + cwd: root, + timeout: 180_000, + }, + ); + const catalog = await client.fetchCatalog({ scope: "workspace", cwd: root, force: true }); + expect(catalog.models.length).toBeGreaterThan(0); + expect(catalog.modes.map((mode) => mode.id)).toContain("build"); + original = await client.createSession( + { provider: "opencode", cwd: root, modeId: "build" }, + undefined, + { persistSession: false }, + ); + const handle = await original.describePersistence(); + expect(original.initialTimeline).toEqual(expectedInitialTimeline); + expect(handle.metadata?.openCodeRuntimeNotices).toEqual(expectedNotices); + resumed = await client.resumeSession(handle, { cwd: root }); + expect((await resumed.describePersistence()).nativeHandle).toBe(handle.nativeHandle); + expect( + (await drainPersistedTimeline(resumed)).map((event) => + event.type === "timeline" ? event.item : event, + ), + ).toEqual(expectedTimeline); + } finally { + await resumed?.close(); + await original?.close(); + await client.shutdown(); + // Windows can retain a transient executable lock after provider shutdown. + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }, + 240_000, +); diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index 74c6143c1f..151fb57610 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; import { mkdtempSync, rmSync } from "node:fs"; import { createServer, type ServerResponse } from "node:http"; -import net from "node:net"; import os from "node:os"; import path from "node:path"; import { Writable } from "node:stream"; @@ -89,6 +88,7 @@ describe("OpenCodeServerManager generations", () => { const acquisition = await manager.acquireCurrent(); expect(runtime.spawnCalls[0]?.options.baseEnv).toEqual(baseEnv); + expect(acquisition.environment).toBe(runtime.spawnCalls[0]?.options.baseEnv); await acquisition.release(); }); @@ -367,7 +367,7 @@ describe("OpenCodeServerManager managed process ledger", () => { describe.runIf(process.platform === "win32")( "OpenCodeServerManager Windows OpenCode npm install", () => { - test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => { + test("resolves the Windows npm shim to its native helper executable", async () => { const detectedOpenCode = await findExecutable("opencode"); expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe( null, @@ -376,43 +376,35 @@ describe.runIf(process.platform === "win32")( const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-")); const opencodeHomeDir = path.join(tempDir, "opencode-home"); - const managedProcesses = new FakeManagedProcesses(); + const runtime = new FakeOpenCodeServerRuntime([4604], { autoAnnounce: true }); const manager = new OpenCodeServerManager({ logger: createTestLogger(), - managedProcesses, + managedProcesses: runtime.managedProcesses, resolveHomeDir: () => opencodeHomeDir, + portAllocator: runtime.allocatePort, + spawnServerProcess: runtime.spawnServerProcess, + terminateProcess: runtime.terminateProcess, }); - let acquiredPort: number | null = null; try { - const acquisition = await manager.acquireDedicated({ - OPENCODE_AUTH_CONTENT: "{}", - OPENCODE_DISABLE_AUTOUPDATE: "1", - OPENCODE_DISABLE_AUTOCOMPACT: "1", - OPENCODE_DISABLE_MODELS_FETCH: "1", - OPENCODE_DISABLE_PROJECT_CONFIG: "1", - OPENCODE_PURE: "1", - OPENCODE_TEST_HOME: path.join(tempDir, "test-home"), - }); - acquiredPort = acquisition.server.port; - - const records = await managedProcesses.list(); + // Resolve the actual npm installation. Generation lifecycle coverage above + // owns process readiness; this regression owns the Windows launch command. + const acquisition = await manager.acquireCurrent(); + const records = await runtime.managedProcesses.list(); expect(records).toHaveLength(1); const record = records[0]!; expect(path.extname(record.command).toLowerCase()).toBe(".exe"); - expect(path.normalize(record.command).toLowerCase()).toContain( - path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(), + expect(path.normalize(record.command).toLowerCase()).toMatch( + /opencode-(?:ai|windows-(?:x64|arm64))[\\/]bin[\\/]opencode\.exe$/, ); expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase()); - expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]); + expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]); + await acquisition.release(); } finally { - await manager.shutdown().catch(() => undefined); - if (acquiredPort !== null) { - await waitForClosedPort(acquiredPort, 5_000); - } - rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await manager.shutdown(); + rmSync(tempDir, { recursive: true, force: true }); } - }, 60_000); + }); }, ); @@ -610,37 +602,3 @@ class FakeManagedProcesses implements ManagedProcessRegistry { }; } } - -async function waitForClosedPort(port: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (!(await canConnectToPort(port))) { - return; - } - await sleep(100); - } - throw new Error(`OpenCode helper server still accepts connections on port ${port}`); -} - -function canConnectToPort(port: number): Promise { - return new Promise((resolve) => { - const socket = net.createConnection({ host: "127.0.0.1", port }); - let settled = false; - const settle = (connected: boolean) => { - if (settled) { - return; - } - settled = true; - socket.destroy(); - resolve(connected); - }; - - socket.once("connect", () => settle(true)); - socket.once("error", () => settle(false)); - socket.setTimeout(500, () => settle(false)); - }); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts new file mode 100644 index 0000000000..625ea99a39 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-v2.local.e2e.test.ts @@ -0,0 +1,409 @@ +import { V2Runtime } from "./opencode/v2/runtime.js"; +import type { V2Api } from "./opencode/v2/api.js"; +import { execCommand } from "../../../utils/spawn.js"; +import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js"; +import { mkdir, mkdtemp, rm, writeFile, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { + autoRespondToPermissions, + collectPermissions, + collectProviderSubagents, + collectRunningToolCalls, + collectStreamedAssistantText, + drainPersistedAssistantText, + drainPersistedTimeline, + findSkillCommand, + requireNativeUserMessageId, +} from "./opencode/test-utils/v2-local-e2e-helpers.js"; + +test("v2 shares a helper across agent identities and keeps the remaining session alive", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-shared-")); + const runtime = new V2Runtime({ logger: createTestLogger() }); + const clients = new Set(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: { + acquire: async (input) => { + const connection = await runtime.acquire(input); + clients.add(connection.client); + return connection; + }, + shutdown: () => runtime.shutdown(), + }, + }); + const sessions: Awaited>[] = []; + try { + for (const agentId of ["first", "second"]) { + sessions.push( + await client.createSession( + { provider: "opencode", cwd: root }, + { agentId, env: { PASEO_AGENT_ID: agentId, PASEO_AGENT_CWD: root } }, + { persistSession: false }, + ), + ); + } + expect(clients.size).toBe(1); + expect(sessions[0].id).not.toBe(sessions[1].id); + await sessions[0].close(); + expect((await sessions[1].getRuntimeInfo()).sessionId).toBe(sessions[1].id); + } finally { + for (const session of sessions) await session.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("v2 structured output survives history and does not affect the next ordinary turn", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-schema-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const result = await session.run("Compute six times seven and return the answer.", { + outputSchema: { + type: "object", + properties: { answer: { type: "integer", const: 42 } }, + required: ["answer"], + additionalProperties: false, + }, + }); + expect(JSON.parse(result.finalText)).toEqual({ answer: 42 }); + expect(await drainPersistedTimeline(session)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: result.finalText }), + }), + ]), + ); + const ordinary = await session.run("Reply with exactly ORDINARY, without using tools."); + expect(ordinary.finalText.trim()).toBe("ORDINARY"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 preserves final text without duplicating streamed content", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-stream-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const chunks = collectStreamedAssistantText(session); + const result = await session.run("Count from one to five, one word per line, with no tools."); + // Delta granularity is the provider's: some coalesce a whole reply into a + // single `session.text.delta`. Assert delivery happened and, critically, + // that the post-turn snapshot did not replay delivered content. + expect(chunks.length).toBeGreaterThanOrEqual(1); + const persistedText = await drainPersistedAssistantText(session); + expect(chunks.join("")).toBe(persistedText.join("")); + expect(result.finalText).toBe(persistedText.join("")); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 handles live tool approvals and questions", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-approvals-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + const active = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + providerOptions: { permission: { bash: "ask" } }, + }, + undefined, + { persistSession: false }, + ); + session = active; + const permissions = autoRespondToPermissions(active); + const command = await active.run( + "Use the shell tool to run exactly: printf PASEO_APPROVED. Then report its output.", + ); + expect(command.finalText).toContain("PASEO_APPROVED"); + expect(permissions.kinds).toContain("tool"); + const question = await active.run( + "Use the question tool to ask me to choose Red or Blue. Wait for my answer, then reply with exactly the color I selected.", + ); + expect(question.finalText).toContain("Blue"); + expect(permissions.kinds).toContain("question"); + expect(permissions.errors).toEqual([]); + expect(active.getPendingPermissions()).toEqual([]); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 invokes an injected MCP tool with exact preapproval", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-mcp-")); + const fixture = path.join(root, "mcp.mjs"); + await writeFile( + fixture, + `import { McpServer } from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/mcp.js"))}; +import { StdioServerTransport } from ${JSON.stringify(import.meta.resolve("@modelcontextprotocol/sdk/server/stdio.js"))}; +const server = new McpServer({ name: "paseo-validation", version: "1.0.0" }); +server.registerTool("marker", { description: "Return the validation marker", inputSchema: {} }, async () => ({ content: [{ type: "text", text: "PASEO_MCP_OK" }] })); +await server.connect(new StdioServerTransport()); +`, + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + const active = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + mcpServers: { validation: { type: "stdio", command: process.execPath, args: [fixture] } }, + toolPolicy: { preapproved: [{ server: "validation", tool: "marker" }] }, + }, + undefined, + { persistSession: false }, + ); + session = active; + const permissions = collectPermissions(active); + const result = await active.run( + "Call the validation_marker MCP tool once and report its output. Do not use other tools.", + ); + expect(result.finalText).toContain("PASEO_MCP_OK"); + expect(result.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "validation_marker", + status: "completed", + }), + ]), + ); + expect(permissions).toEqual([]); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 stops a running tool before replacement work and imports the same session", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-stop-")); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + let imported: Awaited> | undefined; + try { + const config = { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + } as const; + session = await client.createSession(config, undefined, { persistSession: false }); + const tools = collectRunningToolCalls(session); + await session.startTurn("Use the shell tool to run sleep 30, then say finished."); + await expect.poll(tools.hasRunningTool, { timeout: 45_000 }).toBe(true); + const stopping = session.interrupt(); + const replacement = session.run("Reply with exactly REPLACED. Do not use tools."); + await stopping; + expect((await replacement).finalText.trim()).toBe("REPLACED"); + expect(tools.canceledTurns()).toBe(1); + const handle = await session.describePersistence(); + const listing = await client.listImportableSessions({ cwd: root }); + expect(listing).toEqual( + expect.arrayContaining([expect.objectContaining({ providerHandleId: handle.nativeHandle })]), + ); + imported = await client.importSession( + { providerHandleId: handle.nativeHandle!, cwd: root }, + { config, storedConfig: config }, + ); + expect(imported.persistence.nativeHandle).toBe(handle.nativeHandle); + expect(imported.timeline).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ type: "assistant_message", text: "REPLACED" }), + }), + ]), + ); + } finally { + await imported?.session.close(); + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); + +test("v2 rewinds conversation and files and reports autonomous subagents", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-rewind-")); + await execCommand("git", ["init", root]); + await writeFile(path.join(root, "marker.txt"), "before\n"); + await execCommand("git", ["add", "marker.txt"], { cwd: root }); + await execCommand( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "baseline", + ], + { cwd: root }, + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + featureValues: { auto_accept: true }, + }, + undefined, + { persistSession: false }, + ); + await session.setMode!("plan"); + expect(await session.getCurrentMode!()).toBe("plan"); + await session.setMode!("build"); + const edited = await session.run( + "Edit marker.txt so its entire content is after followed by a newline. Use the file editing tools; do not use shell commands. Then say done.", + ); + expect((await readFile(path.join(root, "marker.txt"), "utf8")).trim()).toBe("after"); + const userMessageId = requireNativeUserMessageId(edited.timeline); + await session.revertBoth!({ messageId: userMessageId }); + expect(await readFile(path.join(root, "marker.txt"), "utf8")).toBe("before\n"); + expect(await drainPersistedAssistantText(session)).toEqual([]); + const subagents = collectProviderSubagents(session); + const delegated = await session.run( + "Use the subagent tool to delegate to a general subagent. Tell it to reply with exactly CHILD_OK without using tools. Wait for it, then report CHILD_OK.", + ); + expect(delegated.finalText).toContain("CHILD_OK"); + await expect.poll(subagents.hasCompletedChild, { timeout: 10_000 }).toBe(true); + expect(subagents.childText()).toContain("CHILD_OK"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + +test("v2 executes commands and retains context after compaction", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-commands-")); + const commandDir = path.join(root, ".opencode", "command"); + await mkdir(commandDir, { recursive: true }); + await writeFile( + path.join(commandDir, "validation.md"), + "---\ndescription: Validation command\n---\nReply with exactly COMMAND_OK. Do not use tools.\n", + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + await session.setThinkingOption!("low"); + expect((await session.getRuntimeInfo!()).thinkingOptionId).toBe("low"); + const commands = await session.listCommands!(); + expect(commands).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "validation", kind: "command" })]), + ); + expect((await session.run("/validation")).finalText).toContain("COMMAND_OK"); + await session.run("Remember the secret word ORANGE. Reply OK without tools."); + await session.run("/compact"); + expect( + (await session.run("What secret word did I ask you to remember? Reply with only that word.")) + .finalText, + ).toContain("ORANGE"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 180_000); + +test("v2 executes discovered skills and accepts image attachments", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-attachments-")); + const skillDir = path.join(root, ".opencode", "skills", "validation"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: validation\ndescription: Validation skill\n---\nReply with exactly SKILL_OK when asked to use this skill. Do not use tools.\n", + ); + const client = new OpenCodeV2AgentClient({ logger: createTestLogger() }); + let session: Awaited> | undefined; + try { + session = await client.createSession( + { + provider: "opencode", + cwd: root, + model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra", + }, + undefined, + { persistSession: false }, + ); + const skill = findSkillCommand(await session.listCommands!(), "validation"); + expect((await session.run(`/${skill.name}`)).finalText).toContain("SKILL_OK"); + const result = await session.run([ + { + type: "text", + text: "An image is attached. Reply exactly IMAGE_OK if you received it. Do not use tools.", + }, + { + type: "image", + mimeType: "image/png", + data: ( + await readFile(new URL("../../../../../desktop/assets/32x32.png", import.meta.url)) + ).toString("base64"), + }, + ]); + expect(result.finalText).toContain("IMAGE_OK"); + } finally { + await session?.interrupt(); + await session?.close(); + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs index 058ade60f8..82e4166492 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs +++ b/packages/server/src/server/agent/providers/opencode/bridge-plugin.mjs @@ -33,6 +33,13 @@ export default async function paseoPlugin(input, options) { description: definition.description, args: jsonSchemaObjectToZodShape(definition.inputSchema), execute: async (args, context) => { + // OpenCode leaves permission checks to plugin tools, so ask it the way it asks for MCP tools. + await context.ask({ + permission: `paseo_${definition.name}`, + patterns: ["*"], + always: ["*"], + metadata: {}, + }); let result; try { result = await request( diff --git a/packages/server/src/server/agent/providers/opencode/bridge.test.ts b/packages/server/src/server/agent/providers/opencode/bridge.test.ts index 261487c279..076cbe94bd 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.test.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import type { SessionMessageInfo } from "@opencode/client"; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -8,7 +9,15 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { PaseoToolCatalog } from "../../tools/types.js"; -import { OpenCodeBridge, loadOpenCodeBridgePluginArtifact } from "./bridge.js"; +import { createPaseoToolCatalog } from "../../tools/paseo-tools.js"; +import { AgentManager } from "../../agent-manager.js"; +import { AgentStorage } from "../../agent-storage.js"; +import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js"; +import { + OpenCodeBridge, + loadOpenCodeBridgePluginArtifact, + materializeOpenCodeV2Plugin, +} from "./bridge.js"; const temporaryDirectories: string[] = []; @@ -97,6 +106,12 @@ describe("OpenCodeBridge", () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-")); temporaryDirectories.push(paseoHome); const catalog = createCatalog(); + const executedInputs: unknown[] = []; + const executeTool = catalog.executeTool; + catalog.executeTool = async (name, input, context) => { + executedInputs.push(input); + return await executeTool(name, input, context); + }; const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); await bridge.start(); bridge.setManifestCatalog(catalog); @@ -168,12 +183,30 @@ describe("OpenCodeBridge", () => { token: plugin.token, }, ); + const asked: unknown[] = []; await expect( hooks.tool.paseo_echo_context.execute( { value: "through bundled plugin" }, - { sessionID: "ses_one" }, + { sessionID: "ses_one", ask: async (request: unknown) => void asked.push(request) }, ), ).resolves.toMatchObject({ output: "through bundled plugin" }); + expect(asked).toEqual([ + { permission: "paseo_echo_context", patterns: ["*"], always: ["*"], metadata: {} }, + ]); + + executedInputs.length = 0; + await expect( + hooks.tool.paseo_echo_context.execute( + { value: "rejected" }, + { + sessionID: "ses_one", + ask: async () => { + throw new Error("The user rejected permission to use this specific tool call."); + }, + }, + ), + ).rejects.toThrow("rejected permission"); + expect(executedInputs).toEqual([]); release(); const pluginError = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -196,6 +229,327 @@ describe("OpenCodeBridge", () => { } }); + test("v2 plugin filters caller tools and inherits child session bindings", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-scope-")); + temporaryDirectories.push(paseoHome); + const catalog = createCatalog(); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog }); + const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const config = z + .object({ + plugins: z.array( + z.object({ + package: z.string(), + options: z.object({ baseUrl: z.string(), token: z.string() }), + }), + ), + }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); + expect(await bridge.decorateV2ServerEnv(env)).toEqual(env); + const plugin = config.plugins[0]!; + const tools = new Map(); + let filter!: (input: V2TestContext) => Promise; + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "parent" }), + hook: async (_name, callback) => { + filter = callback; + return { dispose: async () => undefined }; + }, + }, + }); + const allowed: V2TestContext = { + sessionID: "child", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(allowed); + expect(Object.keys(allowed.tools)).toEqual(["paseo_echo_context", "native"]); + const disabled: V2TestContext = { + sessionID: "disabled", + tools: { paseo_echo_context: {}, native: {} }, + }; + await filter(disabled); + expect(Object.keys(disabled.tools)).toEqual(["native"]); + await expect( + tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }), + ).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] }); + await expect( + tools.get("paseo_echo_context")!.execute({ value: "blocked" }, { sessionID: "disabled" }), + ).rejects.toMatchObject({ + message: "Paseo tools are disabled for this session", + status: 403, + }); + await dispose(); + } finally { + release(); + releaseDisabled(); + await bridge.close(); + } + }); + + test("v2 plugin preserves create_agent validation errors from the daemon", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-errors-")); + temporaryDirectories.push(paseoHome); + const logger = createTestLogger(); + const providerSnapshotManager = new ProviderSnapshotManager({ logger }); + const catalog = createPaseoToolCatalog({ + agentManager: new AgentManager({ logger }), + agentStorage: new AgentStorage(paseoHome, logger), + providerSnapshotManager, + logger, + }); + const bridge = new OpenCodeBridge({ paseoHome, logger }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT); + const plugin = plugins[0]; + const module = await import( + pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href + ); + const tools = new Map(); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => { + transform({ add: (tool) => tools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: "" }), + hook: async () => ({ dispose: async () => undefined }), + }, + } satisfies V2TestPluginContext); + try { + const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" }; + const response = await fetch( + `${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`, + { + method: "POST", + headers: { + Authorization: `Bearer ${plugin.options.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(input), + }, + ); + expect(response.status).toBe(500); + const payload = await response.json(); + expect(payload.error).toContain("provider must be provider/model"); + await expect( + tools.get("paseo_create_agent")!.execute(input, { sessionID: "caller" }), + ).rejects.toMatchObject({ message: payload.error, status: 500 }); + } finally { + await dispose(); + } + } finally { + release(); + await bridge.close(); + await providerSnapshotManager.shutdown(); + } + }); + + test("v2 plugin returns a tool image as OpenCode file content", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-image-")); + temporaryDirectories.push(paseoHome); + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; + const screenshot = { + name: "browser_screenshot", + title: "Browser screenshot", + description: "Captures the page.", + inputSchema: {}, + async handler() { + return { + content: [ + { type: "text", text: "Captured browser screenshot." }, + { type: "image", data: png, mimeType: "image/png" }, + ], + }; + }, + }; + const tools = new Map([[screenshot.name, screenshot]]); + const catalog: PaseoToolCatalog = { + tools, + getTool: (name) => tools.get(name), + executeTool: async (name, input, context) => + await tools.get(name)!.handler(input, context ?? {}), + }; + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + bridge.setManifestCatalog(catalog); + await bridge.start(); + const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog }); + try { + const env = await bridge.decorateV2ServerEnv({}); + const plugin = z + .object({ + plugins: z.array( + z.object({ + package: z.string(), + options: z.object({ baseUrl: z.string(), token: z.string() }), + }), + ), + }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!; + const pluginTools = new Map(); + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href); + const dispose = await module.default.setup({ + options: plugin.options, + tool: { + transform: async (transform) => { + transform({ add: (tool) => pluginTools.set(tool.name, tool) }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => [], + get: async () => ({ parentID: undefined }), + hook: async () => ({ dispose: async () => undefined }), + }, + }); + const result = await pluginTools + .get("paseo_browser_screenshot")! + .execute({}, { sessionID: "session" }); + expect(result).toMatchObject({ + content: [ + { type: "text", text: "Captured browser screenshot." }, + { type: "file", mime: "image/png", uri: `data:image/png;base64,${png}` }, + ], + }); + await dispose(); + } finally { + release(); + await bridge.close(); + } + }); + + test("v2 structured output validates values and clears its tool on ordinary turns", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-structured-")); + temporaryDirectories.push(root); + const pluginUrl = await materializeOpenCodeV2Plugin(root); + const module: { + default: { setup(context: V2TestPluginContext): Promise<() => Promise> }; + } = await import(pathToFileURL(path.join(fileURLToPath(pluginUrl), "server.js")).href); + const schema = { + type: "object", + properties: { answer: { type: "integer" } }, + required: ["answer"], + additionalProperties: false, + }; + const history: SessionMessageInfo[] = [ + { + id: "user", + type: "user", + text: "answer", + time: { created: 1 }, + metadata: { paseoOutputSchema: schema }, + }, + ]; + const tools = new Map(); + let hook!: (input: V2TestContext) => Promise; + const dispose = await module.default.setup({ + options: { baseUrl: "", token: "" }, + tool: { + transform: async (transform) => { + transform({ + add: (tool) => { + tools.set(tool.name, tool); + }, + }); + return { dispose: async () => undefined }; + }, + }, + session: { + context: async () => history, + get: async () => ({ parentID: "parent" }), + hook: async (_name, callback) => { + hook = callback; + return { dispose: async () => undefined }; + }, + }, + }); + try { + const context: V2TestContext = { + sessionID: "session", + tools: { paseo_structured_output: {} }, + system: [], + }; + await hook(context); + expect(context.tools.paseo_structured_output.input).toMatchObject({ + properties: { value: schema }, + }); + const tool = tools.get("paseo_structured_output")!; + await expect( + tool.execute({ value: { answer: "wrong" } }, { sessionID: "session" }), + ).rejects.toThrow("Invalid structured output"); + await expect( + tool.execute({ value: { answer: 42 } }, { sessionID: "session" }), + ).resolves.toMatchObject({ metadata: { paseoStructuredOutput: { answer: 42 } } }); + history.push({ id: "next", type: "user", text: "ordinary", time: { created: 2 } }); + await hook(context); + expect(context.tools).toEqual({}); + await expect( + tool.execute({ value: { answer: 42 } }, { sessionID: "session" }), + ).rejects.toThrow("no structured-output request"); + } finally { + await dispose(); + } + }); + + test("packages the v2 bridge as a directory with a server entry point", async () => { + const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-package-")); + temporaryDirectories.push(paseoHome); + const bridge = new OpenCodeBridge({ paseoHome, logger: createTestLogger() }); + await bridge.start(); + try { + expect( + (await readdir(path.join(paseoHome, "runtime", "opencode"))).filter((name) => + name.startsWith("paseo-v2-"), + ), + ).toEqual([]); + const env = await bridge.decorateV2ServerEnv({ + OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }), + }); + const config = z + .object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) }) + .parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)); + const directory = fileURLToPath(config.plugins[1].package); + expect((await stat(directory)).isDirectory()).toBe(true); + const entry = await readFile(path.join(directory, "server.js"), "utf8"); + expect(entry).toContain('id: "paseo"'); + const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } }); + } finally { + await bridge.close(); + } + }); + test("preserves user OpenCode config while installing one content-addressed plugin", async () => { const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-bridge-config-")); temporaryDirectories.push(paseoHome); @@ -224,3 +578,29 @@ describe("OpenCodeBridge", () => { } }); }); + +interface V2TestTool { + name: string; + execute(input: unknown, call: { sessionID: string }): Promise; +} +interface V2TestContext { + sessionID: string; + tools: Record; + system?: Array<{ type: "text"; text: string }>; +} +interface V2TestPluginContext { + options: { baseUrl: string; token: string }; + tool: { + transform( + callback: (editor: { add(tool: V2TestTool): void }) => void, + ): Promise<{ dispose(): Promise }>; + }; + session: { + context(input: { sessionID: string }): Promise; + get(input: { sessionID: string }): Promise<{ parentID: string }>; + hook( + name: string, + callback: (input: V2TestContext) => Promise, + ): Promise<{ dispose(): Promise }>; + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/bridge.ts b/packages/server/src/server/agent/providers/opencode/bridge.ts index 98e12e7eb1..52a2cd3612 100644 --- a/packages/server/src/server/agent/providers/opencode/bridge.ts +++ b/packages/server/src/server/agent/providers/opencode/bridge.ts @@ -48,6 +48,7 @@ export class OpenCodeBridge { private server: Server | null = null; private baseUrl: string | null = null; private pluginUrl: string | null = null; + private v2PluginUrl: string | null = null; private manifestCatalog: PaseoToolCatalog | null = null; constructor(options: OpenCodeBridgeOptions) { @@ -115,6 +116,14 @@ export class OpenCodeBridge { }; } + async decorateV2ServerEnv(env: Record): Promise> { + this.v2PluginUrl ??= await this.materializePlugin(2); + return decorateOpenCodeV2Env(env, this.v2PluginUrl, { + baseUrl: this.requireBaseUrl(), + token: this.token, + }); + } + async close(): Promise { const server = this.server; this.server = null; @@ -123,7 +132,8 @@ export class OpenCodeBridge { if (server) await closeServer(server); } - private async materializePlugin(): Promise { + private async materializePlugin(version: 1 | 2 = 1): Promise { + if (version === 2) return materializeOpenCodeV2Plugin(this.paseoHome); const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url); const digest = createHash("sha256").update(artifact).digest("hex"); const destination = path.join(this.paseoHome, "runtime", "opencode", `paseo-${digest}.mjs`); @@ -152,7 +162,12 @@ export class OpenCodeBridge { sendJson(response, 404, { error: "OpenCode session is not bound to a Paseo agent" }); return; } - sendJson(response, 200, { env: binding.env }); + sendJson(response, 200, { + env: binding.env, + ...(url.searchParams.has("tools") + ? { tools: [...(binding.tools?.tools.keys() ?? [])] } + : {}), + }); return; } @@ -231,13 +246,63 @@ export class OpenCodeBridge { } } +export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise { + const artifact = await loadOpenCodeBridgePluginArtifact(import.meta.url, undefined, 2); + const digest = createHash("sha256").update(artifact).digest("hex"); + // V2 ignores configured file paths; it loads a package directory's server entry point. + const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`); + await writeFileAtomic(path.join(directory, "server.js"), artifact); + await writeFileAtomic( + path.join(directory, "package.json"), + JSON.stringify({ + name: "paseo-opencode-bridge", + private: true, + type: "module", + exports: { "./server": "./server.js" }, + }), + ); + return pathToFileURL(directory).href; +} + +export function decorateOpenCodeV2Env( + env: Record, + pluginUrl: string, + options: Record = {}, +): Record { + const config = parseOpenCodeConfig(env.OPENCODE_CONFIG_CONTENT); + const plugins = config.plugins; + if (plugins !== undefined && !Array.isArray(plugins)) + throw new Error("OpenCode v2 plugins must be an array"); + return { + ...env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + plugins: [ + ...(plugins ?? []).filter( + (plugin: unknown) => + plugin !== pluginUrl && + !( + typeof plugin === "object" && + plugin !== null && + "package" in plugin && + plugin.package === pluginUrl + ), + ), + { package: pluginUrl, options }, + ], + }), + }; +} + type CompileOpenCodeBridgePlugin = (sourcePath: string) => Promise; export async function loadOpenCodeBridgePluginArtifact( moduleUrl: string, compileSource: CompileOpenCodeBridgePlugin = compileOpenCodeBridgePlugin, + version: 1 | 2 = 1, ): Promise { - const bundleUrl = new URL("./bridge-plugin.bundle.mjs", moduleUrl); + const directory = version === 2 ? "./v2/" : "./"; + const bundleUrl = new URL(`${directory}bridge-plugin.bundle.mjs`, moduleUrl); try { return await readFile(bundleUrl); } catch (error) { @@ -245,7 +310,7 @@ export async function loadOpenCodeBridgePluginArtifact( throw new Error("Bundled OpenCode bridge plugin artifact is missing", { cause: error }); } } - return compileSource(fileURLToPath(new URL("./bridge-plugin.mjs", moduleUrl))); + return compileSource(fileURLToPath(new URL(`${directory}bridge-plugin.mjs`, moduleUrl))); } async function compileOpenCodeBridgePlugin(sourcePath: string): Promise { @@ -260,6 +325,7 @@ async function compileOpenCodeBridgePlugin(sourcePath: string): Promise { const upstream = await createSseUpstream(); const timing = new ControlledTiming(); const processExit = deferred(); + const listening = deferred(); const consumer = new OpenCodeEventConsumer({ serverUrl: upstream.url, processExit: processExit.promise, + listening: listening.promise, logger: createRecordingLogger(), timing, }); @@ -62,6 +64,10 @@ describe("OpenCodeEventConsumer", () => { const inputs: OpenCodeEventSourceInput[] = []; consumer.subscribe((input) => inputs.push(input)); + await new Promise((resolve) => setImmediate(resolve)); + expect(upstream.requests).toHaveLength(0); + expect(consumer.diagnostics().attempt).toBe(0); + listening.resolve(); await upstream.connected(1); expect(await promiseState(consumer.ready())).toBe("pending"); upstream.send(0, arbitraryRecord("/one")); @@ -106,10 +112,7 @@ describe("OpenCodeEventConsumer", () => { await consumer.ready(); timing.expireWatchdog(); - expect(timing.watchdogDelays).toEqual([ - EXPECTED_STREAM_WATCHDOG_MS, - EXPECTED_STREAM_WATCHDOG_MS, - ]); + expect(timing.watchdogDelays).toEqual([5_000, EXPECTED_STREAM_WATCHDOG_MS]); await timing.waiting(); timing.advanceWait(); await upstream.connected(2); @@ -120,6 +123,7 @@ describe("OpenCodeEventConsumer", () => { test("retries a first-record watchdog and exposes the recovery attempt", async () => { const upstream = await createSseUpstream(); + upstream.stallNext(1); const timing = new ControlledTiming(); const logger = createRecordingLogger(); const consumer = new OpenCodeEventConsumer({ @@ -127,6 +131,12 @@ describe("OpenCodeEventConsumer", () => { processExit: new Promise(() => undefined), logger, timing, + createClient: (baseUrl) => + createOpencodeClient({ + baseUrl, + // Keep the abandoned socket open to reproduce a fetch that does not unwind on abort. + fetch: (request) => fetch(new Request(request, { signal: null })), + }), }); cleanups.push(async () => { await consumer.close(); @@ -134,6 +144,7 @@ describe("OpenCodeEventConsumer", () => { }); await upstream.connected(1); + expect(timing.watchdogDelays).toEqual([5_000]); timing.expireWatchdog(); await timing.waiting(); expect(consumer.diagnostics()).toMatchObject({ @@ -156,7 +167,18 @@ describe("OpenCodeEventConsumer", () => { await upstream.connected(2); upstream.send(1, connectedRecord("/recovered")); await consumer.ready(); - expect(consumer.diagnostics()).toMatchObject({ attempt: 2, phase: "stream" }); + expect(consumer.diagnostics()).toMatchObject({ + attempt: 2, + phase: "stream", + }); + expect(upstream.isOpen(0)).toBe(true); + upstream.resume(0); + upstream.send(0, connectedRecord("/abandoned")); + upstream.send(0, arbitraryRecord("/abandoned")); + const inputs: OpenCodeEventSourceInput[] = []; + consumer.subscribe((input) => inputs.push(input)); + await new Promise((resolve) => setImmediate(resolve)); + expect(inputs).toEqual([]); }); test("publishes one terminal and stops reconnecting on process exit", async () => { @@ -422,6 +444,16 @@ describe("OpenCodeEventConsumer", () => { await consumer.close(); expect(inputs).toEqual([]); + + const waitingConsumer = new OpenCodeEventConsumer({ + serverUrl: upstream.url, + processExit: new Promise(() => undefined), + listening: new Promise(() => undefined), + logger: createRecordingLogger(), + }); + await waitingConsumer.close(); + await expect(waitingConsumer.ready()).rejects.toThrow("OpenCode event source closed"); + expect(upstream.requests).toHaveLength(1); }); }); @@ -499,8 +531,14 @@ async function createSseUpstream() { const responses: ServerResponse[] = []; const requests: Array<{ url: string | undefined }> = []; let failuresRemaining = 0; + let stallsRemaining = 0; const server = createServer((request, response) => { requests.push({ url: request.url }); + if (stallsRemaining > 0) { + stallsRemaining -= 1; + responses.push(response); + return; + } if (failuresRemaining > 0) { failuresRemaining -= 1; response.writeHead(503).end(); @@ -533,7 +571,21 @@ async function createSseUpstream() { failNext(count: number) { failuresRemaining = count; }, - close: async () => new Promise((resolve) => server.close(() => resolve())), + stallNext(count: number) { + stallsRemaining = count; + }, + isOpen(index: number) { + return responses[index]?.socket?.destroyed === false; + }, + resume(index: number) { + responses[index]?.writeHead(200, { "content-type": "text/event-stream" }); + responses[index]?.flushHeaders(); + }, + close: async () => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), }; } diff --git a/packages/server/src/server/agent/providers/opencode/event-consumer.ts b/packages/server/src/server/agent/providers/opencode/event-consumer.ts index 064d6d6905..9471a28745 100644 --- a/packages/server/src/server/agent/providers/opencode/event-consumer.ts +++ b/packages/server/src/server/agent/providers/opencode/event-consumer.ts @@ -29,11 +29,13 @@ export interface OpenCodeEventConsumerTiming { export interface OpenCodeEventConsumerOptions { serverUrl: string; processExit: Promise; + listening?: Promise; logger: Pick; createClient?: (baseUrl: string) => OpencodeClient; timing?: OpenCodeEventConsumerTiming; } +const FIRST_RECORD_WATCHDOG_MS = 5_000; const WATCHDOG_MS = 30_000; const MAX_BACKOFF_MS = 5_000; const FAILURE_WARNING_ATTEMPT = 4; @@ -98,7 +100,7 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { this.rejectReady = reject; }); void this.readyPromise.catch(() => undefined); - this.connectionTask = this.consume(options.processExit); + this.connectionTask = this.consume(options.processExit, options.listening); void this.connectionTask.catch(() => undefined); } @@ -131,8 +133,23 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { await this.connectionTask.catch(() => undefined); } - private async consume(processExit: Promise): Promise { + private async consume(processExit: Promise, listening?: Promise): Promise { void processExit.then((error) => this.exit(error)); + if (listening) { + const signal = this.connectionAbort.signal; + let stopWaiting!: () => void; + const stopped = new Promise((resolve) => { + stopWaiting = resolve; + }); + signal.addEventListener("abort", stopWaiting, { once: true }); + try { + await Promise.race([listening, stopped]); + } catch { + return; + } finally { + signal.removeEventListener("abort", stopWaiting); + } + } let reconnectAttempt = 0; while (!this.closed) { this.attempt += 1; @@ -150,62 +167,83 @@ export class OpenCodeEventConsumer implements OpenCodeEventSource { private async consumeConnection(signal: AbortSignal): Promise { const requestAbort = new AbortController(); - const abortRequest = () => requestAbort.abort(signal.reason); + const attemptToken = { active: true }; + let resolveAbandoned!: (result: OpenCodeConnectionResult) => void; + const abandoned = new Promise((resolve) => { + resolveAbandoned = resolve; + }); + const abortRequest = () => { + attemptToken.active = false; + resolveAbandoned({ delivered, phase, outcome: "ended" }); + requestAbort.abort(signal.reason); + }; signal.addEventListener("abort", abortRequest, { once: true }); let cancelWatchdog: () => void = () => undefined; let delivered = false; let phase: OpenCodeEventStreamPhase = "first-record"; - let watchdogPhase: OpenCodeEventStreamPhase | null = null; let sseError: unknown; const armWatchdog = () => { cancelWatchdog(); - cancelWatchdog = this.timing.arm(WATCHDOG_MS, () => { - watchdogPhase = phase; - requestAbort.abort(new Error(`OpenCode event stream ${phase} watchdog expired`)); - }); - }; - try { - const result = await this.client.global.event({ - signal: requestAbort.signal, - sseMaxRetryAttempts: 0, - onSseError: (error) => { - sseError = error; + cancelWatchdog = this.timing.arm( + this.connected ? WATCHDOG_MS : FIRST_RECORD_WATCHDOG_MS, + () => { + const error = new Error(`OpenCode event stream ${phase} watchdog expired`); + // Abandon a stalled request even when fetch ignores abort, for example during OpenCode startup. + attemptToken.active = false; + resolveAbandoned({ delivered, phase, outcome: "watchdog", error }); + requestAbort.abort(error); }, - }); - armWatchdog(); - for await (const event of result.stream) { - if (this.closed) { - return { delivered, phase, outcome: "ended" }; + ); + }; + const consumeStream = async (): Promise => { + try { + const result = await this.client.global.event({ + signal: requestAbort.signal, + sseMaxRetryAttempts: 0, + onSseError: (error) => { + sseError = error; + }, + }); + for await (const event of result.stream) { + // Ignore late records from abandoned requests, for example a delayed server.connected event. + if (this.closed || !attemptToken.active) { + return { delivered, phase, outcome: "ended" }; + } + delivered = true; + phase = "stream"; + this.phase = phase; + if (!this.connected && event.payload.type === "server.connected") { + this.connected = true; + this.resolveReady(); + armWatchdog(); + continue; + } + armWatchdog(); + this.logPluginFailure(event); + this.publish(event); } - armWatchdog(); - delivered = true; - phase = "stream"; - this.phase = phase; - if (!this.connected && event.payload.type === "server.connected") { - this.connected = true; - this.resolveReady(); - continue; - } - this.logPluginFailure(event); - this.publish(event); + let outcome: OpenCodeConnectionOutcome = "ended"; + if (sseError !== undefined) outcome = "error"; + return { + delivered, + phase, + outcome, + ...(sseError === undefined ? {} : { error: sseError }), + }; + } catch (error) { + return { + delivered, + phase, + outcome: "error", + error, + }; } - let outcome: OpenCodeConnectionOutcome = "ended"; - if (watchdogPhase) outcome = "watchdog"; - else if (sseError !== undefined) outcome = "error"; - return { - delivered, - phase: watchdogPhase ?? phase, - outcome, - ...(sseError === undefined ? {} : { error: sseError }), - }; - } catch (error) { - return { - delivered, - phase: watchdogPhase ?? phase, - outcome: watchdogPhase ? "watchdog" : "error", - error, - }; + }; + armWatchdog(); + try { + return await Promise.race([consumeStream(), abandoned]); } finally { + attemptToken.active = false; cancelWatchdog(); signal.removeEventListener("abort", abortRequest); requestAbort.abort(); @@ -285,5 +323,5 @@ function containsPluginError(error: unknown): boolean { } export type OpenCodeEventConsumerFactory = ( - options: Pick, + options: Pick, ) => OpenCodeEventConsumer; diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 50a3a9edfe..9ed61fef67 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -1,3 +1,5 @@ +import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client"; +import { V2Timeline } from "./v2/timeline.js"; import { describe, expect, it } from "vitest"; import { translateOpenCodeEvent, type OpenCodeEventTranslationState } from "../opencode-agent.js"; @@ -1276,6 +1278,44 @@ describe("translateOpenCodeEvent", () => { expect(result).toEqual([]); }); + it("shows OpenCode user text parts unless OpenCode marks them synthetic", () => { + const state = createState(); + const userTextPart = (messageId: string, text: string, synthetic: boolean) => { + translateOpenCodeEvent( + { + type: "message.updated", + properties: { info: { id: messageId, sessionID: "session-1", role: "user" } }, + }, + state, + ); + return translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: `part-${messageId}`, + sessionID: "session-1", + messageID: messageId, + type: "text", + text, + synthetic, + }, + }, + }, + state, + ); + }; + + expect(userTextPart("msg-continue", "Continue if you have next steps", true)).toEqual([]); + expect(userTextPart("msg-user", "Plugin prompt", false)).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: "Plugin prompt", messageId: "msg-user" }, + }, + ]); + }); + it("ignores message.part.delta for user messages", () => { const state = createState(); @@ -1518,3 +1558,90 @@ describe("translateOpenCodeEvent", () => { ]); }); }); + +describe("OpenCode v2 history reconciliation", () => { + it("keeps client message identity on live echoes but omits it from history", () => { + const user: SessionMessageUser = { + id: "native-message", + type: "user", + text: "hello", + time: { created: 1 }, + metadata: { paseoClientMessageId: "client-message" }, + }; + expect(new V2Timeline().messages([user])).toMatchObject([ + { item: { clientMessageId: "client-message", messageId: "native-message" } }, + ]); + expect(new V2Timeline(false).messages([user])).toMatchObject([ + { item: { type: "user_message", messageId: "native-message" } }, + ]); + const [event] = new V2Timeline(false).messages([user]); + expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId"); + }); + function assistant(text: string): SessionMessageAssistant { + return { + id: "message", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: 1 }, + content: [{ type: "text", text }], + }; + } + it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => { + const timeline = new V2Timeline(); + const text = (messages: ReturnType) => + messages.flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" + ? [event.item.text] + : [], + ); + expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]); + expect(timeline.messages([assistant("hel")])).toEqual([]); + expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]); + expect(timeline.messages([assistant("hello world")])).toEqual([]); + }); + it("keeps reasoning separate from assistant output", () => { + const timeline = new V2Timeline(); + const message = assistant("answer"); + message.content.unshift({ type: "reasoning", text: "thinking" }); + expect( + timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)), + ).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "assistant_message", text: "answer", messageId: "message" }, + ]); + }); + it("emits tool state transitions once and keeps their call identity", () => { + const timeline = new V2Timeline(); + const message = assistant(""); + message.content = [ + { + type: "tool", + id: "call", + name: "bash", + time: { created: 1 }, + state: { status: "running", input: { command: "pwd" }, metadata: {} }, + }, + ]; + expect(timeline.messages([message])).toMatchObject([ + { item: { type: "tool_call", callId: "call", status: "running" } }, + ]); + message.content = [ + { + type: "tool", + id: "call", + name: "bash", + time: { created: 1, completed: 2 }, + state: { + status: "completed", + input: { command: "pwd" }, + content: [{ type: "text", text: "/workspace" }], + }, + }, + ]; + expect(timeline.messages([message])).toMatchObject([ + { item: { type: "tool_call", callId: "call", status: "completed" } }, + ]); + expect(timeline.messages([message])).toEqual([]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/http-error.ts b/packages/server/src/server/agent/providers/opencode/http-error.ts new file mode 100644 index 0000000000..883f82deae --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/http-error.ts @@ -0,0 +1,16 @@ +export class OpenCodeHttpError extends Error { + readonly name = "OpenCodeHttpError"; + + constructor( + readonly operation: string, + readonly status: number | undefined, + readonly html: boolean, + ) { + const statusLabel = status === undefined ? "" : ` (HTTP ${status})`; + super( + html + ? `OpenCode ${operation} returned HTML instead of JSON; incompatible OpenCode API${statusLabel}` + : `OpenCode ${operation} failed${statusLabel}`, + ); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/rewind.test.ts b/packages/server/src/server/agent/providers/opencode/rewind.test.ts index 18baba3a3f..b65da2de10 100644 --- a/packages/server/src/server/agent/providers/opencode/rewind.test.ts +++ b/packages/server/src/server/agent/providers/opencode/rewind.test.ts @@ -1,3 +1,5 @@ +import { V2Harness } from "./test-utils/v2-harness.js"; +import { OpenCodeV2AgentClient } from "./v2/agent.js"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; @@ -67,3 +69,37 @@ describe("OpenCode rewind", () => { }); }); }); + +test("OpenCode v2 commits conversation and file rewind only after staging succeeds", async () => { + const harness = new V2Harness(); + const operations: string[] = []; + harness.interrupt = async () => { + operations.push("interrupt"); + return { interrupted: true }; + }; + harness.api.session.revert.stage = async (input) => { + expect(input).toEqual({ sessionID: "session", messageID: "user-message", files: true }); + operations.push("stage"); + return harness.info; + }; + harness.api.session.revert.commit = async () => { + operations.push("commit"); + return harness.info; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.revertBoth!({ messageId: "user-message" }); + expect(operations).toEqual(["interrupt", "stage", "commit"]); + harness.api.session.revert.stage = async () => { + throw new Error("snapshot missing"); + }; + await expect(session.revertBoth!({ messageId: "missing" })).rejects.toThrow("snapshot missing"); + expect(operations).toEqual(["interrupt", "stage", "commit", "interrupt"]); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts new file mode 100644 index 0000000000..b1ee5be697 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "vitest"; +import { openCodeMajorVersion } from "./runtime-client.js"; + +test.each([ + ["1.14.46", 1], + ["unexpected wrapper output", 1], + ["", 1], + ["opencode v2.0.10\n", 2], + ["v2.0.10-beta.1", 2], +])("identifies the OpenCode runtime version %s", (output, major) => { + expect(openCodeMajorVersion(String(output))).toBe(major); +}); + +test.each(["3.0.0", "2.0.9", "2.0.7", "2.0.4", "2.0.3", "v2.0.1"])( + "rejects unsupported OpenCode version output %s", + (output) => { + expect(() => openCodeMajorVersion(output)).toThrow(); + }, +); + +test("records the runtime once and retains its position across resume", async () => { + const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js"); + const { V2Harness } = await import("./test-utils/v2-harness.js"); + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const original = withOpenCodeRuntimeNotice( + await client.createSession({ provider: "opencode", cwd: "/tmp/project" }), + 2, + ); + const notice = { type: "notification", level: "info", message: "This chat uses OpenCode v2." }; + try { + expect(original.initialTimeline?.map((entry) => entry.item)).toEqual([notice]); + const handle = original.describePersistence()!; + await original.close(); + const resumed = withOpenCodeRuntimeNotice(await client.resumeSession(handle), 2, handle); + try { + expect(resumed.initialTimeline).toEqual([]); + const history = []; + for await (const event of resumed.streamHistory()) history.push(event); + expect(history).toEqual([ + { type: "timeline", provider: "opencode", ...original.initialTimeline![0] }, + ]); + expect(resumed.describePersistence()?.metadata?.openCodeRuntimeNotices).toEqual( + handle.metadata?.openCodeRuntimeNotices, + ); + } finally { + await resumed.close(); + } + } finally { + await client.shutdown(); + } +}); + +test("retries a rejected version probe after the configured binary changes", async () => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-version-probe-")); + const script = join(root, "version.cjs"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, script] }, + }); + try { + await writeFile(script, 'console.log("opencode v2.0.3")'); + await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow( + "Update OpenCode to 2.0.10 or newer, then refresh the provider in Paseo", + ); + await writeFile(script, 'console.log("opencode v3.0.0")'); + await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow( + "Unsupported OpenCode major version 3", + ); + } finally { + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}); + +test.each([ + ["console.log('wrapper output')", "unrecognized output"], + ["process.exit(1)", "failed command"], +])("retries a %s version probe after updating to v2 (%s)", async (initialSource) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-version-retry-")); + const script = join(root, "version.cjs"); + const client = new OpenCodeRuntimeClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, script] }, + }); + try { + await writeFile(script, initialSource); + expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( + "Auto Accept", + ); + await writeFile(script, 'console.log("opencode v2.0.10")'); + expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( + "Auto-accept", + ); + } finally { + await client.shutdown(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("appends a first notice after old history and records a changed major version", async () => { + const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js"); + const { V2Harness } = await import("./test-utils/v2-harness.js"); + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const harness = new V2Harness(); + harness.history.push({ id: "old", type: "user", text: "Earlier prompt", time: { created: 1 } }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const previous = { + provider: "opencode", + sessionId: "session", + metadata: { cwd: "/tmp/project" }, + }; + const session = withOpenCodeRuntimeNotice(await client.resumeSession(previous), 2, previous); + try { + const history = []; + for await (const event of session.streamHistory()) history.push(event); + expect(history).toEqual([ + expect.objectContaining({ + type: "timeline", + item: { type: "user_message", text: "Earlier prompt", messageId: "old" }, + }), + expect.objectContaining({ + type: "timeline", + item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + }), + ]); + await session.close(); + const v1Handle = { + ...previous, + metadata: { + ...previous.metadata, + openCodeRuntimeNotices: [{ major: 1, timestamp: "2026-01-01T00:00:00.000Z" }], + }, + }; + const changed = withOpenCodeRuntimeNotice(await client.resumeSession(v1Handle), 2, v1Handle); + try { + expect(changed.initialTimeline?.map((entry) => entry.item)).toEqual([ + { type: "notification", level: "info", message: "This chat uses OpenCode v2." }, + ]); + expect(changed.describePersistence()?.metadata?.openCodeRuntimeNotices).toHaveLength(2); + } finally { + await changed.close(); + } + } finally { + await session.close(); + await client.shutdown(); + } +}); + +test.each([ + "process.exit(1)", + 'console.log("custom wrapper output")', + "setTimeout(() => {}, 30000)", +])( + "preserves legacy operations when the version probe is inconclusive: %s", + async (source) => { + const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { OpenCodeRuntimeClient } = await import("./runtime-client.js"); + const { OpenCodeAgentClient } = await import("../opencode-agent.js"); + const { createTestLogger } = await import("../../../../test-utils/test-logger.js"); + const { OpenCodeServerManager } = await import("./server-manager.js"); + const root = await mkdtemp(join(tmpdir(), "opencode-legacy-probe-")); + const script = join(root, "version.cjs"); + await writeFile(script, source); + const logger = createTestLogger(); + let legacyShutdown = false; + class TrackedServerManager extends OpenCodeServerManager { + override async shutdown() { + legacyShutdown = true; + await super.shutdown(); + } + } + const client = new OpenCodeRuntimeClient( + logger, + { + command: { mode: "replace", argv: [process.execPath, script] }, + }, + { serverManager: new TrackedServerManager({ logger }) }, + ); + const legacy = new OpenCodeAgentClient(logger); + const config = { provider: "opencode", cwd: root }; + try { + expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config)); + } finally { + await client.shutdown(); + await legacy.shutdown(); + await rm(root, { recursive: true, force: true }); + } + expect(legacyShutdown).toBe(true); + }, + 10000, +); diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts new file mode 100644 index 0000000000..2a34abe35d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -0,0 +1,164 @@ +import type { Logger } from "pino"; +import type { + AgentClient, + AgentSessionConfig, + AgentLaunchContext, + AgentCreateSessionOptions, + AgentPersistenceHandle, + FetchCatalogOptions, + ProviderRefreshContext, + ListImportableSessionsOptions, + ImportProviderSessionInput, + ImportProviderSessionContext, +} from "../../agent-sdk-types.js"; +import { + createProviderEnvSpec, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../../provider-launch-config.js"; +import { execCommand } from "../../../../utils/spawn.js"; +import { OpenCodeAgentClient } from "../opencode-agent.js"; +import type { OpenCodeV2AgentClient } from "./v2/agent.js"; +import { withOpenCodeRuntimeNotice } from "./runtime-notice.js"; + +// Keep the minimum aligned with the SDK and binary exercised by CI. +const MINIMUM_V2: readonly [number, number] = [0, 10]; +const VERSION_PATTERN = /^(?:opencode\s+)?v?(\d+)\.(\d+)\.(\d+)(?:[-+][\w.-]+)?$/i; + +export function openCodeMajorVersion(output: string): 1 | 2 { + const version = output.trim().match(VERSION_PATTERN); + if (!version) return 1; + if (version[1] === "1") return 1; + if (version[1] === "2") { + const [minimumMinor, minimumPatch] = MINIMUM_V2; + const minor = Number(version[2]); + const patch = Number(version[3]); + if (minor < minimumMinor || (minor === minimumMinor && patch < minimumPatch)) + throw new Error( + `OpenCode ${version[1]}.${version[2]}.${version[3]} is too old for this Paseo integration. Update OpenCode to 2.${minimumMinor}.${minimumPatch} or newer, then refresh the provider in Paseo.`, + ); + return 2; + } + throw new Error( + `Unsupported OpenCode major version ${version[1]}; supported versions are 1 and 2`, + ); +} + +export type OpenCodeRuntimeClientOptions = NonNullable< + ConstructorParameters[2] +>; + +// Selection belongs to the configured client, which provider reload replaces. +export class OpenCodeRuntimeClient implements AgentClient { + readonly provider = "opencode"; + readonly capabilities; + readonly resolveCreateConfig; + readonly isCreateConfigUnattended; + private selected: Promise | null = null; + private legacySelected = false; + private readonly legacy: OpenCodeAgentClient; + constructor( + private readonly logger: Logger, + private readonly settings?: ProviderRuntimeSettings, + private readonly options: OpenCodeRuntimeClientOptions = {}, + ) { + this.legacy = new OpenCodeAgentClient(logger, settings, options); + this.capabilities = this.legacy.capabilities; + this.resolveCreateConfig = this.legacy.resolveCreateConfig; + this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended; + } + private client(): Promise { + this.selected ??= (async () => { + let output: string; + try { + const launch = await resolveProviderLaunch({ + commandConfig: this.settings?.command, + defaultBinary: "opencode", + }); + const result = await execCommand(launch.command, [...launch.args, "--version"], { + ...createProviderEnvSpec({ runtimeSettings: this.settings }), + timeout: 5_000, + }); + output = result.stdout; + } catch { + // Version discovery is additive: legacy wrappers need not support --version. + this.selected = null; + this.legacySelected = true; + return this.legacy; + } + if (!VERSION_PATTERN.test(output.trim())) this.selected = null; + if (openCodeMajorVersion(output) === 1) { + this.legacySelected = true; + return this.legacy; + } + const { OpenCodeV2AgentClient } = await import("./v2/agent.js"); + return new OpenCodeV2AgentClient({ + logger: this.logger, + settings: this.settings, + managedProcesses: this.options.managedProcesses, + bridge: this.options.bridge, + }); + })().catch((error: unknown) => { + // A failed probe must not poison refresh after the user updates the binary. + this.selected = null; + throw error; + }); + return this.selected; + } + async isAvailable() { + return this.legacy.isAvailable(); + } + async getDiagnostic() { + return this.legacy.getDiagnostic(); + } + async createSession( + config: AgentSessionConfig, + launch?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ) { + const client = await this.client(); + const session = await client.createSession(config, launch, options); + return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2); + } + async resumeSession( + handle: AgentPersistenceHandle, + config?: Partial, + launch?: AgentLaunchContext, + ) { + const client = await this.client(); + const session = await client.resumeSession(handle, config, launch); + return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle); + } + async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) { + return (await this.client()).fetchCatalog(options, context); + } + async listCommands(config: AgentSessionConfig) { + return (await this.client()).listCommands(config); + } + async listFeatures(config: AgentSessionConfig) { + return (await this.client()).listFeatures(config); + } + async listImportableSessions(options?: ListImportableSessionsOptions) { + return (await this.client()).listImportableSessions(options); + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const client = await this.client(); + const imported = await client.importSession(input, context); + return client === this.legacy + ? imported + : { ...imported, session: withOpenCodeRuntimeNotice(imported.session, 2) }; + } + async archiveNativeSession(handle: AgentPersistenceHandle) { + const client = await this.client(); + if (client instanceof OpenCodeAgentClient) await client.archiveNativeSession(handle); + } + async unarchiveNativeSession(handle: AgentPersistenceHandle) { + const client = await this.client(); + if (client instanceof OpenCodeAgentClient) await client.unarchiveNativeSession(handle); + } + async shutdown() { + const selected = await this.selected?.catch(() => null); + if (this.legacySelected) await this.legacy.shutdown(); + if (selected && selected !== this.legacy) await selected.shutdown(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/runtime-notice.ts b/packages/server/src/server/agent/providers/opencode/runtime-notice.ts new file mode 100644 index 0000000000..4b480e7c18 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime-notice.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import type { + AgentPersistenceHandle, + AgentSession, + AgentStreamEvent, + ImportedTimelineEntry, +} from "../../agent-sdk-types.js"; + +const Notices = z.array( + z.object({ major: z.union([z.literal(1), z.literal(2)]), timestamp: z.string().datetime() }), +); + +// These rows belong to Paseo, not OpenCode's conversation. Keep their original +// timestamps in the persistence handle so history rebuilds preserve placement. +export function withOpenCodeRuntimeNotice( + session: AgentSession, + major: 1 | 2, + previous?: AgentPersistenceHandle, +): AgentSession { + const notices = Notices.parse(previous?.metadata?.openCodeRuntimeNotices ?? []); + const added = notices.at(-1)?.major !== major; + if (added) notices.push({ major, timestamp: new Date().toISOString() }); + const entries: ImportedTimelineEntry[] = notices.map(({ major: recordedMajor, timestamp }) => ({ + timestamp, + item: { + type: "notification", + level: "info", + message: `This chat uses OpenCode v${recordedMajor}.`, + }, + })); + const describePersistence = session.describePersistence.bind(session); + const streamHistory = session.streamHistory.bind(session); + return Object.assign(session, { + initialTimeline: added ? entries.slice(-1) : [], + describePersistence() { + const handle = describePersistence(); + return ( + handle && { ...handle, metadata: { ...handle.metadata, openCodeRuntimeNotices: notices } } + ); + }, + async *streamHistory(): AsyncGenerator { + const pending = [...entries]; + const event = (entry: ImportedTimelineEntry): AgentStreamEvent => ({ + type: "timeline", + provider: "opencode", + ...entry, + }); + for await (const row of streamHistory()) { + while ( + row.type === "timeline" && + row.timestamp && + pending[0]?.timestamp && + pending[0].timestamp <= row.timestamp + ) { + yield event(pending.shift()!); + } + yield row; + } + for (const entry of pending) yield event(entry); + }, + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 4161cd9876..9ff5dc6c96 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { mkdirSync } from "node:fs"; import { stat } from "node:fs/promises"; import net from "node:net"; +import { createRequire } from "node:module"; import path from "node:path"; import type { Logger } from "pino"; @@ -10,7 +11,7 @@ import { spawnProcess, type SpawnProcessOptions } from "../../../../utils/spawn. import { terminateWithTreeKill, type ProcessTerminator } from "../../../../utils/tree-kill.js"; import type { ManagedProcessRegistry } from "../../../managed-processes/managed-processes.js"; import { - createProviderEnvSpec, + createProviderEnv, resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../../provider-launch-config.js"; @@ -29,6 +30,7 @@ const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000; export interface OpenCodeServerAcquisition { + environment: Record; server: { port: number; url: string }; events: OpenCodeEventSource; release: () => Promise; @@ -43,6 +45,7 @@ export interface OpenCodeServerManagerLike { } export interface OpenCodeServerGeneration { + environment: Record; process: ChildProcess; port: number; url: string; @@ -210,6 +213,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return { server: { port: server.port, url: server.url }, events: server.events, + environment: server.environment, release: async () => { if (releasePromise) { return releasePromise; @@ -331,15 +335,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const bridgeEnv = this.decorateServerEnv?.( existingConfigContent ? { OPENCODE_CONFIG_CONTENT: existingConfigContent } : {}, ); + const environment = createProviderEnv({ + baseEnv: this.baseEnv, + runtimeSettings: this.runtimeSettings, + overlays: [launchEnv, bridgeEnv], + }); const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, { cwd: serverCwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], - ...createProviderEnvSpec({ - baseEnv: this.baseEnv, - runtimeSettings: this.runtimeSettings, - overlays: [launchEnv, bridgeEnv], - }), + baseEnv: environment, }); const managedProcessRecord = this.recordManagedServerProcess({ process: serverProcess, @@ -351,14 +356,26 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { const processExit = new Promise((resolve) => { resolveProcessExit = resolve; }); + let resolveListening!: () => void; + let rejectListening!: (error: Error) => void; + const ready = new Promise((resolve, reject) => { + resolveListening = resolve; + rejectListening = reject; + }); const server: OpenCodeServerGeneration = { + environment, process: serverProcess, port, url, refCount: 0, retired: false, ready: Promise.resolve(), - events: this.createEventSource({ serverUrl: url, processExit, logger: this.logger }), + events: this.createEventSource({ + serverUrl: url, + processExit, + logger: this.logger, + listening: ready, + }), managedProcessRecord, }; this.logger.info( @@ -397,7 +414,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { return sections.join("\n"); }; - const ready = new Promise((resolve, reject) => { + { let timeout: ReturnType; const failStartup = (error: Error) => { if (settled) { @@ -405,7 +422,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } settled = true; clearTimeout(timeout); - reject(error); + rejectListening(error); }; timeout = setTimeout(() => { if (!started) { @@ -420,7 +437,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { started = true; settled = true; clearTimeout(timeout); - resolve(); + resolveListening(); } }); @@ -456,7 +473,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } }); - }); + } server.ready = ready.catch(async (error) => { await this.killServer(server); @@ -622,25 +639,23 @@ async function resolveOpenCodeBinary(): Promise { } if (process.platform === "win32" && path.extname(found).toLowerCase() === ".cmd") { - // Global npm: /opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe - const globalCandidate = path.join( - path.dirname(found), - "node_modules", - "opencode-ai", - "bin", - "opencode.exe", - ); - if (await pathExists(globalCandidate)) return globalCandidate; + const packageDirectories = [ + path.join(path.dirname(found), "node_modules", "opencode-ai"), + path.join(path.dirname(found), "..", "opencode-ai"), + ]; + for (const packageDirectory of packageDirectories) { + const bundledBinary = path.join(packageDirectory, "bin", "opencode.exe"); + if (await pathExists(bundledBinary)) return bundledBinary; - // Local/pnpm: /node_modules/.bin/opencode.cmd → /node_modules/opencode-ai/bin/opencode.exe - const localCandidate = path.join( - path.dirname(found), - "..", - "opencode-ai", - "bin", - "opencode.exe", - ); - if (await pathExists(localCandidate)) return localCandidate; + // Newer npm releases keep the executable in a platform dependency. + // Resolve from the CLI package so nested installs and pnpm both work. + try { + const require = createRequire(path.join(packageDirectory, "package.json")); + return require.resolve(`opencode-windows-${process.arch}/bin/opencode.exe`); + } catch { + // Try the other npm layout before retaining the original command. + } + } console.warn( "[opencode-server] Found opencode.cmd but could not resolve the real opencode.exe. " + diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index fc1748dd28..aa8e644a10 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -46,6 +46,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { this.acquisitions.push(acquisition); return { server: this.server, + environment: input.env ?? {}, events, release: async () => { acquisition.released = true; diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 59c3f9d695..7bad72b8b1 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -74,6 +74,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { return { server: this.server, events: this.events, + environment: input.env ?? {}, release: async () => { acquisition.releaseCount += 1; }, diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts new file mode 100644 index 0000000000..e2dc4f68a6 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -0,0 +1,204 @@ +import type { + OpenCodeEvent, + SessionInfo, + SessionMessageInfo, + SessionCreateInput, + SessionLogItem, +} from "@opencode/client"; +import type { V2Api } from "../v2/api.js"; +import type { V2Connection } from "../v2/runtime.js"; + +function unexpected(): never { + throw new Error("Unexpected OpenCode v2 test operation"); +} + +export class V2Harness { + readonly info: SessionInfo = { + id: "session", + projectID: "project", + location: { directory: "/tmp/project" }, + agent: "build", + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0, + time: { created: 1, updated: 1 }, + outcome: "succeeded", + }; + readonly history: SessionMessageInfo[] = []; + readonly creates: SessionCreateInput[] = []; + readonly prompts: string[] = []; + readonly environments: Array<{ sessionID: string; variables: Record }> = []; + readonly mcpAdds: string[] = []; + releases = 0; + active = false; + activeReads = 0; + autoComplete = true; + constructor(private sequence = 0) {} + private executions: SessionLogItem[] = []; + startExecution() { + this.active = true; + this.push({ + id: `execution-${++this.sequence}`, + created: this.sequence, + type: "session.execution.started", + durable: { aggregateID: this.info.id, seq: this.sequence, version: 1 }, + data: { sessionID: this.info.id }, + }); + } + finishExecution(outcome: "succeeded" | "interrupted" = "succeeded", publish = true) { + this.active = false; + this.info.outcome = outcome; + const common = { + id: `execution-${++this.sequence}`, + created: this.sequence, + durable: { aggregateID: this.info.id, seq: this.sequence, version: 1 as const }, + }; + this.push( + outcome === "succeeded" + ? { ...common, type: "session.execution.succeeded", data: { sessionID: this.info.id } } + : { + ...common, + type: "session.execution.interrupted", + data: { sessionID: this.info.id, reason: "user" }, + }, + publish, + ); + } + prompt: V2Api["session"]["prompt"] = async (input) => { + this.prompts.push(input.text); + if (!this.autoComplete) this.startExecution(); + }; + interrupt: V2Api["session"]["interrupt"] = async () => { + this.finishExecution("interrupted"); + return { interrupted: true }; + }; + private pendingEvents: OpenCodeEvent[] = []; + private notify: (() => void) | null = null; + push(event: OpenCodeEvent, publish = true) { + if ( + event.type === "session.execution.started" || + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) { + this.sequence = Math.max(this.sequence, event.durable.seq); + this.executions.push(event); + } + if (!publish) return; + this.pendingEvents.push(event); + this.notify?.(); + } + + readonly api: V2Api = { + server: { info: unexpected }, + plugin: { + list: async () => ({ + location: { directory: this.info.location.directory }, + data: [ + { id: "paseo", source: { type: "builtin" }, features: {}, state: { status: "active" } }, + ], + }), + }, + agent: { + list: async () => ({ + location: { + ...this.info.location, + project: { id: "project", directory: "/tmp/project", canonical: "/tmp/project" }, + }, + data: [], + }), + }, + model: { list: unexpected, default: unexpected }, + provider: { list: unexpected }, + command: { list: unexpected }, + skill: { list: unexpected }, + mcp: { + add: async (input) => { + this.mcpAdds.push(input.server); + }, + list: async (input) => ({ + location: { directory: input?.location?.directory ?? this.info.location.directory }, + data: this.mcpAdds.map((name) => ({ + name, + status: { status: "connected" as const }, + })), + }), + }, + message: { + list: async (input) => { + if (input.cursor && input.order) throw new Error("cursor cannot be combined with order"); + return { data: [...this.history], cursor: {} }; + }, + }, + permission: { list: async () => [], reply: unexpected }, + session: { + form: { list: async () => [], reply: unexpected, cancel: unexpected }, + create: async (input = {}) => { + this.creates.push(input); + return this.info; + }, + get: async () => this.info, + list: async () => ({ data: [], cursor: {} }), + active: async () => { + this.activeReads += 1; + return this.active ? { [this.info.id]: { type: "running" as const } } : {}; + }, + remove: async () => undefined, + switchAgent: unexpected, + switchModel: unexpected, + environment: async (input) => { + this.environments.push(input); + }, + instructions: { entry: { list: unexpected, put: unexpected, remove: unexpected } }, + prompt: async (input, options) => { + const before = this.executions.length; + await this.prompt(input, options); + if ( + this.autoComplete && + this.executions.length === before && + this.info.outcome !== "failed" + ) + this.finishExecution(); + }, + interrupt: (input, options) => this.interrupt(input, options), + command: unexpected, + compact: unexpected, + revert: { stage: unexpected, clear: unexpected, commit: unexpected }, + log: (input) => this.executionLog(input.after), + }, + event: { subscribe: (options) => this.events(options?.signal) }, + }; + readonly connection: V2Connection = { + client: this.api, + release: async () => { + this.releases += 1; + }, + retain: () => this.connection, + exited: new Promise(() => undefined), + }; + readonly runtime = { acquire: async () => this.connection, shutdown: async () => undefined }; + + private async *executionLog(after = 0) { + for (const event of this.executions) { + if ("durable" in event && event.durable.seq > after) yield event; + } + } + + private async *events(signal?: AbortSignal): AsyncGenerator { + yield { id: "connected", created: 1, type: "server.connected", data: {} }; + const wake = () => this.notify?.(); + signal?.addEventListener("abort", wake); + try { + while (true) { + if (signal?.aborted) return; + const event = this.pendingEvents.shift(); + if (event) yield event; + else + await new Promise((resolve) => { + this.notify = resolve; + }); + } + } finally { + signal?.removeEventListener("abort", wake); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts new file mode 100644 index 0000000000..3365932c6e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-local-e2e-helpers.ts @@ -0,0 +1,152 @@ +import { z } from "zod"; +import type { + AgentPermissionRequest, + AgentSession, + AgentSlashCommand, + AgentStreamEvent, + AgentTimelineItem, +} from "../../../agent-sdk-types.js"; + +export function requireSessionId(session: { id: string | null }): string { + if (!session.id) throw new Error("OpenCode session has no id"); + return session.id; +} + +export function readAssistantText( + timeline: ReadonlyArray & { text?: string }>, +): string { + return timeline + .flatMap((item) => (item.type === "assistant_message" ? [item.text ?? ""] : [])) + .join(""); +} + +export function requireNativeUserMessageId(timeline: readonly AgentTimelineItem[]): string { + const user = timeline.find((item) => item.type === "user_message"); + if (!user || user.type !== "user_message" || !user.messageId) + throw new Error("Missing native user message ID"); + return user.messageId; +} + +export function findSkillCommand( + commands: readonly AgentSlashCommand[], + nameFragment: string, +): AgentSlashCommand { + const skill = commands.find((item) => item.kind === "skill" && item.name.includes(nameFragment)); + if (!skill) throw new Error(`Missing skill command matching ${nameFragment}`); + return skill; +} + +/** Live assistant text as it streams, so tests never spell out the event filter. */ +export function collectStreamedAssistantText(session: AgentSession): string[] { + const text: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + text.push(event.item.text); + }); + return text; +} + +export function drainPersistedTimeline(session: AgentSession): Promise { + return drain(session); +} + +async function drain(session: AgentSession): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) events.push(event); + return events; +} + +/** Assistant text replayed from persisted history. */ +export async function drainPersistedAssistantText(session: AgentSession): Promise { + return (await drainPersistedTimeline(session)).flatMap((event) => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [], + ); +} + +export function collectRunningToolCalls(session: AgentSession) { + let running = false; + let canceledTurns = 0; + session.subscribe((event) => { + if (event.type === "turn_canceled") canceledTurns += 1; + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "running" + ) + running = true; + }); + return { + hasRunningTool: () => running, + canceledTurns: () => canceledTurns, + }; +} + +export function collectPermissions(session: AgentSession): string[] { + const names: string[] = []; + session.subscribe((event) => { + if (event.type === "permission_requested") names.push(event.request.name); + }); + return names; +} + +interface AutoRespondOptions { + /** Answer value applied to every question header. */ + questionAnswer?: string; +} + +/** + * Answers provider permissions as they arrive: questions with the configured + * answer, tools with a one-time approval. Tests assert the collected kinds and + * errors instead of branching on permission type. + */ +export function autoRespondToPermissions(session: AgentSession, options: AutoRespondOptions = {}) { + const kinds: string[] = []; + const errors: unknown[] = []; + session.subscribe((event) => { + if (event.type !== "permission_requested") return; + kinds.push(event.request.kind); + void respondToRequest(session, event.request, options).catch((error: unknown) => { + errors.push(error); + }); + }); + return { kinds, errors }; +} + +function respondToRequest( + session: AgentSession, + request: AgentPermissionRequest, + options: AutoRespondOptions, +): Promise { + if (request.kind !== "question") { + return session.respondToPermission(request.id, { + behavior: "allow", + selectedActionId: "once", + }); + } + const input = z + .object({ questions: z.array(z.object({ header: z.string() })) }) + .parse(request.input); + return session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { + answers: Object.fromEntries( + input.questions.map((question) => [question.header, options.questionAnswer ?? "Blue"]), + ), + }, + }); +} + +export function collectProviderSubagents(session: AgentSession) { + let completed = false; + let text = ""; + session.subscribe((event) => { + if (event.type !== "provider_subagent") return; + if (event.event.type === "upsert" && event.event.status === "completed") completed = true; + if (event.event.type === "timeline" && event.event.item.type === "assistant_message") + text += event.event.item.text; + }); + return { + hasCompletedChild: () => completed, + childText: () => text, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index afe89b85b4..8214ebb1d0 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -221,6 +221,15 @@ function deriveOpencodeTaskDetail( }; } +function toOpencodeEditInput(value: { filePath: string; oldString?: string; newString?: string }) { + return { + filePath: value.filePath, + oldString: nonEmptyString(value.oldString), + newString: nonEmptyString(value.newString), + unifiedDiff: undefined, + }; +} + const OpencodeEditInputSchema = z.union([ z .object({ @@ -229,12 +238,12 @@ const OpencodeEditInputSchema = z.union([ newString: z.string().optional(), }) .passthrough() - .transform((value) => ({ - filePath: value.filePath, - oldString: nonEmptyString(value.oldString), - newString: nonEmptyString(value.newString), - unifiedDiff: undefined, - })), + .transform(toOpencodeEditInput), + // OpenCode v2's edit tool names the file `path`. + z + .object({ path: z.string(), oldString: z.string(), newString: z.string() }) + .passthrough() + .transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })), ToolEditInputSchema, ]); @@ -303,6 +312,10 @@ const OpencodeKnownToolDetailSchema = z.union([ z.unknown(), (input) => toEditToolDetail(input, null), ), + // OpenCode v2 reports apply_patch as `patch`. + toolDetailBranchByToolName("patch", OpencodeApplyPatchTextInputSchema, z.unknown(), (input) => + toEditToolDetail(input, null), + ), toolDetailBranchByToolName( "apply_diff", OpencodeEditInputSchema, diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 54f3faa514..1c9ba212e1 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -387,6 +387,34 @@ describe("opencode tool-call mapper", () => { }); }); + it("maps OpenCode v2 patch payloads from GPT models into edit detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "patch", + callId: "opencode-v2-patch-1", + status: "completed", + input: { + patchText: + "*** Begin Patch\n*** Update File: patch.txt\n@@\n-north south\n+NORTH south\n*** End Patch", + }, + output: "Success. Updated the following files:\nM patch.txt", + }), + ); + + expect(item.detail).toEqual({ + type: "edit", + filePath: "patch.txt", + unifiedDiff: [ + "diff --git a/patch.txt b/patch.txt", + "--- a/patch.txt", + "+++ b/patch.txt", + "@@", + "-north south", + "+NORTH south", + ].join("\n"), + }); + }); + it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( mapOpencodeToolCall({ diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts new file mode 100644 index 0000000000..26cb65885d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -0,0 +1,1076 @@ +import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client"; +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; +import type { V2Api } from "./api.js"; + +function terminalEvents(events: AgentStreamEvent[]) { + return events.filter( + (event) => + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled", + ); +} + +describe("OpenCode v2 session lifecycle", () => { + test("completes from execution events without a long-lived HTTP wait", async () => { + const harness = new V2Harness(); + harness.prompt = async (input) => { + harness.prompts.push(input.text); + harness.startExecution(); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("long-running work"); + await expect.poll(() => harness.prompts).toEqual(["long-running work"]); + expect(events.filter((event) => event.type === "turn_failed")).toEqual([]); + expect(events.filter((event) => event.type === "turn_completed")).toEqual([]); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(events.filter((event) => event.type === "turn_failed")).toEqual([]); + } finally { + await session.close(); + } + }); + test.each(["reconnect", "watchdog"])( + "recovers a missed completion through %s without reconnecting a quiet stream", + async (recovery) => { + const harness = new V2Harness(); + harness.autoComplete = false; + let subscriptions = 0; + const subscribe = harness.api.event.subscribe; + harness.api.event.subscribe = (options) => { + subscriptions += 1; + return subscribe(options); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("finish while disconnected"); + await expect.poll(() => harness.prompts).toHaveLength(1); + // Persisted completion without its event, as after an upstream stream gap. + harness.finishExecution("succeeded", false); + if (recovery === "reconnect") + harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} }); + await expect.poll(() => terminalEvents(events), { timeout: 7_000 }).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + expect(subscriptions).toBe(1); + } finally { + await session.close(); + } + }, + 10_000, + ); + + test("does not finish from a previous outcome before prompt admission", async () => { + const harness = new V2Harness(); + let accept!: () => void; + harness.prompt = () => + new Promise((resolve) => { + accept = resolve; + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("fast execution"); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.startExecution(); + harness.finishExecution(); + accept(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("follows a queued successor until the session is idle", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("first and queued work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + harness.finishExecution(); + harness.startExecution(); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + } finally { + await session.close(); + } + }); + + test("discards an idle snapshot overtaken by a new execution event", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + let readSnapshot!: () => void; + let release!: () => void; + const reading = new Promise((resolve) => { + readSnapshot = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + try { + await session.startTurn("work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + harness.api.session.get = async () => { + const snapshot = { ...harness.info }; + readSnapshot(); + await blocked; + return snapshot; + }; + harness.finishExecution(); + await reading; + const reads = harness.activeReads; + harness.startExecution(); + release(); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + } finally { + release(); + await session.close(); + } + }); + + test("does not treat shutdown interruption as a completed or canceled turn", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("resumable work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + harness.active = false; + // Shutdown does not project an idle outcome; the preceding run's outcome can remain. + harness.info.outcome = "succeeded"; + harness.push({ + id: "shutdown", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.startExecution(); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("ends the turn when the user dismisses a question", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let form: FormInfo | null = null; + harness.api.session.form.list = async () => (form ? [form] : []); + harness.api.session.form.cancel = async () => { + form = null; + harness.active = false; + // OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason. + harness.push({ + id: "dismissed", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("ask me something"); + await expect.poll(() => harness.prompts).toHaveLength(1); + form = { + id: "question", + sessionID: "session", + title: "Color", + fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }], + }; + harness.push({ + id: "form", + created: 2, + type: "form.created", + data: { form }, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("question", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("ends the turn when the user denies a tool permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + let permission: Awaited>[number] | null = null; + harness.api.permission.list = async () => (permission ? [permission] : []); + harness.api.permission.reply = async () => { + permission = null; + harness.active = false; + // OpenCode 2.0.20 reports a denied tool call as an interruption without a user reason. + harness.push({ + id: "denied", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("run printf"); + await expect.poll(() => harness.prompts).toHaveLength(1); + permission = { + id: "approval", + sessionID: "session", + action: "shell", + resources: ["printf TOOL_PERMISSION_QA"], + }; + harness.push({ + id: "asked", + created: 2, + type: "permission.asked", + data: permission, + }); + await expect.poll(() => session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("approval", { behavior: "deny" }); + + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" }); + + await session.startTurn("next message"); + await expect.poll(() => harness.prompts).toHaveLength(2); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(2); + expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("keeps the turn resumable after the user denies a child session's permission", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const child = { ...harness.info, id: "child", parentID: "session" }; + harness.api.session.list = async (input) => ({ + data: input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + let permission: Awaited>[number] | null = { + id: "child-approval", + sessionID: "child", + action: "shell", + resources: ["pwd"], + }; + harness.api.permission.list = async (input) => + permission && input.sessionID === "child" ? [permission] : []; + harness.api.permission.reply = async () => { + permission = null; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("delegate work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + expect(session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission("child-approval", { behavior: "deny" }); + + harness.active = false; + harness.push({ + id: "shutdown", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }); + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.startExecution(); + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("resumable work"); + await expect.poll(() => harness.prompts).toHaveLength(1); + harness.active = false; + harness.info.outcome = "succeeded"; + harness.api.session.log = async function* () { + yield { + id: "shutdown", + created: 3, + type: "session.execution.interrupted", + durable: { aggregateID: "session", seq: 3, version: 1 }, + data: { sessionID: "session", reason: "shutdown" }, + }; + }; + const reads = harness.activeReads; + harness.push({ id: "reconnect", created: 4, type: "server.connected", data: {} }); + await expect.poll(() => harness.activeReads).toBeGreaterThan(reads); + expect(terminalEvents(events)).toEqual([]); + harness.api.session.log = async function* () { + yield { + id: "done", + created: 6, + type: "session.execution.succeeded", + durable: { aggregateID: "session", seq: 6, version: 1 }, + data: { sessionID: "session" }, + }; + }; + harness.push({ + id: "reconnect-after-resume", + created: 7, + type: "server.connected", + data: {}, + }); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test("keeps the turn alive through an observation transport failure", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + const active = harness.api.session.active; + let attempted = false; + harness.api.session.active = async () => { + attempted = true; + throw new Error("fetch failed"); + }; + try { + await session.startTurn("work"); + await expect.poll(() => attempted).toBe(true); + expect(terminalEvents(events)).toEqual([]); + harness.api.session.active = active; + harness.finishExecution(); + await expect.poll(() => terminalEvents(events)).toHaveLength(1); + expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_completed" }); + } finally { + await session.close(); + } + }); + + test.each([ + { variants: ["medium", "custom"], selected: "medium", expected: "medium" }, + { variants: ["custom"], selected: "custom", expected: "custom" }, + { variants: ["high"], selected: "medium", expected: undefined }, + { variants: [], selected: "medium", expected: undefined }, + { variants: ["medium"], selected: "default", expected: undefined }, + ])( + "model switches retain only supported variants: $selected / $variants", + async ({ variants, selected, expected }) => { + const harness = new V2Harness(); + const target: ModelInfo = { + id: "target", + modelID: "target", + providerID: "test", + name: "Target", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: variants.map((id) => ({ id })), + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 10000 }, + }; + harness.info.model = { providerID: "test", id: "source", variant: selected }; + harness.api.model.list = async () => ({ location: harness.info.location, data: [target] }); + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + model: "test/source", + thinkingOptionId: selected, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.setModel!("test/target"); + expect(await session.getRuntimeInfo()).toMatchObject({ + model: "test/target", + thinkingOptionId: expected ?? null, + }); + expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected); + expect(events).toContainEqual({ + type: "thinking_option_changed", + provider: "opencode", + thinkingOptionId: expected ?? null, + }); + await expect(session.setModel!("test/missing")).rejects.toThrow( + "OpenCode model unavailable", + ); + harness.api.session.switchModel = async () => { + throw new Error("Switch failed"); + }; + await expect(session.setModel!("test/target")).rejects.toThrow("Switch failed"); + expect(session.describePersistence().metadata?.thinkingOptionId).toBe(expected); + expect(harness.info.model).toEqual({ + providerID: "test", + id: "target", + ...(expected ? { variant: expected } : {}), + }); + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + }; + harness.api.model.default = async () => ({ location: harness.info.location, data: target }); + await session.setThinkingOption!("unsupported"); + await session.setModel!(null); + expect((await session.getRuntimeInfo()).thinkingOptionId).toBeNull(); + harness.api.model.list = async () => { + throw new Error("Catalog unavailable"); + }; + await expect(session.setModel!("test/source")).rejects.toThrow("Catalog unavailable"); + expect((await session.getRuntimeInfo()).model).toBe("test/target"); + } finally { + await session.close(); + } + }, + ); + + test("restores each same-directory agent environment on reconnect and resume", async () => { + const first = new V2Harness(); + const second = new V2Harness(); + first.info.id = "session-first"; + second.info.id = "session-second"; + const environments = new Map>(); + const bind = async (input: { sessionID: string; variables: Record }) => { + environments.set(input.sessionID, { ...input.variables }); + }; + first.api.session.environment = bind; + second.api.session.environment = bind; + const clients = [first, second].map( + (harness) => + new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + settings: { + env: { + PASEO_ENV_TEST: "configured", + PASEO_AGENT_ID: "configured", + ELECTRON_RUN_AS_NODE: "1", + PASEO_SUPERVISED: "1", + CLAUDECODE: "1", + }, + }, + }), + ); + const config = { provider: "opencode" as const, cwd: "/tmp/project" }; + const launches = ["first", "second"].map((id) => ({ + env: { PASEO_AGENT_ID: id, PASEO_AGENT_CWD: config.cwd }, + })); + const assertEnvironment = (index: number) => { + const env = environments.get([first, second][index].info.id); + expect(env?.PATH).toBe(process.env.PATH); + expect(env).toMatchObject({ ...launches[index].env, PASEO_ENV_TEST: "configured" }); + expect(env?.USER).toBe(process.env.USER); + for (const key of [ + "ELECTRON_RUN_AS_NODE", + "PASEO_SUPERVISED", + "CLAUDECODE", + "ESBUILD_BINARY_PATH", + "PASEO_NODE_ENV", + ]) { + expect(env).not.toHaveProperty(key); + } + }; + const sessions = await Promise.all( + clients.map((client, i) => client.createSession(config, launches[i])), + ); + try { + assertEnvironment(0); + assertEnvironment(1); + environments.clear(); + first.push({ id: "reconnected-first", created: 2, type: "server.connected", data: {} }); + second.push({ id: "reconnected-second", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => environments.size).toBe(2); + assertEnvironment(0); + assertEnvironment(1); + } finally { + await Promise.all(sessions.map((session) => session.close())); + } + environments.clear(); + const resumed = await Promise.all( + clients.map((client, i) => + client.resumeSession(sessions[i].describePersistence(), undefined, launches[i]), + ), + ); + try { + assertEnvironment(0); + assertEnvironment(1); + expect(first.prompts).toEqual([]); + expect(second.prompts).toEqual([]); + } finally { + await Promise.all(resumed.map((session) => session.close())); + } + }); + + test("reconnects after a helper exits and restores session configuration on the next turn", async () => { + const first = new V2Harness(); + const second = new V2Harness(1); + let exit!: (error: Error) => void; + const exited = new Promise((resolve) => { + exit = resolve; + }); + let acquisitions = 0; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: { + acquire: async () => { + acquisitions += 1; + return acquisitions === 1 ? { ...first.connection, exited } : second.connection; + }, + shutdown: async () => undefined, + }, + }); + first.autoComplete = false; + second.prompt = async (input) => { + second.history.push({ + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "text", text: "recovered" }], + }); + second.prompts.push(input.text); + }; + const session = await client.createSession( + { + provider: "opencode", + cwd: "/tmp/project", + mcpServers: { tools: { type: "stdio", command: "tools" } }, + }, + { env: { TOKEN: "test" } }, + ); + const running = session.run("before exit"); + await expect.poll(() => first.prompts).toEqual(["before exit"]); + exit(new Error("helper exited")); + await expect(running).rejects.toThrow("helper exited"); + expect(acquisitions).toBe(1); + try { + expect((await session.run("after exit")).finalText).toBe("recovered"); + expect(second.prompts).toEqual(["after exit"]); + expect(second.mcpAdds).toEqual(["tools"]); + expect(second.environments).toMatchObject([ + { sessionID: "session", variables: { TOKEN: "test", PATH: process.env.PATH } }, + ]); + expect(second.environments).toEqual(first.environments); + expect(first.releases).toBe(1); + } finally { + await session.close(); + } + await expect(session.startTurn("after close")).rejects.toThrow("OpenCode session is closed"); + }); + test("fails initialization when the event stream ends before connecting", async () => { + const harness = new V2Harness(); + harness.api.event.subscribe = async function* () {}; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + await expect( + client.createSession({ provider: "opencode", cwd: "/tmp/project" }), + ).rejects.toThrow("event stream ended before connecting"); + expect(harness.releases).toBeGreaterThan(0); + }); + + test("returns only validated structured output and preserves it in history", async () => { + const harness = new V2Harness(); + harness.prompt = async (input) => { + harness.history.push({ + id: "user", + type: "user", + text: input.text, + metadata: input.metadata, + time: { created: 1 }, + }); + harness.history.push({ + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [ + { type: "text", text: "Here is the answer" }, + { + type: "tool", + id: "output", + name: "paseo_structured_output", + time: { created: 2 }, + state: { + status: "completed", + input: { value: { answer: 42 } }, + content: [{ type: "text", text: "accepted" }], + metadata: { paseoStructuredOutput: { answer: 42 } }, + }, + }, + ], + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const result = await session.run("answer", { + outputSchema: { + type: "object", + properties: { answer: { const: 42 } }, + required: ["answer"], + }, + }); + expect(result.finalText).toBe('{"answer":42}'); + const history = []; + for await (const event of session.streamHistory!()) history.push(event); + expect(history).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: result.finalText }), + }), + ]), + ); + } finally { + await session.close(); + } + }); + + test("fails a structured turn when the model omits the validated output tool", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await expect(session.run("answer", { outputSchema: { type: "object" } })).rejects.toThrow( + "without submitting the required structured output", + ); + } finally { + await session.close(); + } + }); + + test("resumes the native session ID and never replaces a missing session", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const handle = { + provider: "opencode", + sessionId: "session", + nativeHandle: "session", + metadata: { cwd: "/tmp/project" }, + } as const; + harness.api.session.switchAgent = async ({ agent }) => { + harness.info.agent = agent; + }; + harness.api.session.switchModel = async ({ model }) => { + harness.info.model = model; + }; + const session = await client.resumeSession(handle, { + modeId: "plan", + model: "synthetic/test", + thinkingOptionId: "low", + }); + try { + expect(await session.getRuntimeInfo!()).toMatchObject({ + modeId: "plan", + model: "synthetic/test", + thinkingOptionId: "low", + }); + expect(await session.describePersistence()).toMatchObject({ + sessionId: "session", + nativeHandle: "session", + }); + expect(harness.creates).toEqual([]); + } finally { + await session.close(); + } + harness.api.session.get = async () => { + throw new Error("Session not found"); + }; + await expect(client.resumeSession(handle)).rejects.toThrow("Session not found"); + expect(harness.creates).toEqual([]); + }); + + test("reconciles reconnect snapshots without replaying text or losing repeated chunks", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const finish = () => harness.finishExecution(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const chunks: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + chunks.push(event.item.text); + }); + try { + const running = session.run("laugh"); + await expect.poll(() => harness.prompts).toEqual(["laugh"]); + const answer = { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "text", text: "ha" }], + } satisfies SessionMessageInfo; + harness.history.push(answer); + harness.push({ id: "reconnect-1", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => chunks).toEqual(["ha"]); + answer.content[0].text = "haha"; + harness.push({ id: "reconnect-2", created: 3, type: "server.connected", data: {} }); + await expect.poll(() => chunks).toEqual(["ha", "ha"]); + finish(); + expect((await running).finalText).toBe("haha"); + expect(chunks).toEqual(["ha", "ha"]); + } finally { + await session.close(); + } + }); + + test("retries observation failures without replacing the execution error", async () => { + const harness = new V2Harness(); + harness.info.outcome = "failed"; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + let attempted = false; + harness.api.session.log = () => { + attempted = true; + throw new Error("log unavailable"); + }; + await session.startTurn("hello"); + const failures = () => events.filter((event) => event.type === "turn_failed"); + await expect.poll(() => attempted).toBe(true); + expect(failures()).toEqual([]); + harness.api.session.log = async function* () { + yield { + id: "failure", + created: 2, + durable: { aggregateID: "session", seq: 2, version: 1 }, + type: "session.execution.failed", + data: { sessionID: "session", error: { type: "provider", message: "quota exhausted" } }, + }; + }; + harness.push({ id: "reconnect", created: 3, type: "server.connected", data: {} }); + await expect.poll(failures).toHaveLength(1); + expect(failures()[0]).toMatchObject({ error: "quota exhausted" }); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("waits for prompt acceptance before interrupting", async () => { + const harness = new V2Harness(); + let accept!: () => void; + const settle = () => harness.finishExecution("interrupted"); + let interruptions = 0; + harness.prompt = () => + new Promise((resolve) => { + accept = resolve; + }); + + harness.interrupt = async () => { + interruptions += 1; + harness.info.outcome = "interrupted"; + settle(); + return { interrupted: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + const stopping = session.interrupt(); + await Promise.resolve(); + expect(interruptions).toBe(0); + accept(); + await stopping; + expect(interruptions).toBe(1); + } finally { + await session.close(); + } + }); + + test("completes a submitted turn once and reconciles its final text", async () => { + const harness = new V2Harness(); + harness.prompt = async (input) => { + harness.prompts.push(input.text); + harness.history.push({ + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "text", text: "done" }], + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + const result = await session.run("hello"); + expect(result.finalText).toBe("done"); + expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(1); + expect(harness.creates[0]).toMatchObject({ + agent: "build", + location: { directory: "/tmp/project" }, + }); + } finally { + await session.close(); + } + expect(harness.releases).toBe(1); + }); + + test("waits for interruption settlement before submitting replacement work", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const finishFirst = () => harness.finishExecution("interrupted"); + let finishStop!: () => void; + harness.interrupt = () => + new Promise((resolve) => { + finishStop = () => { + harness.info.outcome = "interrupted"; + finishFirst(); + resolve({ interrupted: true }); + }; + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + await expect.poll(() => harness.prompts).toEqual(["first"]); + const stopping = session.interrupt(); + const replacement = session.startTurn("second"); + await Promise.resolve(); + expect(harness.prompts).toEqual(["first"]); + harness.autoComplete = true; + finishStop(); + await stopping; + await replacement; + await expect.poll(() => harness.prompts).toEqual(["first", "second"]); + } finally { + await session.close(); + } + }); + + test("reuses the shared helper for sessions whose env is only agent identity", async () => { + const harness = new V2Harness(); + const acquires: Array<{ env?: Record; dedicated?: boolean }> = []; + const runtime = { + acquire: async (input: { env?: Record; dedicated?: boolean } = {}) => { + acquires.push(input); + return harness.connection; + }, + shutdown: async () => undefined, + }; + const client = new OpenCodeV2AgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ); + try { + expect(acquires).toEqual([{}]); + expect(harness.environments).toMatchObject([ + { + sessionID: "session", + variables: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" }, + }, + ]); + } finally { + await session.close(); + } + }); + + test("starts a dedicated helper when the session carries custom MCP", async () => { + const harness = new V2Harness(); + const acquires: Array<{ env?: Record; dedicated?: boolean }> = []; + const runtime = { + acquire: async (input: { env?: Record; dedicated?: boolean } = {}) => { + acquires.push(input); + return harness.connection; + }, + shutdown: async () => undefined, + }; + const client = new OpenCodeV2AgentClient({ logger: createTestLogger(), runtime }); + const session = await client.createSession( + { + provider: "opencode", + cwd: "/tmp/project", + mcpServers: { custom: { type: "stdio", command: "custom", args: [] } }, + }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ); + try { + expect(acquires).toHaveLength(1); + expect(acquires[0]?.dedicated).toBe(true); + } finally { + await session.close(); + } + }); + + test("refuses replacement work after a failed stop until Stop succeeds", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const finishFirst = () => harness.finishExecution("interrupted"); + harness.interrupt = async () => { + throw new Error("stop failed"); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await session.startTurn("first"); + await expect.poll(() => harness.prompts).toEqual(["first"]); + await expect(session.interrupt()).rejects.toThrow("stop failed"); + await expect(session.startTurn("unsafe replacement")).rejects.toThrow("stop failed"); + expect(harness.prompts).toEqual(["first"]); + harness.interrupt = async () => { + finishFirst(); + return { interrupted: true }; + }; + await session.interrupt(); + harness.autoComplete = true; + await session.startTurn("retry"); + await expect.poll(() => harness.prompts).toEqual(["first", "retry"]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts new file mode 100644 index 0000000000..2e50e759bd --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -0,0 +1,283 @@ +import { OpenCodeV2Session } from "./session.js"; +import { V2_CAPABILITIES } from "./capabilities.js"; +import { + features, + permissionRules, + applyResumeOverrides, + requiresDedicatedV2Server, +} from "./configuration.js"; +import { commands } from "./commands.js"; + +import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; + +import type { SessionInfo } from "@opencode/client"; + +import type { Logger } from "pino"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentCreateSessionOptions, + AgentFeature, + AgentLaunchContext, + AgentPersistenceHandle, + AgentSession, + AgentSessionConfig, + FetchCatalogOptions, + ImportableProviderSession, + ImportProviderSessionInput, + ImportProviderSessionContext, + ListImportableSessionsOptions, + ProviderRefreshContext, +} from "../../../agent-sdk-types.js"; +import { + createProviderEnv, + type ProviderRuntimeSettings, +} from "../../../provider-launch-config.js"; +import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js"; + +import { importSessionFromPersistence } from "../../../provider-session-import.js"; + +import type { OpenCodeBridge } from "../bridge.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { V2Runtime, type V2Connection } from "./runtime.js"; +import { modelRef, modesFromV2, modelsFromV2 } from "./mapping.js"; + +interface V2AgentOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + managedProcesses?: ManagedProcessRegistry; + bridge?: OpenCodeBridge; + runtime?: Pick; +} + +export class OpenCodeV2AgentClient implements AgentClient { + readonly provider = "opencode"; + readonly capabilities: AgentCapabilityFlags; + private readonly runtime: Pick; + private readonly connections = new Map(); + constructor(private readonly options: V2AgentOptions) { + this.capabilities = { ...V2_CAPABILITIES, supportsNativePaseoTools: Boolean(options.bridge) }; + this.runtime = + options.runtime ?? + new V2Runtime({ + ...options, + decorateEnv: options.bridge ? (env) => options.bridge!.decorateV2ServerEnv(env) : undefined, + }); + } + async isAvailable() { + return true; + } + async shutdown() { + await this.runtime.shutdown(); + } + async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) { + const connection = await this.runtime.acquire({ + fresh: options.force, + signal: context?.signal, + }); + const location = { + directory: options.scope === "workspace" ? options.cwd : resolveOpenCodeHomeDir(), + }; + const request = { signal: context?.signal }; + const activity = (name: string, operation: () => Promise) => + context ? context.runActivity(name, operation) : operation(); + try { + await activity("plugin.list", () => + waitForLocationReady({ client: connection.client, location, signal: request.signal }), + ); + const [models, agents, providers] = await Promise.all([ + activity("model.list", () => connection.client.model.list({ location }, request)), + activity("agent.list", () => connection.client.agent.list({ location }, request)), + activity("provider.list", () => connection.client.provider.list({ location }, request)), + ]); + if (!providers.data.length) + throw new Error( + "OpenCode has no connected providers. Authenticate using opencode auth login.", + ); + return { models: modelsFromV2(models.data), modes: modesFromV2(agents.data) }; + } finally { + await connection.release(); + } + } + async createSession( + config: AgentSessionConfig, + launch?: AgentLaunchContext, + options?: AgentCreateSessionOptions, + ): Promise { + const connection = await this.runtime.acquire( + requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, + ); + try { + const info = await connection.client.session.create({ + location: { directory: config.cwd }, + title: config.title, + agent: config.modeId ?? "build", + model: config.model ? modelRef(config.model, config.thinkingOptionId) : undefined, + permissions: permissionRules(config), + }); + return await this.attach(connection, info, config, launch, options?.persistSession !== false); + } catch (error) { + await connection.release(); + throw error; + } + } + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launch?: AgentLaunchContext, + ): Promise { + const cwd = overrides?.cwd ?? handle.metadata?.cwd; + if (typeof cwd !== "string") + throw new Error("OpenCode resume requires the original working directory"); + const config: AgentSessionConfig = { + ...handle.metadata, + ...overrides, + provider: "opencode", + cwd, + }; + const connection = + this.connections.get(handle.nativeHandle ?? handle.sessionId)?.retain() ?? + (await this.runtime.acquire( + requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, + )); + try { + const info = await connection.client.session.get({ + sessionID: handle.nativeHandle ?? handle.sessionId, + }); + await applyResumeOverrides(connection.client, info, overrides); + return await this.attach(connection, info, config, launch, true); + } catch (error) { + await connection.release(); + throw error; + } + } + private async attach( + connection: V2Connection, + info: SessionInfo, + config: AgentSessionConfig, + launch: AgentLaunchContext | undefined, + persist: boolean, + ) { + const acquire = () => + this.runtime.acquire( + requiresDedicatedV2Server(config, launch) ? { env: launch?.env, dedicated: true } : {}, + ); + let ownedConnection = connection; + const unbind = this.options.bridge?.bindSession({ + sessionId: info.id, + env: launch?.env ?? {}, + tools: launch?.paseoTools, + }); + const bound = new Map void>(); + const bindChild = (childId: string) => { + this.connections.set(childId, ownedConnection); + if (bound.has(childId)) return; + const childUnbind = this.options.bridge?.bindSession({ + sessionId: childId, + env: launch?.env ?? {}, + tools: launch?.paseoTools, + }); + if (childUnbind) bound.set(childId, childUnbind); + }; + this.connections.set(info.id, connection); + const releaseBindings = () => { + unbind?.(); + for (const cleanup of bound.values()) cleanup(); + for (const [id, owner] of this.connections) + if (owner === ownedConnection) this.connections.delete(id); + }; + const moved = (next: V2Connection) => { + for (const [id, owner] of this.connections) + if (owner === ownedConnection) this.connections.set(id, next); + ownedConnection = next; + }; + const session = new OpenCodeV2Session( + connection, + info, + config, + this.options.logger, + persist, + Boolean(this.options.bridge), + releaseBindings, + bindChild, + acquire, + moved, + ); + try { + if (this.options.bridge) { + const location = { directory: config.cwd }; + await awaitPaseoPlugin({ client: connection.client, location }); + } + await session.initialize( + launch?.env + ? createProviderEnv({ runtimeSettings: this.options.settings, overlays: [launch.env] }) + : undefined, + ); + return session; + } catch (error) { + await session.close(); + throw error; + } + } + async listFeatures(config: AgentSessionConfig): Promise { + return features(config); + } + async listCommands(config: AgentSessionConfig) { + const connection = await this.runtime.acquire(); + try { + return await commands(connection.client, config.cwd); + } finally { + await connection.release(); + } + } + async listImportableSessions( + options: ListImportableSessionsOptions = {}, + ): Promise { + const connection = await this.runtime.acquire(); + try { + const sessions: SessionInfo[] = []; + let cursor: string | undefined; + const scanLimit = Math.min(options.scanLimit ?? 100, 500); + do { + const page = await connection.client.session.list({ + ...(cursor ? { cursor } : { directory: options.cwd, search: options.query }), + limit: Math.min(50, scanLimit - sessions.length), + }); + sessions.push(...page.data); + cursor = page.cursor.next ?? undefined; + } while (cursor && sessions.length < scanLimit); + return sessions + .filter((info) => !info.parentID && !info.time.archived) + .slice(0, options.limit ?? 50) + .map((info) => ({ + providerHandleId: info.id, + cwd: info.location.directory, + title: info.title ?? null, + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date(info.time.updated), + })); + } finally { + await connection.release(); + } + } + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + const connection = await this.runtime.acquire(); + try { + const info = await connection.client.session.get({ sessionID: input.providerHandleId }); + return await importSessionFromPersistence({ + provider: "opencode", + request: input, + context, + resumeSession: this.resumeSession.bind(this), + config: { + title: info.title, + modeId: info.agent, + model: info.model ? `${info.model.providerID}/${info.model.id}` : undefined, + }, + }); + } finally { + await connection.release(); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts new file mode 100644 index 0000000000..fe225cade2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -0,0 +1,35 @@ +import type { OpenCodeClient } from "@opencode/client"; + +export interface V2Api { + server: OpenCodeClient["server"]; + session: Pick< + OpenCodeClient["session"], + | "create" + | "get" + | "list" + | "active" + | "remove" + | "switchAgent" + | "switchModel" + | "environment" + | "instructions" + | "prompt" + | "command" + | "compact" + | "interrupt" + | "revert" + | "log" + > & { + form: Pick; + }; + plugin: Pick; + model: OpenCodeClient["model"]; + provider: Pick; + agent: Pick; + command: OpenCodeClient["command"]; + skill: OpenCodeClient["skill"]; + message: OpenCodeClient["message"]; + mcp: Pick; + permission: Pick; + event: OpenCodeClient["event"]; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs new file mode 100644 index 0000000000..6b845d19ed --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/bridge-plugin.mjs @@ -0,0 +1,87 @@ +import { registerStructuredOutput, STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs"; + +export default { + id: "paseo", + async setup(context) { + const disposeStructuredOutput = await registerStructuredOutput(context); + const { baseUrl, token } = context.options; + if (!baseUrl || !token) return disposeStructuredOutput; + async function request(pathname, body, allowMissing = false) { + const response = await fetch(new URL(`/_internal/opencode${pathname}`, baseUrl), { + method: body === undefined ? "GET" : "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + if (allowMissing && response.status === 404) return null; + if (!response.ok) { + const payload = await response.json().catch(() => null); + const message = + typeof payload?.error === "string" && payload.error + ? payload.error + : `Paseo tool bridge returned HTTP ${response.status}`; + const error = new Error(message); + error.status = response.status; + throw error; + } + return response.json(); + } + async function scope(sessionID) { + const visited = new Set(); + while (sessionID && !visited.has(sessionID)) { + visited.add(sessionID); + const binding = await request( + `/sessions/${encodeURIComponent(sessionID)}/context?tools`, + undefined, + true, + ); + if (binding) return { ...binding, sessionID }; + const session = await context.session.get({ sessionID }); + sessionID = session.parentID; + } + throw new Error("OpenCode session is not bound to a Paseo agent"); + } + const manifest = await request("/tools"); + const registration = await context.tool.transform((editor) => { + for (const definition of manifest.tools) { + editor.add({ + name: `paseo_${definition.name}`, + description: definition.description, + input: definition.inputSchema, + options: { codemode: false }, + async execute(input, call) { + const binding = await scope(call.sessionID); + const result = await request( + `/sessions/${encodeURIComponent(binding.sessionID)}/tools/${encodeURIComponent(definition.name)}`, + input, + ); + return { + content: result.content.map(toOpenCodeContent), + metadata: { paseoTool: definition.name }, + }; + }, + }); + } + }); + const filtering = await context.session.hook("context", async (input) => { + const binding = await scope(input.sessionID); + const allowed = new Set(binding.tools.map((name) => `paseo_${name}`)); + for (const name of Object.keys(input.tools)) { + if (name !== STRUCTURED_OUTPUT_TOOL && name.startsWith("paseo_") && !allowed.has(name)) + delete input.tools[name]; + } + }); + return async () => { + await disposeStructuredOutput(); + await filtering.dispose(); + await registration.dispose(); + }; + }, +}; + +// OpenCode tool content is text or a file; MCP images arrive as base64 data. +function toOpenCodeContent(part) { + if (part.type === "image") { + return { type: "file", uri: `data:${part.mimeType};base64,${part.data}`, mime: part.mimeType }; + } + return part; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts b/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts new file mode 100644 index 0000000000..4369af279b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/capabilities.ts @@ -0,0 +1,13 @@ +import type { AgentCapabilityFlags } from "../../../agent-sdk-types.js"; +export const V2_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsSessionListing: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindBoth: true, + supportsRewindConversation: false, + supportsRewindFiles: false, +}; diff --git a/packages/server/src/server/agent/providers/opencode/v2/children.ts b/packages/server/src/server/agent/providers/opencode/v2/children.ts new file mode 100644 index 0000000000..95853b71c7 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/children.ts @@ -0,0 +1,91 @@ +import { V2Timeline } from "./timeline.js"; + +import type { SessionInfo } from "@opencode/client"; + +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; + +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; + +import { messages } from "./history.js"; + +import type { V2Api } from "./api.js"; +interface ChildrenOptions { + client(): V2Api; + id: string; + emit(event: AgentStreamEvent): void; + reconcilePermissions(id: string): Promise; + bindChild?: (id: string) => void; +} +export class SessionChildren { + private readonly children = new Map(); + private readonly childStates = new Map(); + constructor(private readonly options: ChildrenOptions) {} + async observe(event: import("@opencode/client").OpenCodeEvent) { + if ( + event.type === "session.created" && + event.data.parentID && + (event.data.parentID === this.options.id || this.children.has(event.data.parentID)) + ) { + await this.reconcile(event.data.parentID); + } + if ( + "sessionID" in event.data && + typeof event.data.sessionID === "string" && + this.children.has(event.data.sessionID) + ) { + await this.reconcileChild( + await this.options.client().session.get({ sessionID: event.data.sessionID }), + ); + } + } + async reconcile(parentID: string) { + let cursor: string | undefined; + do { + const page = await this.options.client().session.list({ + ...(cursor ? { cursor } : { parentID }), + limit: 100, + }); + for (const child of page.data) { + await this.reconcileChild(child); + await this.reconcile(child.id); + } + cursor = page.cursor.next ?? undefined; + } while (cursor); + } + private async reconcileChild(info: SessionInfo) { + await this.options.reconcilePermissions(info.id); + let timeline = this.children.get(info.id); + if (!timeline) { + timeline = new V2Timeline(); + this.children.set(info.id, timeline); + this.options.bindChild?.(info.id); + } + const active = await this.options.client().session.active(); + let status: "running" | "failed" | "canceled" | "completed" = "completed"; + if (active[info.id]) status = "running"; + else if (info.outcome === "failed") status = "failed"; + else if (info.outcome === "interrupted") status = "canceled"; + const presentation: ProviderSubagentInputEvent = { + type: "upsert", + id: info.id, + parentSubagentId: info.parentID === this.options.id ? null : info.parentID, + title: info.title ?? null, + status, + cwd: info.location.directory, + timestamp: new Date(info.time.updated).toISOString(), + }; + const signature = JSON.stringify(presentation); + if (this.childStates.get(info.id) !== signature) { + this.childStates.set(info.id, signature); + this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation }); + } + for (const event of timeline.messages(await messages(this.options.client(), info.id))) { + if (event.type === "timeline") + this.options.emit({ + type: "provider_subagent", + provider: "opencode", + event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp }, + }); + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/commands.ts b/packages/server/src/server/agent/providers/opencode/v2/commands.ts new file mode 100644 index 0000000000..1bd52db1ee --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/commands.ts @@ -0,0 +1,39 @@ +import { waitForLocationReady } from "./readiness.js"; + +import type { V2Api } from "./api.js"; + +import type { AgentSlashCommand } from "../../../agent-sdk-types.js"; + +export async function commands(client: V2Api, directory: string): Promise { + const location = { directory }; + await waitForLocationReady({ client, location }); + const [configured, skills] = await Promise.all([ + client.command.list({ location }), + client.skill.list({ location }), + ]); + const result = new Map(); + for (const name of ["compact", "summarize"]) + result.set(name, { + name, + description: "Compact the current session", + argumentHint: "", + kind: "command", + }); + for (const command of configured.data) + result.set(command.name, { + name: command.name, + description: command.description ?? "", + argumentHint: "", + kind: "command", + }); + for (const skill of skills.data) { + if (result.has(skill.id)) continue; + result.set(skill.id, { + name: skill.id, + description: skill.description ?? "", + argumentHint: "", + kind: "skill", + }); + } + return [...result.values()]; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts new file mode 100644 index 0000000000..6479ca3851 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; + +import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js"; + +describe("OpenCode v2 permission rules", () => { + test("maps authored native permissions and exact injected MCP grants", () => { + expect( + permissionRules({ + provider: "opencode", + cwd: "/tmp/project", + providerOptions: { permission: { bash: { pwd: "allow" }, task: "ask" } }, + toolPolicy: { preapproved: [{ server: "paseo.host", tool: "read/info" }] }, + }), + ).toEqual([ + { action: "paseo_host_read_info", resource: "*", effect: "allow" }, + { action: "shell", resource: "pwd", effect: "allow" }, + { action: "subagent", resource: "*", effect: "ask" }, + ]); + }); +}); + +describe("OpenCode v2 helper isolation", () => { + test("shares the helper for agent-identity environment only", () => { + expect( + requiresDedicatedV2Server( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { PASEO_AGENT_ID: "agent", PASEO_AGENT_CWD: "/tmp/project" } }, + ), + ).toBe(false); + }); + + test("dedicates a helper for any other process environment", () => { + expect( + requiresDedicatedV2Server( + { provider: "opencode", cwd: "/tmp/project" }, + { agentId: "agent", env: { CUSTOM_FLAG: "1" } }, + ), + ).toBe(true); + }); + + test("dedicates a helper when the session carries custom MCP", () => { + expect( + requiresDedicatedV2Server({ + provider: "opencode", + cwd: "/tmp/project", + mcpServers: { custom: { type: "stdio", command: "custom", args: [] } }, + }), + ).toBe(true); + }); +}); + +describe("OpenCode v2 features", () => { + test("reports the auto-accept toggle from the session value", () => { + expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([ + { type: "toggle", id: "auto_accept", label: "Auto-accept", value: false }, + ]); + expect( + features({ + provider: "opencode", + cwd: "/tmp/project", + featureValues: { auto_accept: true }, + }), + ).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts new file mode 100644 index 0000000000..9a565e6fca --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts @@ -0,0 +1,83 @@ +import type { SessionInfo, PermissionRuleset } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +import type { + AgentFeature, + AgentLaunchContext, + AgentSessionConfig, +} from "../../../agent-sdk-types.js"; + +import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../options.js"; + +import { modelRef } from "./mapping.js"; + +export function features(config: AgentSessionConfig): AgentFeature[] { + return [ + { + type: "toggle", + id: "auto_accept", + label: "Auto-accept", + value: config.featureValues?.["auto_accept"] === true, + }, + ]; +} +export function permissionRules(config: AgentSessionConfig): PermissionRuleset { + const rules = + buildOpenCodePermissionRules( + OpenCodeProviderOptionsSchema.parse(config.providerOptions ?? {}), + undefined, + ) ?? []; + const grants: PermissionRuleset = (config.toolPolicy?.preapproved ?? []).map((grant) => ({ + action: `${grant.server.replace(/[^a-zA-Z0-9_-]/g, "_")}_${grant.tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`, + resource: "*", + effect: "allow", + })); + return [ + ...grants, + ...rules.map((rule) => ({ + action: nativePermissionAction(rule.permission), + resource: rule.pattern, + effect: rule.action, + })), + ]; +} +function nativePermissionAction(action: string): string { + if (action === "bash") return "shell"; + if (action === "task") return "subagent"; + return action; +} +export async function applyResumeOverrides( + client: V2Api, + info: SessionInfo, + overrides?: Partial, +) { + if (overrides?.modeId) { + await client.session.switchAgent({ + sessionID: info.id, + agent: overrides.modeId, + }); + info.agent = overrides.modeId; + } + if (overrides?.model || overrides?.thinkingOptionId !== undefined) { + const model = overrides.model + ? modelRef(overrides.model, overrides.thinkingOptionId ?? info.model?.variant) + : info.model && { ...info.model, variant: overrides.thinkingOptionId }; + if (!model) throw new Error("Select an OpenCode model before changing its variant"); + await client.session.switchModel({ sessionID: info.id, model }); + info.model = model; + } +} + +// Agent identity env is applied per session through `session.environment`, so +// it must not force a dedicated server. Custom MCP is location-scoped, and any +// other env key belongs to the process, so both need isolation. Mirrors the v1 +// adapter's `requiresDedicatedOpenCodeServer`. +const V2_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]); + +export function requiresDedicatedV2Server( + config: AgentSessionConfig, + launch?: AgentLaunchContext, +): boolean { + if (Object.keys(config.mcpServers ?? {}).length > 0) return true; + return Object.keys(launch?.env ?? {}).some((key) => !V2_SESSION_ENV_KEYS.has(key)); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/history.ts b/packages/server/src/server/agent/providers/opencode/v2/history.ts new file mode 100644 index 0000000000..8687ced82d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/history.ts @@ -0,0 +1,17 @@ +import type { SessionMessageInfo } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +export async function messages(client: V2Api, sessionID: string): Promise { + const result: SessionMessageInfo[] = []; + let cursor: string | undefined; + do { + const page = await client.message.list({ + sessionID, + ...(cursor ? { cursor } : { order: "asc" }), + limit: 100, + }); + result.push(...page.data); + cursor = page.cursor.next ?? undefined; + } while (cursor); + return result; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts b/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts new file mode 100644 index 0000000000..b0286f0160 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/mapping.test.ts @@ -0,0 +1,50 @@ +import type { AgentInfo, ModelInfo } from "@opencode/client"; +import { expect, test } from "vitest"; +import { modelsFromV2, modesFromV2 } from "./mapping.js"; + +test("normalizes v2 model capabilities, variants, and visible primary modes", () => { + const model: ModelInfo = { + id: "model", + modelID: "native-model", + providerID: "provider", + name: "Model", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 200000, output: 10000 }, + }; + expect(modelsFromV2([model, { ...model, id: "disabled", enabled: false }])).toEqual([ + { + provider: "opencode", + id: "provider/model", + label: "Model", + contextWindowMaxTokens: 200000, + metadata: { + providerId: "provider", + modelId: "model", + supportsAttachments: true, + supportsToolCall: true, + contextWindowMaxTokens: 200000, + }, + thinkingOptions: [{ id: "high", label: "high" }], + }, + ]); + const agent: AgentInfo = { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }; + expect( + modesFromV2([ + agent, + { ...agent, id: "hidden", hidden: true }, + { ...agent, id: "child", mode: "subagent" }, + ]), + ).toEqual([{ id: "build", label: "Build", description: undefined }]); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/mapping.ts b/packages/server/src/server/agent/providers/opencode/v2/mapping.ts new file mode 100644 index 0000000000..fc37897476 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/mapping.ts @@ -0,0 +1,47 @@ +import type { AgentInfo, ModelInfo, ModelRef, SessionInfo } from "@opencode/client"; +import type { AgentMode, AgentModelDefinition, AgentUsage } from "../../../agent-sdk-types.js"; + +export function modelRef(id: string, variant?: string | null): ModelRef { + const slash = id.indexOf("/"); + if (slash < 1 || slash === id.length - 1) + throw new Error("OpenCode model must be provider/model"); + return { + providerID: id.slice(0, slash), + id: id.slice(slash + 1), + ...(variant ? { variant } : {}), + }; +} + +export function modelsFromV2(models: ModelInfo[]): AgentModelDefinition[] { + return models + .filter((model) => model.enabled) + .map((model) => ({ + provider: "opencode", + id: `${model.providerID}/${model.id}`, + label: model.name, + contextWindowMaxTokens: model.limit.context, + metadata: { + providerId: model.providerID, + modelId: model.id, + supportsAttachments: model.capabilities.input.includes("image"), + supportsToolCall: model.capabilities.tools, + contextWindowMaxTokens: model.limit.context, + }, + thinkingOptions: model.variants.map((variant) => ({ id: variant.id, label: variant.id })), + })); +} + +export function modesFromV2(agents: AgentInfo[]): AgentMode[] { + return agents + .filter((agent) => !agent.hidden && agent.mode !== "subagent") + .map((agent) => ({ id: agent.id, label: agent.name, description: agent.description })); +} + +export function usageFromV2(session: SessionInfo): AgentUsage { + return { + inputTokens: session.tokens.input, + outputTokens: session.tokens.output, + cachedInputTokens: session.tokens.cache.read, + totalCostUsd: session.cost, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts new file mode 100644 index 0000000000..50a6d98549 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { V2Api } from "./api.js"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; + +describe("OpenCode v2 questions", () => { + test("maps selected labels to native values and parses numeric and boolean answers", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Preferences", + fields: [ + { key: "color", type: "string", options: [{ label: "Blue", value: "blue-id" }] }, + { + key: "features", + type: "multiselect", + options: [{ label: "Search", value: "search-id" }], + }, + { key: "count", type: "integer" }, + { key: "enabled", type: "boolean" }, + ], + }, + ]; + const answers: Parameters[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { + answers: { + color: "Blue", + features: ["Search"], + count: "3", + enabled: "false", + }, + }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { + color: "blue-id", + features: ["search-id"], + count: 3, + enabled: false, + }, + }, + ]); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session.close(); + } + }); +}); + +describe("OpenCode v2 question tool", () => { + test("shows the question text and accepts a typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Next task", + description: "What would you like me to work on next?", + type: "string", + options: [ + { value: "Explore", label: "Explore", description: "Map the codebase" }, + { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" }, + ], + custom: true, + }, + ], + }, + ]; + const answers: Parameters[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const [request] = session.getPendingPermissions(); + expect(request.input).toEqual({ + questions: [ + { + header: "Next task", + question: "What would you like me to work on next?", + options: [ + { value: "Explore", label: "Explore", description: "Map the codebase" }, + { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" }, + ], + multiple: false, + allowOther: true, + }, + ], + }); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { answers: { "Next task": "Write the release notes" } }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { q0: "Write the release notes" }, + }, + ]); + } finally { + await session.close(); + } + }); +}); + +describe("OpenCode v2 permission routing", () => { + test("routes a child approval back to its owning session", async () => { + const harness = new V2Harness(); + const child = { ...harness.info, id: "child", parentID: "session" }; + harness.api.session.list = async (input) => ({ + data: input?.parentID === "session" ? [child] : [], + cursor: {}, + }); + harness.api.permission.list = async (input) => + input.sessionID === "child" + ? [{ id: "child-permission", sessionID: "child", action: "shell", resources: ["pwd"] }] + : []; + const replies: Parameters[0][] = []; + harness.api.permission.reply = async (input) => { + replies.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + await session.respondToPermission("child-permission", { + behavior: "allow", + selectedActionId: "once", + }); + expect(replies).toEqual([ + { sessionID: "child", requestID: "child-permission", decision: "once" }, + ]); + } finally { + await session.close(); + } + }); + + test("clears an approval resolved by another native client", async () => { + const harness = new V2Harness(); + harness.api.permission.list = async () => [ + { id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }, + ]; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + expect(session.getPendingPermissions()).toHaveLength(1); + harness.api.permission.list = async () => []; + harness.push({ + id: "resolved", + created: 2, + type: "permission.replied", + data: { sessionID: "session", requestID: "approval", reply: "once" }, + }); + await expect.poll(() => session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts new file mode 100644 index 0000000000..cc13b99e4b --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -0,0 +1,164 @@ +import type { FormInfo, FormValue } from "@opencode/client"; +import type { V2Api } from "./api.js"; + +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentSessionConfig, + AgentStreamEvent, +} from "../../../agent-sdk-types.js"; + +export class SessionPermissions { + private readonly pending = new Map(); + private readonly permissionOwners = new Map(); + private readonly forms = new Map(); + constructor( + private readonly getClient: () => V2Api, + private readonly id: string, + private readonly config: AgentSessionConfig, + private readonly emit: (event: AgentStreamEvent) => void, + ) {} + observe(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "permission.replied") { + this.resolvePending(event.data.requestID, { + behavior: event.data.reply === "reject" ? "deny" : "allow", + }); + } + if (event.type === "form.replied" || event.type === "form.cancelled") { + this.resolvePending(event.data.id, { + behavior: event.type === "form.cancelled" ? "deny" : "allow", + }); + } + } + list() { + return [...this.pending.values()]; + } + isOwnedBy(requestId: string, sessionID: string) { + return ( + this.forms.get(requestId)?.sessionID === sessionID || + this.permissionOwners.get(requestId) === sessionID + ); + } + async respondToPermission(requestId: string, response: AgentPermissionResponse) { + const form = this.forms.get(requestId); + if (form) { + if (response.behavior === "deny") + await this.getClient().session.form.cancel({ sessionID: form.sessionID, formID: form.id }); + else { + const raw = response.updatedInput?.answers; + const answer: Record = {}; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw new Error("OpenCode question response requires answers"); + for (const field of form.fields) { + const value: unknown = + Reflect.get(raw, field.key) ?? Reflect.get(raw, field.title ?? field.key); + const normalized = formAnswer(field, value); + if (normalized !== undefined) answer[field.key] = normalized; + } + await this.getClient().session.form.reply({ + sessionID: form.sessionID, + formID: form.id, + answer, + }); + } + this.forms.delete(requestId); + } else { + await this.getClient().permission.reply({ + sessionID: this.permissionOwners.get(requestId) ?? this.id, + requestID: requestId, + decision: permissionReply(response), + }); + } + this.resolvePending(requestId, response); + } + async reconcile(sessionID: string) { + const [permissions, forms] = await Promise.all([ + this.getClient().permission.list({ sessionID }), + this.getClient().session.form.list({ sessionID }), + ]); + for (const request of permissions) { + if (this.pending.has(request.id)) continue; + const pending: AgentPermissionRequest = { + id: request.id, + provider: "opencode", + kind: "tool", + name: request.action, + description: request.message ?? request.resources.join("\n"), + actions: [ + { id: "once", label: "Allow once", behavior: "allow" }, + { id: "always", label: "Always allow", behavior: "allow" }, + { id: "reject", label: "Deny", behavior: "deny" }, + ], + }; + this.pending.set(request.id, pending); + this.permissionOwners.set(request.id, sessionID); + if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true) + await this.respondToPermission(request.id, { behavior: "allow" }); + else this.emit({ type: "permission_requested", provider: "opencode", request: pending }); + } + for (const form of forms) { + if (this.pending.has(form.id)) continue; + const pending: AgentPermissionRequest = { + id: form.id, + provider: "opencode", + name: "question", + kind: "question", + title: form.title, + input: { + questions: form.fields.map((field) => ({ + header: field.title ?? field.key, + question: field.description ?? field.title ?? field.key, + options: "options" in field ? field.options : undefined, + multiple: field.type === "multiselect", + allowOther: "custom" in field && field.custom === true, + })), + }, + }; + this.forms.set(form.id, form); + this.pending.set(form.id, pending); + this.emit({ type: "permission_requested", provider: "opencode", request: pending }); + } + } + resolvePending(requestId: string, resolution: AgentPermissionResponse) { + if (!this.pending.delete(requestId)) return; + this.forms.delete(requestId); + this.permissionOwners.delete(requestId); + this.emit({ type: "permission_resolved", provider: "opencode", requestId, resolution }); + } +} +function permissionReply(response: AgentPermissionResponse): "reject" | "once" | "always" { + if (response.behavior === "deny") return "reject"; + return response.selectedActionId === "always" ? "always" : "once"; +} + +function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValue | undefined { + if (value === undefined) return undefined; + if (field.type === "external") return undefined; + const labelValue = (label: string) => { + const options = "options" in field ? field.options : undefined; + return ( + options?.find((option) => option.value === label)?.value ?? + options?.find((option) => option.label === label)?.value ?? + label + ); + }; + if (field.type === "multiselect") { + if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) + return value.map(labelValue); + } else if (field.type === "string" && typeof value === "string") { + return labelValue(value); + } else if (field.type === "boolean") { + if (typeof value === "boolean") return value; + if (value === "true") return true; + if (value === "false") return false; + } else if (field.type === "number" || field.type === "integer") { + const numeric = typeof value === "string" && value.trim() ? Number(value) : value; + if ( + typeof numeric === "number" && + Number.isFinite(numeric) && + (field.type !== "integer" || Number.isInteger(numeric)) + ) + return numeric; + } + throw new Error(`Invalid answer for OpenCode question ${field.key}`); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts b/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts new file mode 100644 index 0000000000..0305a40c27 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/readiness.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { OpenCodeLocationReadyTimeoutError, waitForLocationReady } from "./readiness.js"; + +describe("OpenCode v2 location readiness", () => { + test("fails location readiness when the plugin inventory never populates", async () => { + await expect( + waitForLocationReady({ + client: { plugin: { list: async () => ({ location: { directory: "/tmp" }, data: [] }) } }, + location: { directory: "/tmp" }, + timeoutMs: 25, + }), + ).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError); + }); + + test("bounds location readiness even when the inventory request never settles", async () => { + await expect( + waitForLocationReady({ + client: { plugin: { list: () => new Promise(() => undefined) } }, + location: { directory: "/tmp" }, + timeoutMs: 25, + }), + ).rejects.toBeInstanceOf(OpenCodeLocationReadyTimeoutError); + }); + + test("preserves inventory errors instead of reporting a readiness timeout", async () => { + const error = new Error("inventory unavailable"); + await expect( + waitForLocationReady({ + client: { + plugin: { + list: async () => { + throw error; + }, + }, + }, + location: { directory: "/tmp" }, + }), + ).rejects.toBe(error); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/readiness.ts b/packages/server/src/server/agent/providers/opencode/v2/readiness.ts new file mode 100644 index 0000000000..186bbeee1d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/readiness.ts @@ -0,0 +1,66 @@ +import { setTimeout as delay } from "node:timers/promises"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; +import type { V2Api } from "./api.js"; + +interface ReadinessInput { + client: Pick; + location: { directory: string }; + signal?: AbortSignal; + timeoutMs?: number; +} + +export class OpenCodeLocationReadyTimeoutError extends Error { + constructor( + readonly directory: string, + readonly waitedMs: number, + ) { + super(`OpenCode location ${directory} did not activate within ${waitedMs}ms`); + this.name = "OpenCodeLocationReadyTimeoutError"; + } +} + +export function waitForLocationReady(input: ReadinessInput): Promise { + return waitForPlugins({ ...input, requireBridge: false }); +} + +export function awaitPaseoPlugin(input: ReadinessInput): Promise { + return waitForPlugins({ ...input, requireBridge: true }); +} + +// A cold location exposes an empty inventory before its config plugins register +// commands and skills. Bound both the inventory request and the polling loop. +interface PluginReadinessInput extends ReadinessInput { + requireBridge: boolean; +} +async function waitForPlugins(input: PluginReadinessInput): Promise { + const { client, location, signal, requireBridge, timeoutMs = 30_000 } = input; + signal?.throwIfAborted(); + const deadline = AbortSignal.timeout(timeoutMs); + const lifetime = signal ? AbortSignal.any([signal, deadline]) : deadline; + try { + while (true) { + const requestTimeout = AbortSignal.timeout(5_000); + const requestSignal = AbortSignal.any([lifetime, requestTimeout]); + try { + const plugins = await raceProviderRefreshAbort( + requestSignal, + client.plugin.list({ location }, { signal: requestSignal }), + ); + if (!requireBridge && plugins.data.length > 0) return; + const bridge = plugins.data.find((plugin) => plugin.id === "paseo"); + if (requireBridge && bridge?.state.status === "active") return; + if (requireBridge && bridge?.state.status === "failed") + throw new Error(`OpenCode Paseo tool bridge plugin failed: ${bridge.state.error}`); + } catch (error) { + lifetime.throwIfAborted(); + if (!requestTimeout.aborted) throw error; + } + await delay(50, undefined, { signal: lifetime }); + } + } catch (error) { + signal?.throwIfAborted(); + if (deadline.aborted) + throw new OpenCodeLocationReadyTimeoutError(location.directory, timeoutMs); + throw error; + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/runtime.ts b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts new file mode 100644 index 0000000000..cab5544582 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/runtime.ts @@ -0,0 +1,270 @@ +import { decorateOpenCodeV2Env, materializeOpenCodeV2Plugin } from "../bridge.js"; +import { resolvePaseoHome } from "../../../../paseo-home.js"; +import { OpenCode } from "@opencode/client"; +import type { V2Api } from "./api.js"; +import { randomBytes } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import type { Logger } from "pino"; +import { spawnProcess } from "../../../../../utils/spawn.js"; +import { terminateWithTreeKill } from "../../../../../utils/tree-kill.js"; +import { + createProviderEnvSpec, + resolveProviderLaunch, + type ProviderRuntimeSettings, +} from "../../../provider-launch-config.js"; +import type { ManagedProcessRegistry } from "../../../../managed-processes/managed-processes.js"; +import { resolveOpenCodeHomeDir } from "../paths.js"; +import { OpenCodeHttpError } from "../http-error.js"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; + +export interface V2Connection { + client: V2Api; + release(): Promise; + retain(): V2Connection; + readonly exited: Promise; +} +interface Generation { + client: V2Api; + users: number; + stop(): Promise; + exited: Promise; +} +export interface V2RuntimeOptions { + logger: Logger; + settings?: ProviderRuntimeSettings; + managedProcesses?: ManagedProcessRegistry; + decorateEnv?: (env: Record) => Promise>; +} + +export interface V2RuntimeAcquireOptions { + fresh?: boolean; + dedicated?: boolean; + env?: Record; + signal?: AbortSignal; +} + +// The runtime owns a credential for each subprocess; sessions only receive its authenticated client. +export class V2Runtime { + private current: Promise | null = null; + private generations = new Set(); + private starts = new Set>(); + private closed = false; + + constructor(private readonly options: V2RuntimeOptions) {} + + async acquire(input: V2RuntimeAcquireOptions = {}): Promise { + if (this.closed) throw new Error("OpenCode runtime is closed"); + input.signal?.throwIfAborted(); + let pending: Promise; + if (input.env || input.dedicated) { + pending = this.startTracked(input.env); + } else { + if (input.fresh || !this.current) this.current = this.startTracked(); + pending = this.current; + } + let generation: Generation; + try { + generation = await raceProviderRefreshAbort(input.signal, pending); + } catch (error) { + if (this.current === pending) this.current = null; + if (input.signal?.aborted) { + void pending + .then(async (started) => { + if (started.users !== 0) return undefined; + this.generations.delete(started); + await started.stop(); + return undefined; + }) + .catch((cleanupError: unknown) => { + this.options.logger.warn( + { error: String(cleanupError) }, + "OpenCode canceled startup cleanup failed", + ); + }); + } + throw error; + } + if (!this.generations.has(generation)) { + if (this.current === pending) this.current = null; + throw new Error("OpenCode helper server exited"); + } + const connection = this.lease(generation, pending); + if (input.signal?.aborted || this.closed) { + await connection.release(); + input.signal?.throwIfAborted(); + throw new Error("OpenCode runtime is closed"); + } + return connection; + } + + private lease(generation: Generation, pending: Promise): V2Connection { + if (this.closed || !this.generations.has(generation)) + throw new Error("OpenCode helper server is no longer running"); + generation.users += 1; + let released = false; + const release = async () => { + if (released) return; + released = true; + generation.users -= 1; + if (generation.users !== 0) return; + if (this.current === pending) this.current = null; + this.generations.delete(generation); + await generation.stop(); + }; + return { + client: generation.client, + release, + retain: () => this.lease(generation, pending), + exited: generation.exited, + }; + } + + async shutdown(): Promise { + this.closed = true; + await Promise.allSettled(this.starts); + await Promise.all([...this.generations].map((generation) => generation.stop())); + this.generations.clear(); + this.current = null; + } + + private startTracked(env?: Record): Promise { + const pending = this.start(env); + this.starts.add(pending); + void pending.then( + (generation) => + generation.exited.then(() => { + if (this.current === pending) this.current = null; + return undefined; + }), + () => undefined, + ); + void pending.then( + () => this.starts.delete(pending), + () => this.starts.delete(pending), + ); + return pending; + } + + private async start(env: Record = {}): Promise { + const { settings, managedProcesses, logger } = this.options; + const launch = await resolveProviderLaunch({ + commandConfig: settings?.command, + defaultBinary: "opencode", + }); + const cwd = resolveOpenCodeHomeDir(); + await mkdir(cwd, { recursive: true }); + const password = randomBytes(32).toString("base64url"); + const inheritedConfig = globalThis.process.env.OPENCODE_CONFIG_CONTENT; + const configured = { + ...(inheritedConfig ? { OPENCODE_CONFIG_CONTENT: inheritedConfig } : {}), + ...settings?.env, + ...env, + }; + const decorated = this.options.decorateEnv + ? await this.options.decorateEnv(configured) + : decorateOpenCodeV2Env(configured, await materializeOpenCodeV2Plugin(resolvePaseoHome())); + const args = [...launch.args, "serve", "--hostname", "127.0.0.1", "--port", "0"]; + const process = spawnProcess(launch.command, args, { + cwd, + detached: globalThis.process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + ...createProviderEnvSpec({ + runtimeSettings: settings, + overlays: [{ ...decorated, OPENCODE_PASSWORD: password }], + }), + }); + const processAbort = new AbortController(); + const exited = new Promise((resolve) => + process.once("exit", (code) => { + const error = new Error(`OpenCode helper server exited (${code})`); + processAbort.abort(error); + resolve(error); + }), + ); + let stopped: Promise | undefined; + const record = + process.pid && managedProcesses + ? managedProcesses + .record({ + owner: { provider: "opencode", kind: "helper-server" }, + pid: process.pid, + command: launch.command, + args, + }) + .catch((error: unknown) => { + logger.warn({ error }, "Could not record OpenCode helper process"); + return null; + }) + : Promise.resolve(null); + const stop = () => { + stopped ??= (async () => { + await terminateWithTreeKill(process, { gracefulTimeoutMs: 5_000, forceTimeoutMs: 1_000 }); + const entry = await record; + if (entry) await managedProcesses?.remove(entry.id); + })(); + return stopped; + }; + const deadline = Date.now() + 30_000; + try { + const url = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => finish(new Error("OpenCode v2 server startup timed out after 30s")), + 30_000, + ); + let buffer = ""; + const finish = (result: string | Error) => { + clearTimeout(timer); + process.stdout?.off("data", onData); + process.off("error", onError); + process.off("exit", onExit); + if (result instanceof Error) reject(result); + else resolve(result); + }; + const onData = (chunk: Buffer) => { + buffer = (buffer + chunk.toString()).slice(-8192); + const match = buffer.match(/server listening on (http:\/\/127\.0\.0\.1:\d+)(?:\r?\n)/); + if (match) finish(match[1]); + }; + const onError = (error: Error) => finish(error); + const onExit = (code: number | null) => + finish(new Error(`OpenCode v2 server exited during startup (${code})`)); + process.stdout?.on("data", onData); + process.once("error", onError); + process.once("exit", onExit); + // Drain stderr without retaining provider output that may contain credentials. + process.stderr?.on("data", () => undefined); + }); + const client = OpenCode.make({ + baseUrl: url, + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`, + }, + fetch: async (request, init) => { + const signal = init?.signal + ? AbortSignal.any([init.signal, processAbort.signal]) + : processAbort.signal; + const response = await fetch(request, { ...init, signal }); + const html = response.headers.get("content-type")?.includes("text/html") ?? false; + if (!response.ok || html) { + const requestUrl = request instanceof Request ? request.url : String(request); + throw new OpenCodeHttpError(new URL(requestUrl).pathname, response.status, html); + } + return response; + }, + }); + await client.server.info({ signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())) }); + const generation: Generation = { client, users: 0, stop, exited }; + this.generations.add(generation); + process.once("exit", () => { + this.generations.delete(generation); + void stop().catch((error: unknown) => + logger.warn({ error }, "OpenCode process cleanup failed"), + ); + }); + return generation; + } catch (error) { + await stop(); + throw error; + } + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts new file mode 100644 index 0000000000..5cd45b1ff7 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -0,0 +1,537 @@ +import { SessionChildren } from "./children.js"; +import { SessionTurns } from "./turns.js"; +import { V2Timeline } from "./timeline.js"; +import { waitForLocationReady, awaitPaseoPlugin } from "./readiness.js"; + +import type { SessionInfo, SessionMessageInfo } from "@opencode/client"; + +import { setTimeout as delay } from "node:timers/promises"; +import type { Logger } from "pino"; +import type { + AgentMode, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunOptions, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + SteerActiveTurnOptions, + SteerResult, +} from "../../../agent-sdk-types.js"; + +import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js"; + +import { runProviderTurn } from "../../provider-runner.js"; + +import { composeSystemPromptParts } from "../../../system-prompt.js"; +import { raceProviderRefreshAbort } from "../../../provider-refresh-deadline.js"; + +import { type V2Connection } from "./runtime.js"; +import { modelRef, modesFromV2 } from "./mapping.js"; + +import { V2_CAPABILITIES } from "./capabilities.js"; +import { features } from "./configuration.js"; +import { commands } from "./commands.js"; +import { messages } from "./history.js"; +import { SessionPermissions } from "./permissions.js"; +import { SessionUsage } from "./usage.js"; + +export class OpenCodeV2Session implements AgentSession { + readonly provider = "opencode"; + readonly capabilities = V2_CAPABILITIES; + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly timeline = new V2Timeline(); + private readonly abort = new AbortController(); + private readonly permissions: SessionPermissions; + private readonly turns: SessionTurns; + private readonly children: SessionChildren; + private readonly usage: SessionUsage; + private stream: Promise | null = null; + private streamAbort = new AbortController(); + private exited = false; + private reconnecting: Promise | null = null; + private launchEnv: Record | undefined; + private sync: Promise = Promise.resolve(); + private dirty = false; + private refreshTimer: ReturnType | null = null; + private closed = false; + private history: SessionMessageInfo[] = []; + private modes: AgentMode[] = []; + constructor( + private connection: V2Connection, + private info: SessionInfo, + private readonly config: AgentSessionConfig, + private readonly logger: Logger, + private readonly persist: boolean, + private readonly requiresPaseoPlugin: boolean, + private readonly unbind?: () => void, + bindChild?: (id: string) => void, + private readonly acquire?: () => Promise, + private readonly moved?: (connection: V2Connection) => void, + ) { + this.permissions = new SessionPermissions( + () => this.client, + info.id, + config, + (event) => this.emit(event), + ); + this.children = new SessionChildren({ + client: () => this.client, + id: info.id, + emit: (event) => this.emit(event), + reconcilePermissions: (id) => this.permissions.reconcile(id), + bindChild, + }); + this.turns = new SessionTurns({ + client: () => this.client, + id: info.id, + cwd: config.cwd, + signal: this.abort.signal, + emit: (event) => this.emit(event), + reconcile: async () => { + await this.reconcile(); + return { info: this.info, history: this.history }; + }, + reportReconciliationError: (error) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode turn reconciliation failed; retrying", + ), + clearPermissions: async () => { + for (const request of this.permissions.list()) + await this.permissions.respondToPermission(request.id, { behavior: "deny" }); + }, + }); + this.usage = new SessionUsage({ + client: () => this.client, + cwd: config.cwd, + info: () => this.info, + emit: (event) => this.emit(event), + reportError: (error) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode context usage update failed", + ), + }); + } + get id() { + return this.info.id; + } + get features() { + return features(this.config); + } + private get client() { + return this.connection.client; + } + async initialize(environment?: Record) { + // OpenCode replaces the whole local shell environment, rather than overlaying it. + this.launchEnv = environment; + this.watchExit(this.connection); + await this.configureConnection(); + const location = { directory: this.config.cwd }; + this.modes = modesFromV2((await this.client.agent.list({ location })).data); + this.timeline.messages(await messages(this.client, this.id)); + await this.startStream(); + await this.children.reconcile(this.id); + } + private watchExit(connection: V2Connection) { + void connection.exited.then((error) => { + if (this.closed || this.connection !== connection) return undefined; + this.exited = true; + this.streamAbort.abort(error); + this.turns.fail(error); + return undefined; + }); + } + private async configureConnection() { + const location = { directory: this.config.cwd }; + await waitForLocationReady({ client: this.client, location, signal: this.abort.signal }); + if (this.requiresPaseoPlugin) + await awaitPaseoPlugin({ client: this.client, location, signal: this.abort.signal }); + for (const [server, config] of Object.entries(this.config.mcpServers ?? {})) { + await this.client.mcp.add({ + server, + location, + config: + config.type === "stdio" + ? { + type: "local", + command: [config.command, ...(config.args ?? [])], + environment: config.env, + codemode: false, + } + : { + type: "remote", + url: config.url, + headers: config.headers, + oauth: false, + codemode: false, + }, + }); + await this.awaitMcp(server); + } + const system = composeSystemPromptParts( + this.config.systemPrompt, + this.config.daemonAppendSystemPrompt, + ); + if (system) + await this.client.session.instructions.entry.put({ + sessionID: this.id, + key: "paseo", + value: system, + }); + } + private async startStream() { + let ready!: () => void; + let fail!: (error: unknown) => void; + const first = new Promise((resolve, reject) => { + ready = resolve; + fail = reject; + }); + this.stream = this.consume(ready, fail); + await raceProviderRefreshAbort( + AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)]), + first, + ); + } + private async reconnectIfExited() { + if (this.closed) throw new Error("OpenCode session is closed"); + if (!this.exited) return; + this.reconnecting ??= this.reconnect().finally(() => { + this.reconnecting = null; + }); + await this.reconnecting; + } + private async reconnect() { + if (!this.acquire) throw new Error("OpenCode helper server exited"); + await this.stream; + await this.sync.catch(() => undefined); + const next = await this.acquire(); + if (this.closed) { + await next.release(); + throw new Error("OpenCode session is closed"); + } + const old = this.connection; + this.connection = next; + this.streamAbort = new AbortController(); + this.exited = false; + this.watchExit(next); + try { + await this.configureConnection(); + await this.startStream(); + } catch (error) { + this.connection = old; + this.exited = true; + this.streamAbort.abort(); + await next.release(); + throw error; + } + this.moved?.(next); + await old.release(); + } + subscribe(callback: (event: AgentStreamEvent) => void) { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + private async awaitMcp(server: string) { + const signal = AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)]); + while (true) { + const catalog = await this.client.mcp.list( + { location: { directory: this.config.cwd } }, + { signal }, + ); + const entry = catalog.data.find((item) => item.name === server); + if (entry?.status.status === "connected") return; + if (entry && entry.status.status !== "pending") { + const reason = entry.status.status === "failed" ? entry.status.error : entry.status.status; + throw new Error(`OpenCode MCP server ${server} failed to connect: ${reason}`); + } + await delay(50, undefined, { signal }); + } + } + private emit(event: AgentStreamEvent) { + for (const listener of this.listeners) listener(event); + } + private emitTimeline(event: AgentStreamEvent) { + this.emit({ ...event, ...(this.turns.id ? { turnId: this.turns.id } : {}) }); + } + async *streamHistory() { + await this.reconnectIfExited(); + const history = new V2Timeline(false); + yield* history.messages(await messages(this.client, this.id)); + } + async getRuntimeInfo() { + await this.reconnectIfExited(); + this.info = await this.client.session.get({ sessionID: this.id }); + return { + provider: "opencode", + sessionId: this.id, + model: this.info.model ? `${this.info.model.providerID}/${this.info.model.id}` : null, + modeId: this.info.agent ?? null, + thinkingOptionId: this.info.model?.variant ?? null, + }; + } + async getAvailableModes() { + return this.modes; + } + async getCurrentMode() { + return this.info.agent ?? null; + } + async setMode(modeId: string) { + await this.reconnectIfExited(); + await this.client.session.switchAgent({ sessionID: this.id, agent: modeId }); + this.info.agent = modeId; + this.config.modeId = modeId; + this.emit({ + type: "mode_changed", + provider: "opencode", + currentModeId: modeId, + availableModes: this.modes, + }); + } + async setModel(model: string | null) { + await this.reconnectIfExited(); + const location = { directory: this.config.cwd }; + const selected = model ? modelRef(model) : (await this.client.model.default({ location })).data; + if (!selected) throw new Error("OpenCode has no default model"); + const catalog = await this.client.model.list({ location }); + const target = catalog.data.find( + (entry) => + entry.enabled && entry.providerID === selected.providerID && entry.id === selected.id, + ); + if (!target) + throw new Error(`OpenCode model unavailable: ${selected.providerID}/${selected.id}`); + const retainedVariant = this.config.thinkingOptionId; + const variant = + retainedVariant !== "default" && target.variants.some((entry) => entry.id === retainedVariant) + ? retainedVariant + : undefined; + const nextModel = { + id: selected.id, + providerID: selected.providerID, + ...(variant ? { variant } : {}), + }; + await this.client.session.switchModel({ sessionID: this.id, model: nextModel }); + this.info.model = nextModel; + this.config.model = model ?? undefined; + this.config.thinkingOptionId = variant; + this.emit({ + type: "thinking_option_changed", + provider: "opencode", + thinkingOptionId: variant ?? null, + }); + this.emit({ + type: "model_changed", + provider: "opencode", + runtimeInfo: { + provider: "opencode", + sessionId: this.id, + model: `${selected.providerID}/${selected.id}`, + modeId: this.info.agent ?? null, + thinkingOptionId: variant ?? null, + }, + }); + } + + async setThinkingOption(variant: string | null) { + await this.reconnectIfExited(); + const model = this.info.model; + if (!model) throw new Error("Select an OpenCode model before changing its variant"); + await this.client.session.switchModel({ + sessionID: this.id, + model: { id: model.id, providerID: model.providerID, ...(variant ? { variant } : {}) }, + }); + this.config.thinkingOptionId = variant ?? undefined; + this.info = await this.client.session.get({ sessionID: this.id }); + this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); + } + async setFeature(id: string, value: unknown) { + if (id !== "auto_accept" || typeof value !== "boolean") + throw new Error("Unknown OpenCode feature"); + this.config.featureValues = { ...this.config.featureValues, [id]: value }; + if (value && !this.config.toolPolicy) + for (const request of this.permissions.list()) + if (request.kind !== "question") + await this.respondToPermission(request.id, { behavior: "allow" }); + } + async listCommands() { + await this.reconnectIfExited(); + return commands(this.client, this.config.cwd); + } + describePersistence(): AgentPersistenceHandle { + return { + provider: "opencode", + sessionId: this.id, + nativeHandle: this.id, + metadata: { ...this.config }, + }; + } + run(prompt: AgentPromptInput, options?: AgentRunOptions) { + return runProviderTurn({ + prompt, + runOptions: options, + startTurn: this.startTurn.bind(this), + subscribe: this.subscribe.bind(this), + getSessionId: () => this.id, + reduceFinalText: ({ current, item }) => + item.type === "assistant_message" ? current + item.text : current, + }); + } + async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { + await this.reconnectIfExited(); + return this.turns.startTurn(prompt, options); + } + steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { + return this.turns.steerActiveTurn(prompt, options); + } + interrupt() { + return this.turns.interrupt(); + } + async revertBoth(input: { messageId: string }) { + await this.interrupt(); + await this.reconnectIfExited(); + await this.client.session.revert.stage({ + sessionID: this.id, + messageID: input.messageId, + files: true, + }); + await this.client.session.revert.commit({ sessionID: this.id }); + } + getPendingPermissions() { + return [...this.permissions.list()]; + } + async respondToPermission(requestId: string, response: AgentPermissionResponse) { + await this.reconnectIfExited(); + const deniesOwnRequest = + response.behavior === "deny" && this.permissions.isOwnedBy(requestId, this.id); + await this.permissions.respondToPermission(requestId, response); + if (deniesOwnRequest) this.turns.requestDenied(); + } + private async reconcileSnapshot() { + const [info, history] = await Promise.all([ + this.client.session.get({ sessionID: this.id }), + messages(this.client, this.id), + ]); + this.info = info; + this.history = history; + for (const event of this.timeline.messages(history)) this.emitTimeline(event); + await this.permissions.reconcile(this.id); + } + private async reconcile() { + this.dirty = true; + this.sync = this.sync.catch(() => undefined).then(() => this.drainReconciliation()); + return this.sync; + } + private async drainReconciliation() { + while (this.dirty && !this.closed) { + this.dirty = false; + await this.reconcileSnapshot(); + } + } + private async reconcileConnection() { + // Session environments are process-local. Reapply this agent's snapshot before + // reconciling a new connection, including event-stream reconnections. + if (this.launchEnv) + await this.client.session.environment({ sessionID: this.id, variables: this.launchEnv }); + await this.reconcile(); + await this.children.reconcile(this.id); + await this.turns.reconcile(); + } + private scheduleReconcile() { + if (this.refreshTimer || this.closed) return; + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + void this.reconcile().catch((error: unknown) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode reconciliation failed", + ), + ); + }, 25); + } + private observeOwnEvent(event: import("@opencode/client").OpenCodeEvent) { + if (event.type === "form.created" && event.data.form.sessionID === this.id) { + this.scheduleReconcile(); + return; + } + if (!("sessionID" in event.data) || event.data.sessionID !== this.id) return; + if (event.type === "session.text.started" || event.type === "session.reasoning.started") { + if (event.type === "session.text.started" && this.turns.hasStructuredOutput) return; + this.timeline.startPart({ + ...event.data, + type: event.type === "session.text.started" ? "text" : "reasoning", + }); + return; + } + if (event.type === "session.text.delta" || event.type === "session.reasoning.delta") { + const isText = event.type === "session.text.delta"; + // A structured-output turn supplies its answer through a tool, so streamed + // prose is not part of the result and must not reach the timeline. + if (isText && this.turns.hasStructuredOutput) return; + const streamed = this.timeline.delta({ + assistantMessageID: event.data.assistantMessageID, + ordinal: event.data.ordinal, + delta: event.data.delta, + type: isText ? "text" : "reasoning", + }); + if (streamed) this.emitTimeline(streamed); + return; + } + this.permissions.observe(event); + this.usage.observe(event); + this.turns.observe(event); + this.scheduleReconcile(); + } + private async consume(ready: () => void, fail: (error: unknown) => void) { + const signal = AbortSignal.any([this.abort.signal, this.streamAbort.signal]); + let connected = false; + while (!this.closed && !signal.aborted) { + try { + for await (const event of this.client.event.subscribe({ signal })) { + if (this.closed || signal.aborted) return; + if (event.type === "server.connected") { + this.timeline.resetStreams(); + await this.reconcileConnection(); + connected = true; + ready(); + } + await this.children.observe(event); + this.observeOwnEvent(event); + } + if (!connected) throw new Error("OpenCode event stream ended before connecting"); + } catch (error) { + if (this.closed || signal.aborted) return; + if (!connected) { + fail(error); + return; + } + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode event stream interrupted; reconciling on reconnect", + ); + } + await delay(100, undefined, { signal }).catch(() => undefined); + } + } + async close() { + if (this.closed) return; + this.closed = true; + this.abort.abort(); + this.turns.close(); + this.streamAbort.abort(); + if (this.refreshTimer) clearTimeout(this.refreshTimer); + await this.stream; + await this.reconnecting?.catch(() => undefined); + await this.sync.catch(() => undefined); + this.unbind?.(); + try { + if (!this.persist) await this.client.session.remove({ sessionID: this.id }); + } finally { + await this.connection.release(); + } + this.listeners.clear(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts new file mode 100644 index 0000000000..aa74faa6cf --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/streaming.test.ts @@ -0,0 +1,205 @@ +import { V2Timeline } from "./timeline.js"; +import type { SessionMessageAssistant } from "@opencode/client"; +import { describe, expect, test } from "vitest"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; + +function collectAssistantText(session: { + subscribe: (cb: (e: AgentStreamEvent) => void) => () => void; +}) { + const chunks: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "assistant_message") + chunks.push(event.item.text); + }); + return chunks; +} + +function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content, + }; +} + +describe("OpenCode v2 token streaming", () => { + test("emits text and reasoning deltas as they arrive", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const text = collectAssistantText(session); + const reasoning: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "reasoning") + reasoning.push(event.item.text); + }); + try { + harness.push({ + id: "rs", + created: 2, + type: "session.reasoning.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "r1", + created: 3, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "think" }, + }); + harness.push({ + id: "ts", + created: 2, + type: "session.text.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "t1", + created: 4, + type: "session.text.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "Hel" }, + }); + harness.push({ + id: "t2", + created: 5, + type: "session.text.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "lo" }, + }); + await expect.poll(() => text).toEqual(["Hel", "lo"]); + expect(reasoning).toEqual(["think"]); + } finally { + await session.close(); + } + }); + + test("deduplicates snapshots before and after deltas using per-type ordinals", () => { + const timeline = new V2Timeline(); + const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; + const reasoning = { ...text, type: "reasoning" } as const; + timeline.startPart(text); + timeline.startPart(reasoning); + expect(timeline.delta({ ...text, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); + expect(timeline.delta({ ...reasoning, delta: "think" })).toMatchObject({ + item: { text: "think" }, + }); + const snapshot = assistant([ + { + type: "reasoning", + text: "think", + state: { reasoningField: "reasoning_content" }, + time: { created: 2, completed: 2 }, + }, + { type: "text", text: "Hello" }, + ]); + expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]); + expect(timeline.delta({ ...text, delta: "lo" })).toBeNull(); + expect(timeline.messages([snapshot])).toEqual([]); + expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } }); + expect(timeline.messages([snapshot])).toEqual([]); + }); + + test("recovers missed fragments from snapshots after reconnect", () => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const; + timeline.startPart(part); + expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } }); + timeline.resetStreams(); + expect(timeline.delta({ ...part, delta: "!" })).toBeNull(); + expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([ + { item: { text: "lo!" } }, + ]); + expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]); + }); + + test("withholds streamed prose during a structured-output turn", async () => { + const harness = new V2Harness(); + harness.autoComplete = false; + const settle = () => harness.finishExecution(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const text = collectAssistantText(session); + const reasoning: string[] = []; + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "reasoning") + reasoning.push(event.item.text); + }); + const failures: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "turn_failed") failures.push(event); + }); + try { + await session.startTurn("answer", { + outputSchema: { type: "object", properties: { answer: { type: "integer" } } }, + }); + await expect.poll(() => harness.prompts).toEqual(["answer"]); + harness.push({ + id: "ts", + created: 2, + type: "session.text.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "t1", + created: 3, + type: "session.text.delta", + data: { + sessionID: "session", + assistantMessageID: "answer", + ordinal: 0, + delta: "ignore me", + }, + }); + // A following reasoning delta is still delivered, so its arrival proves + // the text delta was seen and suppressed rather than merely not yet read. + harness.push({ + id: "rs", + created: 2, + type: "session.reasoning.started", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0 }, + }); + harness.push({ + id: "r1", + created: 4, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "thought" }, + }); + await expect.poll(() => reasoning).toEqual(["thought"]); + expect(text).toEqual([]); + // A late delta after the structured turn failed must remain suppressed. + settle(); + await expect.poll(() => failures.length).toBe(1); + harness.push({ + id: "late", + created: 5, + type: "session.text.delta", + data: { + sessionID: "session", + assistantMessageID: "answer", + ordinal: 0, + delta: "still hidden", + }, + }); + harness.push({ + id: "r2", + created: 6, + type: "session.reasoning.delta", + data: { sessionID: "session", assistantMessageID: "answer", ordinal: 0, delta: "done" }, + }); + await expect.poll(() => reasoning).toEqual(["thought", "done"]); + expect(text).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs b/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs new file mode 100644 index 0000000000..adb306e558 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/structured-output-plugin.mjs @@ -0,0 +1,60 @@ +import Ajv from "ajv"; + +export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output"; + +export async function registerStructuredOutput(context) { + const ajv = new Ajv({ strict: false, allErrors: true }); + async function schemaFor(sessionID) { + const history = await context.session.context({ sessionID }); + const prompt = history.findLast((message) => message.type === "user"); + return prompt?.metadata?.paseoOutputSchema; + } + const tools = await context.tool.transform((editor) => { + editor.add({ + name: STRUCTURED_OUTPUT_TOOL, + description: "Submit the final answer in the required structured format.", + input: { + type: "object", + properties: { value: {} }, + required: ["value"], + additionalProperties: false, + }, + options: { codemode: false }, + async execute(input, call) { + const schema = await schemaFor(call.sessionID); + if (schema === undefined) throw new Error("This turn has no structured-output request"); + const validate = ajv.compile(schema); + if (!validate(input.value)) + throw new Error(`Invalid structured output: ${ajv.errorsText(validate.errors)}`); + return { + content: + "Final structured answer accepted. End this turn without additional text or tool calls.", + metadata: { paseoStructuredOutput: input.value }, + }; + }, + }); + }); + const hook = await context.session.hook("context", async (input) => { + const schema = await schemaFor(input.sessionID); + if (schema === undefined) { + delete input.tools[STRUCTURED_OUTPUT_TOOL]; + return; + } + const tool = input.tools[STRUCTURED_OUTPUT_TOOL]; + if (!tool) throw new Error("Structured-output tool is unavailable"); + tool.input = { + type: "object", + properties: { value: schema }, + required: ["value"], + additionalProperties: false, + }; + input.system.push({ + type: "text", + text: `Complete the user's task, then submit the final answer using ${STRUCTURED_OUTPUT_TOOL}. Its value must satisfy the tool schema. Do not finish with an ordinary text answer. After the tool accepts the value, end the turn without additional text or tool calls.`, + }); + }); + return async () => { + await hook.dispose(); + await tools.dispose(); + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts b/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts new file mode 100644 index 0000000000..dd9472acf2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/structured-output.ts @@ -0,0 +1,36 @@ +import { Ajv } from "ajv"; +import { z } from "zod"; +import type { SessionMessageInfo } from "@opencode/client"; + +export const STRUCTURED_OUTPUT_TOOL = "paseo_structured_output"; +const schemas = new Ajv({ strict: false, allErrors: true }); + +export function structuredOutput(input: unknown) { + const schema = z.union([z.boolean(), z.record(z.string(), z.json())]).parse(input); + const validate = schemas.compile(schema); + return { + schema, + assert(history: SessionMessageInfo[]) { + const start = history.findLastIndex((message) => message.type === "user"); + for (const message of history.slice(start + 1)) { + if (message.type !== "assistant") continue; + for (const part of message.content) { + if ( + part.type !== "tool" || + part.name !== STRUCTURED_OUTPUT_TOOL || + part.state.status !== "completed" + ) + continue; + const result = part.state.metadata?.paseoStructuredOutput; + if (result === undefined) continue; + if (!validate(result)) + throw new Error( + `Invalid OpenCode structured output: ${schemas.errorsText(validate.errors)}`, + ); + return; + } + } + throw new Error("OpenCode finished without submitting the required structured output"); + }, + }; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts new file mode 100644 index 0000000000..9189f5d3c5 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -0,0 +1,97 @@ +import type { SessionMessageAssistant } from "@opencode/client"; +import { expect, test } from "vitest"; +import { V2Timeline } from "./timeline.js"; + +function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content, + }; +} + +test("a completed edit carries the replaced and replacement text", () => { + // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. + const edit = assistant([ + { + type: "tool", + id: "call-edit", + name: "edit", + executed: false, + state: { + status: "completed", + input: { path: "a.txt", oldString: "beta", newString: "BETA" }, + content: [{ type: "text", text: "Edited a.txt (1 replacement)" }], + metadata: { + files: [ + { + file: "a.txt", + patch: + "Index: a.txt\n===================================================================\n--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n", + status: "modified", + additions: 1, + deletions: 1, + }, + ], + truncated: false, + }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]); + + expect(new V2Timeline().messages([edit])).toMatchObject([ + { + item: { + type: "tool_call", + name: "edit", + status: "completed", + detail: { type: "edit", filePath: "a.txt", oldString: "beta", newString: "BETA" }, + }, + }, + ]); +}); + +test("a tool's image output is shown as an image, not as base64 in the tool output", () => { + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + const screenshot = assistant([ + { + type: "tool", + id: "call-screenshot", + name: "paseo_browser_screenshot", + executed: false, + state: { + status: "completed", + input: {}, + content: [ + { type: "text", text: "Captured browser screenshot (1x1)." }, + { type: "file", uri: `data:image/png;base64,${png}`, mime: "image/png" }, + ], + metadata: {}, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]); + const timeline = new V2Timeline(); + + const events = timeline.messages([screenshot]); + + expect(JSON.stringify(events)).not.toContain(png); + expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([ + { + type: "tool_call", + name: "paseo_browser_screenshot", + status: "completed", + detail: { output: "Captured browser screenshot (1x1).\n[image]" }, + }, + { + type: "assistant_message", + text: expect.stringMatching(/^!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)$/), + }, + ]); + expect(timeline.messages([screenshot])).toEqual([]); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts new file mode 100644 index 0000000000..c58316df9e --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -0,0 +1,238 @@ +import { mapOpencodeToolCall } from "../tool-call-mapper.js"; +import { + materializeProviderImage, + renderProviderImageOutputAsAssistantMarkdown, +} from "../../provider-image-output.js"; +import type { SessionMessageAssistantTool } from "@opencode/client"; +import { STRUCTURED_OUTPUT_TOOL } from "./structured-output.js"; +import type { SessionMessageInfo } from "@opencode/client"; +import type { AgentStreamEvent, AgentTimelineItem } from "../../../agent-sdk-types.js"; + +function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: number): string { + return `${messageID}:${part}:${ordinal}`; +} + +// Text and reasoning each number parts from zero, independent of snapshot position. +export class V2Timeline { + private readonly content = new Map(); + private readonly streams = new Map(); + + resetStreams() { + this.streams.clear(); + } + + startPart(part: TextPartIdentity) { + this.streams.set(textPartKey(part.assistantMessageID, part.type, part.ordinal), ""); + } + + constructor(private readonly includeClientMessageId = true) {} + + delta(event: TextPartDelta): AgentStreamEvent | null { + const key = textPartKey(event.assistantMessageID, event.type, event.ordinal); + const streamed = this.streams.get(key); + // Deltas are ephemeral and have no offset. After reconnect, only stream + // parts whose start we observed; snapshots recover any interrupted part. + if (streamed === undefined || event.delta.length === 0) return null; + const text = streamed + event.delta; + this.streams.set(key, text); + const emitted = this.content.get(key) ?? ""; + if (text.length <= emitted.length) return null; + if (!text.startsWith(emitted)) + throw new Error("OpenCode changed previously emitted message content"); + this.content.set(key, text); + const suffix = text.slice(emitted.length); + const item: AgentTimelineItem = + event.type === "text" + ? { type: "assistant_message", text: suffix, messageId: event.assistantMessageID } + : { type: "reasoning", text: suffix }; + return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() }; + } + + messages(messages: SessionMessageInfo[]): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + const state: TimelineState = { structured: false, accepted: false }; + for (const message of messages) { + const timestamp = new Date(message.time.created).toISOString(); + const push = (item: AgentTimelineItem) => + events.push({ type: "timeline", provider: "opencode", item, timestamp }); + if (message.type === "user") this.userMessage(message, state, push); + else if (message.type === "assistant") this.assistantMessage(message, state, push); + else if (message.type === "compaction") this.compactionMessage(message, push); + } + return events; + } + + private userMessage( + message: Extract, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + state.structured = message.metadata?.paseoOutputSchema !== undefined; + state.accepted = false; + if (this.content.has(message.id)) return; + this.content.set(message.id, message.text); + const clientMessageId = message.metadata?.paseoClientMessageId; + push({ + type: "user_message", + text: message.text, + messageId: message.id, + ...(this.includeClientMessageId && typeof clientMessageId === "string" + ? { clientMessageId } + : {}), + }); + } + + private assistantMessage( + message: Extract, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + // Snapshot parts are indexed by position in `message.content`, but deltas + // number text and reasoning separately. Track a per-type ordinal so both + // paths address the same cursor entry. + let textOrdinal = 0; + let reasoningOrdinal = 0; + message.content.forEach((part, partIndex) => { + if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) { + this.structuredOutputPart(message.id, partIndex, part, state, push); + } else if (part.type === "text" || part.type === "reasoning") { + const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++; + this.textPart(message.id, part, ordinal, state, push); + } else { + this.toolPart(message.id, partIndex, part, push); + } + }); + } + + private structuredOutputPart( + messageID: string, + partIndex: number, + part: Extract< + Extract["content"][number], + { type: "tool" } + >, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + if (!state.structured || state.accepted || part.state.status !== "completed") return; + const value = part.state.metadata?.paseoStructuredOutput; + if (value === undefined) return; + state.accepted = true; + const key = `${messageID}:${partIndex}`; + const text = JSON.stringify(value); + if (this.content.get(key) === text) return; + this.content.set(key, text); + push({ type: "assistant_message", text, messageId: messageID }); + } + + private textPart( + messageID: string, + part: Extract< + Extract["content"][number], + { type: "text" } | { type: "reasoning" } + >, + ordinal: number, + state: TimelineState, + push: (item: AgentTimelineItem) => void, + ) { + if (state.structured && part.type === "text") return; + const key = textPartKey(messageID, part.type, ordinal); + const previous = this.content.get(key) ?? ""; + // Upstream snapshots may briefly lag the volatile delta stream. + if (part.text.length <= previous.length) return; + if (!part.text.startsWith(previous)) + throw new Error("OpenCode changed previously emitted message content"); + this.content.set(key, part.text); + const suffix = part.text.slice(previous.length); + if (part.type === "text") + push({ type: "assistant_message", text: suffix, messageId: messageID }); + else push({ type: "reasoning", text: suffix }); + } + + private toolPart( + messageID: string, + partIndex: number, + part: Extract< + Extract["content"][number], + { type: "tool" } + >, + push: (item: AgentTimelineItem) => void, + ) { + const key = `${messageID}:${partIndex}`; + const serialized = JSON.stringify(part); + if (serialized === this.content.get(key)) return; + this.content.set(key, serialized); + const { output, images } = splitToolContent(part.state); + const item = toolFromV2(part, output); + if (item) push(item); + // Images follow their tool call as markdown, as Claude and Codex emit them, + // so base64 never reaches the tool output. + images.forEach((image, index) => { + const imageKey = `${key}:image:${index}`; + if (this.content.has(imageKey)) return; + this.content.set(imageKey, ""); + const imageItem = renderProviderImageOutputAsAssistantMarkdown( + { url: image.uri, mimeType: image.mime }, + { materialize: materializeProviderImage }, + ); + if (imageItem) push(imageItem); + }); + } + + private compactionMessage( + message: Extract, + push: (item: AgentTimelineItem) => void, + ) { + const status = message.status === "running" ? "loading" : "completed"; + if (this.content.get(message.id) === status) return; + this.content.set(message.id, status); + push({ type: "compaction", status }); + } +} + +interface TimelineState { + structured: boolean; + accepted: boolean; +} + +interface TextPartIdentity { + assistantMessageID: string; + type: "text" | "reasoning"; + ordinal: number; +} + +interface TextPartDelta extends TextPartIdentity { + delta: string; +} +function splitToolContent(state: SessionMessageAssistantTool["state"]): { + output: string | undefined; + images: { uri: string; mime: string }[]; +} { + if (!("content" in state) || !state.content) return { output: undefined, images: [] }; + const images: { uri: string; mime: string }[] = []; + const output = state.content + .map((part) => { + if (part.type === "text") return part.text; + if (!part.uri.startsWith("data:image/")) return part.uri; + images.push(part); + return "[image]"; + }) + .join("\n"); + return { output, images }; +} + +function toolFromV2( + tool: SessionMessageAssistantTool, + output: string | undefined, +): AgentTimelineItem | null { + const state = tool.state; + return mapOpencodeToolCall({ + toolName: tool.name, + callId: tool.id, + input: state.input, + status: state.status === "error" ? "failed" : state.status, + output, + error: state.status === "error" ? state.error : undefined, + metadata: "metadata" in state ? state.metadata : undefined, + }); +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/turns.ts b/packages/server/src/server/agent/providers/opencode/v2/turns.ts new file mode 100644 index 0000000000..2b2c21d0c5 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/turns.ts @@ -0,0 +1,375 @@ +import { structuredOutput } from "./structured-output.js"; +import type { OpenCodeEvent, SessionInfo, SessionMessageInfo } from "@opencode/client"; + +import { randomUUID } from "node:crypto"; + +import type { + AgentPromptInput, + AgentRunOptions, + AgentStreamEvent, + SteerActiveTurnOptions, + SteerResult, +} from "../../../agent-sdk-types.js"; + +import { toDiagnosticErrorMessage } from "../../diagnostic-utils.js"; + +import { renderPromptAttachmentAsText } from "../../../prompt-attachments.js"; + +import { usageFromV2 } from "./mapping.js"; + +import { commands } from "./commands.js"; + +import type { V2Api } from "./api.js"; +type ExecutionEvent = Extract< + OpenCodeEvent, + { + type: + | "session.execution.started" + | "session.execution.succeeded" + | "session.execution.failed" + | "session.execution.interrupted"; + } +>; +function isExecutionEvent(event: { type: string }): event is ExecutionEvent { + return ( + event.type === "session.execution.started" || + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ); +} + +interface TurnSnapshot { + info: SessionInfo; + history: SessionMessageInfo[]; +} +interface TurnOptions { + client(): V2Api; + id: string; + cwd: string; + signal: AbortSignal; + emit(event: AgentStreamEvent): void; + reconcile(): Promise; + clearPermissions(): Promise; + reportReconciliationError(error: unknown): void; +} +interface Turn { + output?: ReturnType; + id: string; + submitted: Promise; + completion: Promise; + settle(): void; + accepted: boolean; + requestDenied: boolean; +} + +export class SessionTurns { + private turn: Turn | null = null; + private stopping: Promise | null = null; + private stopFailed = false; + private revision = 0; + private execution: ExecutionEvent | null = null; + private sequence = 0; + private refresh: Promise | null = null; + private dirty = false; + private retryTimer: ReturnType | null = null; + constructor(private readonly options: TurnOptions) {} + get id() { + return this.turn?.id; + } + get hasStructuredOutput() { + return Boolean(this.turn?.output); + } + fail(error: Error) { + const turn = this.turn; + if (!turn) return; + this.release(turn); + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: turn.id, + error: toDiagnosticErrorMessage(error), + }); + } + private release(turn: Turn) { + this.turn = null; + this.revision += 1; + if (this.retryTimer) clearTimeout(this.retryTimer); + this.retryTimer = null; + turn.settle(); + } + close() { + if (this.turn) this.release(this.turn); + } + private createTurn(submitted: Promise, accepted: boolean, output?: Turn["output"]): Turn { + let settle!: () => void; + const completion = new Promise((resolve) => { + settle = resolve; + }); + const turn: Turn = { + id: randomUUID(), + submitted, + completion, + settle, + accepted, + output, + requestDenied: false, + }; + this.turn = turn; + this.execution = null; + this.revision += 1; + this.options.emit({ type: "turn_started", provider: "opencode", turnId: turn.id }); + this.scheduleRecovery(); + return turn; + } + async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions) { + await this.stopping; + if (this.options.signal.aborted) throw new Error("OpenCode session is closed"); + if (this.turn) throw new Error("OpenCode session already has an active turn"); + const input = this.promptInput(prompt); + let accept!: () => void; + const submitted = new Promise((resolve) => { + accept = resolve; + }); + const output = + options?.outputSchema === undefined ? undefined : structuredOutput(options.outputSchema); + const turn = this.createTurn(submitted, false, output); + void this.submit(turn, input, accept, options); + return { turnId: turn.id }; + } + private async submit( + turn: Turn, + input: ReturnType, + accept: () => void, + options?: AgentRunOptions, + ) { + try { + await this.dispatch(input, options, turn.output); + if (this.turn !== turn) return; + turn.accepted = true; + this.revision += 1; + // Covers a fast execution whose terminal event preceded the admission response. + this.requestReconciliation(); + } catch (error) { + if (this.turn === turn) this.fail(new Error(toDiagnosticErrorMessage(error))); + } finally { + accept(); + } + } + observe(event: OpenCodeEvent) { + if (!isExecutionEvent(event) || event.durable.seq <= this.sequence) return; + this.revision += 1; + if (event.type === "session.execution.started") this.observeActiveTurn(); + this.execution = event; + this.sequence = event.durable.seq; + this.requestReconciliation(); + } + // OpenCode ends the execution when the user denies its permission or dismisses its question, + // but reports the interruption as "shutdown", the same reason as an execution it will resume. + requestDenied() { + if (!this.turn) return; + this.turn.requestDenied = true; + this.requestReconciliation(); + } + private requestReconciliation() { + void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error)); + } + // Recovery reads state; a quiet stream (including SSE comment heartbeats) is not a reason to reconnect. + private scheduleRecovery() { + if (this.retryTimer || !this.turn || this.options.signal.aborted) return; + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.requestReconciliation(); + }, 5_000); + this.retryTimer.unref(); + } + reconcile(): Promise { + this.dirty = true; + if (this.refresh) return this.refresh; + this.refresh = Promise.resolve() + .then(() => this.drainReconciliation()) + .finally(() => { + this.refresh = null; + this.scheduleRecovery(); + }); + return this.refresh; + } + private async drainReconciliation() { + while (this.dirty && !this.options.signal.aborted) { + this.dirty = false; + await this.reconcileTurn(); + } + } + private async reconcileTurn() { + const revision = this.revision; + const turn = this.turn; + const active = await this.options.client().session.active({ signal: this.options.signal }); + if (revision !== this.revision) { + this.dirty = true; + return; + } + if (active[this.options.id]) { + this.observeActiveTurn(); + return; + } + if (!turn?.accepted) return; + // Idle outcome survives shutdown, and /event has no replay. Recover the latest + // durable execution even when its start or terminal was lost across reconnect. + const execution = await this.readExecution(); + if (revision !== this.revision) { + this.dirty = true; + return; + } + if (!execution) return; + this.execution = execution; + this.sequence = execution.durable.seq; + if ( + execution.type === "session.execution.started" || + (execution.type === "session.execution.interrupted" && + execution.data.reason === "shutdown" && + !turn.requestDenied) + ) + return; + // Read the final history only after observing idle. Never turn an observation transport error into an execution failure. + const snapshot = await this.options.reconcile(); + if (revision !== this.revision) { + this.dirty = true; + return; + } + try { + this.finish(turn, snapshot, execution); + } catch (error) { + if (this.turn === turn) this.fail(new Error(toDiagnosticErrorMessage(error))); + } + } + private async dispatch( + input: ReturnType, + options?: AgentRunOptions, + output?: ReturnType, + ) { + const command = input.text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (command?.[1] === "compact" || command?.[1] === "summarize") { + await this.options.client().session.compact({ sessionID: this.options.id }); + return; + } + const selected = command + ? (await commands(this.options.client(), this.options.cwd)).find( + (item) => item.name === command[1], + ) + : undefined; + if (selected && selected.kind !== "skill") { + await this.options.client().session.command({ + sessionID: this.options.id, + name: selected.name, + text: command?.[2] ?? "", + files: input.files, + }); + return; + } + await this.options.client().session.prompt({ + sessionID: this.options.id, + ...input, + ...(selected?.kind === "skill" + ? { + skills: [{ id: selected.name }], + text: command?.[2] ?? `Use the ${selected.name} skill.`, + } + : {}), + metadata: { + ...(options?.clientMessageId ? { paseoClientMessageId: options.clientMessageId } : {}), + ...(output ? { paseoOutputSchema: output.schema } : {}), + }, + }); + } + private finish(turn: Turn, { info, history }: TurnSnapshot, execution: ExecutionEvent) { + if (execution.type === "session.execution.succeeded") turn.output?.assert(history); + this.release(turn); + if (execution.type === "session.execution.interrupted") + this.options.emit({ + type: "turn_canceled", + provider: "opencode", + turnId: turn.id, + reason: "OpenCode interrupted execution", + }); + else if (execution.type === "session.execution.failed") + this.options.emit({ + type: "turn_failed", + provider: "opencode", + turnId: turn.id, + error: execution.data.error.message, + }); + else + this.options.emit({ + type: "turn_completed", + provider: "opencode", + turnId: turn.id, + usage: usageFromV2(info), + }); + } + private async readExecution(): Promise { + let execution = this.execution; + for await (const event of this.options + .client() + .session.log( + { sessionID: this.options.id, follow: false, after: this.sequence }, + { signal: this.options.signal }, + )) { + if (isExecutionEvent(event) && event.durable.seq > (execution?.durable.seq ?? this.sequence)) + execution = event; + } + return execution; + } + private promptInput(prompt: AgentPromptInput) { + if (typeof prompt === "string") return { text: prompt, files: [] }; + const text: string[] = []; + const files: Array<{ uri: string }> = []; + for (const part of prompt) { + if (part.type === "text") text.push(part.text); + else if (part.type === "image") + files.push({ uri: `data:${part.mimeType};base64,${part.data}` }); + else text.push(renderPromptAttachmentAsText(part)); + } + return { text: text.join("\n"), files }; + } + async steerActiveTurn( + prompt: AgentPromptInput, + options: SteerActiveTurnOptions, + ): Promise { + if (this.turn?.id !== options.expectedTurnId || this.stopping) return { status: "unavailable" }; + await this.options.client().session.prompt({ + sessionID: this.options.id, + ...this.promptInput(prompt), + delivery: "steer", + metadata: options.clientMessageId + ? { paseoClientMessageId: options.clientMessageId } + : undefined, + }); + if (options.clearPendingPermissions) await this.options.clearPermissions(); + return { status: "accepted" }; + } + async interrupt() { + if (!this.stopping || this.stopFailed) { + this.stopFailed = false; + const turn = this.turn; + const stop = (async () => { + // A stop sent before the queued prompt is accepted would interrupt an idle session. + await turn?.submitted; + await this.options.client().session.interrupt({ sessionID: this.options.id }); + await this.reconcile(); + await turn?.completion; + })(); + this.stopping = stop; + try { + await stop; + this.stopping = null; + } catch (error) { + this.stopFailed = true; + throw error; + } + } else await this.stopping; + } + private observeActiveTurn() { + if (this.turn || this.options.signal.aborted) return; + this.createTurn(Promise.resolve(), true); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts new file mode 100644 index 0000000000..4fa64079c2 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.test.ts @@ -0,0 +1,100 @@ +import type { ModelInfo, TokenUsageInfo } from "@opencode/client"; +import { describe, expect, test } from "vitest"; +import { OpenCodeV2AgentClient } from "./agent.js"; +import { V2Harness } from "../test-utils/v2-harness.js"; +import { createTestLogger } from "../../../../../test-utils/test-logger.js"; +import type { AgentUsage } from "../../../agent-sdk-types.js"; + +function model(context: number): ModelInfo { + return { + id: "model", + modelID: "model", + providerID: "test", + name: "Model", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 1 }, + cost: [], + status: "active", + enabled: true, + limit: { context, output: 10_000 }, + }; +} + +function stepEnded(harness: V2Harness, id: string, tokens: TokenUsageInfo) { + harness.push({ + id, + created: 2, + type: "session.step.ended", + durable: { aggregateID: "session", seq: 1, version: 1 }, + data: { + sessionID: "session", + assistantMessageID: "answer", + finish: "tool-calls", + cost: 0.1, + tokens, + }, + }); +} + +describe("OpenCode v2 context usage", () => { + test("reports each step's context against the model limit, in step order", async () => { + const harness = new V2Harness(); + harness.info.model = { providerID: "test", id: "model" }; + harness.info.tokens = { + input: 900, + output: 90, + reasoning: 0, + cache: { read: 40, write: 0 }, + }; + harness.info.cost = 0.5; + // Each lookup answers faster than the one before it, so a lookup per step would + // deliver step 1's usage after step 2's. + const lookupDelays = [50, 0]; + harness.api.model.list = async (input) => { + await new Promise((resolve) => setTimeout(resolve, lookupDelays.shift() ?? 0)); + return { + location: { directory: input.location.directory }, + data: [model(200_000)], + }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + }); + const usage: AgentUsage[] = []; + session.subscribe((event) => { + if (event.type === "usage_updated") usage.push(event.usage); + }); + try { + stepEnded(harness, "step-1", { + input: 100, + output: 10, + reasoning: 5, + cache: { read: 1_000, write: 20 }, + }); + stepEnded(harness, "step-2", { + input: 300, + output: 10, + reasoning: 5, + cache: { read: 1_000, write: 20 }, + }); + await expect.poll(() => usage.length).toBe(2); + expect(usage.map((entry) => entry.contextWindowUsedTokens)).toEqual([1_135, 1_335]); + expect(usage[1]).toEqual({ + inputTokens: 900, + outputTokens: 90, + cachedInputTokens: 40, + totalCostUsd: 0.5, + contextWindowUsedTokens: 1_335, + contextWindowMaxTokens: 200_000, + }); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/usage.ts b/packages/server/src/server/agent/providers/opencode/v2/usage.ts new file mode 100644 index 0000000000..17a4885cbc --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/usage.ts @@ -0,0 +1,61 @@ +import type { ModelRef, OpenCodeEvent, SessionInfo, TokenUsageInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Api } from "./api.js"; +import { usageFromV2 } from "./mapping.js"; + +interface UsageOptions { + client(): V2Api; + cwd: string; + info(): SessionInfo; + emit(event: AgentStreamEvent): void; + reportError(error: unknown): void; +} + +// A step is one model call, so its tokens are the context that call used. +function contextWindowUsedTokens(tokens: TokenUsageInfo) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write; +} + +export class SessionUsage { + private readonly limits = new Map>(); + private reports: Promise = Promise.resolve(); + constructor(private readonly options: UsageOptions) {} + observe(event: OpenCodeEvent) { + if (event.type !== "session.step.ended") return; + const { tokens } = event.data; + // Reports stay in step order while a model's context limit is still being looked up. + this.reports = this.reports + .then(() => this.report(tokens)) + .catch((error: unknown) => this.options.reportError(error)); + } + private async report(tokens: TokenUsageInfo) { + const model = this.options.info().model; + const contextWindowMaxTokens = model ? await this.contextLimit(model) : undefined; + this.options.emit({ + type: "usage_updated", + provider: "opencode", + usage: { + ...usageFromV2(this.options.info()), + contextWindowUsedTokens: contextWindowUsedTokens(tokens), + ...(contextWindowMaxTokens === undefined ? {} : { contextWindowMaxTokens }), + }, + }); + } + private contextLimit(model: ModelRef) { + const key = `${model.providerID}/${model.id}`; + const cached = this.limits.get(key); + if (cached) return cached; + const limit = this.options + .client() + .model.list({ location: { directory: this.options.cwd } }) + .then( + (catalog) => + catalog.data.find( + (entry) => entry.providerID === model.providerID && entry.id === model.id, + )?.limit.context, + ); + this.limits.set(key, limit); + limit.catch(() => this.limits.delete(key)); + return limit; + } +} diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index da2e785ca5..927a9defd2 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -18,20 +18,46 @@ import { describe, expect, onTestFinished, test } from "vitest"; import type { AgentSession, AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { - PiProviderParamsSchema, + PiProviderOptionsSchema, PiRpcAgentClient, PiRpcAgentSession, transformPiModels, } from "./agent.js"; import { FakePi } from "./test-utils/fake-pi.js"; +import { createPiExtensionHost } from "./extensions/index.js"; +import { PiExtensionHost } from "./extensions/host.js"; +import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; import type { PiUsagePollScheduler } from "./usage-poller.js"; const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; +const RESTRICTED_THINKING_MODEL: PiModel = { + provider: "kimi-coding", + id: "kimi-k3", + name: "Kimi K3", + reasoning: true, + thinkingLevelMap: { + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }, +}; + +interface PiThinkingCatalogCase { + name: string; + model: PiModel; + optionIds: PiThinkingLevel[] | undefined; + defaultThinkingOptionId: PiThinkingLevel | undefined; +} + test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => { - expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000); - expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); + expect(PiProviderOptionsSchema.parse({}).rpcTimeoutMs).toBe(60_000); + expect(PiProviderOptionsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000); }); function createClient( @@ -96,23 +122,72 @@ function readUtf8File(pathname: string): string { type PaseoExtensionListener = (event: unknown, context?: unknown) => unknown; +interface PiSessionEntry { + type: "message"; + id: string; + parentId: string | null; + message: { role: string; content: unknown }; +} + +function piUserEntry(input: { id: string; parentId: string | null; text: string }): PiSessionEntry { + return { + type: "message", + id: input.id, + parentId: input.parentId, + message: { role: "user", content: input.text }, + }; +} + +function piAssistantEntry(id: string, parentId: string): PiSessionEntry { + return { type: "message", id, parentId, message: { role: "assistant", content: [] } }; +} + +// Pi's context path: the entries from the root to the leaf. +function piBranchTo(entries: PiSessionEntry[], leafId: string): PiSessionEntry[] { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const branch: PiSessionEntry[] = []; + for (let entry = byId.get(leafId); entry; entry = byId.get(entry.parentId ?? "")) { + branch.unshift(entry); + } + return branch; +} + +function parseEntryCapture(notification: string): unknown { + const prefix = "PASEO_ENTRY_CAPTURE "; + expect(notification.startsWith(prefix)).toBe(true); + return JSON.parse(notification.slice(prefix.length)); +} + async function loadPaseoExtensionListeners( extensionPath: string, + registerMcpServer: (name: string, config: unknown) => void = () => undefined, ): Promise> { const listeners = new Map(); const extension = (await import(pathToFileURL(extensionPath).href)) as { default: (piApi: { on: (event: string, listener: PaseoExtensionListener) => void; + events: { on: () => void }; registerCommand: () => void; + registerMcpServer: (name: string, config: unknown) => void; }) => void; }; extension.default({ on: (event, listener) => listeners.set(event, listener), + events: { on: () => undefined }, registerCommand: () => undefined, + registerMcpServer, }); return listeners; } +async function readRegisteredMcpServers(extensionPath: string): Promise> { + const servers: Record = {}; + await loadPaseoExtensionListeners(extensionPath, (name, config) => { + servers[name] = config; + }); + return servers; +} + async function applyPaseoExtensionSystemPrompt( extensionPath: string, systemPrompt: string, @@ -179,6 +254,51 @@ test("keeps normal Pi agent sessions persisted", async () => { await session.close(); }); +test("lets Pi choose the thinking level when none is requested", async () => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: RESTRICTED_THINKING_MODEL, + thinkingLevel: "max", + }; + }); + const session = await createClient(pi).createSession( + createConfig({ model: "kimi-coding/kimi-k3" }), + ); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches[0]?.thinkingOptionId).toBeUndefined(); + expect(pi.recordedLaunches[0]?.argv).not.toContain("--thinking"); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("max"); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "max" }); +}); + +test.each([ + { requested: "medium", effective: "high" }, + { requested: "xhigh", effective: "max" }, +] as const)( + "adopts Pi's $effective level when creating with $requested", + async ({ requested, effective }) => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: RESTRICTED_THINKING_MODEL, + thinkingLevel: effective, + }; + }); + const session = await createClient(pi).createSession( + createConfig({ model: "kimi-coding/kimi-k3", thinkingOptionId: requested }), + ); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe(requested); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: effective }); + }, +); + class SessionEvents { private readonly events: AgentStreamEvent[] = []; private readonly waiters: Array<{ @@ -312,6 +432,103 @@ class SessionEvents { } describe("PiRpcAgentSession", () => { + test("completes a turn and answers a dialog when an adapter throws", async () => { + const { pi, session, events } = await createSession(); + Object.assign(session, { + extensionHost: createPiExtensionHost(undefined, [ + { + id: "throwing-test-adapter", + createSession: () => ({ + mapToolCall: () => { + throw new Error("tool failed"); + }, + onToolStart: () => { + throw new Error("start failed"); + }, + onToolEnd: () => { + throw new Error("end failed"); + }, + mapDialog: () => { + throw new Error("dialog failed"); + }, + respondToPermission: () => { + throw new Error("response failed"); + }, + }), + }, + ]), + }); + const fakeSession = pi.latestSession(); + await session.startTurn("run"); + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "x", + toolName: "other", + args: {}, + }); + fakeSession.emit({ + type: "tool_execution_end", + toolCallId: "x", + toolName: "other", + result: { content: [{ type: "text", text: "ok" }] }, + isError: false, + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick", + options: ["A", "B"], + }); + const permission = await events.nextPermissionRequest(); + expect(permission.request.kind).toBe("question"); + await session.respondToPermission("ui-1", { + behavior: "allow", + updatedInput: { answers: { Response: "B" } }, + }); + expect(fakeSession.extensionUiResponses).toEqual([{ id: "ui-1", response: { value: "B" } }]); + fakeSession.finishTurn(); + await events.nextTurnCompletion(); + expect(events.timelineItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "tool_call", + name: "other", + status: "completed", + detail: expect.objectContaining({ type: "unknown" }), + }), + ]), + ); + }); + + test("close resolves after rejected child hydration", async () => { + const { pi, session } = await createSession(); + Object.assign(session, { + extensionHost: new PiExtensionHost( + [ + { + id: "child-test", + createSession: () => ({ + mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }), + }), + }, + ], + undefined, + 2 * 1024 * 1024, + async () => { + throw new Error("read failed"); + }, + ), + }); + await session.startTurn("run"); + pi.latestSession().emit({ + type: "tool_execution_start", + toolCallId: "x", + toolName: "other", + args: {}, + }); + await expect(session.close()).resolves.toBeUndefined(); + }); test("bridges Pi RPC select extension UI requests through question permissions", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -535,19 +752,60 @@ describe("PiRpcAgentSession", () => { ]); }); - test("ignores Pi RPC fire-and-forget extension UI requests", async () => { - const { pi } = await createSession(); + test("surfaces Pi fire-and-forget notify requests as timeline notifications", async () => { + const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); fakeSession.emit({ type: "extension_ui_request", id: "notify-1", method: "notify", - message: "hello", + message: "Search finished", + notifyType: "info", + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-2", + method: "notify", + message: "Command blocked by user", + notifyType: "warning", + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-3", + method: "notify", + message: "no type", }); expect(fakeSession.extensionUiResponses).toEqual([]); expect(fakeSession.canceledExtensionUiRequests).toEqual([]); + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "info", message: "Search finished" }, + { type: "notification", level: "warning", message: "Command blocked by user" }, + { type: "notification", level: "info", message: "no type" }, + ]); + + await session.close(); + }); + + test("surfaces Pi notify requests emitted during an active turn", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + await session.startTurn("hello"); + fakeSession.emit({ + type: "extension_ui_request", + id: "notify-live", + method: "notify", + message: "Turn running notice", + notifyType: "error", + }); + + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "error", message: "Turn running notice" }, + ]); + + await session.close(); }); test("streams assistant text, reasoning, and tool calls from Pi events", async () => { @@ -614,61 +872,6 @@ describe("PiRpcAgentSession", () => { ]); }); - test("streams Pi task calls as sub-agent cards with lifecycle status", async () => { - const { pi, session, events } = await createSession(); - const fakeSession = pi.latestSession(); - - await session.startTurn("delegate this"); - fakeSession.emit({ - type: "tool_execution_start", - toolCallId: "task-1", - toolName: "task", - args: { - agent: "explore", - task: "Trace the Pi provider tool mapper", - }, - }); - fakeSession.emit({ - type: "tool_execution_end", - toolCallId: "task-1", - toolName: "task", - result: { content: [{ type: "text", text: "Found the mapper." }] }, - isError: false, - }); - fakeSession.finishTurn(); - - await events.nextTurnCompletion(); - - expect(events.timelineItems()).toEqual([ - { - type: "tool_call", - callId: "task-1", - name: "task", - status: "running", - detail: { - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "", - }, - error: null, - }, - { - type: "tool_call", - callId: "task-1", - name: "task", - status: "completed", - detail: { - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "Found the mapper.", - }, - error: null, - }, - ]); - }); - test("keeps one generated message id when Pi omits message start and response id", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -811,7 +1014,8 @@ describe("PiRpcAgentSession", () => { })) as PiRpcAgentSession; const events = new SessionEvents(session); const fakeSession = pi.latestSession(); - fakeSession.capturedUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }]; + fakeSession.treeUserEntries = [{ id: "entry-old", parentId: null, text: "old prompt" }]; + fakeSession.contextUserEntries = fakeSession.treeUserEntries; await session.startTurn("new prompt", { clientMessageId: "client-new" }); fakeSession.finishSubmittedUserMessage({ @@ -832,6 +1036,47 @@ describe("PiRpcAgentSession", () => { ]); }); + test("keeps custom context in separate tools while a turn continues", async () => { + const { pi, session, events } = await createSession(); + const runtime = pi.latestSession(); + await session.startTurn("Explain the project"); + runtime.emit({ type: "agent_start" }); + runtime.emit({ type: "turn_start" }); + for (const display of [true, false, true]) { + runtime.emit({ + type: "message_end", + message: { + role: "custom", + customType: "project-context", + content: [{ type: "text", text: "Project instructions" }], + details: { project: "example" }, + display, + }, + }); + } + const items = events.timelineItems(); + expect(items).toEqual( + [1, 2].map(() => ({ + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { + synthetic: true, + customType: "project-context", + details: { project: "example" }, + }, + error: null, + })), + ); + expect(new Set(items.map((item) => item.type === "tool_call" && item.callId)).size).toBe(2); + expect(events.turnCompletedEvents()).toHaveLength(0); + runtime.finishTurn(); + await events.nextTurnCompletion(); + await session.close(); + }); + test("surfaces Pi extension command messages and completes when no agent turn starts", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -848,12 +1093,66 @@ describe("PiRpcAgentSession", () => { expect(events.timelineAndCompletionEvents()).toEqual([ { type: "timeline", - item: { type: "assistant_message", text: "Extension command output" }, + item: { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "Extension command output" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, { type: "turn_completed" }, ]); }); + test("settles an autonomous turn triggered by a Pi extension custom message", async () => { + const { pi, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.emit({ + type: "message_end", + message: { + role: "custom", + content: [{ type: "text", text: "Background process completed" }], + }, + }); + fakeSession.finishAgentRun({ + message: { + role: "assistant", + stopReason: "stop", + content: [{ type: "text", text: "Continuation finished" }], + }, + willRetry: false, + }); + + expect(events.timelineItems()).toEqual([ + { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: "Background process completed", + }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, + ]); + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]); + + fakeSession.settleTurn(); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + ]); + }); + test("canceling a silent Pi extension command leaves the session usable", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -912,6 +1211,143 @@ describe("PiRpcAgentSession", () => { }); }); + test.each(["aborted", "error"])( + "canceling autonomous work with Pi stopReason=%s waits for abort and allows a follow-up", + async (stopReason) => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + const abortFinished = Promise.withResolvers(); + const turnFinished = Promise.withResolvers(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason, + errorMessage: "This operation was aborted", + content: [], + }); + turnFinished.resolve(); + await abortFinished.promise; + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + const stopping = session.interrupt(); + await turnFinished.promise; + expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_started", turnId: undefined }]); + abortFinished.resolve(); + await stopping; + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_canceled", turnId: undefined }, + ]); + + const { turnId } = await session.startTurn("follow-up"); + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + expect(events.turnLifecycleEvents().at(-1)).toEqual({ type: "turn_completed", turnId }); + }, + ); + + test("a natural completion during Stop does not cancel the next autonomous run", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + const abortFinished = Promise.withResolvers(); + const turnFinished = Promise.withResolvers(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + turnFinished.resolve(); + await abortFinished.promise; + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + const stopping = session.interrupt(); + await turnFinished.promise; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + abortFinished.resolve(); + await stopping; + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + { type: "turn_started", turnId: undefined }, + { type: "turn_completed", turnId: undefined }, + ]); + }); + + test("a completed foreground Stop does not suppress a later autonomous abort", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ role: "assistant", stopReason: "stop", content: [] }); + }; + const { turnId } = await session.startTurn("finish while stopping"); + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + await session.interrupt(); + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.finishTurn({ + role: "assistant", + stopReason: "aborted", + errorMessage: "Autonomous run aborted", + content: [], + }); + + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId }, + { type: "turn_completed", turnId }, + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + }); + + test("Pi process exit during Stop emits one autonomous failure", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason: "error", + errorMessage: "This operation was aborted", + content: [], + }); + fakeSession.emit({ type: "process_exit", error: "Pi process exited" }); + throw new Error("Pi process exited"); + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + await expect(session.interrupt()).rejects.toThrow("Pi process exited"); + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Pi process exited" }); + }); + + test("preserves the autonomous terminal error when abort rejects", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + fakeSession.abort = async () => { + fakeSession.finishTurn({ + role: "assistant", + stopReason: "error", + errorMessage: "Provider disconnected", + content: [], + }); + throw new Error("Abort failed"); + }; + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + + await expect(session.interrupt()).rejects.toThrow("Abort failed"); + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + turnId: undefined, + error: expect.stringContaining("Provider disconnected"), + }); + }); + test("suppresses late aborted terminal response arriving after interrupt resolves", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1119,7 +1555,7 @@ describe("PiRpcAgentSession", () => { const pi = new FakePi(); const client = createClient(pi); - await client.resumeSession( + const session = await client.resumeSession( { provider: "pi", sessionId: "pi-session-1", @@ -1134,6 +1570,12 @@ describe("PiRpcAgentSession", () => { { env: { RESUME_PROBE: "expected" } }, ); + expect(session.usageSession?.()).toMatchObject({ + provider: "pi", + sessionKey: expect.any(String), + }); + await session.close(); + expect(session.usageSession?.()).toBeNull(); expect(pi.recordedLaunches).toHaveLength(1); const actualLaunch = pi.recordedLaunches[0]!; expect(actualLaunch).toMatchObject({ @@ -1146,8 +1588,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1157,6 +1597,113 @@ describe("PiRpcAgentSession", () => { ]); }); + test("resumes a session whose model was removed on the model Pi falls back to", async () => { + const pi = new FakePi(); + pi.removeModel("9router/deepseek-v4-flash"); + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "9router/deepseek-v4-flash" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + model: "kimi-coding/kimi-k3", + }); + expect(session.describePersistence()?.metadata?.model).toBe("kimi-coding/kimi-k3"); + }); + + test("resumes a session on the requested model when it differs from the session's", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }); + onTestFinished(() => session.close()); + + expect(pi.latestSession().setModelRequests).toEqual([{ provider: "openrouter", modelId: "a" }]); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + expect(session.describePersistence()?.metadata?.model).toBe("openrouter/a"); + }); + + test("resumes on a requested model written as provider:id", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + session.setModelResult = requestedModel; + }); + + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter:a" }, + }); + onTestFinished(() => session.close()); + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ model: "openrouter/a" }); + }); + + test("fails the resume when switching to an available model fails", async () => { + const pi = new FakePi(); + const requestedModel = { ...RESTRICTED_THINKING_MODEL, provider: "openrouter", id: "a" }; + pi.queueSessionSetup((session) => { + session.state = { ...session.state, model: RESTRICTED_THINKING_MODEL }; + session.models = [RESTRICTED_THINKING_MODEL, requestedModel]; + }); + + await expect( + createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { cwd: "/workspace/project", model: "openrouter/a" }, + }), + ).rejects.toThrow("FakePi setModel requires setModelResult to be scripted"); + }); + + test("adopts Pi's clamped thinking level when resuming a session", async () => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: RESTRICTED_THINKING_MODEL, + thinkingLevel: "high", + }; + }); + const session = await createClient(pi).resumeSession({ + provider: "pi", + sessionId: "pi-session-1", + nativeHandle: "/tmp/native-pi-session", + metadata: { + cwd: "/workspace/project", + model: "kimi-coding/kimi-k3", + thinkingOptionId: "medium", + }, + }); + onTestFinished(() => session.close()); + + expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe("medium"); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("high"); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "high" }); + }); + test("reports the persisted Pi entry attached to the submitted message", async () => { const pi = new FakePi(); const client = createClient(pi); @@ -1203,6 +1750,47 @@ describe("PiRpcAgentSession", () => { await session.close(); }); + test("captures the session's user entries apart from the ones on the current branch", async () => { + const pi = new FakePi(); + const session = await createClient(pi).createSession(createConfig()); + onTestFinished(() => session.close()); + const listeners = await loadPaseoExtensionListeners(pi.recordedLaunches[0]!.extensionPaths[0]!); + // "abandoned" was rewound; "two" was sent from the same parent afterwards. + const entries = [ + piUserEntry({ id: "one", parentId: null, text: "first" }), + piAssistantEntry("one-reply", "one"), + piUserEntry({ id: "abandoned", parentId: "one-reply", text: "rewound away" }), + piAssistantEntry("abandoned-reply", "abandoned"), + piUserEntry({ id: "two", parentId: "one-reply", text: "second" }), + piAssistantEntry("two-reply", "two"), + ]; + const notifications: string[] = []; + const context = { + sessionManager: { + getEntries: () => entries, + buildContextEntries: () => piBranchTo(entries, "two-reply"), + }, + ui: { notify: (message: string) => notifications.push(message) }, + }; + + await listeners.get("session_start")?.({}, context); + + expect(notifications.map(parseEntryCapture)).toEqual([ + { + reason: "session_start", + treeEntries: [ + { id: "one", parentId: null, text: "first" }, + { id: "abandoned", parentId: "one-reply", text: "rewound away" }, + { id: "two", parentId: "one-reply", text: "second" }, + ], + contextEntries: [ + { id: "one", parentId: null, text: "first" }, + { id: "two", parentId: "one-reply", text: "second" }, + ], + }, + ]); + }); + test("appends agent and daemon prompts after Pi's discovered system prompt", async () => { const pi = new FakePi(); const client = createClient(pi); @@ -1223,8 +1811,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--thinking", - "medium", "--extension", actualLaunch.extensionPaths[0], ]); @@ -1268,8 +1854,6 @@ describe("PiRpcAgentSession", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/model-a", "--thinking", "high", "--session", @@ -1294,6 +1878,133 @@ describe("PiRpcAgentSession", () => { expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]); }); + test.each([ + { requested: "medium", effective: "high", rpcRequest: "medium" }, + { requested: "xhigh", effective: "max", rpcRequest: "xhigh" }, + { requested: null, effective: "high", rpcRequest: "medium" }, + ] as const)( + "stores Pi's $effective thinking level after requesting $requested", + async ({ requested, effective, rpcRequest }) => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.state = { + ...session.state, + model: RESTRICTED_THINKING_MODEL, + thinkingLevel: "low", + }; + }); + const { session } = await createSession(pi); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.state = { ...fakeSession.state, thinkingLevel: effective }; + + await session.setThinkingOption(requested); + + expect(fakeSession.setThinkingLevelRequests).toEqual([rpcRequest]); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective); + await expect(session.getRuntimeInfo()).resolves.toEqual({ + provider: "pi", + sessionId: "pi-session-1", + model: "kimi-coding/kimi-k3", + thinkingOptionId: effective, + modeId: null, + }); + }, + ); + + test("refreshes Pi's thinking level after changing models", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.setModelResult = RESTRICTED_THINKING_MODEL; + fakeSession.state = { ...fakeSession.state, thinkingLevel: "high" }; + + await session.setModel("kimi-coding/kimi-k3"); + + expect(fakeSession.setModelRequests).toEqual([{ provider: "kimi-coding", modelId: "kimi-k3" }]); + expect(session.describePersistence()?.metadata).toEqual({ + cwd: "/tmp/paseo-pi-rpc-test", + model: "kimi-coding/kimi-k3", + thinkingOptionId: "high", + }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ + model: "kimi-coding/kimi-k3", + thinkingOptionId: "high", + }); + }); + + test("reports updated Pi thinking state instead of a cached selection", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.state = { ...fakeSession.state, thinkingLevel: "off" }; + + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "off" }); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off"); + }); + + test.each(["high", "off", "max"] as const)( + "publishes an extension's thinking change to %s before refreshing state", + async (level) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const changes: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "thinking_option_changed") { + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level); + changes.push(event); + } + }); + const runtime = pi.latestSession(); + runtime.state = { ...runtime.state, thinkingLevel: level }; + runtime.emit({ type: "thinking_level_changed", level }); + + expect(changes).toEqual([ + { type: "thinking_option_changed", provider: "pi", thinkingOptionId: level }, + ]); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level }); + }, + ); + + test.each([undefined, null, "invalid", 123])( + "ignores a thinking change with invalid level %s", + async (level) => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId; + const changes: AgentStreamEvent[] = []; + session.subscribe((event) => { + if (event.type === "thinking_option_changed") changes.push(event); + }); + + pi.latestSession().emit({ type: "thinking_level_changed", level }); + + expect(changes).toEqual([]); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking); + }, + ); + + test("surfaces state refresh failures after a Pi thinking update", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + pi.latestSession().getStateError = new Error("Pi state unavailable"); + + await expect(session.setThinkingOption("high")).rejects.toThrow("Pi state unavailable"); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium"); + }); + + test("surfaces state refresh failures after a Pi model update", async () => { + const { pi, session } = await createSession(); + onTestFinished(() => session.close()); + const fakeSession = pi.latestSession(); + fakeSession.setModelResult = RESTRICTED_THINKING_MODEL; + fakeSession.getStateError = new Error("Pi state unavailable"); + + await expect(session.setModel("kimi-coding/kimi-k3")).rejects.toThrow("Pi state unavailable"); + expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium"); + }); + test("materializes image prompts as text hints for text-only Pi models", async () => { const { pi, session } = await createSession(); const fakeSession = pi.latestSession(); @@ -1390,6 +2101,24 @@ describe("PiRpcAgentSession", () => { }); }); + test("fails an autonomous turn when the Pi process exits before settlement", async () => { + const { pi, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ type: "agent_start" }); + fakeSession.emit({ type: "turn_start" }); + fakeSession.emit({ type: "process_exit", error: "Pi exited" }); + + await expect(events.nextTurnFailure()).resolves.toMatchObject({ + error: "Pi exited", + turnId: undefined, + }); + expect(events.turnLifecycleEvents()).toEqual([ + { type: "turn_started", turnId: undefined }, + { type: "turn_failed", turnId: undefined }, + ]); + }); + test("completes locally handled slash commands when agentInvoked is false", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1438,7 +2167,7 @@ describe("PiRpcAgentSession", () => { expect(events.turnCompletedEvents()).toHaveLength(1); }); - test("probes slash prompts without agentInvoked and surfaces buffered notify output", async () => { + test("probes slash prompts without agentInvoked and surfaces notify output immediately", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1454,13 +2183,16 @@ describe("PiRpcAgentSession", () => { const completion = await events.nextTurnCompletion(); expect(completion).toMatchObject({ type: "turn_completed", turnId }); expect(events.timelineAndCompletionEvents()).toEqual([ + { + type: "timeline", + item: { type: "notification", level: "info", message: "Plan mode enabled" }, + }, { type: "timeline", item: { type: "user_message", text: "/plan on" } }, - { type: "timeline", item: { type: "assistant_message", text: "Plan mode enabled" } }, { type: "turn_completed" }, ]); }); - test("does not synthesize completion when lifecycle starts before the no-turn probe", async () => { + test("surfaces notify requests immediately even when the turn has started", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); @@ -1469,13 +2201,15 @@ describe("PiRpcAgentSession", () => { type: "extension_ui_request", id: "notify-buffered", method: "notify", - message: "Should not appear after turn starts", + message: "Shown despite turn start", }); fakeSession.emit({ type: "agent_start" }); await flushTurnScheduling(); expect(events.turnCompletedEvents()).toHaveLength(0); - expect(events.timelineItems()).toEqual([]); + expect(events.timelineItems()).toEqual([ + { type: "notification", level: "info", message: "Shown despite turn start" }, + ]); fakeSession.finishTurn(); await flushTurnScheduling(); @@ -1916,10 +2650,11 @@ describe("PiRpcAgentClient", () => { const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: new FakePi(), - providerParams: { sessionDir: sessionsDir }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toEqual([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toEqual([ { providerHandleId: sessionFile, cwd, @@ -1967,7 +2702,9 @@ describe("PiRpcAgentClient", () => { }, }); - await expect(client.listImportableSessions({ cwd })).resolves.toMatchObject([ + await expect( + client.listImportableSessions({ cwd, providerOptions: { sessionDir: sessionsDir } }), + ).resolves.toMatchObject([ { providerHandleId: sessionFile, cwd, @@ -2017,10 +2754,17 @@ describe("PiRpcAgentClient", () => { "utf8", ); const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.state.model = { + ...RESTRICTED_THINKING_MODEL, + provider: "openrouter", + id: "anthropic/claude-sonnet-4.5", + }; + session.state.thinkingLevel = "high"; + }); const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi, - providerParams: { sessionDir: sessionsDir }, }); const imported = await client.importSession( @@ -2034,8 +2778,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--model", - "openrouter/anthropic/claude-sonnet-4.5", "--thinking", "high", "--session", @@ -2088,6 +2830,133 @@ describe("PiRpcAgentClient", () => { expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" }); }); + test("marks the model Pi resolves from its own settings as the catalog default", async () => { + const pi = new FakePi(); + const unconfigured = { provider: "openai", id: "gpt-4", name: "GPT-4", reasoning: false }; + const configured = { provider: "zai", id: "glm-5.3", name: "GLM-5.3", reasoning: true }; + pi.queueSessionSetup((session) => { + session.models = [unconfigured, configured]; + session.state = { ...session.state, model: configured }; + }); + + const catalog = await createClient(pi).fetchCatalog({ + scope: "workspace", + cwd: "/workspace/project", + force: false, + }); + + expect(catalog.models.filter((model) => model.isDefault).map((model) => model.id)).toEqual([ + "zai/glm-5.3", + ]); + }); + + test("honors per-model Pi thinking maps and clamps the catalog default upward", async () => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.models = [RESTRICTED_THINKING_MODEL]; + }); + const client = createClient(pi); + + const catalog = await client.fetchCatalog({ + scope: "workspace", + cwd: "/workspace/project", + force: false, + }); + + expect(catalog.models).toEqual([ + { + provider: "pi", + id: "kimi-coding/kimi-k3", + label: "Kimi K3", + description: "kimi-coding/kimi-k3", + metadata: { provider: "kimi-coding", modelId: "kimi-k3" }, + thinkingOptions: [ + { id: "low", label: "Low", description: "Faster reasoning" }, + { id: "high", label: "High", description: "Deeper reasoning", isDefault: true }, + { id: "max", label: "Max", description: "Extreme reasoning" }, + ], + defaultThinkingOptionId: "high", + }, + ]); + }); + + test.each([ + { + name: "no thinking map", + model: { ...RESTRICTED_THINKING_MODEL, thinkingLevelMap: undefined }, + optionIds: ["off", "minimal", "low", "medium", "high"], + defaultThinkingOptionId: "medium", + }, + { + name: "a partial thinking map", + model: { + ...RESTRICTED_THINKING_MODEL, + thinkingLevelMap: { minimal: null, medium: null, xhigh: "extra-high", max: null }, + }, + optionIds: ["off", "low", "high", "xhigh"], + defaultThinkingOptionId: "high", + }, + { + name: "explicitly mapped extended levels", + model: { + ...RESTRICTED_THINKING_MODEL, + thinkingLevelMap: { xhigh: "extra-high", max: "maximum" }, + }, + optionIds: ["off", "minimal", "low", "medium", "high", "xhigh", "max"], + defaultThinkingOptionId: "medium", + }, + { + name: "only lower thinking levels", + model: { + ...RESTRICTED_THINKING_MODEL, + thinkingLevelMap: { medium: null, high: null }, + }, + optionIds: ["off", "minimal", "low"], + defaultThinkingOptionId: "low", + }, + { + name: "a non-reasoning model", + model: { ...RESTRICTED_THINKING_MODEL, reasoning: false }, + optionIds: undefined, + defaultThinkingOptionId: undefined, + }, + { + name: "no supported thinking levels", + model: { + ...RESTRICTED_THINKING_MODEL, + thinkingLevelMap: { + ...RESTRICTED_THINKING_MODEL.thinkingLevelMap, + low: null, + high: null, + max: null, + }, + }, + optionIds: [], + defaultThinkingOptionId: "off", + }, + ])( + "resolves Pi thinking options for $name", + async ({ model, optionIds, defaultThinkingOptionId }) => { + const pi = new FakePi(); + pi.queueSessionSetup((session) => { + session.models = [model]; + }); + const catalog = await createClient(pi).fetchCatalog({ + scope: "workspace", + cwd: "/workspace/project", + force: false, + }); + + expect(catalog.models).toHaveLength(1); + const thinkingOptions = catalog.models[0]?.thinkingOptions; + expect(thinkingOptions?.map((option) => option.id)).toEqual(optionIds); + expect(catalog.models[0]?.defaultThinkingOptionId).toBe(defaultThinkingOptionId); + expect( + thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(optionIds?.filter((id) => id === defaultThinkingOptionId)); + }, + ); + test("lists no draft features without starting a Pi session", async () => { const pi = new FakePi(); const client = createClient(pi); @@ -2312,10 +3181,11 @@ describe("PiRpcAgentClient", () => { test("rewinds conversation through the Pi tree navigation bridge", async () => { const { pi, session, events } = await createSession(); - pi.latestSession().capturedUserEntries = [ + pi.latestSession().treeUserEntries = [ { id: "entry-1", parentId: null, text: "first prompt" }, { id: "entry-3", parentId: "entry-2", text: "second prompt" }, ]; + pi.latestSession().contextUserEntries = pi.latestSession().treeUserEntries; await session.startTurn("first prompt"); pi.latestSession().finishTurn({ role: "assistant", content: [] }); @@ -2331,6 +3201,23 @@ describe("PiRpcAgentClient", () => { expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]); }); + test("rewinds a row whose Pi entry compaction summarized away", async () => { + const { pi, session } = await createSession(); + const fakeSession = pi.latestSession(); + // Compaction summarized entry-1, so the replayed branch starts at entry-3. + fakeSession.treeUserEntries = [ + { id: "entry-1", parentId: null, text: "first prompt" }, + { id: "entry-3", parentId: "entry-2", text: "second prompt" }, + ]; + fakeSession.contextUserEntries = [ + { id: "entry-3", parentId: "entry-2", text: "second prompt" }, + ]; + + await session.revertConversation?.({ messageId: "entry-1" }); + + expect(fakeSession.treeNavigationRequests).toEqual(["entry-1"]); + }); + test("injects MCP servers without replacing the Pi global MCP config", async () => { const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); @@ -2386,8 +3273,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--thinking", - "medium", "--mcp-config", actualLaunch.mcpConfigPath, "--extension", @@ -2445,6 +3330,102 @@ describe("PiRpcAgentClient", () => { ).rejects.toThrow(`Failed to parse Pi MCP config: ${configPath}`); }); + test("registers MCP servers through Pi's built-in MCP when pi-mcp-adapter is not loaded", async () => { + const pi = new FakePi(); + pi.queueCommands([ + { + name: "mcp", + source: "extension", + sourceInfo: { path: "builtin:mcp", source: "builtin" }, + }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { type: "stdio", command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }, + }), + ); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).not.toContain("--mcp-config"); + const extensionPath = actualLaunch.extensionPaths[0]!; + expect(await readRegisteredMcpServers(extensionPath)).toEqual({ + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1", + headers: { Authorization: "Bearer token" }, + }, + local: { command: "node", args: ["server.js"], env: { KEY: "`${x}`" } }, + }); + + await session.close(); + expect(existsSync(extensionPath)).toBe(false); + }); + + test("registers the resumed session's MCP servers through Pi's built-in MCP", async () => { + const pi = new FakePi(); + const client = createClient(pi); + const original = await client.createSession(createConfig()); + const persistence = original.describePersistence()!; + await original.close(); + + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + const session = await client.resumeSession(persistence, { + mcpServers: { + paseo: { type: "http", url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }, + }); + onTestFinished(() => session.close()); + + expect(session.capabilities.supportsMcpServers).toBe(true); + const actualLaunch = pi.recordedLaunches.at(-1)!; + expect(actualLaunch.session).toBe(persistence.nativeHandle); + expect(actualLaunch.argv).not.toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({ + paseo: { url: "http://127.0.0.1:7777/mcp/agents?callerAgentId=agent-2" }, + }); + }); + + test("uses pi-mcp-adapter when it replaces Pi's built-in MCP", async () => { + const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-agent-")); + onTestFinished(() => rmSync(agentDir, { recursive: true, force: true })); + const pi = new FakePi(); + pi.queueCommands([ + { name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }, + { name: "mcp:1", source: "extension", sourceInfo: { source: "npm:pi-mcp-adapter" } }, + ]); + const session = await createClient(pi).createSession( + createConfig({ + mcpServers: { paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" } }, + }), + { env: { PI_CODING_AGENT_DIR: agentDir } }, + ); + onTestFinished(() => session.close()); + + const actualLaunch = pi.recordedLaunches[1]!; + expect(actualLaunch.argv).toContain("--mcp-config"); + expect(await readRegisteredMcpServers(actualLaunch.extensionPaths[0]!)).toEqual({}); + }); + + test.each([ + { name: "legacy", config: { type: "sse" as const, url: "https://example.com/sse" } }, + { name: "foo.bar", config: { type: "http" as const, url: "https://example.com/mcp" } }, + ])("rejects $name, which Pi's built-in MCP cannot register", async ({ name, config }) => { + const pi = new FakePi(); + pi.queueCommands([{ name: "mcp", source: "extension", sourceInfo: { path: "builtin:mcp" } }]); + + await expect( + createClient(pi).createSession(createConfig({ mcpServers: { [name]: config } })), + ).rejects.toThrow(name); + expect(pi.recordedLaunches).toHaveLength(1); + }); + test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => { const pi = new FakePi(); pi.queueCommands([]); @@ -2468,8 +3449,6 @@ describe("PiRpcAgentClient", () => { "pi", "--mode", "rpc", - "--thinking", - "medium", "--extension", actualLaunch.extensionPaths[0], ]); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index dc5c84375e..146b56b36f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -1,3 +1,4 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import { randomUUID } from "node:crypto"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -24,6 +25,7 @@ import { type AgentRunOptions, type AgentRunResult, type AgentRuntimeInfo, + type AgentSelectOption, type AgentSession, type AgentSessionConfig, type AgentSlashCommand, @@ -38,7 +40,6 @@ import { type ListImportableSessionsOptions, type ProviderCatalog, type ProviderRefreshContext, - type ToolCallDetail, } from "../../agent-sdk-types.js"; import { importSessionFromPersistence } from "../../provider-session-import.js"; import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js"; @@ -65,6 +66,11 @@ import { } from "./history-mapper.js"; import { materializeProviderImage } from "../provider-image-output.js"; import { PiCliRuntime } from "./cli-runtime.js"; +import { + createPiExtensionHost, + piExtensionRuntimeBridge, + type PiExtensionEventOutput, +} from "./extensions/index.js"; import { revertPiConversation } from "./rewind.js"; import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js"; import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js"; @@ -83,7 +89,6 @@ import { mapToolDetail, parseToolArgs, parseToolResult, - resolveToolCallName, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -93,17 +98,15 @@ const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree"; const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; +const PASEO_PI_REWIND_ENTRY_TYPE = "paseo_rewind"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; const PASEO_PI_SUBMITTED_USER_ENTRY_MARKER = "PASEO_SUBMITTED_USER_ENTRY"; const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT"; const DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS = 30_000; const DEFAULT_PI_RPC_TIMEOUT_MS = 60_000; const QUESTION_RESPONSE_HEADER = "Response"; -const QUESTION_COMMENT_HEADER = "Comment"; -const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; -const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; -export const PiProviderParamsSchema = z +export const PiProviderOptionsSchema = z .object({ sessionDir: z.string().min(1).optional(), rpcTimeoutMs: z.number().int().positive().default(DEFAULT_PI_RPC_TIMEOUT_MS), @@ -111,8 +114,6 @@ export const PiProviderParamsSchema = z }) .strict(); -type PiProviderParams = z.infer; - const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [ { name: "compact", @@ -171,12 +172,11 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{ id: PiThinkingLevel; label: string; description: string; - isDefault?: boolean; }> = [ { id: "off", label: "Off", description: "No extra reasoning" }, { id: "minimal", label: "Minimal", description: "Light reasoning" }, { id: "low", label: "Low", description: "Faster reasoning" }, - { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { id: "medium", label: "Medium", description: "Balanced reasoning" }, { id: "high", label: "High", description: "Deeper reasoning" }, { id: "xhigh", label: "XHigh", description: "Very deep reasoning" }, { id: "max", label: "Max", description: "Extreme reasoning" }, @@ -185,7 +185,6 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{ export interface PiRpcAgentClientOptions { logger: Logger; runtimeSettings?: ProviderRuntimeSettings; - providerParams?: unknown; runtime?: PiRuntime; usagePollScheduler?: PiUsagePollScheduler; } @@ -261,13 +260,17 @@ interface PiMcpServerConfig { env?: Record; url?: string; headers?: Record; - auth?: false; - oauth?: false; } -interface PiMcpConfigFile { - path: string; - cleanup: () => void; +// Pi's built-in MCP extension takes servers from Paseo's extension; pi-mcp-adapter replaces it +// and takes them from a --mcp-config file. +type PiMcpInjection = + | { kind: "builtin"; servers: Record } + | { kind: "adapter"; configFile: PiTempFile }; + +interface PiPaseoExtensionOptions { + systemPrompt?: string; + mcpServers?: Record; } interface PiTempFile { @@ -285,22 +288,9 @@ interface PendingExtensionResult { timer: NodeJS.Timeout; } -interface ActiveAskUserDialog { - allowComment: boolean; - allowFreeform: boolean; - allowMultiple: boolean; -} - -interface PendingCombinedAskUserResponse { - comment: string; - freeform: string | null; -} - interface ExtensionUiMappingOptions { provider?: AgentProvider; label?: string; - combineOptionalComment?: boolean; - allowFreeform?: boolean; } interface PiSlashCommandInvocation { @@ -373,21 +363,6 @@ function parseAutoCompactMode(value: string | undefined): AutoCompactMode { return "unknown"; } -function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { - const mappedOption = { - id: option.id, - label: option.label, - description: option.description, - }; - if (option.isDefault) { - return { - ...mappedOption, - isDefault: true, - }; - } - return mappedOption; -} - function piModelSupportsImageInput(model: PiModel | null | undefined): boolean { return model?.input?.includes("image") === true; } @@ -514,16 +489,15 @@ function buildResumeStartInput(input: { resumeConfig: PiResumeConfig; sessionFile: string; launchContext: AgentLaunchContext | undefined; - mcpConfig: PiMcpConfigFile | null; + mcpConfigFile: PiTempFile | null; paseoExtension: PiTempFile | null; }): PiStartSessionInput { return { cwd: input.resumeConfig.cwd, env: input.launchContext?.env, session: input.sessionFile, - model: input.resumeConfig.model, thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined, - mcpConfigPath: input.mcpConfig?.path, + mcpConfigPath: input.mcpConfigFile?.path, extensionPaths: input.paseoExtension ? [input.paseoExtension.path] : undefined, }; } @@ -540,11 +514,36 @@ function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { return { url: config.url, ...(config.headers ? { headers: config.headers } : {}), - auth: false, - oauth: false, }; } +function toPiMcpAdapterConfig( + config: McpServerConfig, +): PiMcpServerConfig & { auth?: false; oauth?: false } { + const piConfig = toPiMcpConfig(config); + return config.type === "stdio" ? piConfig : { ...piConfig, auth: false, oauth: false }; +} + +function toPiBuiltinMcpServers( + servers: Record, +): Record { + const piServers: Record = {}; + for (const [name, config] of Object.entries(servers)) { + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + throw new Error( + `Pi's built-in MCP cannot register server "${name}": names may only contain letters, digits, "_", and "-".`, + ); + } + if (config.type === "sse") { + throw new Error( + `Pi's built-in MCP cannot register SSE server "${name}": use streamable HTTP or load pi-mcp-adapter.`, + ); + } + piServers[name] = toPiMcpConfig(config); + } + return piServers; +} + function resolvePiAgentDir(env: Record | undefined): string { const configured = env?.PI_CODING_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim(); if (!configured) { @@ -585,7 +584,7 @@ function createPiMcpConfigFile( options?: { piGlobalConfigEnv?: Record; }, -): PiMcpConfigFile { +): PiTempFile { const globalConfig = options?.piGlobalConfigEnv ? readPiGlobalMcpConfig(options.piGlobalConfigEnv) : {}; @@ -597,7 +596,7 @@ function createPiMcpConfigFile( } const mcpServers: Record = { ...configuredServers }; for (const [name, serverConfig] of Object.entries(servers)) { - mcpServers[name] = toPiMcpConfig(serverConfig); + mcpServers[name] = toPiMcpAdapterConfig(serverConfig); } const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); @@ -614,7 +613,10 @@ function createPiMcpConfigFile( }; } -function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { +function createPiPaseoExtensionFile({ + systemPrompt, + mcpServers = {}, +}: PiPaseoExtensionOptions): PiTempFile { const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-")); const filePath = join(dir, "paseo-integration.mjs"); writeFileSync( @@ -637,9 +639,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { .join("\\n\\n"); } - function getCapturedUserEntries(ctx) { - return ctx.sessionManager - .getEntries() + function toCapturedUserEntries(entries) { + return entries .filter((entry) => entry.type === "message" && entry.message?.role === "user") .map(toCapturedUserEntry); } @@ -655,7 +656,14 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { function emitEntryCapture(ctx, reason, requestId) { ctx.ui.notify( "${PASEO_PI_ENTRY_CAPTURE_MARKER} " + - JSON.stringify({ reason, requestId, entries: getCapturedUserEntries(ctx) }), + JSON.stringify({ + reason, + requestId, + // Rewind targets: rows can still show entries that a rewind or compaction left off the branch. + treeEntries: toCapturedUserEntries(ctx.sessionManager.getEntries()), + // The entries getMessages() replays, so the nth one is the nth replayed user message. + contextEntries: toCapturedUserEntries(ctx.sessionManager.buildContextEntries()), + }), "info", ); } @@ -668,7 +676,11 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { } export default function paseoIntegration(pi) { + for (const [name, config] of Object.entries(${JSON.stringify(mcpServers)})) { + pi.registerMcpServer(name, config); + } const submittedUserMessages = []; + ${piExtensionRuntimeBridge} function emitSubmittedUserEntries(ctx) { const entries = ctx.sessionManager.getEntries(); @@ -735,6 +747,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { const payload = decodePayload(args.trim()); try { const result = await ctx.navigateTree(payload.targetId, { summarize: false }); + // Pi reopens a session at its last entry, so record the rewind on the new branch to keep it. + pi.appendEntry("${PASEO_PI_REWIND_ENTRY_TYPE}", { targetId: payload.targetId }); emitEntryCapture(ctx, "tree_navigation"); emitCommandResult(ctx, payload.requestId, { ok: true, result }); } catch (error) { @@ -746,7 +760,8 @@ function createPiPaseoExtensionFile(systemPrompt?: string): PiTempFile { }); } `.trimStart(), - "utf8", + // MCP server headers and env can carry credentials. + { encoding: "utf8", mode: 0o600 }, ); return { path: filePath, @@ -776,6 +791,14 @@ function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { return JSON.stringify(command.sourceInfo).includes("pi-mcp-adapter"); } +function isPiBuiltinMcpCommand(command: PiRpcSlashCommand): boolean { + return ( + command.source === "extension" && + command.name === "mcp" && + command.sourceInfo?.path === "builtin:mcp" + ); +} + function withPiCapabilities(supportsMcpServers: boolean): AgentCapabilityFlags { return { ...PI_CAPABILITIES, @@ -791,14 +814,6 @@ function isPiRequestAbortError(error: unknown): boolean { return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error)); } -function resolveThinkingOptionId( - cachedThinkingOptionId: string | null, - sessionThinkingLevel: PiThinkingLevel, -): PiThinkingLevel | null { - const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; - return normalizePiThinkingOption(currentThinking); -} - function modelToId(model: PiModel | null | undefined): string | null { return model?.provider && model.id ? `${model.provider}/${model.id}` : null; } @@ -855,6 +870,13 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function toNotificationLevel(value: unknown): "info" | "warning" | "error" { + if (value === "info" || value === "warning" || value === "error") { + return value; + } + return "info"; +} + function parseExtensionMarkerPayload( message: string, marker: string, @@ -895,21 +917,6 @@ function parseCapturedEntries(value: unknown): PiCapturedEntry[] { }); } -function optionalBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - -function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null { - if (toolName !== "ask_user" || !isRecord(args)) { - return null; - } - return { - allowComment: optionalBoolean(args.allowComment) ?? false, - allowFreeform: optionalBoolean(args.allowFreeform) ?? true, - allowMultiple: optionalBoolean(args.allowMultiple) ?? false, - }; -} - function isOptionalInputPlaceholder(placeholder: string | undefined): boolean { return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); } @@ -930,10 +937,6 @@ function readStringArray(value: unknown): string[] { : []; } -function isPiAskUserFreeformOption(option: string): boolean { - return option === PI_ASK_USER_FREEFORM_SENTINEL; -} - function mapExtensionUiRequestToPermission( event: Extract, options: ExtensionUiMappingOptions = {}, @@ -943,15 +946,6 @@ function mapExtensionUiRequestToPermission( switch (event.method) { case "select": { const selectOptions = readStringArray(event.options); - if (options.combineOptionalComment) { - return buildCombinedAskUserQuestionPermission(event, { - provider, - label, - question: optionalString(event.title) ?? "Select an option", - options: selectOptions, - allowFreeform: options.allowFreeform === true, - }); - } return buildExtensionUiQuestionPermission(event, { provider, label, @@ -1011,6 +1005,8 @@ function isProcessExitEvent( function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent { switch (event.type) { + case "thinking_level_changed": + return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null; case "agent_start": case "turn_start": case "message_start": @@ -1069,63 +1065,6 @@ function buildExtensionUiQuestionPermission( }; } -function buildCombinedAskUserQuestionPermission( - event: Extract, - input: { - provider: AgentProvider; - label: string; - question: string; - options: string[]; - allowFreeform: boolean; - }, -): AgentPermissionRequest { - const visibleOptions = input.options.filter((option) => !isPiAskUserFreeformOption(option)); - const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length; - return { - id: event.id, - provider: input.provider, - name: `${input.label} ask_user`, - kind: "question", - title: input.question, - input: { - questions: [ - { - question: input.question, - header: QUESTION_RESPONSE_HEADER, - options: visibleOptions.map((label) => ({ label })), - multiSelect: false, - ...(allowOther ? { allowOther: true } : {}), - }, - { - question: "Optional comment", - header: QUESTION_COMMENT_HEADER, - options: [], - multiSelect: false, - placeholder: "Optional comment (press Enter to skip)...", - allowEmpty: true, - }, - ], - }, - metadata: { - extensionUiMethod: event.method, - answerHeader: QUESTION_RESPONSE_HEADER, - commentHeader: QUESTION_COMMENT_HEADER, - combinedAskUser: COMBINED_ASK_USER_METADATA, - selectOptions: visibleOptions, - ...(allowOther ? { freeformSentinel: PI_ASK_USER_FREEFORM_SENTINEL } : {}), - }, - }; -} - -function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null { - const answers = isRecord(input?.answers) ? input.answers : null; - if (!answers) { - return null; - } - const answer = answers[header]; - return typeof answer === "string" ? answer : null; -} - function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { const answers = isRecord(input?.answers) ? input.answers : null; if (!answers) { @@ -1135,39 +1074,6 @@ function firstPermissionAnswer(input: AgentMetadata | undefined): string | null return typeof first === "string" ? first : null; } -function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean { - return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA; -} - -function buildCombinedAskUserSelectionResponse( - request: AgentPermissionRequest, - response: AgentPermissionResponse, -): { - uiResponse: { value?: string; cancelled?: boolean }; - pendingResponse: PendingCombinedAskUserResponse | null; -} { - if (response.behavior === "deny") { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER); - if (answer === null) { - return { uiResponse: { cancelled: true }, pendingResponse: null }; - } - - const selectOptions = readStringArray(request.metadata?.selectOptions); - const freeformSentinel = optionalString(request.metadata?.freeformSentinel); - const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer); - const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? ""; - return { - uiResponse: { value: isFreeform ? freeformSentinel : answer }, - pendingResponse: { - comment, - freeform: isFreeform ? answer : null, - }, - }; -} - function buildExtensionUiResponse( request: AgentPermissionRequest, response: AgentPermissionResponse, @@ -1188,7 +1094,50 @@ function buildExtensionUiResponse( return { value: answer }; } +function resolvePiThinkingConfig( + model: PiModel, +): Pick { + if (!model.reasoning) { + return { thinkingOptions: undefined, defaultThinkingOptionId: undefined }; + } + + const supportedOptions = PI_THINKING_OPTIONS.filter((option) => { + const mapped = model.thinkingLevelMap?.[option.id]; + if (mapped === null) { + return false; + } + if (option.id === "xhigh" || option.id === "max") { + return mapped !== undefined; + } + return true; + }); + const defaultIndex = PI_THINKING_OPTIONS.findIndex( + (option) => option.id === DEFAULT_PI_THINKING_LEVEL, + ); + // Pi clamps upward first, then falls back to the highest remaining lower level. + const higherDefault = supportedOptions.find( + (option) => PI_THINKING_OPTIONS.indexOf(option) >= defaultIndex, + ); + const defaultOption = higherDefault ?? supportedOptions.at(-1); + const defaultThinkingOptionId = defaultOption?.id ?? "off"; + return { + thinkingOptions: supportedOptions.map((option) => { + const mappedOption: AgentSelectOption = { + id: option.id, + label: option.label, + description: option.description, + }; + if (option.id === defaultThinkingOptionId) { + mappedOption.isDefault = true; + } + return mappedOption; + }), + defaultThinkingOptionId, + }; +} + function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefinition { + const { thinkingOptions, defaultThinkingOptionId } = resolvePiThinkingConfig(model); return { provider, id: `${model.provider}/${model.id}`, @@ -1198,8 +1147,8 @@ function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefiniti provider: model.provider, modelId: model.id, }, - thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined, - defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined, + thinkingOptions, + defaultThinkingOptionId, }; } @@ -1224,8 +1173,7 @@ export class PiRpcAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); - private activeAskUserDialog: ActiveAskUserDialog | null = null; - private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; + private readonly extensionHost: ReturnType; private activeTurnId: string | null = null; private activeClientMessageId: string | null = null; private activeAssistantMessageId: string | null = null; @@ -1237,10 +1185,9 @@ export class PiRpcAgentSession implements AgentSession { private activePromptRequestId: string | null = null; private readonly pendingPromptResults = new Map(); private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = []; - private lastKnownThinkingOptionId: string | null; currentLeafOverrideId: string | null | undefined; - private readonly capturedUserEntries: PiCapturedEntry[] = []; - private readonly capturedUserEntriesById = new Map(); + private readonly contextUserEntries: PiCapturedEntry[] = []; + private readonly treeUserEntriesById = new Map(); private readonly pendingExtensionResults = new Map(); private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null; private outOfBandCompactionStarted = false; @@ -1251,26 +1198,40 @@ export class PiRpcAgentSession implements AgentSession { private readonly logger: Logger; private readonly usagePoller: PiUsagePoller; private closed = false; - // Pi reports an aborted OpenAI Responses stream before the abort RPC resolves. - // Keep the turn active until that RPC acknowledges the user-requested cancellation. - private interruptingTurnId: string | null = null; - private lastInterruptedTurnId: string | null = null; - private interruptedTerminalError: { turnId: string; error: string } | null = null; + private readonly closeController = new AbortController(); + private readonly pendingExtensionHydrations = new Set>(); + // Pi publishes the terminal before acknowledging abort. Autonomous runs have no + // turn ID; retain their errors too until the cancellation request settles. + private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null; + + private readonly usageSessionKey = randomUUID(); + + usageSession() { + const env = this.runtimeSession.environment; + if (this.closed) return null; + return { + provider: "pi", + model: modelToId(this.state.model) ?? undefined, + env, + sessionKey: this.usageSessionKey, + }; + } constructor(options: PiRpcAgentSessionOptions) { this.runtimeSession = options.runtimeSession; this.config = options.config; this.state = options.initialState; + this.config.thinkingOptionId = this.state.thinkingLevel; this.capabilities = options.capabilities; this.provider = PI_PROVIDER; this.currentModeId = options.currentModeId ?? null; this.cleanup = options.cleanup; - this.lastKnownThinkingOptionId = - normalizePiThinkingOption(options.config.thinkingOptionId) ?? - this.state.thinkingLevel ?? - null; this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS; this.logger = options.logger; + this.extensionHost = createPiExtensionHost(this.logger); + this.extensionHost.follow((event) => { + if (!this.closeController.signal.aborted) this.emit(event); + }); this.usagePoller = new PiUsagePoller({ scheduler: options.usagePollScheduler, readStats: () => this.runtimeSession.getSessionStats(), @@ -1322,7 +1283,6 @@ export class PiRpcAgentSession implements AgentSession { const turnId = randomUUID(); this.activeTurnId = turnId; this.usagePoller.startTurn(); - this.lastInterruptedTurnId = null; this.activeClientMessageId = options?.clientMessageId ?? null; this.activeAssistantMessageId = null; this.activeTurnStarted = false; @@ -1455,7 +1415,11 @@ export class PiRpcAgentSession implements AgentSession { yield* streamPiHistory( this.provider, await this.runtimeSession.getMessages(), - this.capturedUserEntries, + this.contextUserEntries, + {}, + // At most eight 2 MiB child files per replay; later cards retain their summaries. + createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024), + this.closeController.signal, ); } @@ -1465,10 +1429,7 @@ export class PiRpcAgentSession implements AgentSession { provider: this.provider, sessionId: this.state.sessionId, model: modelToId(this.state.model), - thinkingOptionId: resolveThinkingOptionId( - this.lastKnownThinkingOptionId, - this.state.thinkingLevel, - ), + thinkingOptionId: this.state.thinkingLevel, modeId: this.currentModeId, }; } @@ -1496,15 +1457,11 @@ export class PiRpcAgentSession implements AgentSession { } this.pendingExtensionUiRequests.delete(requestId); - if (isCombinedAskUserPermission(request)) { - const combined = buildCombinedAskUserSelectionResponse(request, response); - this.pendingCombinedAskUserResponse = combined.pendingResponse; - this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse); - } else { - this.runtimeSession.respondToExtensionUiRequest( - requestId, - buildExtensionUiResponse(request, response), - ); + const mapped = this.extensionHost.respondToPermission(request, response); + for (const reply of mapped?.responses ?? [ + { id: requestId, response: buildExtensionUiResponse(request, response) }, + ]) { + this.runtimeSession.respondToExtensionUiRequest(reply.id, reply.response); } this.emit({ type: "permission_resolved", @@ -1530,11 +1487,10 @@ export class PiRpcAgentSession implements AgentSession { } async interrupt(): Promise { - const turnId = this.activeTurnId; - if (turnId) { - this.interruptingTurnId = turnId; - this.lastInterruptedTurnId = turnId; - } + const turnId = this.activeTurnId ?? undefined; + const interruption: typeof this.interruptingTurn = + this.activeTurnId || this.activeTurnStarted ? { turnId, error: null } : null; + this.interruptingTurn = interruption; try { try { await this.runtimeSession.clearQueue(); @@ -1547,12 +1503,11 @@ export class PiRpcAgentSession implements AgentSession { } await this.runtimeSession.abort(); } catch (error) { - if (this.interruptingTurnId === turnId) { - this.interruptingTurnId = null; + const terminalError = this.interruptingTurn === interruption ? interruption?.error : null; + if (this.interruptingTurn === interruption) { + this.interruptingTurn = null; } - if (this.interruptedTerminalError?.turnId === turnId) { - const terminalError = this.interruptedTerminalError; - this.interruptedTerminalError = null; + if (terminalError) { this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -1566,12 +1521,17 @@ export class PiRpcAgentSession implements AgentSession { type: "turn_failed", provider: this.provider, turnId, - error: terminalError.error, + error: terminalError, }); } throw error; } - if (turnId && this.activeTurnId === turnId) { + if ( + interruption && + this.interruptingTurn === interruption && + (this.activeTurnId || this.activeTurnStarted) && + (this.activeTurnId ?? undefined) === turnId + ) { this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -1588,11 +1548,8 @@ export class PiRpcAgentSession implements AgentSession { turnId, }); } - if (this.interruptingTurnId === turnId) { - this.interruptingTurnId = null; - } - if (this.interruptedTerminalError?.turnId === turnId) { - this.interruptedTerminalError = null; + if (this.interruptingTurn === interruption) { + this.interruptingTurn = null; } } @@ -1602,7 +1559,7 @@ export class PiRpcAgentSession implements AgentSession { } await this.refreshState().catch(() => undefined); await this.requestEntryCapture("rewind"); - const targetEntry = this.capturedUserEntriesById.get(input.messageId); + const targetEntry = this.treeUserEntriesById.get(input.messageId); if (!targetEntry) { throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`); } @@ -1629,10 +1586,13 @@ export class PiRpcAgentSession implements AgentSession { return; } this.closed = true; + this.closeController.abort(); + this.extensionHost.close(); this.usagePoller.close(); try { await this.runtimeSession.close(); } finally { + await Promise.all(this.pendingExtensionHydrations); this.rejectAllExtensionResults(new Error("Pi session closed")); this.cleanup?.(); } @@ -1685,22 +1645,14 @@ export class PiRpcAgentSession implements AgentSession { } const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); - this.state = { - ...this.state, - model, - }; + await this.refreshState(); this.config.model = `${model.provider}/${model.id}`; } async setThinkingOption(thinkingOptionId: string | null): Promise { const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL; await this.runtimeSession.setThinkingLevel(thinkingLevel); - this.lastKnownThinkingOptionId = thinkingLevel; - this.config.thinkingOptionId = thinkingLevel; - this.state = { - ...this.state, - thinkingLevel, - }; + await this.refreshState(); } private emit(event: AgentStreamEvent): void { @@ -1709,6 +1661,24 @@ export class PiRpcAgentSession implements AgentSession { } } + private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void { + if (!output || this.closed) return; + for (const event of output.events) { + this.emit(event.type === "timeline" ? { ...event, turnId } : event); + } + const pending = output.hydration + .then((events) => { + if (this.closeController.signal.aborted) return; + for (const event of events) this.emit(event); + return undefined; + }) + .catch((error) => { + this.logger.warn({ err: error }, "Pi extension hydration failed"); + }); + this.pendingExtensionHydrations.add(pending); + void pending.then(() => this.pendingExtensionHydrations.delete(pending)); + } + private currentTurnIdForEvent(): string | undefined { return this.activeTurnId ?? undefined; } @@ -1956,11 +1926,14 @@ export class PiRpcAgentSession implements AgentSession { } } - private recordCapturedUserEntries(entries: PiCapturedEntry[]): void { - this.capturedUserEntries.splice(0, this.capturedUserEntries.length, ...entries); - this.capturedUserEntriesById.clear(); - for (const entry of entries) { - this.capturedUserEntriesById.set(entry.id, entry); + private recordCapturedUserEntries(input: { + treeEntries: PiCapturedEntry[]; + contextEntries: PiCapturedEntry[]; + }): void { + this.contextUserEntries.splice(0, this.contextUserEntries.length, ...input.contextEntries); + this.treeUserEntriesById.clear(); + for (const entry of input.treeEntries) { + this.treeUserEntriesById.set(entry.id, entry); } } @@ -1996,10 +1969,12 @@ export class PiRpcAgentSession implements AgentSession { if (!payload) { return false; } - const entries = parseCapturedEntries(payload.entries); - this.recordCapturedUserEntries(entries); + this.recordCapturedUserEntries({ + treeEntries: parseCapturedEntries(payload.treeEntries), + contextEntries: parseCapturedEntries(payload.contextEntries), + }); if (typeof payload.requestId === "string") { - this.resolveExtensionResult(payload.requestId, entries); + this.resolveExtensionResult(payload.requestId, undefined); } return true; } @@ -2026,6 +2001,11 @@ export class PiRpcAgentSession implements AgentSession { ): void { const message = optionalString(event.message); if (event.method === "notify" && message) { + const extensionMapping = this.extensionHost.mapRuntimeNotification(message); + if (extensionMapping) { + this.emitExtensionOutput(extensionMapping, this.currentTurnIdForEvent()); + return; + } if ( this.handleSubmittedUserEntryMarker(message) || this.handleEntryCaptureMarker(message) || @@ -2033,23 +2013,29 @@ export class PiRpcAgentSession implements AgentSession { ) { return; } - this.bufferNoTurnOutput(message); - } - - if (this.respondToCombinedAskUserFollowUp(event)) { + this.emit({ + type: "timeline", + provider: this.provider, + turnId: this.currentTurnIdForEvent(), + item: { + type: "notification", + level: toNotificationLevel(event.notifyType), + message, + }, + }); return; } - const shouldCombineOptionalComment = - event.method === "select" && - this.activeAskUserDialog?.allowComment === true && - this.activeAskUserDialog.allowMultiple === false; - const request = mapExtensionUiRequestToPermission(event, { - provider: this.provider, - label: "Pi", - combineOptionalComment: shouldCombineOptionalComment, - allowFreeform: this.activeAskUserDialog?.allowFreeform, - }); + const mapped = this.extensionHost.mapDialog(event, this.provider); + if (mapped?.type === "response") { + this.runtimeSession.respondToExtensionUiRequest(event.id, mapped.response); + return; + } + if (mapped?.type === "deferred") return; + const request = + mapped?.type === "permission" + ? mapped.request + : mapExtensionUiRequestToPermission(event, { provider: this.provider, label: "Pi" }); if (!request) { return; } @@ -2063,33 +2049,6 @@ export class PiRpcAgentSession implements AgentSession { }); } - private respondToCombinedAskUserFollowUp( - event: Extract, - ): boolean { - const pending = this.pendingCombinedAskUserResponse; - if (!pending || event.method !== "input") { - return false; - } - - const placeholder = optionalString(event.placeholder); - if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = { - ...pending, - freeform: null, - }; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform }); - return true; - } - - if (isOptionalInputPlaceholder(placeholder)) { - this.pendingCombinedAskUserResponse = null; - this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment }); - return true; - } - - return false; - } - private handleCommandOutput(textValue: unknown): void { if (!this.activeTurnId) { return; @@ -2150,10 +2109,11 @@ export class PiRpcAgentSession implements AgentSession { private handleProcessExit(error: string): void { this.rejectAllExtensionResults(new Error(error)); - if (!this.activeTurnId) { + this.interruptingTurn = null; + if (!this.activeTurnId && !this.activeTurnStarted) { return; } - const turnId = this.activeTurnId; + const turnId = this.activeTurnId ?? undefined; this.usagePoller.stopTurn(); this.activeTurnId = null; this.activeClientMessageId = null; @@ -2178,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession { } switch (event.type) { + case "thinking_level_changed": + this.state = { ...this.state, thinkingLevel: event.level }; + this.config.thinkingOptionId = event.level; + this.emit({ + type: "thinking_option_changed", + provider: this.provider, + thinkingOptionId: event.level, + }); + return; case "agent_start": this.activeTurnStarted = true; this.clearNoTurnBuffers(); @@ -2212,7 +2181,25 @@ export class PiRpcAgentSession implements AgentSession { case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); - this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args); + const request = this.extensionHost.onToolStart( + { + callId: event.toolCallId, + toolName: event.toolName, + args: toolCall.args, + status: "running", + result: null, + }, + this.provider, + ); + if (request) { + this.pendingExtensionUiRequests.set(request.id, request); + this.emit({ + type: "permission_requested", + provider: this.provider, + request, + turnId: this.currentTurnIdForEvent(), + }); + } this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); return; } @@ -2273,6 +2260,9 @@ export class PiRpcAgentSession implements AgentSession { event: Extract; turnId: string | undefined; }): void { + if (!this.activeTurnId && !this.activeTurnStarted) { + return; + } if (event.type === "agent_end") { // COMPAT(piAgentSettled): added in v0.5.0, remove after 2027-02-21 once the Pi // floor emits agent_settled and willRetry. @@ -2280,14 +2270,10 @@ export class PiRpcAgentSession implements AgentSession { this.completeTurn(turnId, event.messages ?? []); return; } - if (this.activeTurnId) { - this.pendingSettledMessages = event.messages ?? []; - } + this.pendingSettledMessages = event.messages ?? []; return; } - if (this.activeTurnId) { - this.completeTurn(turnId, this.pendingSettledMessages ?? []); - } + this.completeTurn(turnId, this.pendingSettledMessages ?? []); } private handleToolExecutionEnd( @@ -2297,14 +2283,16 @@ export class PiRpcAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); - if (event.toolName === "ask_user") { - this.activeAskUserDialog = null; - this.pendingCombinedAskUserResponse = null; - } - const result = parseToolResult(event.result); const error = event.isError ? event.result : null; const status = event.isError ? "failed" : "completed"; + this.extensionHost.onToolEnd({ + callId: event.toolCallId, + toolName: toolCall.toolName, + args: toolCall.args, + status, + result, + }); this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); } @@ -2389,16 +2377,24 @@ export class PiRpcAgentSession implements AgentSession { return; } if (event.message.role === "custom") { + const customMapping = this.extensionHost.mapCustomMessage(event.message); + this.emitExtensionOutput(customMapping, turnId); const text = getUserMessageText(event.message.content); - if (text) { + if (event.message.display !== false && text) { this.emit({ type: "timeline", provider: this.provider, turnId, - item: { type: "assistant_message", text }, + item: mapCustomMessageToToolCall( + event.message, + text, + `${this.provider}-custom-${randomUUID()}`, + ), }); } - this.completeTurn(turnId, []); + if (!this.activeTurnStarted) { + this.completeTurn(turnId, []); + } return; } } @@ -2411,14 +2407,21 @@ export class PiRpcAgentSession implements AgentSession { error: unknown, ): boolean { const turnId = this.currentTurnIdForEvent(); - const detail = this.mapToolDetail(toolCallId, toolCall, result); + const mapping = this.extensionHost.mapToolCall({ + callId: toolCallId, + toolName: toolCall.toolName, + args: toolCall.args, + status, + result, + }); + const detail = mapping?.detail ?? mapToolDetail(toolCall, result); if (!detail) { return false; } const baseItem = { type: "tool_call" as const, callId: toolCallId, - name: resolveToolCallName(toolCall, result), + name: mapping?.name ?? toolCall.toolName, detail, }; const item = @@ -2429,32 +2432,21 @@ export class PiRpcAgentSession implements AgentSession { turnId, item, }); + this.emitExtensionOutput(mapping, turnId); return true; } - private mapToolDetail( - _toolCallId: string, - toolCall: PiTrackedToolCall, - result: PiToolResult, - ): ToolCallDetail | null { - return mapToolDetail(toolCall, result); - } - private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { - if (turnId && this.interruptingTurnId === turnId && isPiAbortedTerminalResponse(messages)) { - this.interruptedTerminalError = { - turnId, - error: latestPiErrorMessage(messages) ?? "Pi turn failed", - }; - return; - } + const errorMessage = latestPiErrorMessage(messages); if ( - isPiAbortedTerminalResponse(messages) && - (turnId === this.lastInterruptedTurnId || (!turnId && this.lastInterruptedTurnId !== null)) + this.interruptingTurn && + this.interruptingTurn.turnId === turnId && + (errorMessage || isPiAbortedTerminalResponse(messages)) ) { - this.lastInterruptedTurnId = null; + this.interruptingTurn.error = errorMessage ?? "Pi turn failed"; return; } + this.interruptingTurn = null; this.activeTurnId = null; this.activeClientMessageId = null; this.activeAssistantMessageId = null; @@ -2463,7 +2455,6 @@ export class PiRpcAgentSession implements AgentSession { this.pendingSettledMessages = null; this.pendingSteerSubmissions.length = 0; this.clearNoTurnBuffers(); - const errorMessage = latestPiErrorMessage(messages); if (typeof errorMessage === "string" && errorMessage.length > 0) { this.usagePoller.stopTurn(); this.emit({ @@ -2485,6 +2476,7 @@ export class PiRpcAgentSession implements AgentSession { private async refreshState(): Promise { this.state = await this.runtimeSession.getState(); + this.config.thinkingOptionId = this.state.thinkingLevel; } private async refreshAfterTurn(finalUsage: Promise): Promise { @@ -2498,8 +2490,7 @@ export class PiRpcAgentClient implements AgentClient { private readonly logger: Logger; private readonly runtimeSettings?: ProviderRuntimeSettings; - private readonly providerParams: PiProviderParams; - private readonly runtime: PiRuntime; + private readonly runtime?: PiRuntime; private readonly usagePollScheduler?: PiUsagePollScheduler; constructor(options: PiRpcAgentClientOptions) { @@ -2507,10 +2498,7 @@ export class PiRpcAgentClient implements AgentClient { this.capabilities = capabilitiesForClient(); this.logger = options.logger; this.runtimeSettings = options.runtimeSettings; - this.providerParams = PiProviderParamsSchema.parse(options.providerParams ?? {}); - this.runtime = - options.runtime ?? - createRuntime(options.logger, options.runtimeSettings, this.providerParams.rpcTimeoutMs); + this.runtime = options.runtime; this.usagePollScheduler = options.usagePollScheduler; } @@ -2518,28 +2506,31 @@ export class PiRpcAgentClient implements AgentClient { config: AgentSessionConfig, launchContext?: AgentLaunchContext, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(config.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers, mcpEnv); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), - ); + const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv, runtime); + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt), + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: config.cwd, model: config.model, - thinkingOptionId: - normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, + thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined, noSession: config.internal === true, env: launchContext?.env, - mcpConfigPath: mcpConfig?.path, + mcpConfigPath: mcpConfigFile?.path, extensionPaths: paseoExtension ? [paseoExtension.path] : undefined, }); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2548,15 +2539,15 @@ export class PiRpcAgentClient implements AgentClient { runtimeSession, config, initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } @@ -2575,60 +2566,102 @@ export class PiRpcAgentClient implements AgentClient { const persistenceMetadata = parsePersistenceMetadata(handle.metadata); const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider); + const providerOptions = PiProviderOptionsSchema.parse( + resumeConfig.config.providerOptions ?? {}, + ); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); const mcpEnv = { ...this.runtimeSettings?.env, ...launchContext?.env, }; - const mcpConfig = await this.prepareMcpConfig( + const mcp = await this.prepareMcpInjection( resumeConfig.cwd, resumeConfig.config.mcpServers, mcpEnv, + runtime, ); - const paseoExtension = createPiPaseoExtensionFile( - composeSystemPromptParts( + const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null; + const paseoExtension = createPiPaseoExtensionFile({ + systemPrompt: composeSystemPromptParts( resumeConfig.config.systemPrompt, resumeConfig.config.daemonAppendSystemPrompt, ), - ); + mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined, + }); let runtimeSession: PiRuntimeSession; try { - runtimeSession = await this.runtime.startSession( + runtimeSession = await runtime.startSession( buildResumeStartInput({ resumeConfig, sessionFile, launchContext, - mcpConfig, + mcpConfigFile, paseoExtension, }), ); } catch (error) { - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } try { + const initialState = await this.applyResumeModel(runtimeSession, resumeConfig.model); return new PiRpcAgentSession({ runtimeSession, - config: resumeConfig.config, - initialState: await runtimeSession.getState(), - capabilities: capabilitiesForSession(mcpConfig !== null), - cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension?.cleanup]), - extensionTimeoutMs: this.providerParams.extensionTimeoutMs, + config: { + ...resumeConfig.config, + model: modelToId(initialState.model) ?? resumeConfig.config.model, + }, + initialState, + capabilities: capabilitiesForSession(mcp !== null), + cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]), + extensionTimeoutMs: providerOptions.extensionTimeoutMs, logger: this.logger, usagePollScheduler: this.usagePollScheduler, }); } catch (error) { await runtimeSession.close().catch(() => undefined); - mcpConfig?.cleanup(); + mcpConfigFile?.cleanup(); paseoExtension?.cleanup(); throw error; } } + // Pi resumes a session on the model it recorded, or on its default when that model + // is gone. Switching afterwards keeps a removed model from blocking the resume. + private async applyResumeModel( + runtimeSession: PiRuntimeSession, + requestedModel: string | undefined, + ): Promise { + const state = await runtimeSession.getState(); + const reference = parseModelReference(requestedModel ?? null); + if (!reference?.provider) { + return state; + } + const { provider, id } = reference; + const isRequested = (model: PiModel | null | undefined) => + model?.provider === provider && model.id === id; + if (isRequested(state.model)) { + return state; + } + const availableModels = await runtimeSession.getAvailableModels(); + if (!availableModels.some(isRequested)) { + this.logger.warn( + { requestedModel, sessionModel: modelToId(state.model) }, + "Pi resumed on the session's model because the requested model is unavailable", + ); + return state; + } + await runtimeSession.setModel(provider, id); + return runtimeSession.getState(); + } + async fetchCatalog( options: FetchCatalogOptions, context?: ProviderRefreshContext, ): Promise { + const providerOptions = PiProviderOptionsSchema.parse(options.providerOptions ?? {}); + const runtime = this.resolveRuntime(providerOptions.rpcTimeoutMs); let runtimeSession: PiRuntimeSession | undefined; let closePromise: Promise | undefined; const closeSession = () => { @@ -2640,7 +2673,7 @@ export class PiRpcAgentClient implements AgentClient { context?.signal.addEventListener("abort", handleAbort, { once: true }); try { await runProviderRefreshActivity(context, "runtime.start", async () => { - runtimeSession = await this.runtime.startSession({ + runtimeSession = await runtime.startSession({ cwd: options.scope === "global" ? homedir() : options.cwd, signal: context?.signal, }); @@ -2648,12 +2681,23 @@ export class PiRpcAgentClient implements AgentClient { }); if (!runtimeSession) throw new Error("Pi catalog runtime did not start"); const catalogSession = runtimeSession; + const piModels = await runProviderRefreshActivity(context, "get_available_models", () => + catalogSession.getAvailableModels(null), + ); + // A fresh Pi session starts on the model Pi resolves from its own settings. + const { model: configuredModel } = await runProviderRefreshActivity( + context, + "get_state", + () => catalogSession.getState(), + ); const models = transformPiModels( - ( - await runProviderRefreshActivity(context, "get_available_models", () => - catalogSession.getAvailableModels(null), - ) - ).map((model) => mapPiModel(model, PI_PROVIDER)), + piModels.map((model) => { + const mapped = mapPiModel(model, PI_PROVIDER); + const isConfigured = + model.provider === configuredModel?.provider && model.id === configuredModel?.id; + if (isConfigured) mapped.isDefault = true; + return mapped; + }), ); return { models, modes: [] }; } finally { @@ -2671,7 +2715,7 @@ export class PiRpcAgentClient implements AgentClient { ): Promise { return await listPiImportableSessions({ ...options, - sessionDir: this.providerParams.sessionDir, + sessionDir: PiProviderOptionsSchema.parse(options?.providerOptions ?? {}).sessionDir, runtimeSettings: this.runtimeSettings, }); } @@ -2723,33 +2767,53 @@ export class PiRpcAgentClient implements AgentClient { } } - private async prepareMcpConfig( + private resolveRuntime(rpcTimeoutMs: number): PiRuntime { + return this.runtime ?? createRuntime(this.logger, this.runtimeSettings, rpcTimeoutMs); + } + + private async prepareMcpInjection( cwd: string, servers: Record | undefined, env: Record | undefined, - ): Promise { + runtime: PiRuntime, + ): Promise { if (!servers || Object.keys(servers).length === 0) { return null; } - if (!(await this.detectMcpAdapter(cwd, env))) { - return null; + const mcpSupport = await this.detectMcpSupport(cwd, env, runtime); + if (mcpSupport === "builtin") { + return { kind: "builtin", servers: toPiBuiltinMcpServers(servers) }; } - return createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }); + if (mcpSupport === "adapter") { + return { + kind: "adapter", + configFile: createPiMcpConfigFile(servers, { piGlobalConfigEnv: env }), + }; + } + return null; } - private async detectMcpAdapter(cwd: string, env?: Record): Promise { - const runtimeSession = await this.runtime.startSession({ cwd, env }).catch((error) => { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start"); + private async detectMcpSupport( + cwd: string, + env: Record | undefined, + runtime: PiRuntime, + ): Promise { + const runtimeSession = await runtime.startSession({ cwd, env }).catch((error) => { + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed to start"); return null; }); if (!runtimeSession) { - return false; + return null; } try { - return (await runtimeSession.getCommands()).some(isPiMcpAdapterCommand); + const commands = await runtimeSession.getCommands(); + if (commands.some(isPiMcpAdapterCommand)) { + return "adapter"; + } + return commands.some(isPiBuiltinMcpCommand) ? "builtin" : null; } catch (error) { - this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed"); - return false; + this.logger.debug({ err: error, cwd }, "Pi MCP probe failed"); + return null; } finally { await runtimeSession.close().catch(() => undefined); } diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts index 94517bde0d..463870760f 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -62,6 +62,15 @@ function onPiCommand(child: PiChild, handler: (command: Record) }); } +/** Kill the child the moment it receives `type`, so the request is in flight when it dies. */ +function exitOnCommand(child: PiChild, type: string): void { + onPiCommand(child, (command) => { + if (command.type === type) { + child.emit("exit", 1, null); + } + }); +} + function replyToCommands( child: PiChild, handler: (command: Record) => unknown, @@ -137,7 +146,12 @@ describe("PiCliRuntime", () => { const launches: PiRuntimeLaunch[] = []; const runtime = createRuntime(child, launches); - const session = await runtime.startSession({ cwd: "/workspace/project" }); + const session = await runtime.startSession({ + cwd: "/workspace/project", + env: { HOME: "/fixture/pi-home" }, + }); + expect(session.environment).toBe(launches[0]?.env); + expect(session.environment?.HOME).toBe("/fixture/pi-home"); await expect(session.getState()).resolves.toMatchObject({ sessionId: "pi-session-1", @@ -278,7 +292,10 @@ describe("PiCliRuntime", () => { const state = session.getState(); child.stderr.write("boom"); + const environment = session.environment; child.emit("exit", 1, null); + expect(session.environment).toBe(environment); + expect(environment).toBeDefined(); await expect(state).rejects.toThrow("boom"); }); @@ -506,4 +523,25 @@ describe("PiCliRuntime", () => { // Neither RPC returned usable data — should resolve with empty object expect(stats).toEqual({}); }); + + // A dead runtime owns no turn, so interrupting it is already satisfied. Rejecting here + // makes AgentManager treat the interrupt as unacknowledged and refuse the stop, which + // pins the agent at `running` until the daemon restarts. See issue #3749. + test("abort and clearQueue resolve once the Pi process has exited", async () => { + const child = createPiChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + child.emit("exit", 1, null); + + await expect(session.clearQueue()).resolves.toBeUndefined(); + await expect(session.abort()).resolves.toBeUndefined(); + }); + + test("abort resolves when the Pi process exits while the abort is in flight", async () => { + const child = createPiChild(); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + exitOnCommand(child, "abort"); + + await expect(session.abort()).resolves.toBeUndefined(); + }); }); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts index 783be30249..62ee01c503 100644 --- a/packages/server/src/server/agent/providers/pi/cli-runtime.ts +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -1,3 +1,4 @@ +import { createExternalProcessEnv } from "../../../paseo-env.js"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { Logger } from "pino"; @@ -56,6 +57,7 @@ export class PiCliRuntime implements PiRuntime { runtimeSettings: this.options.runtimeSettings, session: input, }); + launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {}); const [command, ...args] = launch.argv; const processLaunch: JsonlRpcLaunch = { command, @@ -76,7 +78,7 @@ export class PiCliRuntime implements PiRuntime { await process.close(input.signal.reason); input.signal.throwIfAborted(); } - return new PiCliRuntimeSession(process, this.commandsRpcName); + return new PiCliRuntimeSession(process, this.commandsRpcName, launch.env); } } @@ -86,6 +88,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { constructor( private readonly process: JsonlRpcProcess, private readonly commandsRpcName: string, + private readonly launchEnvironment: Record, ) { process.onMessage((message) => { this.emit(message as PiRuntimeEvent); @@ -95,6 +98,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { }); } + get environment(): Record { + return this.launchEnvironment; + } + onEvent(callback: (event: PiRuntimeEvent) => void): () => void { this.subscribers.add(callback); return () => { @@ -133,7 +140,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { } async clearQueue(): Promise { - await this.request({ type: "clear_queue" }); + await this.requestStopWork({ type: "clear_queue" }); } async compact(customInstructions?: string): Promise { @@ -148,7 +155,7 @@ class PiCliRuntimeSession implements PiRuntimeSession { } async abort(): Promise { - await this.request({ type: "abort" }); + await this.requestStopWork({ type: "abort" }); } async getState(): Promise { @@ -237,6 +244,10 @@ class PiCliRuntimeSession implements PiRuntimeSession { return this.process.request(command, timeoutMs); } + private requestStopWork(command: PiRpcCommand): Promise { + return this.process.requestStopWork(command); + } + private async waitForCompletion(command: PiRpcCommand): Promise { // Pi only replies after its compaction work is durable. Its child process and // session close paths already reject pending RPCs, so no elapsed-time failure diff --git a/packages/server/src/server/agent/providers/pi/extensions/child-session.ts b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts new file mode 100644 index 0000000000..2013b831be --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/child-session.ts @@ -0,0 +1,98 @@ +import { open } from "node:fs/promises"; +import { limitAgentTimelineItemContent } from "../../../agent-timeline-content.js"; +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import type { PiAgentMessage } from "../rpc-types.js"; +import { extractTextFromToolResult, type PiToolResult } from "../tool-call-mapper.js"; + +const MAX_BYTES = 2 * 1024 * 1024; +const MAX_ITEMS = 200; + +export function outputFileFromToolResult(result: PiToolResult): string | undefined { + return extractTextFromToolResult(result)?.match(/^Output file:\s*(\S+)$/m)?.[1]; +} + +/** Reads a finished child transcript once, up to `maxBytes`. */ +export function mapPiChildSession( + id: string, + file: string, + maxBytes = MAX_BYTES, +): Promise { + return new PiChildSessionFollower(id, file, maxBytes).readNew(); +} + +function mapChildLine( + mapper: PiHistoryMapper, + id: string, + line: string, +): ProviderSubagentInputEvent[] { + let entry: { message?: PiAgentMessage; timestamp?: string }; + try { + entry = JSON.parse(line); + } catch { + return []; + } + if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return []; + return mapper.mapMessages([entry.message]).flatMap((mapped) => + mapped.type === "timeline" + ? [ + { + type: "timeline" as const, + id, + item: limitAgentTimelineItemContent(mapped.item), + ...(entry.timestamp ? { timestamp: entry.timestamp } : {}), + }, + ] + : [], + ); +} + +/** Incrementally reads one append-only child transcript. A partial JSONL row is held until newline. */ +export class PiChildSessionFollower { + private offset = 0; + private pending = Buffer.alloc(0); + private readonly mapper = new PiHistoryMapper("pi"); + private items = 0; + private readonly maxBytes: number; + + constructor( + private readonly id: string, + private readonly file: string, + maxBytes = MAX_BYTES, + ) { + this.maxBytes = Math.min(maxBytes, MAX_BYTES); + } + + async readNew(): Promise { + if (this.offset >= this.maxBytes || this.items >= MAX_ITEMS) return []; + try { + const handle = await open(this.file, "r"); + try { + const size = Math.min((await handle.stat()).size, this.maxBytes) - this.offset; + if (size <= 0) return []; + const buffer = Buffer.alloc(size); + const { bytesRead } = await handle.read(buffer, 0, size, this.offset); + this.offset += bytesRead; + const data = Buffer.concat([this.pending, buffer.subarray(0, bytesRead)]); + const lastNewline = data.lastIndexOf(10); + if (lastNewline < 0) { + this.pending = data; + return []; + } + this.pending = data.subarray(lastNewline + 1); + const events: ProviderSubagentInputEvent[] = []; + for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) { + if (this.items >= MAX_ITEMS) break; + const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items); + this.items += mapped.length; + events.push(...mapped); + } + return events; + } finally { + await handle.close(); + } + } catch { + return []; + } + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/contract.ts b/packages/server/src/server/agent/providers/pi/extensions/contract.ts new file mode 100644 index 0000000000..93129a0476 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/contract.ts @@ -0,0 +1,83 @@ +import type { + AgentPermissionRequest, + AgentPermissionResponse, + ToolCallDetail, + AgentTimelineItem, +} from "../../../agent-sdk-types.js"; +import type { PiRuntimeEvent } from "../rpc-types.js"; +import type { PiToolResult } from "../tool-call-mapper.js"; +import type { ProviderSubagentInputEvent } from "../../../provider-subagents/store.js"; +import type { PiAgentMessage } from "../rpc-types.js"; + +export interface PiExtensionToolCall { + callId: string; + toolName: string; + args: unknown; + status: "running" | "completed" | "failed"; + result: PiToolResult; +} + +export interface PiExtensionToolMapping { + name?: string; + detail?: ToolCallDetail; + timeline?: AgentTimelineItem[]; + subagents?: ProviderSubagentInputEvent[]; + childSessions?: PiChildSessionFile[]; +} + +export interface PiExtensionCustomMapping { + subagents: ProviderSubagentInputEvent[]; + childSessions?: PiChildSessionFile[]; +} + +/** + * A Pi child's append-only session file, mapped through the normal Pi history mapper. Report it as + * soon as it is known; a live session follows it until the child's upsert reaches a final status. + */ +export interface PiChildSessionFile { + id: string; + file: string; +} + +export type PiExtensionDialog = Extract; +export interface PiExtensionUiResponse { + value?: string; + cancelled?: boolean; + confirmed?: boolean; +} +export type PiExtensionDialogMapping = + | { type: "permission"; request: AgentPermissionRequest } + | { type: "response"; response: PiExtensionUiResponse } + | { type: "deferred" }; + +export interface PiExtensionUiReply { + responses: Array<{ id: string; response: PiExtensionUiResponse }>; +} + +export interface PiExtensionSession { + mapToolCall?(call: PiExtensionToolCall): PiExtensionToolMapping | undefined; + /** Claims a `ctx.ui.notify` message sent by this extension's `runtimeBridge`. */ + mapRuntimeNotification?(message: string): PiExtensionCustomMapping | undefined; + /** Called on the live follow cadence to report state Pi only writes to disk. */ + poll?(): PiExtensionCustomMapping | undefined; + mapCustomMessage?( + message: Extract, + ): PiExtensionCustomMapping | undefined; + onToolStart?(call: PiExtensionToolCall, provider: string): AgentPermissionRequest | undefined; + onToolEnd?(call: PiExtensionToolCall): void; + mapDialog?(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined; + respondToPermission?( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined; +} + +export interface PiExtension { + id: string; + /** + * JavaScript statements run inside Paseo's Pi integration extension, with `pi` in scope. Reports + * state that Pi's RPC stream omits through `ctx.ui.notify`, which reaches `mapRuntimeNotification`. + */ + runtimeBridge?: string; + createSession(): PiExtensionSession; +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..3da491c8ca --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/background.json @@ -0,0 +1,248 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "21.7.6", + "sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_403820", + "toolName": "subagent", + "args": { + "description": "list files", + "subagent_type": "general-purpose", + "prompt": "list files in this empty workspace", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_403820", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "b913f6d2-2b1e-4be" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "\nb913f6d2-2b1e-4be\ncall_403820\n/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nDone\nSubagent \"list files\" completed\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n8843113714\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.", + "display": true, + "details": { + "id": "b913f6d2-2b1e-4be", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 3, + "totalTokens": 8843, + "durationMs": 3714, + "outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl", + "resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty." + }, + "timestamp": 1790197700057 + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "args": { + "agent_id": "b913f6d2-2b1e-4be" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "result": { + "content": [ + { + "type": "text", + "text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + ], + "details": { + "agentId": "b913f6d2-2b1e-4be", + "displayName": "Agent", + "status": "completed", + "description": "list files", + "toolUses": 1, + "tokens": "8.8k token", + "contextPercent": 0.6055831909179688, + "compactionCount": 0, + "duration": "3.7s", + "preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.", + "verbose": false, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_403820", + "name": "subagent", + "arguments": { + "description": "list files", + "subagent_type": "general-purpose", + "prompt": "list files in this empty workspace", + "run_in_background": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 6590, + "output": 46, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 6636, + "cost": { + "input": 0.0049425, + "output": 0.0001725, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.005115 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197694784, + "responseId": "gen-1790197695-1oeKbQMynKXs4RNlIMQ7", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_403820", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: b913f6d2-2b1e-4be\nType: Agent\nDescription: list files\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "b913f6d2-2b1e-4be" + }, + "isError": false, + "timestamp": 1790197696346 + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "\nb913f6d2-2b1e-4be\ncall_403820\n/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nDone\nSubagent \"list files\" completed\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n8843113714\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl\nCall get_subagent_result(\"b913f6d2-2b1e-4be\") to collect the full result.", + "display": true, + "details": { + "id": "b913f6d2-2b1e-4be", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 3, + "totalTokens": 8843, + "durationMs": 3714, + "outputFile": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl", + "resultPreview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty." + }, + "timestamp": 1790197700057 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_380752", + "name": "get_subagent_result", + "arguments": { + "agent_id": "b913f6d2-2b1e-4be" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3383, + "output": 36, + "cacheRead": 3846, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 7265, + "cost": { + "input": 0.00253725, + "output": 0.000135, + "cacheRead": 0.00028845, + "cacheWrite": 0, + "total": 0.0029607 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197700058, + "responseId": "gen-1790197700-cKcLwp60V13TphEI0fPk", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_380752", + "toolName": "get_subagent_result", + "content": [ + { + "type": "text", + "text": "Agent: b913f6d2-2b1e-4be\nType: Agent | Status: completed | Tool uses: 1 | 8.8k token | Context: 1% | Duration: 3.7s\nDescription: list files\n\nThe workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.\n\nFull transcript available at: /tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + } + ], + "details": { + "agentId": "b913f6d2-2b1e-4be", + "displayName": "Agent", + "status": "completed", + "description": "list files", + "toolUses": 1, + "tokens": "8.8k token", + "contextPercent": 0.6055831909179688, + "compactionCount": 0, + "duration": "3.7s", + "preview": "The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty.", + "verbose": false, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-gotgenes/2026-09-23T21-08-13-733Z_01a0d018-fd25-783b-b9e9-27f4bd6c8851/tasks/2026-09-23T21-08-17-217Z_01a0d019-0ac1-72e0-a277-0fea25d9da6c.jsonl" + }, + "isError": false, + "timestamp": 1790197701316 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..16da52431b --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,4 @@ +{"type":"message","id":"813a565d","parentId":"2837e00c","timestamp":"2026-09-23T21:08:17.356Z","message":{"role":"user","content":[{"type":"text","text":"list files in this empty workspace"}],"timestamp":1790197697355}} +{"type":"message","id":"869bc287","parentId":"813a565d","timestamp":"2026-09-23T21:08:18.797Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19A4eeufqsAJet9RoM5pf8bwBMUrJ4+R+m99YFQJ3pNYkkRTPI+5rHRCLH4sAKmyqJiSFTcFR+qKfVFQN91nm2RkncAczuy2UwYJ+mcvU1bG42y9Ca3gcGSZcBhP6IU6cu4KdfkqeEuBCx+bZQwylVRZlpiarhlv+fLQD/nE+m9KeFW+eZUJrIkd6nZYIdK0xAGVAxcbjDCh4rbZKMzCE7RB0G7adwu4ZxY2Sw1IVmIXz2Hgob+U1BXcm1YYNutIIDCN3q343v7QBEvmYpCm/ugM4C4KUrZHet8wudZUGmnAxDkVJxzBUjeUdmJiLDbJ/uCuRwjrvmxnaixsq0yeiSq4EisboKHtgWTMflaAAary+ClXQgY8sh00LjLQdO++Cp+Sn3per2ehlGW7680KBncXSDNiZXcjPJPRju6TRYseddNfdJdW6eDelqm6/GuBKX9hc8cVUT33kdkOqQDHbcHCw==\",\"format\":\"google-gemini-v1\",\"id\":\"call_342819\",\"index\":0}]"},{"type":"toolCall","id":"call_342819","name":"ls","arguments":{}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":6230,"output":76,"cacheRead":0,"cacheWrite":0,"reasoning":68,"totalTokens":6306,"cost":{"input":0.0046725000000000004,"output":0.000285,"cacheRead":0,"cacheWrite":0,"total":0.0049575}},"stopReason":"toolUse","timestamp":1790197697356,"responseId":"gen-1790197697-mVNTI1th8irocnyQ3PSP","rawStopReason":"tool_calls"}} +{"type":"message","id":"e2c2c160","parentId":"869bc287","timestamp":"2026-09-23T21:08:18.799Z","message":{"role":"toolResult","toolCallId":"call_342819","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197698799}} +{"type":"message","id":"97009824","parentId":"e2c2c160","timestamp":"2026-09-23T21:08:20.054Z","message":{"role":"assistant","content":[{"type":"text","text":"The workspace `/tmp/paseo-pi-extension-phase3/workspace` is currently empty."}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2500,"output":37,"cacheRead":3813,"cacheWrite":0,"reasoning":16,"totalTokens":6350,"cost":{"input":0.0018750000000000001,"output":0.00013875,"cacheRead":0.000285975,"cacheWrite":0,"total":0.0022997250000000003}},"stopReason":"stop","timestamp":1790197698800,"responseId":"gen-1790197698-HsngQmcltNO0wKvZKyQe","rawStopReason":"stop"}} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..45e4d39429 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/fixtures/foreground.json @@ -0,0 +1,111 @@ +{ + "provenance": { + "package": "@gotgenes/pi-subagents", + "version": "21.7.6", + "sourceCommit": "1ac6bc6a36e6da5d00f1cd2482f1b971f5ea1fdf", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_502323", + "toolName": "subagent", + "args": { + "subagent_type": "general-purpose", + "description": "List workspace files", + "prompt": "list the files in this empty workspace." + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_502323", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty." + } + ], + "details": { + "displayName": "Agent", + "description": "List workspace files", + "subagentType": "general-purpose", + "tags": ["twin"], + "toolUses": 1, + "tokens": "9.0k token", + "turnCount": 3, + "durationMs": 4723, + "status": "completed", + "agentId": "848583f5-6749-4a1" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_502323", + "name": "subagent", + "arguments": { + "subagent_type": "general-purpose", + "description": "List workspace files", + "prompt": "list the files in this empty workspace." + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 6584, + "output": 96, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 55, + "totalTokens": 6680, + "cost": { + "input": 0.0049380000000000005, + "output": 0.00036, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.005298000000000001 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197658835, + "responseId": "gen-1790197658-Jx3ylOivC0gHsP3uBU0f", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_502323", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty." + } + ], + "details": { + "displayName": "Agent", + "description": "List workspace files", + "subagentType": "general-purpose", + "tags": ["twin"], + "toolUses": 1, + "tokens": "9.0k token", + "turnCount": 3, + "durationMs": 4723, + "status": "completed", + "agentId": "848583f5-6749-4a1" + }, + "isError": false, + "timestamp": 1790197665254 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts new file mode 100644 index 0000000000..6bbe1b3b06 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import { streamPiHistory } from "../../history-mapper.js"; +import { GOTGENES_CHILD_SESSION_MARKER, gotgenesRuntimeBridge } from "./runtime-bridge.js"; + +const SERVICE_KEY = Symbol.for("@gotgenes/pi-subagents:service"); + +function loadRuntimeBridge() { + const listeners = new Map void>(); + const pi = { + on: (name: string, listener: (event: unknown, ctx?: unknown) => void) => + listeners.set(name, listener), + events: { + on: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), + }, + }; + new Function("pi", gotgenesRuntimeBridge)(pi); + return (name: string, event: unknown, ctx?: unknown) => listeners.get(name)?.(event, ctx); +} + +function publishService(agents: Array<{ id: string; outputFile?: string }>): void { + (globalThis as Record)[SERVICE_KEY] = { listAgents: () => agents }; +} + +describe("@gotgenes/pi-subagents adapter", () => { + test("reports a child's file after the child republishes the gotgenes service", async () => { + const notifications: string[] = []; + const emit = loadRuntimeBridge(); + const file = "/sessions/parent/tasks/2026-09-30_child-session.jsonl"; + try { + publishService([{ id: "native-1", outputFile: file }]); + emit( + "session_start", + {}, + { ui: { notify: (message: string) => notifications.push(message) } }, + ); + // Pi loads gotgenes again inside the child, and that copy replaces the global service. + publishService([]); + emit("subagents:child:session-created", { sessionId: "child-session" }); + + await expect.poll(() => notifications).toHaveLength(1); + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + expect(host.mapRuntimeNotification(notifications[0]!)?.childSessions).toEqual([ + { id: "call-1", file }, + ]); + } finally { + delete (globalThis as Record)[SERVICE_KEY]; + } + }); + + test("accepts a live child path before or after the spawn result", () => { + for (const early of [true, false]) { + const host = createPiExtensionHost(); + const marker = `${GOTGENES_CHILD_SESSION_MARKER} ${JSON.stringify({ agentId: "native-1", file: "/tmp/child.jsonl" })}`; + if (early) expect(host.mapRuntimeNotification(marker)?.subagents).toEqual([]); + const spawn = host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const path = early + ? spawn?.childSessions + : host.mapRuntimeNotification(marker)?.childSessions; + expect(path).toEqual([{ id: "call-1", file: "/tmp/child.jsonl" }]); + } + }); + + test("accepts a running subagent-update without a status field", () => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const update = host.mapCustomMessage({ + role: "custom", + customType: "subagent-update", + content: "progress", + details: { id: "native-1", description: "Inspect", message: "progress" }, + }); + expect(update?.subagents).toEqual([ + { type: "upsert", id: "call-1", description: "Inspect", status: "running" }, + ]); + }); + test("maps captured foreground lifecycle live and on replay", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), + ); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "completed"]); + }); + test("correlates a background notification and result collection", async () => { + const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const file = ( + source.messages.find((message) => message.role === "custom") as { + details: { outputFile: string }; + } + ).details.outputFile; + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), { + from: file, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const upserts = events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event : null)); + expect(upserts.map((event) => event?.status)).toEqual([ + "running", + "running", + "completed", + "completed", + ]); + expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + test("keeps claimed notification text in a tool row in history", async () => { + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const notification = fixture.messages.find((message) => message.role === "custom"); + const text = notification?.content; + const events = []; + for await (const event of streamPiHistory("pi", fixture.messages)) events.push(event); + expect(events).toContainEqual({ + type: "timeline", + provider: "pi", + item: { + type: "tool_call", + callId: "pi-custom-1", + name: notification?.customType, + status: "completed", + detail: { type: "plain_text", text }, + metadata: { + synthetic: true, + customType: notification?.customType, + details: notification?.details, + }, + error: null, + }, + }); + expect(events.some((event) => event.type === "provider_subagent")).toBe(true); + }); + test("shares the subagent tool name with Nico without claiming Nico's args", () => { + const host = createPiExtensionHost(); + const nico = host.mapToolCall({ + callId: "nico", + toolName: "subagent", + args: { agent: "scout", task: "Inspect" }, + status: "running", + result: null, + }); + const gotgenes = host.mapToolCall({ + callId: "gotgenes", + toolName: "subagent", + args: { subagent_type: "general-purpose", prompt: "Inspect" }, + status: "running", + result: null, + }); + expect(nico?.detail).toEqual( + expect.objectContaining({ type: "sub_agent", subAgentType: "scout" }), + ); + expect(gotgenes?.detail).toEqual( + expect.objectContaining({ type: "sub_agent", subAgentType: "general-purpose" }), + ); + expect( + host.mapToolCall({ + callId: "foreign", + toolName: "subagent", + args: { description: "Inspect" }, + status: "running", + result: null, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts new file mode 100644 index 0000000000..62db93c79e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/index.ts @@ -0,0 +1,189 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import { outputFileFromToolResult } from "../child-session.js"; +import { GOTGENES_CHILD_SESSION_MARKER, gotgenesRuntimeBridge } from "./runtime-bridge.js"; +import type { PiExtension, PiExtensionToolCall } from "../contract.js"; + +const SpawnArgs = z + .object({ + subagent_type: z.string().trim().min(1), + prompt: z.string().trim().min(1), + description: z.string().optional(), + }) + .passthrough(); +const FollowupArgs = z.object({ agent_id: z.string().trim().min(1) }).passthrough(); +const Details = z + .object({ + agentId: z.string().trim().min(1), + status: z.string(), + displayName: z.string().optional(), + description: z.string().optional(), + transcriptPath: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Notification = z + .object({ + id: z.string().trim().min(1), + status: z.string().optional(), + description: z.string().optional(), + outputFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const LiveSession = z.object({ + agentId: z.string().trim().min(1), + file: z.string().trim().min(1), +}); +const status = (value: string): "running" | "completed" | "failed" | "canceled" => { + if (value === "completed") return "completed"; + if (value === "error") return "failed"; + if (value === "aborted" || value === "stopped") return "canceled"; + return "running"; +}; + +export const gotgenesPiSubagents: PiExtension = { + id: "@gotgenes/pi-subagents", + runtimeBridge: gotgenesRuntimeBridge, + createSession: () => { + const callsByAgent = new Map(); + const readSessions = new Set(); + const pendingFilesByAgent = new Map(); + const childSession = (id: string, file: string | undefined) => { + if (!file || readSessions.has(file)) return []; + readSessions.add(file); + return [{ id, file }]; + }; + const mapSpawn = (call: PiExtensionToolCall) => { + const args = SpawnArgs.safeParse(call.args); + if (!args.success) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const title = args.data.subagent_type; + const description = args.data.description ?? args.data.prompt; + const detail = { + type: "sub_agent" as const, + subAgentType: title, + description, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running") + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: "running" as const, + }, + ], + }; + if (!details.success) + return call.status === "failed" + ? { + detail, + subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], + } + : { detail }; + callsByAgent.set(details.data.agentId, call.callId); + const file = + outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId); + pendingFilesByAgent.delete(details.data.agentId); + const childSessions = childSession(call.callId, file); + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: status(details.data.status), + }, + ], + childSessions, + }; + }; + const mapFollowup = (call: PiExtensionToolCall) => { + const args = FollowupArgs.safeParse(call.args); + if (!args.success) return undefined; + const id = callsByAgent.get(args.data.agent_id); + if (!id) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const detail = { + type: "sub_agent" as const, + description: details.success ? details.data.description : undefined, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running" || !details.success) return { detail }; + const file = details.data.transcriptPath; + const childSessions = childSession(id, file); + return { + detail, + subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }], + childSessions, + }; + }; + return { + mapToolCall(call) { + // This fork's result details resemble tintinweb's, but its spawn tool is `subagent` and its + // follow-up result shape differs. Keep each parser at its own package boundary. + if (call.toolName === "subagent") return mapSpawn(call); + if (call.toolName === "get_subagent_result" || call.toolName === "steer_subagent") + return mapFollowup(call); + return undefined; + }, + mapCustomMessage(message) { + if ( + message.customType !== "subagent-notification" && + message.customType !== "subagent-update" + ) + return undefined; + const details = Notification.safeParse(message.details); + if (!details.success) return undefined; + const id = callsByAgent.get(details.data.id); + if (!id) return undefined; + const file = details.data.outputFile; + const childSessions = childSession(id, file); + return { + subagents: [ + { + type: "upsert", + id, + description: details.data.description, + status: details.data.status ? status(details.data.status) : "running", + }, + ], + childSessions, + }; + }, + mapRuntimeNotification(message) { + const prefix = `${GOTGENES_CHILD_SESSION_MARKER} `; + if (!message.startsWith(prefix)) return undefined; + let raw: unknown; + try { + raw = JSON.parse(message.slice(prefix.length)); + } catch { + return { subagents: [] }; + } + const parsed = LiveSession.safeParse(raw); + if (!parsed.success) return { subagents: [] }; + const { agentId, file } = parsed.data; + const id = callsByAgent.get(agentId); + if (!id) { + pendingFilesByAgent.set(agentId, file); + return { subagents: [] }; + } + return { + subagents: [{ type: "upsert", id, status: "running" }], + childSessions: childSession(id, file), + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts new file mode 100644 index 0000000000..386fbb4705 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/gotgenes-pi-subagents/runtime-bridge.ts @@ -0,0 +1,33 @@ +export const GOTGENES_CHILD_SESSION_MARKER = "PASEO_GOTGENES_CHILD_SESSION"; + +/** + * Runs inside Pi. The spawn result omits the child's session file, so the parent's public service + * is the live source. Each child session loads its own copy of gotgenes, which republishes the + * global service and deletes it on exit, so the parent's service is captured at session_start. + */ +export const gotgenesRuntimeBridge = ` + let gotgenesParent; + pi.on("session_start", (_event, ctx) => { + gotgenesParent = { ctx, service: globalThis[Symbol.for("@gotgenes/pi-subagents:service")] }; + }); + pi.events.on("subagents:child:session-created", (event) => { + const parent = gotgenesParent; + let attempts = 0; + // The record gets its session file after the child's extensions bind. + const report = () => { + const record = parent?.service?.listAgents().find((agent) => + agent.outputFile?.includes(event.sessionId), + ); + if (record) { + parent.ctx.ui.notify( + "${GOTGENES_CHILD_SESSION_MARKER} " + + JSON.stringify({ agentId: record.id, file: record.outputFile }), + "info", + ); + } else if (++attempts < 240) { + setTimeout(report, 250); + } + }; + setTimeout(report, 0); + }); +`; diff --git a/packages/server/src/server/agent/providers/pi/extensions/host.ts b/packages/server/src/server/agent/providers/pi/extensions/host.ts new file mode 100644 index 0000000000..8915e3df01 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/host.ts @@ -0,0 +1,256 @@ +import type { Logger } from "pino"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, + AgentStreamEvent, +} from "../../../agent-sdk-types.js"; +import type { PiAgentMessage } from "../rpc-types.js"; +import { mapPiChildSession, PiChildSessionFollower } from "./child-session.js"; +import type { + PiExtension, + PiExtensionCustomMapping, + PiExtensionDialog, + PiExtensionDialogMapping, + PiExtensionSession, + PiExtensionToolCall, + PiExtensionToolMapping, + PiExtensionUiReply, +} from "./contract.js"; + +export type PiExtensionOutput = T & { + events: AgentStreamEvent[]; + hydration: Promise; +}; +export type PiExtensionEventOutput = PiExtensionOutput< + PiExtensionToolMapping | PiExtensionCustomMapping +>; + +interface Session { + id: string; + adapter: PiExtensionSession; +} + +interface Follower { + reader: PiChildSessionFollower; + /** Reads run in order so a final read cannot overtake a tick. */ + pending: Promise; +} + +export class PiExtensionHost { + private readonly sessions: Session[] = []; + private remainingHydrationBytes: number; + private readonly followers = new Map(); + private onFollowEvent?: (event: AgentStreamEvent) => void; + private followTimer?: ReturnType; + private pollingExtensions = false; + + constructor( + extensions: readonly PiExtension[], + private readonly logger?: Pick, + hydrationByteBudget = Number.POSITIVE_INFINITY, + private readonly readChildSession: typeof mapPiChildSession = mapPiChildSession, + ) { + this.remainingHydrationBytes = hydrationByteBudget; + for (const extension of extensions) { + const adapter = this.safe(extension.id, "createSession", () => extension.createSession()); + if (adapter) this.sessions.push({ id: extension.id, adapter }); + } + } + + /** Live sessions follow child files and adapter polls; replay hosts read each file once. */ + follow(onEvent: (event: AgentStreamEvent) => void): void { + this.onFollowEvent = onEvent; + this.followTimer ??= setInterval(() => { + for (const [id, follower] of this.followers) void this.readFollower(id, follower); + void this.pollExtensions(); + }, 250); + } + + close(): void { + if (this.followTimer) clearInterval(this.followTimer); + this.followTimer = undefined; + this.onFollowEvent = undefined; + this.followers.clear(); + } + + private readFollower(id: string, follower: Follower): Promise { + follower.pending = follower.pending + .then(async () => { + const events = await follower.reader.readNew(); + for (const event of events) { + this.onFollowEvent?.({ type: "provider_subagent", provider: "pi", event }); + } + return undefined; + }) + .catch((error) => { + this.logger?.warn( + { err: error, operation: "follow", childId: id }, + "Pi child follow failed", + ); + }); + return follower.pending; + } + + private async pollExtensions(): Promise { + if (this.pollingExtensions || !this.onFollowEvent) return; + this.pollingExtensions = true; + try { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "poll", () => adapter.poll?.()); + if (!mapping) continue; + const output = this.prepare(id, mapping); + for (const event of output.events) this.onFollowEvent?.(event); + for (const event of await output.hydration) this.onFollowEvent?.(event); + } + } finally { + this.pollingExtensions = false; + } + } + + private safe(id: string, operation: string, call: () => T): T | undefined { + try { + return call(); + } catch (error) { + this.logger?.warn({ err: error, extensionId: id, operation }, "Pi extension adapter failed"); + return undefined; + } + } + + mapToolCall(call: PiExtensionToolCall): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapToolCall", () => adapter.mapToolCall?.(call)); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + + mapCustomMessage( + message: Extract, + ): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapCustomMessage", () => adapter.mapCustomMessage?.(message)); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + + mapRuntimeNotification(message: string): PiExtensionOutput | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapRuntimeNotification", () => + adapter.mapRuntimeNotification?.(message), + ); + if (mapping) { + const prepared = this.safe(id, "prepare", () => this.prepare(id, mapping)); + if (prepared) return prepared; + } + } + return undefined; + } + + private prepare( + id: string, + mapping: T, + ): PiExtensionOutput { + const events: AgentStreamEvent[] = [ + ...("timeline" in mapping ? (mapping.timeline ?? []) : []).map( + (item): AgentStreamEvent => ({ type: "timeline", provider: "pi", item }), + ), + ...(mapping.subagents ?? []).map( + (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), + ), + ]; + const hydration = ( + this.onFollowEvent ? this.followChildren(mapping) : this.readChildren(mapping) + ).catch((error): AgentStreamEvent[] => { + this.logger?.warn( + { err: error, extensionId: id, operation: "hydrate" }, + "Pi extension adapter failed", + ); + return []; + }); + return { ...mapping, events, hydration }; + } + + private async followChildren( + mapping: PiExtensionToolMapping | PiExtensionCustomMapping, + ): Promise { + for (const { id, file } of mapping.childSessions ?? []) { + if (!this.followers.has(id)) { + this.followers.set(id, { + reader: new PiChildSessionFollower(id, file), + pending: Promise.resolve(), + }); + } + } + const finished = (mapping.subagents ?? []).flatMap((event) => + event.type === "upsert" && event.status && event.status !== "running" ? [event.id] : [], + ); + const reads = [ + ...new Set([...(mapping.childSessions ?? []).map(({ id }) => id), ...finished]), + ].flatMap((id) => { + const follower = this.followers.get(id); + return follower ? [this.readFollower(id, follower)] : []; + }); + for (const id of finished) this.followers.delete(id); + await Promise.all(reads); + return []; + } + + private async readChildren( + mapping: PiExtensionToolMapping | PiExtensionCustomMapping, + ): Promise { + const groups = await Promise.all( + (mapping.childSessions ?? []).map(async ({ id, file }) => { + const bytes = Math.min(this.remainingHydrationBytes, 2 * 1024 * 1024); + if (bytes <= 0) return []; + this.remainingHydrationBytes -= bytes; + return (await this.readChildSession(id, file, bytes)).map( + (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "pi", event }), + ); + }), + ); + return groups.flat(); + } + + onToolStart(call: PiExtensionToolCall, provider = "pi"): AgentPermissionRequest | undefined { + let request: AgentPermissionRequest | undefined; + for (const { id, adapter } of this.sessions) { + const candidate = this.safe(id, "onToolStart", () => adapter.onToolStart?.(call, provider)); + request ??= candidate; + } + return request; + } + + onToolEnd(call: PiExtensionToolCall): void { + for (const { id, adapter } of this.sessions) { + this.safe(id, "onToolEnd", () => adapter.onToolEnd?.(call)); + } + } + + mapDialog(dialog: PiExtensionDialog, provider: string): PiExtensionDialogMapping | undefined { + for (const { id, adapter } of this.sessions) { + const mapping = this.safe(id, "mapDialog", () => adapter.mapDialog?.(dialog, provider)); + if (mapping) return mapping; + } + return undefined; + } + + respondToPermission( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined { + for (const { id, adapter } of this.sessions) { + const mapped = this.safe(id, "respondToPermission", () => + adapter.respondToPermission?.(request, response), + ); + if (mapped) return mapped; + } + return undefined; + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts new file mode 100644 index 0000000000..9afacae977 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/index.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from "vitest"; +import { mkdtemp, appendFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPiExtensionHost } from "./index.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import { parseToolArgs, parseToolResult } from "../tool-call-mapper.js"; +import type { PiExtension } from "./contract.js"; +import { PiExtensionHost } from "./host.js"; + +const throwingAdapter: PiExtension = { + id: "throwing-test-adapter", + createSession: () => ({ + mapToolCall: () => { + throw new Error("tool failed"); + }, + mapDialog: () => { + throw new Error("dialog failed"); + }, + respondToPermission: () => { + throw new Error("response failed"); + }, + onToolStart: () => { + throw new Error("start failed"); + }, + onToolEnd: () => { + throw new Error("end failed"); + }, + mapCustomMessage: () => { + throw new Error("custom failed"); + }, + }), +}; + +describe("Pi extension host", () => { + test("streams appended child rows before the child completes", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-live-child-")); + const file = join(dir, "child.jsonl"); + const received: string[] = []; + const host = new PiExtensionHost([ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ + subagents: [{ type: "upsert", id: "child-1", status: "running" }], + childSessions: [{ id: "child-1", file }], + }), + }), + }, + ]); + try { + host.follow((event) => { + if (event.type === "provider_subagent" && event.event.type === "timeline") { + received.push(JSON.stringify(event.event.item)); + } + }); + host.mapToolCall({ + callId: "x", + toolName: "child", + args: {}, + status: "completed", + result: null, + }); + await appendFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"BEGIN"}]}}\n', + ); + await expect.poll(() => received.length).toBe(1); + await appendFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"DO', + ); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(received).toHaveLength(1); + await appendFile(file, 'NE"}]}}\n'); + await expect.poll(() => received.length).toBe(2); + expect(received.join(" ")).toContain("DONE"); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + test("declines a throwing adapter and preserves generic history", async () => { + const host = createPiExtensionHost(undefined, [throwingAdapter]); + const mapper = new PiHistoryMapper("pi", [], {}, host); + const events = mapper.mapMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "other", + content: [{ type: "text", text: "ok" }], + }, + { role: "assistant", content: [{ type: "text", text: "done" }] }, + ]); + expect(events.filter((event) => event.type === "timeline")).toHaveLength(3); + expect(await mapper.hydrate()).toEqual([]); + }); + + test("declines throwing lifecycle and dialog hooks", () => { + const host = createPiExtensionHost(undefined, [throwingAdapter]); + const call = { + callId: "x", + toolName: "other", + args: {}, + status: "running" as const, + result: null, + }; + expect(host.onToolStart(call)).toBeUndefined(); + expect(() => host.onToolEnd(call)).not.toThrow(); + expect( + host.mapDialog( + { + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Pick", + options: ["A"], + }, + "pi", + ), + ).toBeUndefined(); + expect( + host.respondToPermission( + { id: "ui-1", provider: "pi", kind: "question", title: "Pick", input: { questions: [] } }, + { behavior: "allow" }, + ), + ).toBeUndefined(); + expect( + host.mapCustomMessage({ role: "custom", customType: "other", content: "hello" }), + ).toBeUndefined(); + }); + + test("rejected child hydration emits no events", async () => { + const host = new PiExtensionHost( + [ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ childSessions: [{ id: "child-1", file: "unused" }] }), + }), + }, + ], + undefined, + 2 * 1024 * 1024, + async () => { + throw new Error("read failed"); + }, + ); + const output = host.mapToolCall({ + callId: "x", + toolName: "other", + args: {}, + status: "completed", + result: null, + }); + expect(await output?.hydration).toEqual([]); + }); + + test("limits total child reads during replay", async () => { + const reads: string[] = []; + const host = new PiExtensionHost( + [ + { + id: "child", + createSession: () => ({ + mapToolCall: () => ({ + childSessions: Array.from({ length: 10 }, (_, index) => ({ + id: String(index), + file: String(index), + })), + }), + }), + }, + ], + undefined, + 16 * 1024 * 1024, + async (id) => { + reads.push(id); + return []; + }, + ); + const output = host.mapToolCall({ + callId: "x", + toolName: "other", + args: {}, + status: "completed", + result: null, + }); + await output?.hydration; + expect(reads).toHaveLength(8); + }); + test("claims a tool, declines an unrelated one, and maps history like live events", () => { + const host = createPiExtensionHost(); + const args = { agent: "scout", task: "Inspect files" }; + const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] }); + const live = host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args, + status: "completed", + result, + }); + expect(live).toEqual( + expect.objectContaining({ + detail: { + type: "sub_agent", + subAgentType: "scout", + description: "Inspect files", + log: "Found two files", + }, + }), + ); + expect( + host.mapToolCall({ + callId: "call-2", + toolName: "unrelated", + args: {}, + status: "completed", + result, + }), + ).toBeUndefined(); + + const mapper = new PiHistoryMapper("pi"); + const events = mapper.mapMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }], + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "subagent", + content: [{ type: "text", text: "Found two files" }], + }, + ]); + const completed = events.find( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ); + expect( + completed?.type === "timeline" && completed.item.type === "tool_call" + ? completed.item.detail + : null, + ).toEqual(live?.detail); + expect(parseToolArgs("subagent", args).toolName).toBe("subagent"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/index.ts b/packages/server/src/server/agent/providers/pi/extensions/index.ts new file mode 100644 index 0000000000..2ac68178da --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/index.ts @@ -0,0 +1,19 @@ +import { PiExtensionHost } from "./host.js"; +import { piExtensions } from "./registry.js"; +import type { Logger } from "pino"; +import type { PiExtension } from "./contract.js"; + +export const piExtensionRuntimeBridge = piExtensions + .flatMap((extension) => extension.runtimeBridge ?? []) + .join("\n"); + +export type { PiExtensionHost } from "./host.js"; +export type { PiExtensionEventOutput } from "./host.js"; + +export function createPiExtensionHost( + logger?: Pick, + extensions: readonly PiExtension[] = piExtensions, + hydrationByteBudget?: number, +): PiExtensionHost { + return new PiExtensionHost(extensions, logger, hydrationByteBudget); +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts new file mode 100644 index 0000000000..4d6991495a --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "vitest"; +import { createPiExtensionHost } from "../index.js"; + +describe("pi-ask-user adapter", () => { + test("combines selection and comment, then answers the follow-up dialog", () => { + const host = createPiExtensionHost(); + host.onToolStart({ + callId: "ask-1", + toolName: "ask_user", + args: { allowComment: true, allowFreeform: false }, + status: "running", + result: null, + }); + const mapped = host.mapDialog( + { + type: "extension_ui_request", + id: "select-1", + method: "select", + title: "Pick one", + options: ["A", "B"], + }, + "pi", + ); + expect(mapped?.type).toBe("permission"); + if (mapped?.type !== "permission") throw new Error("Expected a combined permission"); + expect(mapped.request.input?.questions).toHaveLength(2); + expect( + host.respondToPermission(mapped.request, { + behavior: "allow", + updatedInput: { answers: { Response: "B", Comment: "Looks good" } }, + }), + ).toEqual({ responses: [{ id: "select-1", response: { value: "B" } }] }); + expect( + host.mapDialog( + { + type: "extension_ui_request", + id: "comment-1", + method: "input", + placeholder: "Optional comment (press Enter to skip)...", + }, + "pi", + ), + ).toEqual({ type: "response", response: { value: "Looks good" } }); + }); + + test("declines unrelated dialogs and cancels denied selection", () => { + const host = createPiExtensionHost(); + expect( + host.mapDialog( + { type: "extension_ui_request", id: "other", method: "select", options: ["A"] }, + "pi", + ), + ).toBeUndefined(); + host.onToolStart({ + callId: "ask-2", + toolName: "ask_user", + args: { allowComment: true }, + status: "running", + result: null, + }); + const mapped = host.mapDialog( + { type: "extension_ui_request", id: "select-2", method: "select", options: ["A"] }, + "pi", + ); + if (mapped?.type !== "permission") throw new Error("Expected a combined permission"); + expect(host.respondToPermission(mapped.request, { behavior: "deny" })).toEqual({ + responses: [{ id: "select-2", response: { cancelled: true } }], + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts new file mode 100644 index 0000000000..4de58c86f8 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-ask-user/index.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import type { + AgentMetadata, + AgentPermissionRequest, + AgentPermissionResponse, +} from "../../../../agent-sdk-types.js"; +import type { PiExtension, PiExtensionDialog, PiExtensionUiReply } from "../contract.js"; + +const QUESTION_RESPONSE_HEADER = "Response"; +const QUESTION_COMMENT_HEADER = "Comment"; +const FREEFORM_SENTINEL = "✏️ Type custom response..."; +const COMBINED_METADATA = "ask_user_select_optional_comment"; +const Args = z + .object({ + allowComment: z.boolean().catch(false), + allowFreeform: z.boolean().catch(true), + allowMultiple: z.boolean().catch(false), + }) + .passthrough(); +const Select = z + .object({ title: z.string().optional(), options: z.array(z.string()).optional() }) + .passthrough(); +const Input = z.object({ placeholder: z.string().optional() }).passthrough(); + +function answer(input: AgentMetadata | undefined, header: string): string | null { + const answers = input?.answers; + if (!answers || typeof answers !== "object" || Array.isArray(answers)) return null; + const value = Reflect.get(answers, header); + return typeof value === "string" ? value : null; +} + +function optionalInput(placeholder: string | undefined): boolean { + return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); +} + +function combinedPermission( + dialog: PiExtensionDialog, + provider: string, + options: string[], + allowFreeform: boolean, + question: string, +): AgentPermissionRequest { + const visibleOptions = options.filter((option) => option !== FREEFORM_SENTINEL); + const allowOther = allowFreeform || visibleOptions.length !== options.length; + return { + id: dialog.id, + provider, + name: "Pi ask_user", + kind: "question", + title: question, + input: { + questions: [ + { + question, + header: QUESTION_RESPONSE_HEADER, + options: visibleOptions.map((label) => ({ label })), + multiSelect: false, + ...(allowOther ? { allowOther: true } : {}), + }, + { + question: "Optional comment", + header: QUESTION_COMMENT_HEADER, + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + }, + ], + }, + metadata: { + extensionUiMethod: dialog.method, + answerHeader: QUESTION_RESPONSE_HEADER, + commentHeader: QUESTION_COMMENT_HEADER, + combinedAskUser: COMBINED_METADATA, + selectOptions: visibleOptions, + ...(allowOther ? { freeformSentinel: FREEFORM_SENTINEL } : {}), + }, + }; +} + +export const piAskUser: PiExtension = { + id: "pi-ask-user", + createSession() { + let active: z.infer | null = null; + let pending: { comment: string; freeform: string | null } | null = null; + return { + onToolStart(call) { + active = call.toolName === "ask_user" ? (Args.safeParse(call.args).data ?? null) : null; + return undefined; + }, + onToolEnd(call) { + if (call.toolName === "ask_user") { + active = null; + pending = null; + } + }, + mapDialog(dialog, provider) { + if (pending && dialog.method === "input") { + const input = Input.safeParse(dialog); + const placeholder = input.success ? input.data.placeholder : undefined; + if (pending.freeform !== null && !optionalInput(placeholder)) { + const value = pending.freeform; + pending = { ...pending, freeform: null }; + return { type: "response", response: { value } }; + } + if (optionalInput(placeholder)) { + const value = pending.comment; + pending = null; + return { type: "response", response: { value } }; + } + } + if (dialog.method !== "select" || !active?.allowComment || active.allowMultiple) + return undefined; + const select = Select.safeParse(dialog); + const options = select.success ? (select.data.options ?? []) : []; + const question = select.success + ? (select.data.title ?? "Select an option") + : "Select an option"; + return { + type: "permission", + request: combinedPermission(dialog, provider, options, active.allowFreeform, question), + }; + }, + respondToPermission( + request: AgentPermissionRequest, + response: AgentPermissionResponse, + ): PiExtensionUiReply | undefined { + if (request.metadata?.combinedAskUser !== COMBINED_METADATA) return undefined; + if (response.behavior === "deny") { + pending = null; + return { responses: [{ id: request.id, response: { cancelled: true } }] }; + } + const selected = answer(response.updatedInput, QUESTION_RESPONSE_HEADER); + if (selected === null) { + pending = null; + return { responses: [{ id: request.id, response: { cancelled: true } }] }; + } + const selectOptions = Array.isArray(request.metadata?.selectOptions) + ? request.metadata.selectOptions.filter( + (item): item is string => typeof item === "string", + ) + : []; + const sentinel = + typeof request.metadata?.freeformSentinel === "string" + ? request.metadata.freeformSentinel + : undefined; + const isFreeform = Boolean(sentinel) && !selectOptions.includes(selected); + pending = { + comment: answer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? "", + freeform: isFreeform ? selected : null, + }; + return { + responses: [{ id: request.id, response: { value: isFreeform ? sentinel : selected } }], + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json new file mode 100644 index 0000000000..46465d196e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/fixtures/rpc-session.json @@ -0,0 +1,250 @@ +{ + "provenance": { + "package": "@earendil-works/pi-coding-agent examples/extensions/todo.ts", + "version": "0.87.1", + "sourceCommit": "7fd564cbb78f35f3de14d5382fea692b87ec4026", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_433291", + "toolName": "todo", + "args": { + "action": "add", + "text": "Inspect workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_433291", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Added todo #1: Inspect workspace" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + } + ], + "nextId": 2 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_453655", + "toolName": "todo", + "args": { + "action": "add", + "text": "Summarize findings" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_453655", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Added todo #2: Summarize findings" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_525797", + "toolName": "todo", + "args": { + "action": "toggle", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_525797", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Todo #1 completed" + } + ], + "details": { + "action": "toggle", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": true + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_433291", + "name": "todo", + "arguments": { + "action": "add", + "text": "Inspect workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_433291", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Added todo #1: Inspect workspace" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + } + ], + "nextId": 2 + }, + "isError": false, + "timestamp": 1790195609694 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_453655", + "name": "todo", + "arguments": { + "action": "add", + "text": "Summarize findings" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_453655", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Added todo #2: Summarize findings" + } + ], + "details": { + "action": "add", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": false + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195610962 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_525797", + "name": "todo", + "arguments": { + "action": "toggle", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_525797", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Todo #1 completed" + } + ], + "details": { + "action": "toggle", + "todos": [ + { + "id": 1, + "text": "Inspect workspace", + "done": true + }, + { + "id": 2, + "text": "Summarize findings", + "done": false + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195612441 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts new file mode 100644 index 0000000000..e2a858fd6c --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { piExampleTodo } from "./index.js"; + +test("Pi example todo maps captured RPC tasks through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending", + "pending,pending", + "completed,pending", + ]); +}); + +test("example todo declines rpiv-todo's shape", () => { + const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url)); + expect(piExampleTodo.createSession().mapToolCall?.(call)).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts new file mode 100644 index 0000000000..31d7e21199 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-example-todo/index.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import type { PiExtension } from "../contract.js"; + +const Details = z.object({ + action: z.enum(["list", "add", "toggle", "clear"]), + nextId: z.number().int(), + todos: z.array(z.object({ id: z.number().int(), text: z.string(), done: z.boolean() })), + error: z.string().optional(), +}); + +export const piExampleTodo: PiExtension = { + id: "pi-example-todo", + createSession: () => ({ + mapToolCall(call) { + if ( + call.toolName !== "todo" || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + return { + timeline: parsed.data.error + ? [] + : [ + { + type: "todo", + items: parsed.data.todos.map((todo) => ({ + id: String(todo.id), + text: todo.text, + status: todo.done ? "completed" : "pending", + completed: todo.done, + })), + }, + ], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json new file mode 100644 index 0000000000..2ef2df977f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/fixtures/rpc-session.json @@ -0,0 +1,422 @@ +{ + "provenance": { + "package": "pi-goal-x", + "version": "0.31.8", + "sourceCommit": "f366b5cdf36e9329c980f8c44b0d300c910e2f41", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_476725", + "toolName": "create_goal", + "args": { + "objective": "Inspect the empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_476725", + "toolName": "create_goal", + "result": { + "content": [ + { + "type": "text", + "text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 0, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 0, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:06.093Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "048961d8-6a89-471e-9562-0c09798cc070", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md" + } + }, + "terminate": true, + "addedToolNames": ["update_goal", "set_goal_tasks"] + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "args": { + "tasks": [ + { + "title": "Inspect workspace", + "id": "task-1" + }, + { + "title": "Summarize findings", + "id": "task-2" + } + ] + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "result": { + "content": [ + { + "type": "text", + "text": "Task list set and confirmed. 2 tasks." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 6287, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 5, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:08.560Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "pending" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "terminate": true, + "addedToolNames": ["update_goal_task"] + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "args": { + "updates": [ + { + "status": "start", + "task_id": "task-1" + }, + { + "status": "complete", + "task_id": "task-1" + } + ] + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "result": { + "content": [ + { + "type": "text", + "text": "task-1 start; task-1 complete. 1/2 tasks complete." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 13828, + "activeSeconds": 2 + }, + "sisyphus": false, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:10.977Z", + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "revision": 11, + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "d4598e43-6c04-47ab-a004-0f335da65426", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "complete", + "completedAt": "2026-09-23T20:35:10.976Z" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_476725", + "name": "create_goal", + "arguments": { + "objective": "Inspect the empty workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_476725", + "toolName": "create_goal", + "content": [ + { + "type": "text", + "text": "Goal confirmed and created.\n\nFinalized goal:\n\nInspect the empty workspace\n\nGoal details:\nBudget: none\nGoal: Inspect the empty workspace\nStatus: running\nAuto-continue: on\nTime spent: 0s\nTokens used: 0 tokens\nFile: .pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md\n1 other open goal remain in .pi/goals — this goal is now the session focus." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 0, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 0, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:06.093Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "048961d8-6a89-471e-9562-0c09798cc070", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md" + } + }, + "addedToolNames": ["update_goal", "set_goal_tasks"], + "isError": false, + "timestamp": 1790195706099 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_434120", + "name": "set_goal_tasks", + "arguments": { + "tasks": [ + { + "title": "Inspect workspace", + "id": "task-1" + }, + { + "title": "Summarize findings", + "id": "task-2" + } + ] + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_434120", + "toolName": "set_goal_tasks", + "content": [ + { + "type": "text", + "text": "Task list set and confirmed. 2 tasks." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 6287, + "activeSeconds": 0 + }, + "sisyphus": false, + "revision": 5, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:08.560Z", + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "3eb8953c-0d95-44d9-b319-b4c4ab9d2cfe", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "pending" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "addedToolNames": ["update_goal_task"], + "isError": false, + "timestamp": 1790195708561 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_23432", + "name": "update_goal_task", + "arguments": { + "updates": [ + { + "status": "start", + "task_id": "task-1" + }, + { + "status": "complete", + "task_id": "task-1" + } + ] + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_23432", + "toolName": "update_goal_task", + "content": [ + { + "type": "text", + "text": "task-1 start; task-1 complete. 1/2 tasks complete." + } + ], + "details": { + "version": 3, + "goal": { + "id": "muekaval-uhhfg8", + "objective": "Inspect the empty workspace", + "status": "active", + "autoContinue": true, + "usage": { + "tokensUsed": 13828, + "activeSeconds": 2 + }, + "sisyphus": false, + "createdAt": "2026-09-23T20:35:06.093Z", + "updatedAt": "2026-09-23T20:35:10.977Z", + "activePath": ".pi/goals/active_goal_2026092322350609_muekaval-uhhfg8.md", + "revision": 11, + "scheduler": { + "version": 1, + "owner": "01a0cffa-99a6-7e64-888a-debde9407a25", + "generation": "d4598e43-6c04-47ab-a004-0f335da65426", + "used": 0, + "phase": "idle", + "repairUsed": false + }, + "taskList": { + "tasks": [ + { + "id": "task-1", + "title": "Inspect workspace", + "status": "complete", + "completedAt": "2026-09-23T20:35:10.976Z" + }, + { + "id": "task-2", + "title": "Summarize findings", + "status": "pending" + } + ], + "blockCompletion": false, + "proposedAt": "2026-09-23T20:35:08.559Z" + } + } + }, + "isError": false, + "timestamp": 1790195710978 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts new file mode 100644 index 0000000000..14de2391a3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { piGoalX } from "./index.js"; + +test("pi-goal-x maps captured RPC task tree through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending,pending", + "completed,pending", + ]); +}); + +test("pi-goal-x declines a foreign goal shape", () => { + const call = capturedToolCall(new URL("../rpiv-todo/fixtures/rpc-session.json", import.meta.url)); + expect(piGoalX.createSession().mapToolCall?.({ ...call, toolName: "get_goal" })).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts new file mode 100644 index 0000000000..581d7096d1 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-goal-x/index.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import type { AgentTaskItem } from "../../../../agent-sdk-types.js"; +import type { PiExtension } from "../contract.js"; + +interface GoalTask { + id: string; + title: string; + status: "pending" | "complete" | "skipped"; + subtasks?: GoalTask[]; +} +const Task: z.ZodType = z.lazy(() => + z.object({ + id: z.string(), + title: z.string(), + status: z.enum(["pending", "complete", "skipped"]), + subtasks: z.array(Task).optional(), + }), +); +const Details = z.object({ + version: z.literal(3), + goal: z + .object({ + currentTaskId: z.string().optional(), + taskList: z.object({ tasks: z.array(Task) }).optional(), + }) + .nullable(), +}); +const toolNames = new Set([ + "create_goal", + "get_goal", + "update_goal", + "set_goal_tasks", + "update_goal_task", +]); + +function toItems(tasks: GoalTask[], currentTaskId?: string): AgentTaskItem[] { + return tasks.flatMap((task) => { + let status: AgentTaskItem["status"] = "pending"; + if (task.status !== "pending") status = "completed"; + else if (task.id === currentTaskId) status = "in_progress"; + return [ + { + id: task.id, + text: task.title, + status, + completed: task.status !== "pending", + }, + ...toItems(task.subtasks ?? [], currentTaskId), + ]; + }); +} + +export const piGoalX: PiExtension = { + id: "pi-goal-x", + createSession: () => ({ + mapToolCall(call) { + if ( + !toolNames.has(call.toolName) || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + const goal = parsed.data.goal; + if (!goal?.taskList) return undefined; + return { + timeline: [{ type: "todo", items: toItems(goal.taskList.tasks, goal.currentTaskId) }], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts new file mode 100644 index 0000000000..5316d6f6be --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { createPiExtensionHost } from "../index.js"; +import { + parseToolArgs, + parseToolResult, + type PiToolResult, + type PiTrackedToolCall, +} from "../../tool-call-mapper.js"; + +function mapping(toolCall: PiTrackedToolCall, result: PiToolResult) { + return createPiExtensionHost().mapToolCall({ + callId: "test-call", + toolName: toolCall.toolName, + args: toolCall.args, + status: result ? "completed" : "running", + result, + }); +} +function resolveToolCallName(toolCall: PiTrackedToolCall, result: PiToolResult) { + return mapping(toolCall, result)?.name ?? toolCall.toolName; +} + +describe("pi-mcp-adapter adapter", () => { + test("normalizes Pi MCP proxy calls from requested tool args while running", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + + expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models"); + }); + + test("normalizes Pi MCP proxy calls from result details when completed", () => { + const toolCall = parseToolArgs("mcp", { + tool: "paseo_list_models", + args: '{"provider":"pi"}', + }); + const result = parseToolResult({ + content: [{ type: "text", text: "(empty result)" }], + details: { + mode: "call", + server: "paseo", + tool: "list_models", + }, + }); + + expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts new file mode 100644 index 0000000000..c36e18731f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-mcp-adapter/index.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; +import type { PiExtension } from "../contract.js"; + +const Args = z + .object({ + tool: z.string().optional().catch(undefined), + server: z.string().optional().catch(undefined), + }) + .passthrough(); +const Details = z + .object({ + server: z.string().optional().catch(undefined), + tool: z.string().optional().catch(undefined), + }) + .passthrough(); +const nonEmpty = (value: string | undefined) => value?.trim() || undefined; + +export const piMcpAdapter: PiExtension = { + id: "pi-mcp-adapter", + createSession: () => ({ + mapToolCall(call) { + if (call.toolName !== "mcp") return undefined; + if (call.result && typeof call.result !== "string") { + const details = Details.safeParse(call.result.details); + if (details.success) { + const server = nonEmpty(details.data.server); + const tool = nonEmpty(details.data.tool); + if (server && tool) return { name: `${server}.${tool}` }; + } + } + const args = Args.safeParse(call.args); + if (!args.success) return undefined; + const tool = nonEmpty(args.data.tool); + const server = nonEmpty(args.data.server); + if (tool && server) + return { + name: `${server}.${tool.startsWith(`${server}_`) ? tool.slice(server.length + 1) : tool}`, + }; + if (tool) { + const [prefix, ...rest] = tool.split("_"); + if (prefix && rest.length > 0) return { name: `${prefix}.${rest.join("_")}` }; + } + return undefined; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..e5f9c9f770 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/background.json @@ -0,0 +1,238 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_1334451", + "toolName": "subagent", + "args": { + "task": "list files in this empty workspace and report", + "agent": "scout", + "async": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1334451", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [005cfec2-1376-4e96-b622-5ad8f4c15cb5]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: bc1a1172-b106-4b35-9325-9fd304278827 (active)" + } + ], + "details": { + "mode": "single", + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "results": [], + "asyncId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "launchContractDigest": "d7ed0cb6c99feb265efdaf7294fdac2e9eceacf3560c3cf8fd5a7fa22ea4dac1", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790199499998, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "bc1a1172-b106-4b35-9325-9fd304278827", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/bc1a1172-b106-4b35-9325-9fd304278827.json", + "mission": { + "schemaVersion": 1, + "id": "bc1a1172-b106-4b35-9325-9fd304278827", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:08:19.994Z", + "updatedAt": "2026-09-23T21:08:20.008Z", + "runs": [ + { + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "status": "active", + "startedAt": "2026-09-23T21:08:20.008Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff.jsonl" + } + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\n(No files found; the workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.)\n\n## Key Code\nN/A - Empty repository / workspace.\n\n## Architecture\nN/A - Empty repository / workspace.\n\n## Start Here\nThe workspace is currently empty. Initialize or populate the workspace with source code to begin development.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/context.md (356 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff/2a37ef22-e1f9-4f75-b9a0-510e82ffd422/run-0/session.jsonl", + "display": false, + "timestamp": 1790197711046 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1334451", + "name": "subagent", + "arguments": { + "task": "list files in this empty workspace and report", + "agent": "scout", + "async": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3303, + "output": 114, + "cacheRead": 7053, + "cacheWrite": 0, + "reasoning": 81, + "totalTokens": 10470, + "cost": { + "input": 0.00247725, + "output": 0.00042750000000000004, + "cacheRead": 0.000528975, + "cacheWrite": 0, + "total": 0.003433725 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197697608, + "responseId": "gen-1790197697-lxluaTrDkdaxjSXsO957", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_1334451", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [005cfec2-1376-4e96-b622-5ad8f4c15cb5]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: bc1a1172-b106-4b35-9325-9fd304278827 (active)" + } + ], + "details": { + "mode": "single", + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "results": [], + "asyncId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "launchContractDigest": "d7ed0cb6c99feb265efdaf7294fdac2e9eceacf3560c3cf8fd5a7fa22ea4dac1", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790199499998, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "bc1a1172-b106-4b35-9325-9fd304278827", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/bc1a1172-b106-4b35-9325-9fd304278827.json", + "mission": { + "schemaVersion": 1, + "id": "bc1a1172-b106-4b35-9325-9fd304278827", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:08:19.994Z", + "updatedAt": "2026-09-23T21:08:20.008Z", + "runs": [ + { + "runId": "005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5", + "status": "active", + "startedAt": "2026-09-23T21:08:20.008Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff.jsonl" + } + }, + "isError": false, + "timestamp": 1790197700011 + }, + { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\n(No files found; the workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.)\n\n## Key Code\nN/A - Empty repository / workspace.\n\n## Architecture\nN/A - Empty repository / workspace.\n\n## Start Here\nThe workspace is currently empty. Initialize or populate the workspace with source code to begin development.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/005cfec2-1376-4e96-b622-5ad8f4c15cb5/context.md (356 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/005cfec2-1376-4e96-b622-5ad8f4c15cb5\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-08-13-722Z_01a0d018-fd1a-7711-9c17-933fd34f0cff/2a37ef22-e1f9-4f75-b9a0-510e82ffd422/run-0/session.jsonl", + "display": false, + "timestamp": 1790197711046 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..4c91517bc7 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,6 @@ +{"type":"message","id":"e795eaf5","parentId":"1246b701","timestamp":"2026-09-23T21:07:15.911Z","message":{"role":"user","content":[{"type":"text","text":"Task: list the files in the current empty workspace\n\n---\n**Output:**\nWrite your findings to exactly this path: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md\nThis path is authoritative for this run.\nIgnore any other output filename or output path mentioned elsewhere, including output destinations in the base agent prompt, system prompt, or task instructions."}],"timestamp":1790197635909}} +{"type":"message","id":"9c3e635a","parentId":"e795eaf5","timestamp":"2026-09-23T21:07:17.023Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a18r+r0Pz/qlYHWE7rzBu05Qld2Nrf1tWvryqCbkr58iwJN1Xd5cBJXIWN9eGQldY3pt9HBzDHMx7+0/FrcUJrNRI9pMFmZ1+RX/\",\"format\":\"google-gemini-v1\",\"id\":\"call_1077973\",\"index\":0}]"},{"type":"toolCall","id":"call_1077973","name":"ls","arguments":{"path":"."}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2269,"output":14,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2283,"cost":{"input":0.0017017500000000001,"output":0.0000525,"cacheRead":0,"cacheWrite":0,"total":0.0017542500000000002}},"stopReason":"toolUse","timestamp":1790197635958,"responseId":"gen-1790197636-iGm6QGfRbBF1JVOGTV3F","rawStopReason":"tool_calls"}} +{"type":"message","id":"5dc50c52","parentId":"9c3e635a","timestamp":"2026-09-23T21:07:17.025Z","message":{"role":"toolResult","toolCallId":"call_1077973","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197637025}} +{"type":"message","id":"ccd5b53b","parentId":"5dc50c52","timestamp":"2026-09-23T21:07:18.699Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"","thinkingSignature":"[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a1/cR3gdBgtKF94+BF+RZ/GhtIJDc1LiOKIgJzdWTU0PUyGthIMRtW3RcTe+vmD6jxfZpiQnLfpVHFF2u0nRbYKUS2d2JdYvEYoP\",\"format\":\"google-gemini-v1\",\"id\":\"call_509063\",\"index\":0}]"},{"type":"toolCall","id":"call_509063","name":"write","arguments":{"content":"# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n","path":"/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md"}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2286,"output":161,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2447,"cost":{"input":0.0017145,"output":0.00060375,"cacheRead":0,"cacheWrite":0,"total":0.00231825}},"stopReason":"toolUse","timestamp":1790197637026,"responseId":"gen-1790197637-JwIo6AVv6AVYwzaK9hmY","rawStopReason":"tool_calls"}} +{"type":"message","id":"5ae9c4b7","parentId":"ccd5b53b","timestamp":"2026-09-23T21:07:18.701Z","message":{"role":"toolResult","toolCallId":"call_509063","toolName":"write","content":[{"type":"text","text":"Successfully wrote 312 bytes to /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md"}],"isError":false,"timestamp":1790197638701}} +{"type":"message","id":"034e2862","parentId":"5ae9c4b7","timestamp":"2026-09-23T21:07:21.572Z","message":{"role":"assistant","content":[{"type":"text","text":"I have inspected the current workspace and confirmed that it is an empty directory. The scouting context documentation has been written to `/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md`.\n\n# Code Context Summary\n\n## Files Retrieved\n- `.`: Directory contents checked via `ls`, confirmed empty directory.\n\n## Key Code\n- None (workspace is empty).\n\n## Architecture\n- N/A\n\n## Start Here\n- N/A\n\n```acceptance-report\n{\n \"criteriaSatisfied\": [\n {\n \"id\": \"criterion-1\",\n \"status\": \"satisfied\",\n \"evidence\": \"Listed current workspace using ls and confirmed it is empty; output written to designated context path.\"\n }\n ],\n \"changedFiles\": [],\n \"testsAddedOrUpdated\": [],\n \"commandsRun\": [],\n \"validationOutput\": [\n \"Directory contents: (empty directory)\"\n ],\n \"residualRisks\": [\n \"Workspace is empty\"\n ],\n \"noStagedFiles\": true,\n \"diffSummary\": \"No source files changed. Output written to subagent artifacts path.\",\n \"reviewFindings\": [\n \"No code issues; directory is empty.\"\n ],\n \"manualNotes\": \"Listed current workspace directory and generated required context artifact.\"\n}\n```"}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2510,"output":336,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2846,"cost":{"input":0.0018825,"output":0.00126,"cacheRead":0,"cacheWrite":0,"total":0.0031425000000000003}},"stopReason":"stop","timestamp":1790197638701,"responseId":"gen-1790197638-a2hJ13SAsm2PpAbEH64L","rawStopReason":"stop"}} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..e48353293e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/foreground.json @@ -0,0 +1,619 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_930237", + "toolName": "subagent", + "args": { + "async": false, + "agent": "scout", + "task": "list the files in the current empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_930237", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.\nMission: 17cc387f-50eb-4a34-bb98-4452434c1274 (completed)" + } + ], + "details": { + "mode": "single", + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "timeoutMs": 1800000, + "results": [ + { + "index": 0, + "agent": "scout", + "task": "[prompt redacted]", + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "launchContractDigest": "017d16009783e723e7ceb19f4895d8a91d9ac4b1cade1ca0f5f50c8fbbac8a41", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": true, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "exitCode": 0, + "outputState": "present", + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "model": "openrouter/google/gemini-3.6-flash:low", + "thinking": "low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + }, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "context": "fresh", + "progressSummary": { + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "toolCount": 2, + "tokens": 7576, + "durationMs": 6330 + }, + "savedOutputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "outputReference": { + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "bytes": 311, + "lines": 13, + "message": "Output saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed." + }, + "outputMode": "inline", + "finalOutput": "# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.", + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403/679ba95f-8fae-466c-a889-6717de1dc2f0/run-0/session.jsonl", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + }, + "toolCalls": [ + { + "text": "ls {\"path\":\".\"}", + "expandedText": "ls {\"path\":\".\"}" + }, + { + "text": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "expandedText": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + } + ] + } + ], + "artifacts": { + "dir": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts", + "files": [ + { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + } + ] + }, + "totalChildUsage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "totalCost": { + "inputTokens": 7065, + "outputTokens": 511, + "costUsd": 0.007215 + }, + "context": "fresh", + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "17cc387f-50eb-4a34-bb98-4452434c1274", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/17cc387f-50eb-4a34-bb98-4452434c1274.json", + "mission": { + "schemaVersion": 1, + "id": "17cc387f-50eb-4a34-bb98-4452434c1274", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "completed", + "createdAt": "2026-09-23T21:07:15.238Z", + "updatedAt": "2026-09-23T21:07:21.578Z", + "runs": [ + { + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "mode": "single", + "agent": "scout", + "status": "completed", + "startedAt": "2026-09-23T21:07:21.578Z", + "completedAt": "2026-09-23T21:07:21.578Z", + "usage": { + "tokens": 7576 + } + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md" + }, + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + }, + { + "kind": "other", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "description": "Child transcript" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403.jsonl", + "summary": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + } + } + }, + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 7576, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.007215 + } + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_930237", + "name": "subagent", + "arguments": { + "async": false, + "agent": "scout", + "task": "list the files in the current empty workspace" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 3368, + "output": 130, + "cacheRead": 7060, + "cacheWrite": 0, + "reasoning": 97, + "totalTokens": 10558, + "cost": { + "input": 0.002526, + "output": 0.00048750000000000003, + "cacheRead": 0.0005295, + "cacheWrite": 0, + "total": 0.003543 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197632837, + "responseId": "gen-1790197632-7WtHYojgcq3aQeXi5a9Y", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_930237", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.\nMission: 17cc387f-50eb-4a34-bb98-4452434c1274 (completed)" + } + ], + "details": { + "mode": "single", + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "timeoutMs": 1800000, + "results": [ + { + "index": 0, + "agent": "scout", + "task": "[prompt redacted]", + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "launchContractDigest": "017d16009783e723e7ceb19f4895d8a91d9ac4b1cade1ca0f5f50c8fbbac8a41", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": true, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "exitCode": 0, + "outputState": "present", + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "model": "openrouter/google/gemini-3.6-flash:low", + "thinking": "low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + }, + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "context": "fresh", + "progressSummary": { + "sessionName": "scout: list the files in the current empty workspace --- **Outpu...", + "toolCount": 2, + "tokens": 7576, + "durationMs": 6330 + }, + "savedOutputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "outputReference": { + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "bytes": 311, + "lines": 13, + "message": "Output saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed." + }, + "outputMode": "inline", + "finalOutput": "# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.", + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403/679ba95f-8fae-466c-a889-6717de1dc2f0/run-0/session.jsonl", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + }, + "toolCalls": [ + { + "text": "ls {\"path\":\".\"}", + "expandedText": "ls {\"path\":\".\"}" + }, + { + "text": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md", + "expandedText": "write /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + } + ] + } + ], + "artifacts": { + "dir": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts", + "files": [ + { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_meta.json" + } + ] + }, + "totalChildUsage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.007215, + "turns": 3 + }, + "totalCost": { + "inputTokens": 7065, + "outputTokens": 511, + "costUsd": 0.007215 + }, + "context": "fresh", + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "17cc387f-50eb-4a34-bb98-4452434c1274", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/17cc387f-50eb-4a34-bb98-4452434c1274.json", + "mission": { + "schemaVersion": 1, + "id": "17cc387f-50eb-4a34-bb98-4452434c1274", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "completed", + "createdAt": "2026-09-23T21:07:15.238Z", + "updatedAt": "2026-09-23T21:07:21.578Z", + "runs": [ + { + "runId": "679ba95f-8fae-466c-a889-6717de1dc2f0", + "mode": "single", + "agent": "scout", + "status": "completed", + "startedAt": "2026-09-23T21:07:21.578Z", + "completedAt": "2026-09-23T21:07:21.578Z", + "usage": { + "tokens": 7576 + } + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_output.md" + }, + { + "kind": "output", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md" + }, + { + "kind": "other", + "path": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/679ba95f-8fae-466c-a889-6717de1dc2f0_scout_0_transcript.jsonl", + "description": "Child transcript" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-07-08-019Z_01a0d017-fc73-7e52-b822-9235801e5403.jsonl", + "summary": "Run fan-out: 1/64 used, 63 remaining\n# Code Context\n\n## Files Retrieved\nWorkspace directory `/tmp/paseo-pi-extension-phase3/workspace` was inspected using `ls`. No files found.\n\n## Key Code\nN/A - The workspace is currently empty.\n\n## Architecture\nN/A - No code structure or files exist in the workspace yet.\n\n## Start Here\nN/A - Workspace is empty.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/679ba95f-8fae-466c-a889-6717de1dc2f0/context.md (311 B, 13 lines). Read this file if needed.", + "acceptance": { + "status": "attested", + "evidenceStatus": "attested", + "explicit": false, + "effectiveAcceptance": { + "level": "attested", + "explicit": false, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "evidence": ["manual-notes", "residual-risks"], + "verify": [], + "stopRules": [] + }, + "inferredReason": ["default lightweight attestation"], + "criteria": [ + { + "id": "criterion-1", + "must": "Return a concise result and residual risks when applicable", + "evidence": ["manual-notes", "residual-risks"], + "severity": "required" + } + ], + "runtimeChecks": [], + "verifyRuns": [], + "childReport": { + "criteriaSatisfied": [ + { + "id": "criterion-1", + "status": "satisfied", + "evidence": "Listed current workspace using ls and confirmed it is empty; output written to designated context path." + } + ], + "changedFiles": [], + "testsAddedOrUpdated": [], + "commandsRun": [], + "validationOutput": ["Directory contents: (empty directory)"], + "residualRisks": ["Workspace is empty"], + "noStagedFiles": true, + "diffSummary": "No source files changed. Output written to subagent artifacts path.", + "reviewFindings": ["No code issues; directory is empty."], + "manualNotes": "Listed current workspace directory and generated required context artifact." + } + } + } + }, + "usage": { + "input": 7065, + "output": 511, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 7576, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.007215 + } + }, + "isError": false, + "timestamp": 1790197641579 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json new file mode 100644 index 0000000000..0e43c8dd25 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/fixtures/wait.json @@ -0,0 +1,412 @@ +{ + "provenance": { + "package": "pi-subagents", + "version": "0.71.0", + "sourceCommit": "4af5e85a427b9f87334585ae8d0eb365d4dd2a1e", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_451394", + "toolName": "subagent", + "args": { + "agent": "scout", + "task": "list files in this empty workspace", + "async": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_451394", + "toolName": "subagent", + "result": { + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [33b193f7-7519-4395-a09c-e45e8f5ce4fa]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: 812446a0-b883-48e0-ac3c-f810ffbcb7a9 (active)" + } + ], + "details": { + "mode": "single", + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "results": [], + "asyncId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "launchContractDigest": "1c2799c32119977a96c1e4987cea70fccbe931d8cd1904fd2da8d8689dd0fa08", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790200012953, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/812446a0-b883-48e0-ac3c-f810ffbcb7a9.json", + "mission": { + "schemaVersion": 1, + "id": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:16:52.950Z", + "updatedAt": "2026-09-23T21:16:52.962Z", + "runs": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "status": "active", + "startedAt": "2026-09-23T21:16:52.962Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198.jsonl" + } + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "args": { + "all": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "result": { + "content": [ + { + "type": "text", + "text": "Waited 7.8s for 1 async run(s); done. Outcome: 1 complete.\nResult [33b193f7-7519-4395-a09c-e45e8f5ce4fa]: /tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json\n Completion/control events have been observed; inspect status if a notification is not visible yet." + } + ], + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 8319, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00889125 + } + }, + "details": { + "mode": "management", + "results": [], + "completions": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "agent": "scout", + "mode": "single", + "state": "complete", + "success": true, + "results": [ + { + "agent": "scout", + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.00889125, + "turns": 3 + }, + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "success": true, + "outputState": "present", + "model": "openrouter/google/gemini-3.6-flash:low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_meta.json" + } + } + ], + "archivePath": "/tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json" + } + ] + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\nNo files found in workspace `/tmp/paseo-pi-extension-phase3/workspace`.\n\n## Key Code\nNone. The directory is empty.\n\n## Architecture\nN/A - Empty workspace.\n\n## Start Here\n`/tmp/paseo-pi-extension-phase3/workspace` - Root workspace directory.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/context.md (275 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "display": false, + "timestamp": 1790198222119 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_451394", + "name": "subagent", + "arguments": { + "agent": "scout", + "task": "list files in this empty workspace", + "async": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 9469, + "output": 127, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 96, + "totalTokens": 9596, + "cost": { + "input": 0.00710175, + "output": 0.00047625, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.0075780000000000005 + } + }, + "stopReason": "toolUse", + "timestamp": 1790198210866, + "responseId": "gen-1790198211-rIrfe6SMT2s34Gp8iGou", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_451394", + "toolName": "subagent", + "content": [ + { + "type": "text", + "text": "Run fan-out: 1/64 used, 63 remaining\nAsync: scout [33b193f7-7519-4395-a09c-e45e8f5ce4fa]\n\nThe async run is detached and running in the background.\nYou are in an interactive session. Return control to the user now; Pi will wake you through the native completion notification when this subagent completes or needs attention. Do not run sleep/polling loops to wait for this async subagent; it does not need a wait call.\nUse bg_wait only for provider, detached, or other background work that lacks a native completion notification.\nIf the current turn must receive results from work without a native notification before it ends, call blocking bg_wait(); ordinary async subagent runs do not need a wait call because their completion is delivered natively.\nOtherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.\nMission: 812446a0-b883-48e0-ac3c-f810ffbcb7a9 (active)" + } + ], + "details": { + "mode": "single", + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "results": [], + "asyncId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "launchContractDigest": "1c2799c32119977a96c1e4987cea70fccbe931d8cd1904fd2da8d8689dd0fa08", + "launchResolvedExtensions": { + "version": 1, + "source": "launch-resolved", + "disableAmbientExtensions": false, + "runtime": ["sha256:c41552bd9557eaab"], + "configured": [], + "required": [], + "effective": ["sha256:c41552bd9557eaab"], + "omitted": { + "runtime": 0, + "configured": 0, + "required": 0, + "effective": 0 + } + }, + "context": "fresh", + "timeoutMs": 1800000, + "deadlineAt": 1790200012953, + "runFanoutBudget": { + "used": 1, + "limit": 64, + "remaining": 63 + }, + "missionId": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "missionPath": "/tmp/paseo-pi-extension-phase3/pi-agent/missions/projects/8618955a5c85eb731bdb33d90016edaaf64963be032225b8a26a6936621a378d/812446a0-b883-48e0-ac3c-f810ffbcb7a9.json", + "mission": { + "schemaVersion": 1, + "id": "812446a0-b883-48e0-ac3c-f810ffbcb7a9", + "title": "[prompt redacted]", + "objective": "[prompt redacted]", + "status": "active", + "createdAt": "2026-09-23T21:16:52.950Z", + "updatedAt": "2026-09-23T21:16:52.962Z", + "runs": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "mode": "single", + "asyncDir": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "status": "active", + "startedAt": "2026-09-23T21:16:52.962Z" + } + ], + "workflowChildren": [], + "decisions": [], + "artifacts": [ + { + "kind": "status", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/status.json" + }, + { + "kind": "other", + "path": "/tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/events.jsonl", + "description": "Lifecycle events" + } + ], + "receipts": [], + "cwd": "/tmp/paseo-pi-extension-phase3/workspace", + "ownerSessionId": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198.jsonl" + } + }, + "isError": false, + "timestamp": 1790198212965 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_512773", + "name": "bg_wait", + "arguments": { + "all": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2622, + "output": 32, + "cacheRead": 6971, + "cacheWrite": 0, + "reasoning": 18, + "totalTokens": 9625, + "cost": { + "input": 0.0019665, + "output": 0.00012, + "cacheRead": 0.000522825, + "cacheWrite": 0, + "total": 0.002609325 + } + }, + "stopReason": "toolUse", + "timestamp": 1790198212966, + "responseId": "gen-1790198213-Dg2TqjSl8UQiCKWHWbRE", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_512773", + "toolName": "bg_wait", + "content": [ + { + "type": "text", + "text": "Waited 7.8s for 1 async run(s); done. Outcome: 1 complete.\nResult [33b193f7-7519-4395-a09c-e45e8f5ce4fa]: /tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json\n Completion/control events have been observed; inspect status if a notification is not visible yet." + } + ], + "details": { + "mode": "management", + "results": [], + "completions": [ + { + "runId": "33b193f7-7519-4395-a09c-e45e8f5ce4fa", + "agent": "scout", + "mode": "single", + "state": "complete", + "success": true, + "results": [ + { + "agent": "scout", + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "cost": 0.00889125, + "turns": 3 + }, + "sessionFile": "/tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "success": true, + "outputState": "present", + "model": "openrouter/google/gemini-3.6-flash:low", + "artifactPaths": { + "inputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_input.md", + "outputPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_output.md", + "jsonlPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout.jsonl", + "transcriptPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_transcript.jsonl", + "metadataPath": "/tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/33b193f7-7519-4395-a09c-e45e8f5ce4fa_scout_meta.json" + } + } + ], + "archivePath": "/tmp/pi-subagents-uid-1000/async-subagent-results/output-archives/33b193f7-7519-4395-a09c-e45e8f5ce4fa.json" + } + ] + }, + "usage": { + "input": 7435, + "output": 884, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": 8319, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.00889125 + } + }, + "isError": false, + "timestamp": 1790198222122 + }, + { + "role": "custom", + "customType": "subagent-notify", + "content": "Background task completed: **scout**\n\nscout:\n# Code Context\n\n## Files Retrieved\nNo files found in workspace `/tmp/paseo-pi-extension-phase3/workspace`.\n\n## Key Code\nNone. The directory is empty.\n\n## Architecture\nN/A - Empty workspace.\n\n## Start Here\n`/tmp/paseo-pi-extension-phase3/workspace` - Root workspace directory.\n\nOutput saved to: /tmp/paseo-pi-extension-phase3/sessions-nico/subagent-artifacts/outputs/33b193f7-7519-4395-a09c-e45e8f5ce4fa/context.md (275 B, 13 lines). Read this file if needed.\n\nRetention-managed async directory: /tmp/pi-subagents-uid-1000/async-subagent-runs/33b193f7-7519-4395-a09c-e45e8f5ce4fa\n\nSession file: /tmp/paseo-pi-extension-phase3/sessions-nico/2026-09-23T21-16-49-809Z_01a0d020-dd11-7a6e-a902-107f51c3c198/53dcdf6f-7871-44ae-bf72-7744df7845f2/run-0/session.jsonl", + "display": false, + "timestamp": 1790198222119 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts new file mode 100644 index 0000000000..8e9db54047 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPiExtensionHost } from "../index.js"; +import { + parseToolArgs, + parseToolResult, + type PiToolResult, + type PiTrackedToolCall, +} from "../../tool-call-mapper.js"; +import { mapPiChildSession } from "../child-session.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import pino from "pino"; +import { PiRpcAgentClient } from "../../agent.js"; +import { FakePi } from "../../test-utils/fake-pi.js"; +import type { AgentStreamEvent } from "../../../../agent-sdk-types.js"; + +function mapping(toolCall: PiTrackedToolCall, result: PiToolResult) { + return createPiExtensionHost().mapToolCall({ + callId: "test-call", + toolName: toolCall.toolName, + args: toolCall.args, + status: result ? "completed" : "running", + result, + }); +} +function mapToolDetail(toolCall: PiTrackedToolCall, result: PiToolResult) { + return mapping(toolCall, result)?.detail; +} + +function hasChildTimeline(events: AgentStreamEvent[]) { + return events.some( + (event) => event.type === "provider_subagent" && event.event.type === "timeline", + ); +} + +function hasCompletedChild(events: AgentStreamEvent[]) { + return events.some( + (event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.status === "completed", + ); +} + +describe("pi-subagents adapter", () => { + test("follows async status and transcript without bg_wait", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-")); + const file = join(dir, "child.jsonl"); + const received: AgentStreamEvent[] = []; + const host = createPiExtensionHost(); + try { + host.follow((event) => received.push(event)); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { agent: "scout", task: "Inspect", async: true }, + status: "completed", + result: { + details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] }, + }, + }); + await writeFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"BEGIN"}]}}\n', + ); + await writeFile( + join(dir, "status.json"), + JSON.stringify({ + state: "running", + steps: [{ agent: "scout", status: "running", sessionFile: file }], + }), + ); + await expect.poll(() => hasChildTimeline(received)).toBe(true); + await writeFile( + join(dir, "status.json"), + JSON.stringify({ + state: "complete", + steps: [{ agent: "scout", status: "complete", sessionFile: file }], + }), + ); + await expect.poll(() => hasCompletedChild(received)).toBe(true); + } finally { + host.close(); + await rm(dir, { recursive: true, force: true }); + } + }); + test("settles an async run and reads its transcript from the completion notice on replay", async () => { + const dir = await mkdtemp(join(tmpdir(), "paseo-pi-async-")); + const file = join(dir, "child.jsonl"); + try { + await writeFile( + file, + '{"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"DONE"}]}}\n', + ); + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "subagent", + args: { agent: "scout", task: "Inspect", async: true }, + status: "completed", + result: { + details: { mode: "single", runId: "run-1", asyncId: "run-1", asyncDir: dir, results: [] }, + }, + }); + const notice = host.mapCustomMessage({ + role: "custom", + customType: "subagent-notify", + content: [ + "Background task completed: **scout**", + "", + "Session file: /tmp/not-the-session-line.jsonl", + "", + `Retention-managed async directory: ${dir}`, + "", + `Session file: ${file}`, + ].join("\n"), + }); + expect(notice?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]); + expect(hasChildTimeline(await notice!.hydration)).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + test("maps completed subagent calls with task input to sub-agent detail", () => { + const toolCall = parseToolArgs("subagent", { + agent: "reviewer", + task: "Review the Pi mapper change", + }); + const result = parseToolResult({ + content: [{ type: "text", text: "The mapper change preserves provider status." }], + }); + + expect(mapToolDetail(toolCall, result)).toEqual({ + type: "sub_agent", + subAgentType: "reviewer", + description: "Review the Pi mapper change", + log: "The mapper change preserves provider status.", + }); + }); + + test("maps the captured foreground run and child Pi timeline live and on replay", async () => { + const raw = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)); + const result = raw.events.find( + (event) => + event.type === "tool_execution_end" && + event.toolName === "subagent" && + event.result && + typeof event.result === "object" && + "details" in event.result && + Array.isArray(event.result.details?.results) && + event.result.details.results.length > 0, + ); + if (!result || result.type !== "tool_execution_end") + throw new Error("Missing captured child session"); + const original = (result.result as { details: { results: Array<{ sessionFile: string }> } }) + .details.results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toContain("completed"); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + + test("settles captured async runs from the completion notice live and on replay", async () => { + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const events = await verifySubagentFixture(fixture); + expect(events.findLast((event) => event.event.type === "upsert")?.event).toEqual( + expect.objectContaining({ status: "completed" }), + ); + }); + + test("maps bg_wait completion to the original async child", async () => { + const raw = readSubagentFixture(new URL("./fixtures/wait.json", import.meta.url)); + const wait = raw.events.find( + (event) => event.type === "tool_execution_end" && event.toolName === "bg_wait", + ); + if (!wait || wait.type !== "tool_execution_end") + throw new Error("Missing captured bg_wait result"); + const original = ( + wait.result as { + details: { completions: Array<{ results: Array<{ sessionFile: string }> }> }; + } + ).details.completions[0].results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/wait.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + const upserts = events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event : null)); + // bg_wait settles the child; the later completion notice repeats it. + expect(upserts.map((event) => event?.status)).toEqual([ + "running", + "running", + "completed", + "completed", + ]); + expect(new Set(upserts.map((event) => event?.id)).size).toBe(1); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + + test("missing child file produces no timeline", async () => { + expect(await mapPiChildSession("child", "/does-not-exist/pi-child.jsonl")).toEqual([]); + }); + + test("does not emit child timeline after session close", async () => { + const raw = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)); + const completion = raw.events.find( + (event) => event.type === "tool_execution_end" && event.toolName === "subagent", + ); + if (!completion || completion.type !== "tool_execution_end") throw new Error("No completion"); + const original = (completion.result as { details: { results: Array<{ sessionFile: string }> } }) + .details.results[0].sessionFile; + const fixture = readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url), { + from: original, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-child-close", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.startTurn("Delegate work"); + for (const event of fixture.events) pi.latestSession().emit(event); + await session.close(); + expect( + events.some((event) => event.type === "provider_subagent" && event.event.type === "upsert"), + ).toBe(true); + expect( + events.some((event) => event.type === "provider_subagent" && event.event.type === "timeline"), + ).toBe(false); + }); + + test("a failed spawn without result rows finishes its descriptor", () => { + const host = createPiExtensionHost(); + const args = { agent: "scout", task: "Inspect" }; + host.mapToolCall({ + callId: "failed", + toolName: "subagent", + args, + status: "running", + result: null, + }); + expect( + host.mapToolCall({ + callId: "failed", + toolName: "subagent", + args, + status: "failed", + result: { content: [{ type: "text", text: "spawn failed" }] }, + })?.subagents, + ).toEqual([ + { + type: "upsert", + id: "failed", + title: "scout", + description: "Inspect", + toolCallId: "failed", + status: "failed", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts new file mode 100644 index 0000000000..64e814af3f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/pi-subagents/index.ts @@ -0,0 +1,247 @@ +import { z } from "zod"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import type { PiExtension, PiExtensionToolCall, PiExtensionToolMapping } from "../contract.js"; + +const Args = z + .object({ + agent: z.string().trim().min(1).optional(), + task: z.string().trim().min(1).optional(), + async: z.boolean().optional(), + action: z.unknown().optional(), + }) + .passthrough(); +const Row = z + .object({ + index: z.number().int().nonnegative().optional(), + agent: z.string().trim().min(1), + exitCode: z.number().optional(), + success: z.boolean().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +const Completion = z + .object({ runId: z.string().trim().min(1), results: z.array(Row) }) + .passthrough(); +const Details = z + .object({ + mode: z.string(), + runId: z.string().optional(), + asyncId: z.string().optional(), + asyncDir: z.string().trim().min(1).optional(), + results: z.array(Row), + completions: z.array(Completion).optional(), + }) + .passthrough(); +const AsyncStatus = z + .object({ + state: z.string(), + steps: z.array( + z + .object({ + agent: z.string().trim().min(1), + status: z.string().optional(), + sessionFile: z.string().trim().min(1).optional(), + }) + .passthrough(), + ), + }) + .passthrough(); + +function asyncChildStatus(runState: string, stepStatus?: string) { + if (!["complete", "failed", "stopped"].includes(runState)) return "running"; + if (runState === "stopped" || stepStatus === "stopped") return "canceled"; + if (runState === "failed" || stepStatus === "failed" || stepStatus === "error") return "failed"; + return "completed"; +} + +/** The `subagent-notify` header; a grouped notice reports every run as completed. */ +function noticeStatus(header: string) { + if (/^Background tasks completed \(\d+\):/.test(header)) return "completed"; + const outcome = header.match( + /^(?:Background task|Detached foreground task) (completed|failed|stopped): \*\*/, + )?.[1]; + if (outcome === "stopped") return "canceled"; + return outcome as "completed" | "failed" | undefined; +} + +export const piSubagents: PiExtension = { + id: "pi-subagents", + createSession: () => { + const callsByRun = new Map(); + const readSessions = new Set(); + /** Keyed by async directory, which both status.json and the completion notice carry. */ + const asyncRuns = new Map(); + const rememberAsyncRun = (details: z.infer, call: PiExtensionToolCall) => { + if (details.asyncDir && call.status !== "failed") { + asyncRuns.set(details.asyncDir, { owner: call.callId, polling: true }); + } + }; + const collectRows = ( + owner: string, + rows: z.infer[], + description?: string, + failed = false, + ) => { + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const [index, row] of rows.entries()) { + const id = rows.length === 1 ? owner : `${owner}:${row.index ?? index}`; + subagents.push({ + type: "upsert", + id, + title: row.agent, + description, + toolCallId: owner, + status: + failed || row.success === false || (row.success !== true && row.exitCode !== 0) + ? "failed" + : "completed", + }); + if (row.sessionFile && !readSessions.has(row.sessionFile)) { + readSessions.add(row.sessionFile); + childSessions.push({ id, file: row.sessionFile }); + } + } + return { subagents, childSessions }; + }; + const mapWait = (call: PiExtensionToolCall) => { + if (call.status === "running") return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + if (!details.success || !details.data.completions) return undefined; + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const completion of details.data.completions) { + const owner = callsByRun.get(completion.runId); + if (!owner) continue; + const mapped = collectRows(owner, completion.results); + subagents.push(...mapped.subagents); + childSessions.push(...mapped.childSessions); + } + return subagents.length ? { subagents, childSessions } : undefined; + }; + const mapSpawn = (call: PiExtensionToolCall) => { + const args = Args.safeParse(call.args); + if (!args.success || args.data.action !== undefined || !args.data.agent || !args.data.task) + return undefined; + const detail = { + type: "sub_agent" as const, + subAgentType: args.data.agent, + description: args.data.task, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + const base = { + type: "upsert" as const, + id: call.callId, + title: args.data.agent, + description: args.data.task, + toolCallId: call.callId, + }; + if (call.status === "running") + return { detail, subagents: [{ ...base, status: "running" as const }] }; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + if (!details.success || details.data.mode === "management") + return call.status === "failed" + ? { detail, subagents: [{ ...base, status: "failed" as const }] } + : { detail }; + if (details.data.runId) callsByRun.set(details.data.runId, call.callId); + if (details.data.results.length === 0 && (details.data.asyncId || args.data.async)) { + rememberAsyncRun(details.data, call); + return { + detail, + subagents: [ + { + ...base, + status: call.status === "failed" ? ("failed" as const) : ("running" as const), + }, + ], + }; + } + if (details.data.results.length === 0 && call.status === "failed") + return { detail, subagents: [{ ...base, status: "failed" as const }] }; + return { + detail, + ...collectRows(call.callId, details.data.results, args.data.task, call.status === "failed"), + }; + }; + return { + mapToolCall(call) { + if (call.toolName === "bg_wait") return mapWait(call); + if (call.toolName === "subagent") return mapSpawn(call); + return undefined; + }, + // The notice is the only completion Pi persists, so replay settles async runs from it. + mapCustomMessage(message) { + if (message.customType !== "subagent-notify") return undefined; + const text = + typeof message.content === "string" + ? message.content + : extractTextFromToolResult({ content: message.content }); + const lines = text?.split("\n") ?? []; + const status = noticeStatus(lines[0] ?? ""); + if (!status) return undefined; + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + // Each run's session line follows its async directory line; earlier lines are child output. + let owner: string | undefined; + for (const line of lines) { + const dir = line.match(/^Retention-managed async directory: (.+)$/)?.[1]; + if (dir) { + owner = asyncRuns.get(dir)?.owner; + if (owner) subagents.push({ type: "upsert", id: owner, status }); + continue; + } + const file = owner ? line.match(/^Session file: (.+)$/)?.[1] : undefined; + if (!owner || !file) continue; + if (!readSessions.has(file)) { + readSessions.add(file); + childSessions.push({ id: owner, file }); + } + owner = undefined; + } + return subagents.length ? { subagents, childSessions } : undefined; + }, + poll() { + const subagents: NonNullable = []; + const childSessions: NonNullable = []; + for (const [dir, run] of asyncRuns) { + if (!run.polling) continue; + let raw: unknown; + try { + raw = JSON.parse(readFileSync(join(dir, "status.json"), "utf8")); + } catch { + continue; + } + const parsed = AsyncStatus.safeParse(raw); + if (!parsed.success) continue; + const terminal = ["complete", "failed", "stopped"].includes(parsed.data.state); + for (const [index, step] of parsed.data.steps.entries()) { + const id = parsed.data.steps.length === 1 ? run.owner : `${run.owner}:${index}`; + const status = asyncChildStatus(parsed.data.state, step.status); + subagents.push({ type: "upsert", id, title: step.agent, status }); + if (step.sessionFile && !readSessions.has(step.sessionFile)) { + readSessions.add(step.sessionFile); + childSessions.push({ id, file: step.sessionFile }); + } + } + if (terminal) { + if (parsed.data.steps.length === 0) { + subagents.push({ + type: "upsert", + id: run.owner, + status: parsed.data.state === "complete" ? "completed" : "failed", + }); + } + run.polling = false; + } + } + return subagents.length || childSessions.length ? { subagents, childSessions } : undefined; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/registry.ts b/packages/server/src/server/agent/providers/pi/extensions/registry.ts new file mode 100644 index 0000000000..c66fabad80 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/registry.ts @@ -0,0 +1,21 @@ +import { piAskUser } from "./pi-ask-user/index.js"; +import { piSubagents } from "./pi-subagents/index.js"; +import { piMcpAdapter } from "./pi-mcp-adapter/index.js"; +import { rpivTodo } from "./rpiv-todo/index.js"; +import { piExampleTodo } from "./pi-example-todo/index.js"; +import { piGoalX } from "./pi-goal-x/index.js"; +import { tintinwebPiSubagents } from "./tintinweb-pi-subagents/index.js"; +import { gotgenesPiSubagents } from "./gotgenes-pi-subagents/index.js"; +import { rpivAskUserQuestion } from "./rpiv-ask-user-question/index.js"; + +export const piExtensions = [ + piAskUser, // Dialog correlation + piSubagents, // Delegation calls + tintinwebPiSubagents, + gotgenesPiSubagents, + piMcpAdapter, // MCP proxy names + rpivTodo, + piExampleTodo, + piGoalX, + rpivAskUserQuestion, +]; diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json new file mode 100644 index 0000000000..801757b33f --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/cancel-result.json @@ -0,0 +1,23 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-ask-user-question", + "version": "2.11.0", + "sourceCommit": "61904e69e1a50e12585bdf15f0310e633a62ba36", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "model": "openrouter/google/gemini-3.6-flash", + "source": "isolated Paseo web QA Pi session JSONL" + }, + "result": { + "content": [ + { + "type": "text", + "text": "User declined to answer questions" + } + ], + "details": { + "answers": [], + "cancelled": true + } + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json new file mode 100644 index 0000000000..eea1cde847 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/fixtures/rpc-session.json @@ -0,0 +1,134 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-ask-user-question", + "version": "2.11.0", + "sourceCommit": "61904e69e1a50e12585bdf15f0310e633a62ba36", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "model": "openrouter/google/gemini-3.6-flash" + }, + "frames": [ + { + "type": "tool_execution_start", + "toolCallId": "call_1400136", + "toolName": "ask_user_question", + "args": { + "questions": [ + { + "options": [ + { + "label": "Apple", + "description": "Fresh fruit" + }, + { + "description": "Sweet treat", + "label": "Cookie" + } + ], + "question": "Which snack?", + "header": "Snack" + }, + { + "multiSelect": true, + "question": "Which colors?", + "options": [ + { + "description": "Warm", + "label": "Red" + }, + { + "description": "Cool", + "label": "Blue" + } + ], + "header": "Colors" + }, + { + "header": "Project", + "options": [ + { + "description": "Map theme", + "label": "Atlas" + }, + { + "description": "Light theme", + "label": "Beacon" + } + ], + "question": "What project name?" + } + ] + } + }, + { + "type": "extension_ui_request", + "id": "8b00ad15-da50-42d8-ab9c-6cbc1a4d31a5", + "method": "select", + "title": "[Snack] Which snack?", + "options": [ + "1. Apple \u2014 Fresh fruit", + "2. Cookie \u2014 Sweet treat", + "3. Type something." + ] + }, + { + "type": "extension_ui_request", + "id": "ade366ea-0cfd-4e9d-9fd0-0690736947c8", + "method": "input", + "title": "[Colors] Which colors?\n\n1. Red \u2014 Warm\n2. Blue \u2014 Cool\n\nEnter the numbers of all that apply, comma-separated (e.g. \"1,3\"), or type a custom answer as plain text.", + "placeholder": "1,3" + }, + { + "type": "extension_ui_request", + "id": "be2fd390-c4e3-4dcb-b14e-3aaf0a64ca70", + "method": "select", + "title": "[Project] What project name?", + "options": ["1. Atlas \u2014 Map theme", "2. Beacon \u2014 Light theme", "3. Type something."] + }, + { + "type": "extension_ui_request", + "id": "8b34e9ca-75d4-4cc2-8439-0bb9836ad128", + "method": "input", + "title": "[Project] What project name?\n\nType your answer:", + "placeholder": "" + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1400136", + "toolName": "ask_user_question", + "result": { + "content": [ + { + "type": "text", + "text": "User has answered your questions: \"Which snack?\"=\"Cookie\". \"Which colors?\"=\"Red, Blue\". \"What project name?\"=\"Nebula\". You can now continue with the user's answers in mind." + } + ], + "details": { + "answers": [ + { + "questionIndex": 0, + "question": "Which snack?", + "kind": "option", + "answer": "Cookie" + }, + { + "questionIndex": 1, + "question": "Which colors?", + "kind": "multi", + "answer": null, + "selected": ["Red", "Blue"] + }, + { + "questionIndex": 2, + "question": "What project name?", + "kind": "custom", + "answer": "Nebula" + } + ], + "cancelled": false + } + }, + "isError": false + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts new file mode 100644 index 0000000000..3ec9256a29 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.test.ts @@ -0,0 +1,171 @@ +import { readFileSync } from "node:fs"; +import { expect, test } from "vitest"; +import pino from "pino"; +import { PiRpcAgentClient } from "../../agent.js"; +import { PiHistoryMapper } from "../../history-mapper.js"; +import { parseToolResult } from "../../tool-call-mapper.js"; +import { FakePi } from "../../test-utils/fake-pi.js"; +import { createPiExtensionHost } from "../index.js"; +import { rpivAskUserQuestion } from "./index.js"; + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/rpc-session.json", import.meta.url), "utf8"), +); +const [start, ...rest] = fixture.frames; +const dialogs = rest.filter((frame: { type: string }) => frame.type === "extension_ui_request"); +const end = rest.find((frame: { type: string }) => frame.type === "tool_execution_end"); + +function startedHost() { + const host = createPiExtensionHost(); + const permission = host.onToolStart({ + callId: start.toolCallId, + toolName: start.toolName, + args: start.args, + status: "running", + result: null, + }); + if (!permission) throw new Error("Question permission missing"); + return { host, permission }; +} + +test("real rpiv RPC dialogs use one form and preserve choices, multi-select, and custom text", () => { + const { host, permission } = startedHost(); + expect(permission).toMatchObject({ + kind: "question", + input: { + questions: [ + { + header: "Snack", + options: [{ description: "Fresh fruit" }, { description: "Sweet treat" }], + allowOther: true, + multiSelect: false, + }, + { header: "Colors", multiSelect: true }, + { header: "Project", allowOther: true }, + ], + }, + }); + expect(host.mapDialog(dialogs[0], "pi")).toEqual({ type: "deferred" }); + expect( + host.respondToPermission(permission, { + behavior: "allow", + updatedInput: { + answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" }, + }, + }), + ).toEqual({ responses: [{ id: dialogs[0].id, response: { value: dialogs[0].options[1] } }] }); + expect(host.mapDialog(dialogs[1], "pi")).toEqual({ + type: "response", + response: { value: "1,2" }, + }); + expect(host.mapDialog(dialogs[2], "pi")).toEqual({ + type: "response", + response: { value: dialogs[2].options[2] }, + }); + expect(host.mapDialog(dialogs[3], "pi")).toEqual({ + type: "response", + response: { value: "Nebula" }, + }); +}); + +test("cancellation declines each remaining real RPC dialog", () => { + const { host, permission } = startedHost(); + expect(host.respondToPermission(permission, { behavior: "deny" })).toEqual({ responses: [] }); + for (const dialog of dialogs) { + expect(host.mapDialog(dialog, "pi")).toEqual({ + type: "response", + response: { cancelled: true }, + }); + } +}); + +test("captured result renders in live and history tool rows, while foreign shapes decline", () => { + const call = { + callId: end.toolCallId, + toolName: end.toolName, + args: start.args, + status: "completed" as const, + result: parseToolResult(end.result), + }; + const live = createPiExtensionHost().mapToolCall(call); + expect(live?.detail).toEqual({ + type: "plain_text", + label: "Questions", + text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula", + }); + const history = new PiHistoryMapper("pi").mapMessages([ + { + role: "assistant", + content: [ + { type: "toolCall", id: start.toolCallId, name: start.toolName, arguments: start.args }, + ], + }, + { + role: "toolResult", + toolCallId: end.toolCallId, + toolName: end.toolName, + content: end.result.content, + details: end.result.details, + }, + ]); + expect(history).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + type: "tool_call", + status: "completed", + detail: live?.detail, + }), + }), + ); + expect( + rpivAskUserQuestion.createSession().mapToolCall?.({ + ...call, + result: parseToolResult({ content: [], details: { unrelated: true } }), + }), + ).toBeUndefined(); +}); + +test("captured cancellation renders as cancelled in the tool row", () => { + const cancelled = JSON.parse( + readFileSync(new URL("./fixtures/cancel-result.json", import.meta.url), "utf8"), + ); + const mapped = createPiExtensionHost().mapToolCall({ + callId: "cancelled", + toolName: "ask_user_question", + args: start.args, + status: "completed", + result: parseToolResult(cancelled.result), + }); + expect(mapped?.detail).toEqual({ type: "plain_text", label: "Questions", text: "Cancelled" }); +}); + +test("the Pi agent releases a deferred captured dialog when the form is answered", async () => { + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-question-test", + }); + const runtime = pi.latestSession(); + runtime.emit(start); + const [permission] = session.getPendingPermissions(); + expect(permission.input?.questions).toHaveLength(3); + runtime.emit(dialogs[0]); + expect(runtime.extensionUiResponses).toEqual([]); + await session.respondToPermission(permission.id, { + behavior: "allow", + updatedInput: { + answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" }, + }, + }); + for (const dialog of dialogs.slice(1)) runtime.emit(dialog); + expect(runtime.extensionUiResponses).toEqual([ + { id: dialogs[0].id, response: { value: dialogs[0].options[1] } }, + { id: dialogs[1].id, response: { value: "1,2" } }, + { id: dialogs[2].id, response: { value: dialogs[2].options[2] } }, + { id: dialogs[3].id, response: { value: "Nebula" } }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + await session.close(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts new file mode 100644 index 0000000000..07906cc955 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-ask-user-question/index.ts @@ -0,0 +1,212 @@ +import { z } from "zod"; +import type { AgentPermissionRequest } from "../../../../agent-sdk-types.js"; +import type { PiExtension, PiExtensionDialog, PiExtensionUiResponse } from "../contract.js"; + +const Params = z + .object({ + questions: z + .array( + z.object({ + question: z.string(), + header: z.string(), + options: z + .array( + z.object({ + label: z.string(), + description: z.string(), + preview: z.string().optional(), + }), + ) + .min(2) + .max(4), + multiSelect: z.boolean().optional(), + }), + ) + .min(1) + .max(4), + }) + .refine(({ questions }) => { + const texts = questions.map((question) => question.question); + return ( + new Set(texts).size === texts.length && + questions.every((question) => { + const labels = question.options.map((option) => option.label); + return ( + new Set(labels).size === labels.length && + labels.every( + (label) => label !== "Other" && label !== "Type something." && label !== "Next", + ) + ); + }) + ); + }); +const Result = z.object({ + answers: z.array( + z.object({ + question: z.string(), + kind: z.enum(["option", "custom", "multi"]), + answer: z.string().nullable(), + selected: z.array(z.string()).optional(), + }), + ), + cancelled: z.boolean(), + error: z.string().optional(), +}); +type Question = z.infer["questions"][number]; +const PERMISSION_PREFIX = "rpiv-question:"; + +function answerFor(answers: Record, question: Question): string | null { + const answer = answers[question.header]; + return typeof answer === "string" ? answer : null; +} + +function replyToDialog( + dialog: PiExtensionDialog, + question: Question, + answer: string, + customInput: boolean, +): { response: PiExtensionUiResponse; customInput: boolean } { + const options = z.array(z.string()).safeParse(dialog.options).data; + if (customInput) return { response: { value: answer }, customInput: false }; + if (question.multiSelect) { + const labels = answer.split(", "); + const indices = labels.map((label) => + question.options.findIndex((option) => option.label === label), + ); + if (indices.every((index) => index >= 0)) { + return { + response: { value: indices.map((index) => index + 1).join(",") }, + customInput: false, + }; + } + return { response: { value: answer }, customInput: false }; + } + const index = question.options.findIndex((option) => option.label === answer); + if (index >= 0) { + return { + response: { + value: + options?.[index] ?? `${index + 1}. ${answer} — ${question.options[index].description}`, + }, + customInput: false, + }; + } + return { + response: { + value: + options?.[question.options.length] ?? `${question.options.length + 1}. Type something.`, + }, + customInput: true, + }; +} + +export const rpivAskUserQuestion: PiExtension = { + id: "rpiv-ask-user-question", + createSession() { + let active: { + callId: string; + questions: Question[]; + answers: Record | null; + cancelled: boolean; + index: number; + customInput: boolean; + deferred: PiExtensionDialog | null; + } | null = null; + + function answerDialog(dialog: PiExtensionDialog): PiExtensionUiResponse { + if (!active || active.cancelled) return { cancelled: true }; + const question = active.questions[active.index]; + if (!question || !active.answers) return { cancelled: true }; + const answer = answerFor(active.answers, question); + if (answer === null) return { cancelled: true }; + const mapped = replyToDialog(dialog, question, answer, active.customInput); + active.customInput = mapped.customInput; + if (!active.customInput) active.index++; + return mapped.response; + } + + return { + onToolStart(call, provider): AgentPermissionRequest | undefined { + if (call.toolName !== "ask_user_question") return undefined; + const parsed = Params.safeParse(call.args); + if (!parsed.success) return undefined; + active = { + callId: call.callId, + questions: parsed.data.questions, + answers: null, + cancelled: false, + index: 0, + customInput: false, + deferred: null, + }; + return { + id: `${PERMISSION_PREFIX}${call.callId}`, + provider, + name: "Pi ask_user_question", + kind: "question", + title: parsed.data.questions[0].question, + input: { + questions: parsed.data.questions.map((question) => ({ + question: question.question, + header: question.header, + options: question.options, + multiSelect: question.multiSelect ?? false, + allowOther: true, + })), + }, + }; + }, + onToolEnd(call) { + if (active?.callId === call.callId) active = null; + }, + mapDialog(dialog) { + if (!active || (dialog.method !== "select" && dialog.method !== "input")) return undefined; + if (!active.answers && !active.cancelled) { + active.deferred = dialog; + return { type: "deferred" }; + } + return { type: "response", response: answerDialog(dialog) }; + }, + respondToPermission(request, response) { + if (!active || request.id !== `${PERMISSION_PREFIX}${active.callId}`) return undefined; + const answers = response.behavior === "allow" ? response.updatedInput?.answers : undefined; + active.answers = + answers && typeof answers === "object" && !Array.isArray(answers) + ? (answers as Record) + : null; + active.cancelled = response.behavior === "deny" || !active.answers; + const deferred = active.deferred; + active.deferred = null; + return { + responses: deferred ? [{ id: deferred.id, response: answerDialog(deferred) }] : [], + }; + }, + mapToolCall(call) { + if ( + call.toolName !== "ask_user_question" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const result = Result.safeParse(call.result.details); + if (!result.success) return undefined; + return { + detail: { + type: "plain_text", + label: "Questions", + text: + result.data.error ?? + (result.data.cancelled + ? "Cancelled" + : result.data.answers + .map( + (answer) => + `${answer.question}: ${answer.kind === "multi" ? (answer.selected ?? []).join(", ") : (answer.answer ?? "")}`, + ) + .join("\n")), + }, + }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json new file mode 100644 index 0000000000..41dbd8b755 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/fixtures/rpc-session.json @@ -0,0 +1,380 @@ +{ + "provenance": { + "package": "@juicesharp/rpiv-todo", + "version": "2.11.0", + "sourceCommit": "d74b1c99830a565f3df3f37e0a36616d17ffc574", + "piVersion": "0.84.4", + "captureDate": "2026-09-23", + "mode": "pi --mode rpc; OpenRouter google/gemini-3.6-flash" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_406425", + "toolName": "todo", + "args": { + "action": "create", + "subject": "Inspect workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_406425", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Created #1: Inspect workspace (pending)" + } + ], + "details": { + "action": "create", + "params": { + "action": "create", + "subject": "Inspect workspace" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + } + ], + "nextId": 2 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1389683", + "toolName": "todo", + "args": { + "subject": "Summarize findings", + "action": "create" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1389683", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Created #2: Summarize findings (pending)" + } + ], + "details": { + "action": "create", + "params": { + "subject": "Summarize findings", + "action": "create" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1228319", + "toolName": "todo", + "args": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1228319", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Updated #1 (pending → in_progress)" + } + ], + "details": { + "action": "update", + "params": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "in_progress", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + }, + { + "type": "tool_execution_start", + "toolCallId": "call_1444595", + "toolName": "todo", + "args": { + "action": "update", + "status": "completed", + "id": 1 + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_1444595", + "toolName": "todo", + "result": { + "content": [ + { + "type": "text", + "text": "Updated #1 (in_progress → completed)" + } + ], + "details": { + "action": "update", + "params": { + "action": "update", + "status": "completed", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "completed", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_406425", + "name": "todo", + "arguments": { + "action": "create", + "subject": "Inspect workspace" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_406425", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Created #1: Inspect workspace (pending)" + } + ], + "details": { + "action": "create", + "params": { + "action": "create", + "subject": "Inspect workspace" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + } + ], + "nextId": 2 + }, + "isError": false, + "timestamp": 1790195575419 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1389683", + "name": "todo", + "arguments": { + "subject": "Summarize findings", + "action": "create" + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1389683", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Created #2: Summarize findings (pending)" + } + ], + "details": { + "action": "create", + "params": { + "subject": "Summarize findings", + "action": "create" + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "pending" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195576353 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1228319", + "name": "todo", + "arguments": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1228319", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Updated #1 (pending → in_progress)" + } + ], + "details": { + "action": "update", + "params": { + "status": "in_progress", + "activeForm": "inspecting workspace", + "action": "update", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "in_progress", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195577401 + }, + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_1444595", + "name": "todo", + "arguments": { + "action": "update", + "status": "completed", + "id": 1 + } + } + ] + }, + { + "role": "toolResult", + "toolCallId": "call_1444595", + "toolName": "todo", + "content": [ + { + "type": "text", + "text": "Updated #1 (in_progress → completed)" + } + ], + "details": { + "action": "update", + "params": { + "action": "update", + "status": "completed", + "id": 1 + }, + "tasks": [ + { + "id": 1, + "subject": "Inspect workspace", + "status": "completed", + "activeForm": "inspecting workspace" + }, + { + "id": 2, + "subject": "Summarize findings", + "status": "pending" + } + ], + "nextId": 3 + }, + "isError": false, + "timestamp": 1790195578305 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts new file mode 100644 index 0000000000..9e5b28fd73 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "vitest"; +import { capturedToolCall, verifyTaskFixture } from "../task-fixture-test.js"; +import { rpivTodo } from "./index.js"; + +test("rpiv-todo maps captured RPC tasks through live and history", async () => { + await verifyTaskFixture(new URL("./fixtures/rpc-session.json", import.meta.url), [ + "pending", + "pending,pending", + "in_progress,pending", + "completed,pending", + ]); +}); + +test("rpiv-todo declines the example todo result shape", () => { + const call = capturedToolCall( + new URL("../pi-example-todo/fixtures/rpc-session.json", import.meta.url), + ); + expect(rpivTodo.createSession().mapToolCall?.(call)).toBeUndefined(); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts new file mode 100644 index 0000000000..d439e079fe --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/rpiv-todo/index.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import type { AgentTaskItem } from "../../../../agent-sdk-types.js"; +import type { PiExtension } from "../contract.js"; + +const Details = z.object({ + action: z.enum(["create", "update", "list", "get", "delete", "clear"]), + params: z.record(z.string(), z.unknown()), + nextId: z.number().int(), + tasks: z.array( + z.object({ + id: z.number().int(), + subject: z.string(), + status: z.enum(["pending", "in_progress", "completed", "deleted"]), + activeForm: z.string().optional(), + }), + ), + error: z.string().optional(), +}); + +export const rpivTodo: PiExtension = { + id: "rpiv-todo", + createSession: () => ({ + mapToolCall(call) { + if ( + call.toolName !== "todo" || + call.status !== "completed" || + !call.result || + typeof call.result === "string" + ) + return undefined; + const parsed = Details.safeParse(call.result.details); + if (!parsed.success) return undefined; + return { + timeline: parsed.data.error + ? [] + : [ + { + type: "todo", + items: parsed.data.tasks.flatMap((task): AgentTaskItem[] => { + if (task.status === "deleted") return []; + const item: AgentTaskItem = { + id: String(task.id), + text: task.subject, + status: task.status, + completed: task.status === "completed", + }; + if (task.activeForm) item.activeForm = task.activeForm; + return [item]; + }), + }, + ], + }; + }, + }), +}; diff --git a/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts new file mode 100644 index 0000000000..8a9ca61d08 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/subagent-fixture-test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import pino from "pino"; +import { expect, vi } from "vitest"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { PiRpcAgentClient } from "../agent.js"; +import { streamPiHistory } from "../history-mapper.js"; +import type { PiAgentMessage, PiAgentSessionEvent } from "../rpc-types.js"; +import { FakePi } from "../test-utils/fake-pi.js"; + +interface Fixture { + provenance: { + package: string; + version: string; + sourceCommit: string; + piVersion: string; + captureDate: string; + }; + events: PiAgentSessionEvent[]; + messages: PiAgentMessage[]; +} + +export function readSubagentFixture( + path: URL, + replaceSessionFile?: { from: string; to: string }, +): Fixture { + let source = readFileSync(path, "utf8"); + if (replaceSessionFile) + source = source.replaceAll( + replaceSessionFile.from, + JSON.stringify(replaceSessionFile.to).slice(1, -1), + ); + const fixture = JSON.parse(source) as Fixture; + expect(fixture.provenance).toEqual({ + package: expect.any(String), + version: expect.any(String), + sourceCommit: expect.any(String), + piVersion: expect.any(String), + captureDate: expect.any(String), + }); + return fixture; +} + +export async function verifySubagentFixture( + fixture: Fixture, +): Promise[]> { + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ + provider: "pi", + cwd: "/tmp/paseo-pi-subagent-fixture", + }); + const live: AgentStreamEvent[] = []; + session.subscribe((event) => live.push(event)); + try { + await session.startTurn("Delegate work"); + for (const event of fixture.events) pi.latestSession().emit(event); + const immediate = live.filter((event) => event.type === "provider_subagent"); + expect(immediate.some((event) => event.event.type === "timeline")).toBe(false); + pi.latestSession().finishTurn(); + const replay: AgentStreamEvent[] = []; + for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event); + const replaySubagents = replay.filter((event) => event.type === "provider_subagent"); + await vi.waitFor(() => { + expect(live.filter((event) => event.type === "provider_subagent")).toEqual(replaySubagents); + }); + for (const message of fixture.messages) { + if (message.role !== "custom" || typeof message.content !== "string") continue; + for (const events of [live, replay]) { + const tools = events.flatMap((event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.detail.type === "plain_text" && + event.item.detail.text === message.content + ? [event.item] + : [], + ); + expect(tools).toEqual( + message.display === false + ? [] + : [ + { + type: "tool_call", + callId: expect.stringMatching(/^pi-custom-/), + name: message.customType ?? "custom-message", + status: "completed", + detail: { + type: "plain_text", + text: message.content, + }, + metadata: { + synthetic: true, + customType: message.customType ?? "custom-message", + ...(message.details === undefined ? {} : { details: message.details }), + }, + error: null, + }, + ], + ); + expect( + events.filter( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text === message.content, + ), + ).toEqual([]); + } + } + const liveSubagents = live.filter((event) => event.type === "provider_subagent"); + const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline"); + if (firstTimeline !== -1) { + expect( + liveSubagents.slice(0, firstTimeline).some((event) => event.event.type === "upsert"), + ).toBe(true); + } + expect(liveSubagents).toEqual(replaySubagents); + return liveSubagents; + } finally { + await session.close(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts new file mode 100644 index 0000000000..a3b2114cab --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/task-fixture-test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import pino from "pino"; +import { expect } from "vitest"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import { PiRpcAgentClient } from "../agent.js"; +import { PiHistoryMapper } from "../history-mapper.js"; +import type { PiAgentMessage, PiAgentSessionEvent } from "../rpc-types.js"; +import { FakePi } from "../test-utils/fake-pi.js"; +import { parseToolResult } from "../tool-call-mapper.js"; +import { createPiExtensionHost } from "./index.js"; +import type { PiExtensionToolCall } from "./contract.js"; + +interface TaskFixture { + provenance: { + package: string; + version: string; + sourceCommit: string; + piVersion: string; + captureDate: string; + }; + events: PiAgentSessionEvent[]; + messages: PiAgentMessage[]; +} + +function todos(events: readonly AgentStreamEvent[]) { + return events.flatMap((event) => + event.type === "timeline" && event.item.type === "todo" ? [event.item] : [], + ); +} + +export function capturedToolCall(path: URL, index = 0): PiExtensionToolCall { + const fixture = JSON.parse(readFileSync(path, "utf8")) as TaskFixture; + const ends = fixture.events.filter((event) => event.type === "tool_execution_end"); + const end = ends[index]; + if (!end) throw new Error(`Missing captured tool result ${index}`); + const start = fixture.events.find( + (event) => event.type === "tool_execution_start" && event.toolCallId === end.toolCallId, + ); + if (!start || start.type !== "tool_execution_start") + throw new Error("Missing captured tool start"); + return { + callId: end.toolCallId, + toolName: end.toolName, + args: start.args, + status: end.isError ? "failed" : "completed", + result: parseToolResult(end.result), + }; +} + +export async function verifyTaskFixture(path: URL, expectedStatuses: string[]): Promise { + const fixture = JSON.parse(readFileSync(path, "utf8")) as TaskFixture; + expect(fixture.provenance).toEqual( + expect.objectContaining({ + package: expect.any(String), + version: expect.any(String), + sourceCommit: expect.any(String), + piVersion: expect.any(String), + captureDate: expect.any(String), + }), + ); + const host = createPiExtensionHost(); + const starts = new Map>(); + const mapped = []; + for (const event of fixture.events) { + if (event.type === "tool_execution_start") starts.set(event.toolCallId, event); + if (event.type !== "tool_execution_end") continue; + const start = starts.get(event.toolCallId); + expect(start).toBeDefined(); + const mapping = host.mapToolCall({ + callId: event.toolCallId, + toolName: event.toolName, + args: start?.args, + status: event.isError ? "failed" : "completed", + result: parseToolResult(event.result), + }); + mapped.push(...(mapping?.timeline ?? []).filter((item) => item.type === "todo")); + } + expect(mapped.map((item) => item.items.map((task) => task.status))).toEqual( + expectedStatuses.map((status) => status.split(",")), + ); + + const pi = new FakePi(); + const client = new PiRpcAgentClient({ logger: pino({ level: "silent" }), runtime: pi }); + const session = await client.createSession({ provider: "pi", cwd: "/tmp/paseo-pi-task-fixture" }); + const live: AgentStreamEvent[] = []; + session.subscribe((event) => live.push(event)); + try { + await session.startTurn("Use tasks"); + for (const event of fixture.events) pi.latestSession().emit(event); + pi.latestSession().finishTurn(); + expect(todos(live)).toEqual(mapped); + const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages); + expect(todos(replay)).toEqual(mapped); + expect( + replay.filter( + (event) => + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "completed", + ), + ).toHaveLength(fixture.events.filter((event) => event.type === "tool_execution_end").length); + } finally { + await session.close(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json new file mode 100644 index 0000000000..083e57744e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background-group.json @@ -0,0 +1,419 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-10-01" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_277173", + "toolName": "Agent", + "args": { + "run_in_background": true, + "description": "Test agent 1", + "prompt": "reply with exactly OK", + "subagent_type": "general-purpose" + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_277176", + "toolName": "Agent", + "args": { + "prompt": "reply with exactly OK", + "run_in_background": true, + "description": "Test agent 2", + "subagent_type": "general-purpose" + } + }, + { + "type": "tool_execution_start", + "toolCallId": "call_277179", + "toolName": "Agent", + "args": { + "subagent_type": "general-purpose", + "description": "Test agent 3", + "prompt": "reply with exactly OK", + "run_in_background": true + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277173", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 4b4fd01d-aaa8-46d\nType: Agent\nDescription: Test agent 1\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 1", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "4b4fd01d-aaa8-46d" + } + }, + "isError": false + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277176", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: a92b022c-cd9e-41e\nType: Agent\nDescription: Test agent 2\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 2", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "a92b022c-cd9e-41e" + } + }, + "isError": false + }, + { + "type": "tool_execution_end", + "toolCallId": "call_277179", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 13336b9f-79c3-49e\nType: Agent\nDescription: Test agent 3\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 3", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "13336b9f-79c3-49e" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "Background agent group completed: 3 agent(s) finished\n\n\n4b4fd01d-aaa8-46d\ncall_277173\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\nDone\nAgent \"Test agent 1\" completed\nOK\n10186011054\n\n\n\na92b022c-cd9e-41e\ncall_277176\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\nDone\nAgent \"Test agent 2\" completed\nOK\n10186011182\n\n\n\n13336b9f-79c3-49e\ncall_277179\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\nDone\nAgent \"Test agent 3\" completed\nOK\n10186011472\n\n\nUse get_subagent_result for full output.", + "display": true, + "details": { + "id": "4b4fd01d-aaa8-46d", + "description": "Test agent 1", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1054, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output", + "resultPreview": "OK", + "others": [ + { + "id": "a92b022c-cd9e-41e", + "description": "Test agent 2", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1182, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output", + "resultPreview": "OK" + }, + { + "id": "13336b9f-79c3-49e", + "description": "Test agent 3", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1472, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output", + "resultPreview": "OK" + } + ] + }, + "timestamp": 1790833737082 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "", + "thinkingSignature": "[{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a19lxhI31c8Kjto2eEqQuEoAa1jXk+6WHw4RxcJiLq9eTkm6UtOU0r9mP+xFrs3A9GkNyIfpaIzLUGuPKjiYw648QdaOOVbR7jCqPVFA+CEYQH0V\",\"format\":\"google-gemini-v1\",\"id\":\"call_277173\",\"index\":0}]" + }, + { + "type": "toolCall", + "id": "call_277173", + "name": "Agent", + "arguments": { + "run_in_background": true, + "description": "Test agent 1", + "prompt": "reply with exactly OK", + "subagent_type": "general-purpose" + } + }, + { + "type": "toolCall", + "id": "call_277176", + "name": "Agent", + "arguments": { + "prompt": "reply with exactly OK", + "run_in_background": true, + "description": "Test agent 2", + "subagent_type": "general-purpose" + } + }, + { + "type": "toolCall", + "id": "call_277179", + "name": "Agent", + "arguments": { + "subagent_type": "general-purpose", + "description": "Test agent 3", + "prompt": "reply with exactly OK", + "run_in_background": true + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13147, + "output": 135, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13282, + "cost": { + "input": 0.009860250000000001, + "output": 0.00050625, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.0103665 + } + }, + "stopReason": "toolUse", + "timestamp": 1790833733536, + "responseId": "gen-1790833733-Qo0mjOAU6129xnYfl8rM", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_277173", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 4b4fd01d-aaa8-46d\nType: Agent\nDescription: Test agent 1\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 1", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "4b4fd01d-aaa8-46d" + }, + "isError": false, + "timestamp": 1790833735411 + }, + { + "role": "toolResult", + "toolCallId": "call_277176", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: a92b022c-cd9e-41e\nType: Agent\nDescription: Test agent 2\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 2", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "a92b022c-cd9e-41e" + }, + "isError": false, + "timestamp": 1790833735412 + }, + { + "role": "toolResult", + "toolCallId": "call_277179", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 13336b9f-79c3-49e\nType: Agent\nDescription: Test agent 3\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "Test agent 3", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "13336b9f-79c3-49e" + }, + "isError": false, + "timestamp": 1790833735412 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I have spawned the 3 background agents and am waiting for their completion notifications." + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2004, + "output": 16, + "cacheRead": 11743, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13763, + "cost": { + "input": 0.001503, + "output": 6e-5, + "cacheRead": 0.000880725, + "cacheWrite": 0, + "total": 0.002443725 + } + }, + "stopReason": "stop", + "timestamp": 1790833735412, + "responseId": "gen-1790833735-adM3whTd4mLRXnKi2Xu0", + "rawStopReason": "stop" + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "Background agent group completed: 3 agent(s) finished\n\n\n4b4fd01d-aaa8-46d\ncall_277173\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output\nDone\nAgent \"Test agent 1\" completed\nOK\n10186011054\n\n\n\na92b022c-cd9e-41e\ncall_277176\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output\nDone\nAgent \"Test agent 2\" completed\nOK\n10186011182\n\n\n\n13336b9f-79c3-49e\ncall_277179\n/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output\nDone\nAgent \"Test agent 3\" completed\nOK\n10186011472\n\n\nUse get_subagent_result for full output.", + "display": true, + "details": { + "id": "4b4fd01d-aaa8-46d", + "description": "Test agent 1", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1054, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/4b4fd01d-aaa8-46d.output", + "resultPreview": "OK", + "others": [ + { + "id": "a92b022c-cd9e-41e", + "description": "Test agent 2", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1182, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/a92b022c-cd9e-41e.output", + "resultPreview": "OK" + }, + { + "id": "13336b9f-79c3-49e", + "description": "Test agent 3", + "status": "completed", + "toolUses": 0, + "turnCount": 0, + "totalTokens": 10186, + "totalCost": 0.007642500000000001, + "durationMs": 1472, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-subagent-fixture/01a0f602-2dd3-7755-8969-96e4d126cd2e/tasks/13336b9f-79c3-49e.output", + "resultPreview": "OK" + } + ] + }, + "timestamp": 1790833737082 + }, + { + "role": "assistant", + "content": [ + { + "type": "text", + "text": "DONE" + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 2740, + "output": 1, + "cacheRead": 11762, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 14503, + "cost": { + "input": 0.002055, + "output": 3.75e-6, + "cacheRead": 0.00088215, + "cacheWrite": 0, + "total": 0.0029408999999999998 + } + }, + "stopReason": "stop", + "timestamp": 1790833737083, + "responseId": "gen-1790833737-3IfsB2wLuE8DMNlJMgmI", + "rawStopReason": "stop" + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json new file mode 100644 index 0000000000..41ebae67f3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/background.json @@ -0,0 +1,154 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_411074", + "toolName": "Agent", + "args": { + "prompt": "list files in this empty workspace.", + "subagent_type": "general-purpose", + "run_in_background": true, + "description": "list files" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_411074", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 98cdb309-045e-45d\nType: Agent\nDescription: list files\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "98cdb309-045e-45d" + } + }, + "isError": false + }, + { + "type": "message_end", + "message": { + "role": "custom", + "customType": "subagent-notification", + "content": "\n98cdb309-045e-45d\ncall_411074\n/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\nDone\nAgent \"list files\" completed\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.\n18642115292\n\nFull transcript available at: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "display": true, + "details": { + "id": "98cdb309-045e-45d", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 0, + "totalTokens": 18642, + "totalCost": 0.015582600000000002, + "durationMs": 5292, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "resultPreview": "The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty." + }, + "timestamp": 1790197702084 + } + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_411074", + "name": "Agent", + "arguments": { + "prompt": "list files in this empty workspace.", + "subagent_type": "general-purpose", + "run_in_background": true, + "description": "list files" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13438, + "output": 125, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 79, + "totalTokens": 13563, + "cost": { + "input": 0.0100785, + "output": 0.00046875000000000004, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.010547250000000001 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197694776, + "responseId": "gen-1790197694-XL8DSeExpZQLQE8377rX", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_411074", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent started in background.\nAgent ID: 98cdb309-045e-45d\nType: Agent\nDescription: list files\nOutput file: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\n\nYou will be notified when this agent completes.\nUse get_subagent_result to retrieve full results, or steer_subagent to send it messages.\nDo not duplicate this agent's work." + } + ], + "details": { + "displayName": "Agent", + "description": "list files", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "background"], + "toolUses": 0, + "tokens": "", + "durationMs": 0, + "status": "background", + "agentId": "98cdb309-045e-45d" + }, + "isError": false, + "timestamp": 1790197696594 + }, + { + "role": "custom", + "customType": "subagent-notification", + "content": "\n98cdb309-045e-45d\ncall_411074\n/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output\nDone\nAgent \"list files\" completed\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty.\n18642115292\n\nFull transcript available at: /tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "display": true, + "details": { + "id": "98cdb309-045e-45d", + "description": "list files", + "status": "completed", + "toolUses": 1, + "turnCount": 0, + "totalTokens": 18642, + "totalCost": 0.015582600000000002, + "durationMs": 5292, + "outputFile": "/tmp/pi-subagents-1000/tmp-paseo-pi-extension-phase3-workspace/01a0d018-fd1c-7ee8-bafa-16831f748cf1/tasks/98cdb309-045e-45d.output", + "resultPreview": "The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty." + }, + "timestamp": 1790197702084 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl new file mode 100644 index 0000000000..b25ac4a4a3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/child-session.jsonl @@ -0,0 +1,4 @@ +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"user","message":{"role":"user","content":"list files in this empty workspace."},"timestamp":"2026-09-23T21:08:16.594Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"**Exploring File Listing Options**\n\nI'm currently evaluating the available tools like `ls` and `find` to fulfill the request of listing files in the empty `/tmp/paseo-pi-extension-phase3/workspace`. My next step is to determine the most efficient command for this purpose.\n\n","thinkingSignature":"[{\"type\":\"reasoning.text\",\"text\":\"**Exploring File Listing Options**\\n\\nI'm currently evaluating the available tools like `ls` and `find` to fulfill the request of listing files in the empty `/tmp/paseo-pi-extension-phase3/workspace`. My next step is to determine the most efficient command for this purpose.\\n\\n\",\"format\":\"google-gemini-v1\",\"index\":0},{\"type\":\"reasoning.encrypted\",\"data\":\"AY89a192ypUlDWGHPw18bSatn/b/48MmqVr7V0YSiUNNQ+0T+GC5rJwP1JXRK5sYaYHmvC3vKWcK9ecoV7oK0HTgrg9sNj4x8EVGiIi0NnIBR5jRegqcJ/uzfPEWcrl4J2VZ3UDTrjLCQPvQhVm0cr2G0W3LQd+jDaH4OBDNufV7kQPoYi9RMJPV9WdX7Mqh0TTfg+/pyUDZ5J3IESZgs5P+MKhiH38wpd5+0FqvalefDkAhJSXPwOkyylSG8+8L3lQf4bR+cZlD0k+lN73AjihntAZhAFGfbQWzeUfqGRCC+nsdcE4Fb5nGBu4iToIiu7FNWKt8q7oYQL5g0i5VB0raV8He5OkZV3QzxjvjtWykchkb6wpJWRkBOYkNNQXZKEj1E9seJ+PhiJWaNCGEr1IUoOu9u4W2O4VGoKhnFrEK3QN0Ny8HJAASG4zcZpR1RmkUyImmnydgQelbll3i1osGfl2zAq9ndNtuWpiv31kw6mNntcM2GR8vj46wynIoVltSmrCf4Ki47Xzbew+8neOVJZ54DHWl4Un72VtfUAqvkrXNuPC4S9P6YQBH8XquUzvzpU7PeZZ/l+WjBFaBWPXz2DlPdHiCqa9bf8biI/fL4yL+maEx0DcWVG+1LT/HT6T9b6AzHDDr7XewKv5A0XL769IJDb+ROTUS6275Sj/GL4CYaz8ejfyDfgx8i/jI1SzehSeBnF99Pv3EY9jFTavNt86gLU31EsUNPt5iC18W4ReIoGt6AExRApczMd6fWhLagF6vTXzGq+qgS+BaCsLAMWiptdJaEfp5x9mcN6Iuddk1nxRUEuO7/PmJgC5NnD6xHVsWc6eEl6VGBS7uqrGtGmXfHhfQDtZxf+qLpJKBsHMbuOXe85ZwhQ==\",\"format\":\"google-gemini-v1\",\"id\":\"call_417499\",\"index\":0}]"},{"type":"toolCall","id":"call_417499","name":"ls","arguments":{"path":"/tmp/paseo-pi-extension-phase3/workspace"}}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":15478,"output":175,"cacheRead":0,"cacheWrite":0,"reasoning":148,"totalTokens":15653,"cost":{"input":0.0116085,"output":0.00065625,"cacheRead":0,"cacheWrite":0,"total":0.012264750000000001}},"stopReason":"toolUse","timestamp":1790197697300,"responseId":"gen-1790197697-VIbshov8B0i407MK5sjj","rawStopReason":"tool_calls"},"timestamp":"2026-09-23T21:08:20.155Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"toolResult","message":{"role":"toolResult","toolCallId":"call_417499","toolName":"ls","content":[{"type":"text","text":"(empty directory)"}],"isError":false,"timestamp":1790197700155},"timestamp":"2026-09-23T21:08:20.155Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} +{"isSidechain":true,"agentId":"98cdb309-045e-45d","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"The workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty."}],"api":"openai-completions","provider":"openrouter","model":"google/gemini-3.6-flash","usage":{"input":2948,"output":41,"cacheRead":12708,"cacheWrite":0,"reasoning":20,"totalTokens":15697,"cost":{"input":0.002211,"output":0.00015375,"cacheRead":0.0009530999999999999,"cacheWrite":0,"total":0.00331785}},"stopReason":"stop","timestamp":1790197700155,"responseId":"gen-1790197700-mOWiePGBCZu5WhYs3r1n","rawStopReason":"stop"},"timestamp":"2026-09-23T21:08:21.881Z","cwd":"/tmp/paseo-pi-extension-phase3/workspace"} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json new file mode 100644 index 0000000000..66857a3ced --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/fixtures/foreground.json @@ -0,0 +1,117 @@ +{ + "provenance": { + "package": "@tintinweb/pi-subagents", + "version": "0.19.0", + "sourceCommit": "4f572eaa04c09d3dbc16e4a5f13a16b295e84e14", + "piVersion": "0.84.4", + "captureDate": "2026-09-23" + }, + "events": [ + { + "type": "tool_execution_start", + "toolCallId": "call_458926", + "toolName": "Agent", + "args": { + "subagent_type": "general-purpose", + "run_in_background": false, + "description": "List files in empty workspace", + "prompt": "list the files in this empty workspace" + } + }, + { + "type": "tool_execution_end", + "toolCallId": "call_458926", + "toolName": "Agent", + "result": { + "content": [ + { + "type": "text", + "text": "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles)." + } + ], + "details": { + "displayName": "Agent", + "description": "List files in empty workspace", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "thinking: high"], + "toolUses": 1, + "tokens": "18.8k token", + "cost": 0.016011675, + "turnCount": 2, + "durationMs": 6637, + "status": "completed", + "agentId": "4f961541-49c3-430" + } + }, + "isError": false + } + ], + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "toolCall", + "id": "call_458926", + "name": "Agent", + "arguments": { + "subagent_type": "general-purpose", + "run_in_background": false, + "description": "List files in empty workspace", + "prompt": "list the files in this empty workspace" + } + } + ], + "api": "openai-completions", + "provider": "openrouter", + "model": "google/gemini-3.6-flash", + "usage": { + "input": 13432, + "output": 49, + "cacheRead": 0, + "cacheWrite": 0, + "reasoning": 0, + "totalTokens": 13481, + "cost": { + "input": 0.010074, + "output": 0.00018375, + "cacheRead": 0, + "cacheWrite": 0, + "total": 0.01025775 + } + }, + "stopReason": "toolUse", + "timestamp": 1790197658831, + "responseId": "gen-1790197658-itiLcQoBdeRe3YHKtihF", + "rawStopReason": "tool_calls" + }, + { + "role": "toolResult", + "toolCallId": "call_458926", + "toolName": "Agent", + "content": [ + { + "type": "text", + "text": "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles)." + } + ], + "details": { + "displayName": "Agent", + "description": "List files in empty workspace", + "subagentType": "general-purpose", + "modelName": "google: gemini 3.6 flash", + "tags": ["twin", "thinking: high"], + "toolUses": 1, + "tokens": "18.8k token", + "cost": 0.016011675, + "turnCount": 2, + "durationMs": 6637, + "status": "completed", + "agentId": "4f961541-49c3-430" + }, + "isError": false, + "timestamp": 1790197667103 + } + ] +} diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts new file mode 100644 index 0000000000..ad666bb4d3 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "vitest"; +import { fileURLToPath } from "node:url"; +import { createPiExtensionHost } from "../index.js"; +import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js"; +import type { PiAgentMessage } from "../../rpc-types.js"; + +describe("@tintinweb/pi-subagents adapter", () => { + test("exposes the background output file while the child is running", () => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "background" }, + content: [ + { type: "text", text: "Agent started in background.\nOutput file: /tmp/child.output\n" }, + ], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "running" }), + ]); + expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]); + }); + test("marks a foreground child that wrapped up at its turn limit as completed", () => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "steered" }, + content: [{ type: "text", text: "Wrapped up (turn limit)" }], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "completed" }), + ]); + expect(mapping?.detail).toEqual({ + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect", + log: "Wrapped up (turn limit)", + }); + }); + test("maps captured foreground lifecycle live and on replay", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)), + ); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "completed"]); + }); + test("uses a structured notification to complete a background child", async () => { + const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url)); + const file = ( + source.messages.find((message) => message.role === "custom") as { + details: { outputFile: string }; + } + ).details.outputFile; + const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), { + from: file, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + const events = await verifySubagentFixture(fixture); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "running", "completed"]); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + test("completes every background child in a grouped notification", async () => { + const events = await verifySubagentFixture( + readSubagentFixture(new URL("./fixtures/background-group.json", import.meta.url)), + ); + const finalStatus = new Map(); + for (const { event } of events) { + if (event.type === "upsert" && event.status) finalStatus.set(event.id, event.status); + } + expect(Object.fromEntries(finalStatus)).toEqual({ + call_277173: "completed", + call_277176: "completed", + call_277179: "completed", + }); + }); + test.each(["subagent-notification", "subagent-update"])( + "completes turn-limited children in a grouped %s", + (customType) => { + const host = createPiExtensionHost(); + for (const id of ["1", "2", "3"]) { + host.mapToolCall({ + callId: `call-${id}`, + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: `native-${id}`, status: "background" } }, + }); + } + const mapping = host.mapCustomMessage({ + role: "custom", + customType, + content: "Wrapped up (turn limit)", + display: true, + timestamp: 0, + details: { + id: "native-1", + status: "steered", + others: [ + { id: "native-2", status: "steered" }, + { id: "native-3", status: "running" }, + ], + }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: "completed" }), + expect.objectContaining({ id: "call-2", status: "completed" }), + expect.objectContaining({ id: "call-3", status: "running" }), + ]); + }, + ); + test.each(["get_subagent_result", "steer_subagent"])( + "completes a turn-limited child when collected through %s", + (toolName) => { + const host = createPiExtensionHost(); + host.mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status: "background" } }, + }); + const mapping = host.mapToolCall({ + callId: "followup-1", + toolName, + args: { agent_id: "native-1" }, + status: "completed", + result: { + details: { agentId: "native-1", status: "steered" }, + content: [{ type: "text", text: "Wrapped up (turn limit)" }], + }, + }); + expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]); + expect(mapping?.detail).toEqual({ + type: "sub_agent", + description: undefined, + log: "Wrapped up (turn limit)", + }); + }, + ); + test.each([ + ["background", "running"], + ["queued", "running"], + ["running", "running"], + ["completed", "completed"], + ["error", "failed"], + ["aborted", "canceled"], + ["stopped", "canceled"], + ["unknown", "running"], + ])("preserves the mapping of %s to %s", (status, expected) => { + const mapping = createPiExtensionHost().mapToolCall({ + callId: "call-1", + toolName: "Agent", + args: { subagent_type: "Explore", prompt: "Inspect" }, + status: "completed", + result: { details: { agentId: "native-1", status } }, + }); + expect(mapping?.subagents).toEqual([ + expect.objectContaining({ id: "call-1", status: expected }), + ]); + }); + test("completes a turn-limited background child live and on history replay", async () => { + const path = new URL("./fixtures/background.json", import.meta.url); + const source = readSubagentFixture(path); + const notification = source.messages.find((message) => message.role === "custom"); + const notificationDetails = notification?.details; + if ( + !notificationDetails || + typeof notificationDetails !== "object" || + !("outputFile" in notificationDetails) || + typeof notificationDetails.outputFile !== "string" + ) { + throw new Error("Background fixture is missing its child output file"); + } + const fixture = readSubagentFixture(path, { + from: notificationDetails.outputFile, + to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)), + }); + function wrapUp(message: PiAgentMessage): PiAgentMessage { + if (message.role !== "custom") return message; + const details = message.details; + if (!details || typeof details !== "object") { + throw new Error("Background fixture is missing its notification details"); + } + return { + ...message, + content: "Wrapped up (turn limit)", + details: { ...details, status: "steered" }, + }; + } + const events = await verifySubagentFixture({ + ...fixture, + events: fixture.events.map((event) => + event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event, + ), + messages: fixture.messages.map(wrapUp), + }); + expect( + events + .filter((event) => event.event.type === "upsert") + .map((event) => (event.event.type === "upsert" ? event.event.status : null)), + ).toEqual(["running", "running", "completed"]); + expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0); + }); + test("declines foreign Agent results", () => { + expect( + createPiExtensionHost().mapToolCall({ + callId: "foreign", + toolName: "Agent", + args: { prompt: "foo" }, + status: "completed", + result: { details: { agentId: "other", status: "completed" } }, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts new file mode 100644 index 0000000000..56b5935172 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/extensions/tintinweb-pi-subagents/index.ts @@ -0,0 +1,158 @@ +import { z } from "zod"; +import { extractTextFromToolResult } from "../../tool-call-mapper.js"; +import { outputFileFromToolResult } from "../child-session.js"; +import type { PiExtension, PiExtensionToolCall } from "../contract.js"; + +const SpawnArgs = z + .object({ + subagent_type: z.string().trim().min(1), + prompt: z.string().trim().min(1), + description: z.string().optional(), + }) + .passthrough(); +const FollowupArgs = z.object({ agent_id: z.string().trim().min(1) }).passthrough(); +const Details = z + .object({ + agentId: z.string().trim().min(1), + status: z.string(), + displayName: z.string().optional(), + description: z.string().optional(), + }) + .passthrough(); +const NotificationEntry = z + .object({ + id: z.string().trim().min(1), + status: z.string(), + description: z.string().optional(), + outputFile: z.string().trim().min(1).optional(), + }) + .passthrough(); +// Completions that land close together arrive as one notification: the first +// agent at the top level and the rest in `others`. +const Notification = NotificationEntry.extend({ + others: z.array(NotificationEntry).optional(), +}); +const status = (value: string): "running" | "completed" | "failed" | "canceled" => { + // A soft turn-limit wrap-up has finished; it is not an in-flight steering request. + if (value === "completed" || value === "steered") return "completed"; + if (value === "error") return "failed"; + if (value === "aborted" || value === "stopped") return "canceled"; + return "running"; +}; + +export const tintinwebPiSubagents: PiExtension = { + id: "@tintinweb/pi-subagents", + createSession: () => { + const callsByAgent = new Map(); + const readSessions = new Set(); + const mapSpawn = (call: PiExtensionToolCall) => { + const args = SpawnArgs.safeParse(call.args); + if (!args.success) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const title = args.data.subagent_type; + const description = args.data.description ?? args.data.prompt; + const detail = { + type: "sub_agent" as const, + subAgentType: title, + description, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running") + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: "running" as const, + }, + ], + }; + if (!details.success) + return call.status === "failed" + ? { + detail, + subagents: [{ type: "upsert" as const, id: call.callId, status: "failed" as const }], + } + : { detail }; + callsByAgent.set(details.data.agentId, call.callId); + const file = outputFileFromToolResult(call.result); + const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : []; + if (file) readSessions.add(file); + return { + detail, + subagents: [ + { + type: "upsert" as const, + id: call.callId, + title, + description, + toolCallId: call.callId, + status: status(details.data.status), + }, + ], + childSessions, + }; + }; + const mapFollowup = (call: PiExtensionToolCall) => { + const args = FollowupArgs.safeParse(call.args); + if (!args.success) return undefined; + const id = callsByAgent.get(args.data.agent_id); + if (!id) return undefined; + const details = Details.safeParse( + typeof call.result === "object" ? call.result?.details : null, + ); + const detail = { + type: "sub_agent" as const, + description: details.success ? details.data.description : undefined, + log: extractTextFromToolResult(call.result)?.trim() ?? "", + }; + if (call.status === "running" || !details.success) return { detail }; + return { + detail, + subagents: [{ type: "upsert" as const, id, status: status(details.data.status) }], + }; + }; + return { + mapToolCall(call) { + if (call.toolName === "Agent") return mapSpawn(call); + if (call.toolName === "get_subagent_result" || call.toolName === "steer_subagent") + return mapFollowup(call); + return undefined; + }, + mapCustomMessage(message) { + if ( + message.customType !== "subagent-notification" && + message.customType !== "subagent-update" + ) + return undefined; + const details = Notification.safeParse(message.details); + if (!details.success) return undefined; + const subagents = []; + const childSessions = []; + for (const entry of [details.data, ...(details.data.others ?? [])]) { + const id = callsByAgent.get(entry.id); + if (!id) continue; + const file = entry.outputFile; + if (file && !readSessions.has(file)) { + readSessions.add(file); + childSessions.push({ id, file }); + } + subagents.push({ + type: "upsert" as const, + id, + description: entry.description, + status: status(entry.status), + }); + } + if (subagents.length === 0) return undefined; + return { subagents, childSessions }; + }, + }; + }, +}; diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 380cf4ee75..24d18306b2 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -16,6 +16,35 @@ async function collectHistory( } describe("Pi history mapper", () => { + test("renders visible custom messages as completed tools and hides private context", async () => { + const events = await collectHistory([ + { + role: "custom", + customType: "project-context", + content: "Project instructions", + display: true, + }, + { + role: "custom", + customType: "private-context", + content: "Hidden instructions", + display: false, + }, + { role: "custom", customType: "project-context", content: "Project instructions" }, + ]); + expect(events.map((event) => event.item)).toEqual( + [1, 2].map((index) => ({ + type: "tool_call", + callId: `pi-custom-${index}`, + name: "project-context", + status: "completed", + detail: { type: "plain_text", text: "Project instructions" }, + metadata: { synthetic: true, customType: "project-context" }, + error: null, + })), + ); + }); + test("replays user, assistant, reasoning, and completed tool calls", async () => { await expect( collectHistory([ @@ -128,14 +157,22 @@ describe("Pi history mapper", () => { ]); }); - test("replays non-notice custom messages as assistant text, matching the live path", async () => { + test("replays custom messages as completed tools, matching the live path", async () => { await expect( collectHistory([{ role: "custom", content: "Extension command output" }]), ).resolves.toEqual([ { type: "timeline", provider: "pi", - item: { type: "assistant_message", text: "Extension command output" }, + item: { + type: "tool_call", + callId: "pi-custom-1", + name: "custom-message", + status: "completed", + detail: { type: "plain_text", text: "Extension command output" }, + metadata: { synthetic: true, customType: "custom-message" }, + error: null, + }, }, ]); }); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index cdb2257daa..27a0250b00 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -1,11 +1,16 @@ +import { mapCustomMessageToToolCall } from "../custom-message.js"; import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js"; +import { + createPiExtensionHost, + type PiExtensionEventOutput, + type PiExtensionHost, +} from "./extensions/index.js"; import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js"; import { extractTextFromToolResult, mapToolDetail, parseToolArgs, parseToolResult, - resolveToolCallName, type PiToolResult, type PiTrackedToolCall, } from "./tool-call-mapper.js"; @@ -54,13 +59,16 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon export class PiHistoryMapper { private readonly pendingToolCalls = new Map(); + private readonly hydrations: Promise[] = []; private userIndex = 0; private assistantIndex = 0; + private customIndex = 0; constructor( private readonly provider: string, private readonly userEntries: readonly PiCapturedUserMessageEntry[] = [], private readonly hooks: PiHistoryMapperHooks = {}, + private readonly extensionHost: PiExtensionHost = createPiExtensionHost(), ) {} mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] { @@ -78,10 +86,7 @@ export class PiHistoryMapper { events.push(...this.mapAssistantMessage(message)); break; case "toolResult": { - const event = this.mapToolResultMessage(message); - if (event) { - events.push(event); - } + events.push(...this.mapToolResultMessage(message)); break; } case "bashExecution": @@ -93,6 +98,10 @@ export class PiHistoryMapper { return events; } + async hydrate(): Promise { + return (await Promise.all(this.hydrations)).flat(); + } + private mapUserMessage(message: Extract): AgentStreamEvent[] { const text = getUserMessageText(message.content); this.userIndex += 1; @@ -116,20 +125,30 @@ export class PiHistoryMapper { private mapCustomMessage( message: Extract, ): AgentStreamEvent[] { + const extensionMapping = this.extensionHost.mapCustomMessage(message); + const extensionEvents = this.extensionEvents(extensionMapping); + if (message.display === false) return extensionEvents; const text = getUserMessageText(message.content); const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null; if (mappedEvent) { - return [mappedEvent]; + return [...extensionEvents, mappedEvent]; } - return text - ? [ - { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text }, - }, - ] - : []; + return [ + ...extensionEvents, + ...(text + ? [ + { + type: "timeline", + provider: this.provider, + item: mapCustomMessageToToolCall( + message, + text, + `${this.provider}-custom-${++this.customIndex}`, + ), + } as AgentStreamEvent, + ] + : []), + ]; } private mapAssistantMessage( @@ -159,7 +178,14 @@ export class PiHistoryMapper { if (content.type === "toolCall") { const tracked = parseToolArgs(content.name, content.arguments); this.pendingToolCalls.set(content.id, tracked); - const detail = this.mapToolDetail(content.id, tracked, null); + const mapping = this.extensionHost.mapToolCall({ + callId: content.id, + toolName: tracked.toolName, + args: tracked.args, + status: "running", + result: null, + }); + const detail = this.mapToolDetail(content.id, tracked, null, mapping?.detail); if (!detail) { continue; } @@ -169,12 +195,13 @@ export class PiHistoryMapper { item: { type: "tool_call", callId: this.resolveToolCallId(content.id, tracked), - name: tracked.toolName, + name: mapping?.name ?? tracked.toolName, status: "running", detail, error: null, }, }); + events.push(...this.extensionEvents(mapping)); } } return events; @@ -182,26 +209,42 @@ export class PiHistoryMapper { private mapToolResultMessage( message: Extract, - ): AgentStreamEvent | null { + ): AgentStreamEvent[] { const tracked = this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null); this.pendingToolCalls.delete(message.toolCallId); const result = parseToolResult({ content: message.content, details: message.details }); - const detail = this.mapToolDetail(message.toolCallId, tracked, result); + const mapping = this.extensionHost.mapToolCall({ + callId: message.toolCallId, + toolName: tracked.toolName, + args: tracked.args, + status: message.isError ? "failed" : "completed", + result, + }); + const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail); if (!detail) { - return null; + return []; } - return { - type: "timeline", - provider: this.provider, - item: toToolResultTimelineItem({ - callId: this.resolveToolCallId(message.toolCallId, tracked), - name: resolveToolCallName(tracked, result), - isError: Boolean(message.isError), - detail, - errorText: extractTextFromToolResult(result) ?? "Tool call failed", - }), - }; + return [ + { + type: "timeline", + provider: this.provider, + item: toToolResultTimelineItem({ + callId: this.resolveToolCallId(message.toolCallId, tracked), + name: mapping?.name ?? tracked.toolName, + isError: Boolean(message.isError), + detail, + errorText: extractTextFromToolResult(result) ?? "Tool call failed", + }), + }, + ...this.extensionEvents(mapping), + ]; + } + + private extensionEvents(mapping: PiExtensionEventOutput | undefined): AgentStreamEvent[] { + if (!mapping) return []; + this.hydrations.push(mapping.hydration); + return mapping.events; } private mapBashExecutionMessage( @@ -235,9 +278,12 @@ export class PiHistoryMapper { toolCallId: string, toolCall: PiTrackedToolCall, result: PiToolResult, + extensionDetail?: ToolCallDetail, ): ToolCallDetail | null { const hook = this.hooks.mapToolDetail; - return hook ? hook(toolCall, result, { toolCallId }) : mapToolDetail(toolCall, result); + return hook + ? hook(toolCall, result, { toolCallId }) + : (extensionDetail ?? mapToolDetail(toolCall, result)); } } @@ -246,13 +292,21 @@ export async function* streamPiHistory( messages: PiAgentMessage[], userEntries: readonly PiCapturedUserMessageEntry[] = [], hooks: PiHistoryMapperHooks = {}, + // At most eight 2 MiB child files per replay; remaining cards keep their summaries. + extensionHost: PiExtensionHost = createPiExtensionHost(undefined, undefined, 16 * 1024 * 1024), + signal?: AbortSignal, ): AsyncGenerator { - const mapper = new PiHistoryMapper(provider, userEntries, hooks); + const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost); for (const event of mapper.mapMessages(messages)) { + if (signal?.aborted) return; if (event) { yield event; } } + for (const event of await mapper.hydrate()) { + if (signal?.aborted) return; + yield event; + } } function toToolResultTimelineItem(input: { diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index f84e0ff69e..3e488c028c 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -41,6 +41,9 @@ export type PiAgentMessage = | { role: "custom"; content: string | Array; + customType?: string; + details?: unknown; + display?: boolean; } | { role: "assistant"; @@ -74,6 +77,7 @@ export interface PiModel { id: string; name?: string; reasoning?: boolean; + thinkingLevelMap?: Record; contextWindow?: number; maxTokens?: number; api?: string; @@ -156,6 +160,7 @@ export type PiAssistantMessageEvent = | { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" }; export type PiAgentSessionEvent = + | { type: "thinking_level_changed"; level: PiThinkingLevel } | { type: "agent_start" } | { type: "turn_start" } | { type: "message_start"; message: PiAgentMessage } diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index 3582a902bd..1064952e6d 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -40,6 +40,7 @@ export interface PiStartSessionInput { } export interface PiRuntimeSession { + readonly environment: Record; onEvent(callback: (event: PiRuntimeEvent) => void): () => void; prompt( message: string, diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index b1d369eb85..b8c7930bc4 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -57,6 +57,7 @@ export class FakePi implements PiRuntime { private readonly command: [string, ...string[]]; private readonly queuedCommands: PiRpcSlashCommand[][] = []; private readonly queuedSessionSetups: Array<(session: FakePiSession) => void> = []; + private readonly removedModels = new Set(); constructor(command: [string, ...string[]] = ["pi"]) { this.command = command; @@ -68,7 +69,10 @@ export class FakePi implements PiRuntime { session: input, }); this.recordedLaunches.push(launch); - const session = new FakePiSession(launch); + if (launch.model && this.removedModels.has(launch.model)) { + throw new Error(`Model "${launch.model}" not found`); + } + const session = new FakePiSession(launch, this.removedModels); session.commands = this.queuedCommands.shift() ?? []; this.queuedSessionSetups.shift()?.(session); this.sessions.push(session); @@ -83,6 +87,11 @@ export class FakePi implements PiRuntime { this.queuedSessionSetups.push(setup); } + // Pi refuses to launch with, or switch to, a model it no longer knows. + removeModel(modelId: string): void { + this.removedModels.add(modelId); + } + latestSession(): FakePiSession { const session = this.sessions.at(-1); if (!session) { @@ -93,6 +102,7 @@ export class FakePi implements PiRuntime { } export class FakePiSession implements PiRuntimeSession { + readonly environment: Record; readonly prompts: Array<{ message: string; imageCount: number }> = []; readonly steerCalls: Array<{ message: string; imageCount: number }> = []; steerError: Error | null = null; @@ -108,7 +118,10 @@ export class FakePiSession implements PiRuntimeSession { readonly handoffRequests: Array<{ customInstructions?: string }> = []; readonly sessionNameRequests: string[] = []; readonly rawFrames: Array = []; - capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = []; + // Every user entry in the session file, including rewound and compacted ones. + treeUserEntries: FakePiUserEntry[] = []; + // The user entries on the current branch that getMessages() replays. + contextUserEntries: FakePiUserEntry[] = []; abortRequested = false; readonly canceledExtensionUiRequests: string[] = []; readonly extensionUiResponses: Array<{ @@ -141,7 +154,11 @@ export class FakePiSession implements PiRuntimeSession { private activeHeldPrompt: { promise: Promise; reject: (error: Error) => void } | null = null; - constructor(launch: PiRuntimeLaunch) { + constructor( + launch: PiRuntimeLaunch, + private readonly removedModels: ReadonlySet = new Set(), + ) { + this.environment = launch.env ?? {}; this.state = { model: null, thinkingLevel: "medium", @@ -262,9 +279,13 @@ export class FakePiSession implements PiRuntimeSession { async setModel(provider: string, modelId: string): Promise { this.setModelRequests.push({ provider, modelId }); + if (this.removedModels.has(`${provider}/${modelId}`)) { + throw new Error(`Model not found: ${provider}/${modelId}`); + } if (!this.setModelResult) { throw new Error("FakePi setModel requires setModelResult to be scripted"); } + this.state = { ...this.state, model: this.setModelResult }; return this.setModelResult; } @@ -453,7 +474,8 @@ export class FakePiSession implements PiRuntimeSession { message: `PASEO_ENTRY_CAPTURE ${JSON.stringify({ reason, requestId, - entries: this.capturedUserEntries, + treeEntries: this.treeUserEntries, + contextEntries: this.contextUserEntries, })}`, }); } diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts index 224e861769..63b557f994 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -1,11 +1,6 @@ import { describe, expect, test } from "vitest"; -import { - mapToolDetail, - parseToolArgs, - parseToolResult, - resolveToolCallName, -} from "./tool-call-mapper.js"; +import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js"; describe("Pi tool call mapper", () => { test("maps bash args and result to shell detail", () => { @@ -50,81 +45,6 @@ describe("Pi tool call mapper", () => { }); }); - test("maps executed xdev writes to their wrapped tool detail", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "{}", - }); - const result = parseToolResult({ - content: [{ type: "text", text: "Opened Example Domain" }], - details: { - xdev: { - tool: "browser", - mode: "execute", - args: { action: "open", url: "https://example.com" }, - inner: { title: "Example Domain" }, - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { action: "open", url: "https://example.com" }, - output: { - content: [{ type: "text", text: "Opened Example Domain" }], - details: { title: "Example Domain" }, - }, - }); - expect(resolveToolCallName(toolCall, result)).toBe("browser"); - }); - - test("does not treat xdev help metadata as an executed inner tool", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "", - }); - const result = parseToolResult({ - details: { - xdev: { - tool: "browser", - mode: "help", - inner: "Browser help", - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { path: "xd://browser", content: "" }, - output: result, - }); - expect(resolveToolCallName(toolCall, result)).toBe("write"); - }); - - test("does not treat malformed xdev metadata as an executed inner tool", () => { - const toolCall = parseToolArgs("write", { - path: "xd://browser", - content: "{}", - }); - const result = parseToolResult({ - details: { - xdev: { - tool: "", - mode: "execute", - args: { action: "open" }, - inner: { title: "must not surface" }, - }, - }, - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "unknown", - input: { path: "xd://browser", content: "{}" }, - output: result, - }); - expect(resolveToolCallName(toolCall, result)).toBe("write"); - }); - test("preserves unknown tool input and parsed output", () => { const toolCall = parseToolArgs("custom_tool", { value: 42 }); const result = parseToolResult({ text: "custom result" }); @@ -135,61 +55,4 @@ describe("Pi tool call mapper", () => { output: { text: "custom result" }, }); }); - - test("maps task calls to sub-agent detail while running", () => { - const toolCall = parseToolArgs("task", { - agent: "explore", - task: "Trace the Pi provider tool mapper", - }); - - expect(mapToolDetail(toolCall, null)).toEqual({ - type: "sub_agent", - subAgentType: "explore", - description: "Trace the Pi provider tool mapper", - log: "", - }); - }); - - test("maps completed subagent calls with task input to sub-agent detail", () => { - const toolCall = parseToolArgs("subagent", { - agent: "reviewer", - task: "Review the Pi mapper change", - }); - const result = parseToolResult({ - content: [{ type: "text", text: "The mapper change preserves provider status." }], - }); - - expect(mapToolDetail(toolCall, result)).toEqual({ - type: "sub_agent", - subAgentType: "reviewer", - description: "Review the Pi mapper change", - log: "The mapper change preserves provider status.", - }); - }); - - test("normalizes Pi MCP proxy calls from requested tool args while running", () => { - const toolCall = parseToolArgs("mcp", { - tool: "paseo_list_models", - args: '{"provider":"pi"}', - }); - - expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models"); - }); - - test("normalizes Pi MCP proxy calls from result details when completed", () => { - const toolCall = parseToolArgs("mcp", { - tool: "paseo_list_models", - args: '{"provider":"pi"}', - }); - const result = parseToolResult({ - content: [{ type: "text", text: "(empty result)" }], - details: { - mode: "call", - server: "paseo", - tool: "list_models", - }, - }); - - expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models"); - }); }); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts index 499627377e..8c18a8fdbf 100644 --- a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -59,11 +59,7 @@ interface PiToolResultObject { interface PiToolResultDetails { diff?: string; - mode?: string; - server?: string; - tool?: string; - mcpResult?: unknown; - xdev?: unknown; + [key: string]: unknown; } interface PiToolResultTextContent { @@ -163,13 +159,6 @@ const PiToolResultDetailsSchema = z }) .passthrough(); -const XdevExecuteDetailsSchema = z.object({ - tool: z.string().trim().min(1), - mode: z.literal("execute"), - args: z.unknown().optional(), - inner: z.unknown().optional(), -}); - const PiToolResultObjectSchema = z .object({ output: z.string().optional(), @@ -184,14 +173,6 @@ const PiToolResultObjectSchema = z const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - const BashToolInputSchema: z.ZodType = z.object({ command: z.string(), timeout: z.number().optional(), @@ -295,55 +276,9 @@ export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedTool return { kind: "unknown", toolName, args: rawArgs ?? null }; } -function stripMcpProxyPrefix(toolName: string, serverName: string): string { - const prefix = `${serverName}_`; - return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName; -} - -export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string { - if (toolCall.kind === "write" && result && typeof result !== "string") { - const xdev = XdevExecuteDetailsSchema.safeParse(result.details?.xdev); - if (xdev.success) { - return xdev.data.tool; - } - } - - if (toolCall.toolName !== "mcp") { - return toolCall.toolName; - } - - if (result && typeof result !== "string") { - const serverName = readNonEmptyString(result.details?.server); - const toolName = readNonEmptyString(result.details?.tool); - if (serverName && toolName) { - return `${serverName}.${toolName}`; - } - } - - if (isRecord(toolCall.args)) { - const requestedTool = readNonEmptyString(toolCall.args.tool); - const requestedServer = readNonEmptyString(toolCall.args.server); - if (requestedTool && requestedServer) { - return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`; - } - if (requestedTool) { - const [serverName, ...toolParts] = requestedTool.split("_"); - if (serverName && toolParts.length > 0) { - return `${serverName}.${toolParts.join("_")}`; - } - } - } - - return toolCall.toolName; -} - export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail { const parsedResult = result ?? null; - if (isTaskToolCall(toolCall)) { - return mapTaskToolDetail(toolCall.args, parsedResult); - } - switch (toolCall.kind) { case "bash": { const summary = resolveToolCallOutput(parsedResult); @@ -376,7 +311,7 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult }; } case "write": - return mapWriteToolDetail(toolCall.args, parsedResult); + return { type: "write", filePath: toolCall.args.path, content: toolCall.args.content }; case "find": return mapFindToolDetail(toolCall.args, parsedResult); case "grep": @@ -392,58 +327,6 @@ export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult } } -function isTaskToolCall(toolCall: PiTrackedToolCall): boolean { - if (toolCall.toolName === "task") { - return true; - } - if (toolCall.toolName !== "subagent" || !isRecord(toolCall.args)) { - return false; - } - return ( - toolCall.args.action === undefined && - (readNonEmptyString(toolCall.args.agent) !== undefined || - readNonEmptyString(toolCall.args.task) !== undefined) - ); -} - -function mapTaskToolDetail(args: unknown, result: PiToolResult): ToolCallDetail { - const argRecord = isRecord(args) ? args : {}; - return { - type: "sub_agent", - subAgentType: readNonEmptyString(argRecord.agent), - description: readNonEmptyString(argRecord.task), - log: extractTextFromToolResult(result)?.trim() ?? "", - }; -} - -function mapWriteToolDetail(args: WriteToolInput, result: PiToolResult): ToolCallDetail { - if (result && typeof result !== "string" && result.details && "xdev" in result.details) { - const xdev = XdevExecuteDetailsSchema.safeParse(result.details.xdev); - if (xdev.success) { - return { - type: "unknown", - input: xdev.data.args ?? null, - output: { - ...result, - details: xdev.data.inner ?? null, - }, - }; - } - - return { - type: "unknown", - input: args, - output: result, - }; - } - - return { - type: "write", - filePath: args.path, - content: args.content, - }; -} - function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary { if (typeof result === "string") { return { output: result }; diff --git a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts index 93ea5e526c..1f48d7edfe 100644 --- a/packages/server/src/server/agent/providers/provider-windows-launch.test.ts +++ b/packages/server/src/server/agent/providers/provider-windows-launch.test.ts @@ -90,7 +90,7 @@ if (${JSON.stringify(launchMode)} === "piRuntime") { }); process.stdin.on("end", () => process.exit(0)); } else { - console.log("ARGV_OK"); + process.stdout.write("ARGV_OK\\n", () => process.exit(0)); } `, ); diff --git a/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts index 5cd550a730..e49a57e309 100644 --- a/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts +++ b/packages/server/src/server/agent/providers/test-utils/session-stream-adapter.ts @@ -76,3 +76,15 @@ export async function* streamSession( } } } + +export async function collectSessionTurnEvents( + session: Pick, + prompt: AgentPromptInput, + options?: AgentRunOptions, +): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, prompt, options)) { + events.push(event); + } + return events; +} diff --git a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts index 281a2bcf32..cd0e4776b7 100644 --- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -7,6 +7,7 @@ const FAILED_STATUS_VOCAB = new Set([ "errored", "rejected", "denied", + "declined", ]); const CANCELED_STATUS_VOCAB = new Set(["canceled", "cancelled", "interrupted", "aborted"]); const COMPLETED_STATUS_VOCAB = new Set(["completed", "complete", "done", "success", "succeeded"]); diff --git a/packages/server/src/server/agent/providers/trae-acp-agent.ts b/packages/server/src/server/agent/providers/trae-acp-agent.ts index b1e0893e4b..c14525211e 100644 --- a/packages/server/src/server/agent/providers/trae-acp-agent.ts +++ b/packages/server/src/server/agent/providers/trae-acp-agent.ts @@ -8,7 +8,6 @@ interface TraeACPAgentClientOptions { env?: Record; providerId?: string; label?: string; - providerParams?: unknown; } const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000; @@ -21,7 +20,6 @@ export class TraeACPAgentClient extends GenericACPAgentClient { env: options.env, providerId: options.providerId, label: options.label, - providerParams: options.providerParams, // traecli publishes slash commands and skills asynchronously via available_commands_update. waitForInitialCommands: true, initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS, diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts index 7040c42e3e..df5c242251 100644 --- a/packages/server/src/server/agent/rewind/rewind.test.ts +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -82,10 +82,11 @@ describe("AgentManager rewind", () => { test("replaces the canonical epoch without replaying reconstructed parent rows", async () => { const { manager, session, agentId } = await createRewindHarness(); - session.history = Array.from({ length: 250 }, (_, index) => ({ + const history = Array.from({ length: 250 }, (_, index) => ({ type: "assistant_message" as const, text: `rewound ${index}`, })); + session.history = history; const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch; const events: string[] = []; const unsubscribe = manager.subscribe((event) => events.push(event.type), { @@ -97,7 +98,18 @@ describe("AgentManager rewind", () => { const replacement = manager.fetchTimeline(agentId, { limit: 0 }); expect(replacement.epoch).not.toBe(epochBefore); - expect(replacement.rows).toHaveLength(250); + expect(replacement.rows).toEqual([ + expect.objectContaining({ + seq: 250, + seqStart: 1, + seqEnd: 250, + sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }], + item: { + type: "assistant_message", + text: history.map((item) => item.text).join(""), + }, + }), + ]); expect(events.filter((type) => type === "agent_stream")).toEqual([]); }); diff --git a/packages/server/src/server/agent/stale-provider-session-error.ts b/packages/server/src/server/agent/stale-provider-session-error.ts new file mode 100644 index 0000000000..7039da3c61 --- /dev/null +++ b/packages/server/src/server/agent/stale-provider-session-error.ts @@ -0,0 +1,11 @@ +/** Thrown when a prompt reaches a plugin session whose connection died with its plugin. */ +export class StaleProviderSessionError extends Error { + constructor(sessionId: string) { + super(`Provider session ${sessionId} is stale after its plugin reloaded`); + this.name = "StaleProviderSessionError"; + } +} + +export function isStaleProviderSessionError(error: unknown): error is StaleProviderSessionError { + return error instanceof StaleProviderSessionError; +} diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index cbcde7944e..1f72cdc498 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -1,7 +1,8 @@ import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js"; -import type { AgentTimelineRow } from "./agent-manager.js"; import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity"; +import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; + export type TimelineProjectionMode = "canonical" | "projected"; export interface TimelineSeqRange { @@ -19,6 +20,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after"; export interface TimelineProjectionEntry { item: AgentTimelineItem; turnId?: string; + providerMessageId?: string; timestamp: string; seqStart: number; seqEnd: number; @@ -54,7 +56,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq } if (seq <= last.endSeq + 1) { - last.endSeq = Math.max(last.endSeq, seq); + next[next.length - 1] = { ...last, endSeq: Math.max(last.endSeq, seq) }; return next; } @@ -85,7 +87,8 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail) return incoming; } -function mergeToolCallItems( +/** Fold a later update for the same tool call into the one it follows. */ +export function mergeToolCallItems( existing: Extract, incoming: Extract, ): Extract { @@ -114,15 +117,19 @@ function mergeToolCallItems( } function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] { - return rows.map((row) => ({ - item: row.item, - ...(row.turnId ? { turnId: row.turnId } : {}), - timestamp: row.timestamp, - seqStart: row.seq, - seqEnd: row.seq, - sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], - collapsed: [], - })); + return rows.map((row) => { + if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) }; + return { + item: row.item, + timestamp: row.timestamp, + ...(row.turnId ? { turnId: row.turnId } : {}), + ...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}), + seqStart: row.seq, + seqEnd: row.seq, + sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }], + collapsed: [], + }; + }); } function mergeIdentityMetadata( @@ -285,6 +292,54 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] return output; } +/** A retained item. `seq` is its latest source event, not its display anchor. */ +export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry; + +/** Owns projected state; source events are consumed, never retained. */ +export class TimelineProjection { + private readonly rows: ProjectedTimelineRow[] = []; + private readonly identities = new Map(); + + append(row: AgentTimelineRow): void { + const entry = makeCanonicalEntries([row])[0]; + const identity = timelineItemIdentity(row.item); + const index = identity === null ? undefined : this.identities.get(identity); + const existing = index === undefined ? undefined : this.rows[index]; + const merged = existing ? mergeIdentityEntries(existing, entry) : null; + if (merged && index !== undefined) { + this.rows[index] = { ...merged, seq: merged.seqEnd }; + return; + } + const previous = this.rows.at(-1); + const adjacent = previous + ? mergeReasoningChunks(mergeAssistantChunks([previous, entry])) + : [entry]; + if (previous && adjacent.length === 1) { + const mergedText = adjacent[0]; + this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd }; + return; + } + if (identity !== null) this.identities.set(identity, this.rows.length); + this.rows.push({ ...entry, seq: entry.seqEnd }); + } + + getRows(): ProjectedTimelineRow[] { + return this.rows.map((row) => ({ ...row })); + } + + enrichSubmittedUserMessage( + clientMessageId: string, + providerMessageId: string, + ): ProjectedTimelineRow | null { + const index = this.rows.findIndex( + (row) => row.item.type === "user_message" && row.item.clientMessageId === clientMessageId, + ); + if (index < 0) return null; + this.rows[index] = { ...this.rows[index], providerMessageId }; + return { ...this.rows[index] }; + } +} + export function projectTimelineRows(input: { rows: readonly AgentTimelineRow[]; mode: TimelineProjectionMode; @@ -397,7 +452,11 @@ function getTimelineBounds( if (!first || !last) { return null; } - return { minSeq: first.seq, maxSeq: last.seq }; + const entries = makeCanonicalEntries(rows); + return { + minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity), + maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0), + }; } function firstSourceSeqInRange( @@ -420,7 +479,6 @@ interface ProjectedEntryCandidate { function selectProjectedEntriesAfter(input: { entries: readonly TimelineProjectionEntry[]; - rows: readonly AgentTimelineRow[]; startSeq: number; maxSeq: number; limit: number; @@ -445,16 +503,10 @@ function selectProjectedEntriesAfter(input: { // Wide projected entries can include future, discontiguous source ranges. The // page cursor advances only through source rows covered without a gap. let endSeq = input.startSeq - 1; - let rangeIndex = 0; - for (const row of input.rows) { - if (row.seq < input.startSeq) continue; - if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break; - while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) { - rangeIndex += 1; - } - const range = selectedRanges[rangeIndex]; - if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break; - endSeq = row.seq; + for (const range of selectedRanges) { + if (range.endSeq <= endSeq) continue; + if (range.startSeq > endSeq + 1) break; + endSeq = Math.min(range.endSeq, input.maxSeq); } return { @@ -484,6 +536,27 @@ function selectProjectedEntriesBefore(input: { }; } +function selectProjectedEntriesTail( + projectedAll: TimelineProjectionEntry[], + limit: number, + bounds: { minSeq: number; maxSeq: number }, +): ProjectedTimelinePageSelection { + let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit); + // Include earlier display anchors with updates in the selected source window, + // so a tail certifies contiguous coverage even with interleaved tool updates. + for (let i = start - 1; i >= 0; i--) { + if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i; + } + const selected = projectedAll.slice(start); + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + endSeq: bounds.maxSeq, + hasOlder: start > 0, + hasNewer: false, + }; +} + export function selectProjectedTimelinePage(input: { rows: readonly AgentTimelineRow[]; bounds?: { minSeq: number; maxSeq: number }; @@ -535,18 +608,7 @@ export function selectProjectedTimelinePage(input: { } if (input.direction === "tail") { - const selected = selectTimelineWindowByProjectedLimit({ - rows: input.rows, - direction: "tail", - limit, - }); - return { - entries: selected.projectedEntries, - startSeq: selected.minSeq, - endSeq: selected.maxSeq, - hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq, - hasNewer: false, - }; + return selectProjectedEntriesTail(projectedAll, limit, bounds); } if (input.direction === "after") { @@ -554,7 +616,6 @@ export function selectProjectedTimelinePage(input: { const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); const selected = selectProjectedEntriesAfter({ entries: projectedAll, - rows: input.rows, startSeq, maxSeq: bounds.maxSeq, limit, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index fcc314ac11..dae9c16f4f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -1,3 +1,5 @@ +import { AgentDirectory } from "../directory.js"; +import { stat } from "node:fs/promises"; import { z } from "zod"; import { ensureValidJson } from "../../json-utils.js"; import type { Logger } from "pino"; @@ -58,9 +60,13 @@ import { sanitizePermissionRequest, serializeSnapshotWithMetadata, toScheduleSummary, - waitForAgentWithTimeout, } from "../mcp-shared.js"; -import { sendPromptToAgent, setupFinishNotification } from "../agent-prompt.js"; +import { + type PromptDispatchDisposition, + sendPromptToAgent, + setupFinishNotification, + waitForAgentRunStartWithTimeout, +} from "../agent-prompt.js"; import { respondToAgentPermission } from "../permission-response.js"; import { archiveAgentCommand, @@ -92,6 +98,8 @@ import type { PaseoToolExecutionContext, PaseoToolResult, } from "./types.js"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; +import { isPaseoToolEnabled } from "../paseo-tool-policy.js"; export interface PaseoToolHostDependencies { agentManager: AgentManager; @@ -104,7 +112,7 @@ export interface PaseoToolHostDependencies { github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees" >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; @@ -116,6 +124,7 @@ export interface PaseoToolHostDependencies { cwd: string, title?: string | null, projectId?: string, + context?: { background?: boolean; callerWorkspaceId?: string }, ) => Promise; workspaceScripts?: Pick; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; @@ -125,9 +134,11 @@ export interface PaseoToolHostDependencies { ensureWorkspaceForCreate?: ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ) => Promise; browserToolsEnabled?: boolean; browserToolsBroker?: BrowserToolsBroker | null; + paseoToolPolicy?: ProviderPaseoToolsPolicy; paseoHome?: string; worktreesRoot?: string; /** @@ -181,6 +192,7 @@ const WorkspaceAutomationSummarySchema = z.object({ isolation: z.enum(["local", "worktree"]), kind: z.enum(["directory", "local_checkout", "worktree"]), title: z.string().nullable(), + background: z.boolean().optional(), }); function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { @@ -191,6 +203,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { isolation: workspace.kind === "worktree" ? ("worktree" as const) : ("local" as const), kind: workspace.kind, title: workspace.title, + background: workspace.background, }; } @@ -220,6 +233,20 @@ function assertOptionsAbsent( } } +async function isExistingDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; + } +} + +function isMissingPathError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget { switch (input.mode ?? "branch-off") { case "branch-off": @@ -576,6 +603,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries. handler: (input: any, context: PaseoToolExecutionContext) => Promise, ) => { + if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) { + return; + } tools.set(name, { name, title: config.title, @@ -1022,13 +1052,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const canonicalTopLevelInputSchema = { ...canonicalCreateAgentFields, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1046,7 +1069,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...commonCreateAgentFields, relationship: legacyCreateAgentPlacementFields.relationship.optional(), workspace: legacyCreateAgentPlacementFields.workspace.optional(), - background: canonicalTopLevelInputSchema.background, notifyOnFinish: canonicalTopLevelInputSchema.notifyOnFinish, cwd: z .string() @@ -1104,13 +1126,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const agentToAgentSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(true) - .describe( - "Run agent in background. Agent-scoped default is true so you can continue until the finish notification arrives. Set false only when you need a blocking response.", - ), notifyOnFinish: z .boolean() .optional() @@ -1121,13 +1136,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }; const topLevelSendAgentPromptInputSchema = { ...commonSendAgentPromptInputSchema, - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), notifyOnFinish: z .boolean() .optional() @@ -1219,9 +1227,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path: z .string() .optional() - .describe("Local directory or source checkout. Defaults to your current workspace."), + .describe( + "Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.", + ), projectId: z.string().optional().describe("Existing project id to own the workspace."), title: z.string().trim().min(1).optional(), + background: z + .boolean() + .optional() + .describe( + "True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.", + ), mode: z .enum(["branch-off", "checkout-branch", "checkout-pr"]) .optional() @@ -1260,6 +1276,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase path, projectId, title, + background, mode, worktreeSlug, branchName, @@ -1271,6 +1288,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase let workspace: PersistedWorkspaceRecord; if (isolation === "local") { const cwd = resolveScopedCwd(path, { required: true }); + if (!(await isExistingDirectory(cwd))) { + throw new Error(`Directory not found: ${cwd}`); + } assertOptionsAbsent( [ ["mode", mode], @@ -1286,7 +1306,10 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase if (!options.createDirectoryWorkspace) { throw new Error("Workspace provisioning is not configured"); } - workspace = await options.createDirectoryWorkspace(cwd, title, projectId); + workspace = await options.createDirectoryWorkspace(cwd, title, projectId, { + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }); } else { let cwd = path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null; @@ -1317,6 +1340,8 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(worktreeSlug ? { worktreeSlug } : {}), ...worktreeTarget, ...(title ? { title } : {}), + background, + callerWorkspaceId: resolveCallerAgent()?.workspaceId, }, ); if (!result.ok) { @@ -1337,15 +1362,17 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase { title: "List workspaces", description: "List active workspaces.", - inputSchema: {}, + inputSchema: { includeBackground: z.boolean().optional().default(false) }, outputSchema: { workspaces: z.array(WorkspaceAutomationSummarySchema) }, }, - async () => { + async ({ includeBackground = false }) => { if (!options.workspaceRegistry) { throw new Error("Workspace registry is not configured"); } const workspaces = (await options.workspaceRegistry.list()) - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => !workspace.archivedAt && (includeBackground || !workspace.background), + ) .map(toWorkspaceAutomationSummary); return { content: [], @@ -1412,30 +1439,21 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase workspaceId: z.string().optional(), currentModeId: z.string().nullable(), availableModes: z.array(ProviderModeSchema), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, async (args: unknown) => { const resolvedArgs = await resolveCreateAgentToolArgs(args); const { parsedArgs, worktree } = resolvedArgs; - let requestedBackground: boolean; let notifyOnFinish: boolean; if (resolvedArgs.kind === "agent-scoped") { - requestedBackground = true; notifyOnFinish = parsedArgs.notifyOnFinish; } else { - requestedBackground = resolvedArgs.parsedArgs.background; notifyOnFinish = resolvedArgs.parsedArgs.notifyOnFinish ?? false; } const selectedProvider = resolveRequiredProviderModel(parsedArgs.provider).provider; const inheritedConfig = resolveInheritedProviderConfig(selectedProvider); - const { - snapshot, - background: createdInBackground, - initialPromptStarted, - } = await createAgentCommand( + const { snapshot, initialPromptStarted } = await createAgentCommand( { agentManager, agentStorage, @@ -1461,7 +1479,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase features: parsedArgs.settings?.features, labels: parsedArgs.labels, mode: parsedArgs.settings?.modeId, - background: requestedBackground, + promptFailure: "throw", notifyOnFinish, detached: resolvedArgs.detached, callerAgentId, @@ -1470,43 +1488,12 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); - try { - if (!createdInBackground && initialPromptStarted) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const liveSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; - const responseData = { - agentId: snapshot.id, - type: snapshot.provider, - status: result.status, - cwd: liveSnapshot.cwd, - ...(liveSnapshot.workspaceId ? { workspaceId: liveSnapshot.workspaceId } : {}), - currentModeId: liveSnapshot.currentModeId, - availableModes: liveSnapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - throw error; - } - // Return immediately for async creation. const currentSnapshot = agentManager.getAgent(snapshot.id) ?? snapshot; const guidance = callerAgentId && notifyOnFinish && initialPromptStarted ? "You will get notified when the created agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives." - : undefined; + : "Creation acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs."; const response = { content: [], structuredContent: ensureValidJson({ @@ -1517,8 +1504,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase ...(currentSnapshot.workspaceId ? { workspaceId: currentSnapshot.workspaceId } : {}), currentModeId: currentSnapshot.currentModeId, availableModes: currentSnapshot.availableModes, - lastMessage: null, - permission: null, ...(guidance ? { guidance } : {}), }), }; @@ -1652,7 +1637,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase const cwd = process.cwd(); return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), }; } const caller = resolveCallerAgent(); @@ -1820,7 +1807,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } return { cwd, - workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext), + workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, { + callerWorkspaceId: resolveCallerAgent()?.workspaceId, + }), worktree: undefined, }; } @@ -1859,85 +1848,70 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase } } + const PROMPTED_AGENT_NOTIFICATION_GUIDANCE = + "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives."; + registerTool( "send_agent_prompt", { title: "Send agent prompt", description: - "Send a task to a running agent. Agent-scoped callers run in background by default; top-level callers wait by default.", + "Send a task and return after dispatch acknowledgement. Calling agents receive finish notifications by default.", inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), + disposition: z.enum(["turn_started", "out_of_band"]).optional(), status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), guidance: z.string().optional(), }, }, - async ({ - agentId, - prompt, - sessionMode, - background = Boolean(callerAgentId), - notifyOnFinish = Boolean(callerAgentId), - }) => { - const shouldNotifyOnFinish = Boolean(callerAgentId && notifyOnFinish && background); - - await sendPromptToAgent({ - agentManager, - agentStorage, - agentId, - prompt, - sessionMode, - logger: childLogger, - }); - - if (shouldNotifyOnFinish && callerAgentId) { - setupFinishNotification({ + async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + let stopNotification: (() => void) | undefined; + function armFinishNotification(disposition: PromptDispatchDisposition): void { + if (!callerAgentId || !notifyOnFinish) { + return; + } + stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, + waitForTurnStart: disposition === "turn_started", logger: childLogger, }); } - // If not running in background, wait for completion - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, + const notifying = Boolean(callerAgentId && notifyOnFinish); + const { disposition } = await sendPromptToAgent({ + agentManager, + agentStorage, + agentId, + prompt, + source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, + sessionMode, + onDispatch: armFinishNotification, + logger: childLogger, + }).catch((error) => { + stopNotification?.(); + throw error; + }); + + // Return once the provider has accepted the turn, so the status reports it running. + if (disposition === "turn_started") { + await waitForAgentRunStartWithTimeout(agentManager, agentId).catch((error) => { + stopNotification?.(); + throw error; }); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - const response = { - content: [], - structuredContent: validJson, - }; - return response; } - - // Return immediately if background=true - // Re-fetch snapshot since the state may have changed const currentSnapshot = agentManager.getAgent(agentId); const responseData = { success: true, + disposition, status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - ...(shouldNotifyOnFinish - ? { - guidance: - "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", - } - : {}), + guidance: notifying + ? PROMPTED_AGENT_NOTIFICATION_GUIDANCE + : "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.", }; const validJson = ensureValidJson(responseData); @@ -2005,6 +1979,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase title: "List agents", description: "List recent agents as compact metadata.", inputSchema: { + includeBackground: z.boolean().optional().default(false), includeArchived: z.boolean().optional().default(false), cwd: z.string().optional(), sinceHours: z @@ -2021,29 +1996,38 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase agents: z.array(AgentListItemPayloadSchema), }, }, - async ({ includeArchived = false, cwd, sinceHours = 48, statuses, limit = 50 }) => { + async ({ + includeBackground = false, + includeArchived = false, + cwd, + sinceHours = 48, + statuses, + limit = 50, + }) => { const callerCwd = callerAgentId ? resolveCallerAgent()?.cwd : undefined; const requestedCwd = cwd?.trim() ? expandUserPath(cwd) : callerCwd; const statusFilter = statuses && statuses.length > 0 ? new Set(statuses) : null; const sinceMs = Date.now() - sinceHours * 60 * 60 * 1000; - const liveSnapshots = agentManager.listAgents(); - const liveAgents = await Promise.all( - liveSnapshots.map((snapshot) => + if (!options.workspaceRegistry) throw new Error("Workspace registry is not configured"); + const directory = new AgentDirectory({ + manager: agentManager, + storage: agentStorage, + workspaces: options.workspaceRegistry, + projectLive: (snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); - const storedRecords = await agentStorage.list(); - const registeredProviderIds = new Set(providerSnapshotManager.listRegisteredProviderIds()); - const storedAgents = storedRecords - .filter((record) => !record.internal && !liveIds.has(record.id)) - .filter((record) => includeArchived || !record.archivedAt) - .filter( - (record) => - includeArchived || isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => buildStoredAgentPayload(record, registeredProviderIds)); - const agents = [...liveAgents, ...storedAgents] + projectStored: (record) => + buildStoredAgentPayload( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + isProviderVisible: () => true, + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(providerSnapshotManager.listRegisteredProviderIds()), + ), + }); + const agents = (await directory.list({ includeBackground, includeArchived })) .map(toAgentListItemPayload) .filter((agent) => !requestedCwd || isSameOrDescendantPath(requestedCwd, agent.cwd)) .filter((agent) => !statusFilter || statusFilter.has(agent.status)) diff --git a/packages/server/src/server/agent/tools/types.ts b/packages/server/src/server/agent/tools/types.ts index 1eb54dabfd..66a4a23da2 100644 --- a/packages/server/src/server/agent/tools/types.ts +++ b/packages/server/src/server/agent/tools/types.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config"; export interface PaseoToolExecutionContext { signal?: AbortSignal; @@ -36,6 +37,7 @@ export interface PaseoToolCatalog { export interface PaseoToolRuntimeContext { callerAgentId?: string; + paseoToolPolicy?: ProviderPaseoToolsPolicy; enableVoiceTools?: boolean; voiceOnly?: boolean; } diff --git a/packages/server/src/server/auth.test.ts b/packages/server/src/server/auth.test.ts index f53ce43a65..0adf442fb6 100644 --- a/packages/server/src/server/auth.test.ts +++ b/packages/server/src/server/auth.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "vitest"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { extractHttpBearerToken, @@ -7,25 +10,27 @@ import { hashDaemonPassword, isAgentMcpRequestAuthorized, isBearerTokenValidAsync, - isBearerTokenValid, shouldBypassBearerAuth, } from "./auth.js"; +import { resolveSessionAdmission } from "./session-admission-auth.js"; +import { + deleteLocalCredential, + readLocalCredentialForTarget, + writeLocalCredential, +} from "./local-credential.js"; const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; describe("daemon bearer validator", () => { - test("allows any token when no password is configured", () => { - expect(isBearerTokenValid({ password: undefined, token: null })).toBe(true); - expect(isBearerTokenValid({ password: undefined, token: "anything" })).toBe(true); + test("allows any token when no password is configured", async () => { + expect(await isBearerTokenValidAsync({ password: undefined, token: null })).toBe(true); + expect(await isBearerTokenValidAsync({ password: undefined, token: "anything" })).toBe(true); }); test("accepts the plaintext token against the bcrypt hash and rejects missing or wrong tokens", async () => { expect( await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: "correct-password" }), ).toBe(true); - expect(isBearerTokenValid({ password: CORRECT_PASSWORD_HASH, token: "correct-password" })).toBe( - true, - ); expect(await isBearerTokenValidAsync({ password: CORRECT_PASSWORD_HASH, token: null })).toBe( false, ); @@ -34,11 +39,11 @@ describe("daemon bearer validator", () => { ); }); - test("hashes a password into a bcrypt value", () => { + test("hashes a password into a bcrypt value", async () => { const hash = hashDaemonPassword("correct-password"); expect(hash).toMatch(/^\$2[aby]\$12\$/); - expect(isBearerTokenValid({ password: hash, token: "correct-password" })).toBe(true); + expect(await isBearerTokenValidAsync({ password: hash, token: "correct-password" })).toBe(true); }); test("extracts HTTP bearer tokens", () => { @@ -121,3 +126,88 @@ describe("agent MCP request authorizer", () => { ).toBe(false); }); }); + +describe("hello admission", () => { + test("admits a stale local credential when no password is configured", async () => { + expect( + await resolveSessionAdmission({ + credential: { kind: "localCredential", token: "stale" }, + passwordHash: undefined, + localCredential: "current", + transport: "direct", + }), + ).toMatchObject({ admission: { principalId: "owner" } }); + }); + test("accepts a password and current local credential, but rejects old and wrong credentials", async () => { + const home = await mkdtemp(join(tmpdir(), "paseo-local-auth-")); + try { + const first = await writeLocalCredential(home); + const second = await writeLocalCredential(home); + const input = { + passwordHash: CORRECT_PASSWORD_HASH, + localCredential: second, + transport: "direct" as const, + }; + expect( + await resolveSessionAdmission({ + ...input, + credential: { kind: "password", password: "correct-password" }, + }), + ).toMatchObject({ admission: { principalId: "owner" } }); + expect( + await resolveSessionAdmission({ + ...input, + credential: { kind: "localCredential", token: second }, + }), + ).toMatchObject({ admission: { principalId: "owner" } }); + expect( + await resolveSessionAdmission({ + ...input, + credential: { kind: "localCredential", token: first }, + }), + ).toEqual({ rejection: "incorrect_password" }); + expect( + await resolveSessionAdmission({ + ...input, + credential: { kind: "password", password: "wrong" }, + }), + ).toEqual({ rejection: "incorrect_password" }); + expect( + await resolveSessionAdmission({ + ...input, + transport: "relay", + credential: { kind: "password", password: "wrong" }, + }), + ).toEqual({ rejection: "incorrect_password" }); + expect(await resolveSessionAdmission({ ...input, credential: undefined })).toEqual({ + rejection: "password_required", + }); + expect( + await resolveSessionAdmission({ ...input, transport: "relay", credential: undefined }), + ).toMatchObject({ admission: { principalId: "owner" } }); + } finally { + await deleteLocalCredential(home); + await rm(home, { recursive: true, force: true }); + } + }); + + test("writes a private rotating credential and reads it only for the matching target", async () => { + const home = await mkdtemp(join(tmpdir(), "paseo-local-file-")); + try { + await writeFile(join(home, "paseo.pid"), JSON.stringify({ listen: "127.0.0.1:6767" })); + const first = await writeLocalCredential(home); + if (process.platform !== "win32") { + expect((await stat(join(home, "local-credential"))).mode & 0o777).toBe(0o600); + } + expect(readLocalCredentialForTarget(home, "tcp://localhost:6767")).toBe(first); + expect(readLocalCredentialForTarget(home, "tcp://remote.example:6767")).toBeNull(); + const second = await writeLocalCredential(home); + expect(second).not.toBe(first); + expect((await readFile(join(home, "local-credential"), "utf8")).trim()).toBe(second); + await deleteLocalCredential(home); + expect(readLocalCredentialForTarget(home, "localhost:6767")).toBeNull(); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/server/src/server/auth.ts b/packages/server/src/server/auth.ts index 619bb238e0..81848927f3 100644 --- a/packages/server/src/server/auth.ts +++ b/packages/server/src/server/auth.ts @@ -1,11 +1,13 @@ -import { compare, compareSync, hashSync } from "bcryptjs"; +import { compare, hashSync } from "bcryptjs"; import { timingSafeEqual } from "node:crypto"; import type { RequestHandler } from "express"; +import { matchesLocalCredential } from "./local-credential.js"; export const DAEMON_PASSWORD_BCRYPT_COST = 12; export interface DaemonAuthConfig { password?: string; + localCredential?: () => string | null; } export interface BearerAuthRejectContext { @@ -19,10 +21,6 @@ interface BearerValidationInput { token: string | null; } -export function isBearerTokenValid(input: BearerValidationInput): boolean { - return isBearerTokenValidSync(input); -} - export async function isBearerTokenValidAsync(input: BearerValidationInput): Promise { if (!input.password) { return true; @@ -34,17 +32,6 @@ export async function isBearerTokenValidAsync(input: BearerValidationInput): Pro return compare(input.token, input.password); } -export function isBearerTokenValidSync(input: BearerValidationInput): boolean { - if (!input.password) { - return true; - } - if (input.token === null) { - return false; - } - - return compareSync(input.token, input.password); -} - export function hashDaemonPassword(password: string): string { return hashSync(password, DAEMON_PASSWORD_BCRYPT_COST); } @@ -101,7 +88,13 @@ export function createRequireBearerMiddleware( void (async () => { try { const token = extractHttpBearerToken(req.header("authorization")); - if (!(await isBearerTokenValidAsync({ password, token }))) { + const localCredential = req.path === "/api/status" ? auth?.localCredential?.() : null; + const isLocal = + localCredential !== null && + localCredential !== undefined && + token !== null && + matchesLocalCredential(localCredential, token); + if (!isLocal && !(await isBearerTokenValidAsync({ password, token }))) { onReject?.({ path: req.path, method: req.method, diff --git a/packages/server/src/server/authorization/index.test.ts b/packages/server/src/server/authorization/index.test.ts index 18e7feca2b..5230e51261 100644 --- a/packages/server/src/server/authorization/index.test.ts +++ b/packages/server/src/server/authorization/index.test.ts @@ -26,6 +26,11 @@ function inboundMessage(type: SessionInboundMessage["type"]): SessionInboundMess } function outboundMessage(type: SessionOutboundMessage["type"]): SessionOutboundMessage { + if (type === "status") + return { + type, + payload: { status: "agent_create_failed", error: "test", requestId: "test" }, + } as SessionOutboundMessage; return { type } as SessionOutboundMessage; } @@ -69,6 +74,58 @@ describe("SessionAuthorization", () => { ).toBe(false); }); + test("Hub can operate ordinary agents and recover workspaces without daemon administration", () => { + const authorization = new SessionAuthorization(["hub.execute"]); + for (const type of [ + "create_agent_request", + "workspace.title.set.request", + "fetch_agents_request", + "fetch_agent_request", + "agent.timeline.set_subscription.request", + "send_agent_message_request", + "workspace.recovery.inspect.request", + "workspace.recovery.restore.request", + "archive_workspace_request", + "cancel_agent_request", + ] as const) { + expect(authorization.allowsInbound(inboundMessage(type))).toBe(true); + } + for (const type of [ + "status", + "agent_update", + "agent_stream", + "workspace_update", + "rpc_error", + "workspace.title.set.response", + "fetch_agents_response", + "fetch_agent_response", + "agent.timeline.set_subscription.response", + "send_agent_message_response", + "workspace.recovery.inspect.response", + "workspace.recovery.restore.response", + "archive_workspace_response", + "cancel_agent_response", + ] as const) { + expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true); + } + for (const type of [ + "restart_server_request", + "terminal_input", + "hub.management.daemon.permissions.update.request", + ] as const) { + expect(authorization.allowsInbound(inboundMessage(type))).toBe(false); + } + expect( + authorization.allowsOutbound({ + type: "status", + payload: { status: "shutdown_requested", clientId: "owner", requestId: "shutdown" }, + }), + ).toBe(false); + authorization.replacePermissions([]); + expect(authorization.allowsInbound(inboundMessage("send_agent_message_request"))).toBe(false); + expect(authorization.allowsOutbound(outboundMessage("agent_update"))).toBe(false); + }); + test("correlated authorization errors can always be emitted", () => { const authorization = new SessionAuthorization([]); diff --git a/packages/server/src/server/authorization/index.ts b/packages/server/src/server/authorization/index.ts index 38414f33f9..e79f5d28e7 100644 --- a/packages/server/src/server/authorization/index.ts +++ b/packages/server/src/server/authorization/index.ts @@ -34,7 +34,7 @@ export class SessionAuthorization { } allowsOutbound(message: SessionOutboundMessage): boolean { - return this.allows(requiredPermissionForOutbound(message.type)); + return this.allows(requiredPermissionForOutbound(message)); } replacePermissions(permissions: readonly DaemonPermission[]): void { diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 95995bc65e..e7e77bc0d3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -7,7 +7,7 @@ export type PermissionRequirement = DaemonPermission | readonly DaemonPermission const INBOUND_PERMISSION = { abort_request: "workspace.write", - "agent.config.apply.request": "workspace.write", + "agent.config.apply.request": ["workspace.write", "hub.execute"], "agent.detach.request": "workspace.write", "agent.fork_context.request": "workspace.read", "agent.provider_subagents.list.request": "workspace.read", @@ -18,16 +18,20 @@ const INBOUND_PERMISSION = { "agent.skills.reconcile.request": "daemon.manage", "agent.skills.save_selection.request": "daemon.manage", "agent.skills.uninstall.request": "daemon.manage", + "agent.timeline.search.request": "workspace.read", "agent.timeline.list_prompts.request": "workspace.read", "agent.timeline.append.request": "workspace.write", - "agent.timeline.set_subscription.request": "workspace.read", + "browser.host.register.request": ["workspace.write"], + "subscription.release.request": null, + "session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"], + "agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"], agent_permission_response: "workspace.write", - archive_agent_request: "workspace.write", - archive_workspace_request: "workspace.manage", + archive_agent_request: ["workspace.write", "hub.execute"], + archive_workspace_request: ["workspace.manage", "hub.execute"], audio_played: "workspace.write", branch_suggestions_request: "workspace.read", "browser.automation.execute.response": "workspace.write", - cancel_agent_request: "workspace.write", + cancel_agent_request: ["workspace.write", "hub.execute"], capture_terminal_request: "workspace.read", "chat/create": "workspace.write", "chat/delete": "workspace.write", @@ -58,7 +62,9 @@ const INBOUND_PERMISSION = { clear_agent_attention: "workspace.write", client_heartbeat: "workspace.read", close_items_request: "workspace.manage", - create_agent_request: "workspace.write", + create_agent_request: ["workspace.write", "hub.execute"], + "agent.create.request": ["workspace.write", "hub.execute"], + "creation.subscribe.request": "workspace.read", create_paseo_worktree_request: "workspace.manage", create_terminal_request: "workspace.write", "daemon.config.reload.request": "daemon.manage", @@ -73,11 +79,11 @@ const INBOUND_PERMISSION = { dictation_stream_start: "workspace.write", directory_suggestions_request: "workspace.read", fetch_agent_history_request: "workspace.read", - fetch_agent_request: "workspace.read", - fetch_agent_timeline_request: "workspace.read", - fetch_agents_request: "workspace.read", + fetch_agent_request: ["workspace.read", "hub.execute"], + fetch_agent_timeline_request: ["workspace.read", "hub.execute"], + fetch_agents_request: ["workspace.read", "hub.execute"], fetch_recent_provider_sessions_request: "workspace.read", - fetch_workspaces_request: "workspace.read", + fetch_workspaces_request: ["workspace.read", "hub.execute"], "file.upload.request": "workspace.write", file_download_token_request: "workspace.read", file_explorer_request: "workspace.read", @@ -131,6 +137,8 @@ const INBOUND_PERMISSION = { "plugin.source.install.request": "daemon.manage", "plugin.source.status.request": "daemon.manage", "plugin.source.update.request": "daemon.manage", + "plugin.source.update.preview.request": "daemon.manage", + "plugin.source.update.apply.request": "daemon.manage", "project.add.request": "workspace.manage", "project.create_directory.request": "workspace.manage", "project.github.clone.request": "workspace.manage", @@ -141,6 +149,7 @@ const INBOUND_PERMISSION = { "project.rename.request": "workspace.manage", project_icon_request: "workspace.read", "provider.usage.list.request": "daemon.read", + "usage.list_reports.request": "daemon.read", provider_diagnostic_request: "daemon.read", pull_request_timeline_request: "workspace.read", "push.unregister.request": "workspace.read", @@ -159,7 +168,7 @@ const INBOUND_PERMISSION = { "schedule/resume": "automation.manage", "schedule/run-once": "automation.manage", "schedule/update": "automation.manage", - send_agent_message_request: "workspace.write", + send_agent_message_request: ["workspace.write", "hub.execute"], set_agent_feature_request: "workspace.write", set_agent_mode_request: "workspace.write", set_agent_model_request: "workspace.write", @@ -171,6 +180,7 @@ const INBOUND_PERMISSION = { stash_list_request: "workspace.read", stash_pop_request: "workspace.write", stash_save_request: "workspace.write", + "checkout.diff.get.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -184,6 +194,7 @@ const INBOUND_PERMISSION = { voice_audio_chunk: "workspace.write", wait_for_finish_request: "workspace.read", "workspace.clear_attention.request": "workspace.write", + "workspace.mark_unread.request": "workspace.write", "workspace.create.request": "workspace.manage", "workspace.github.search_repositories.request": "workspace.read", "workspace.label.assignment.set.request": "workspace.manage", @@ -192,20 +203,24 @@ const INBOUND_PERMISSION = { "workspace.label.list.request": "workspace.read", "workspace.label.update.request": "workspace.manage", "workspace.pin.set.request": "workspace.manage", - "workspace.recovery.inspect.request": "workspace.read", - "workspace.recovery.restore.request": "workspace.manage", + "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], + "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", "workspace.script.start.request": "workspace.write", "workspace.script.stop.request": "workspace.write", - "workspace.title.set.request": "workspace.manage", + "workspace.title.set.request": ["workspace.manage", "hub.execute"], workspace_setup_status_request: "workspace.read", "workspace.setup.run.request": "workspace.write", write_project_config_request: "workspace.write", } as const satisfies Record; const OUTBOUND_PERMISSION = { + "agent.create.response": ["workspace.write", "hub.execute"], + "agent.create.update": ["workspace.write", "hub.execute"], + "workspace.create.update": "workspace.manage", + "creation.subscribe.response": "workspace.read", activity_log: "workspace.read", - "agent.config.apply.response": "workspace.write", + "agent.config.apply.response": ["workspace.write", "hub.execute"], "agent.detach.response": "workspace.write", "agent.fork_context.response": "workspace.read", "agent.provider_subagents.list.response": "workspace.read", @@ -217,25 +232,29 @@ const OUTBOUND_PERMISSION = { "agent.skills.reconcile.response": "daemon.manage", "agent.skills.save_selection.response": "daemon.manage", "agent.skills.uninstall.response": "daemon.manage", + "agent.timeline.search.response": "workspace.read", "agent.timeline.list_prompts.response": "workspace.read", "agent.timeline.append.response": "workspace.write", - "agent.timeline.replacement": "workspace.read", - "agent.timeline.set_subscription.response": "workspace.read", - agent_archived: "workspace.read", - agent_attention_required: "workspace.read", - agent_deleted: "workspace.read", - agent_permission_request: "workspace.read", - agent_permission_resolved: "workspace.write", - agent_status: "workspace.read", - agent_stream: "workspace.read", - agent_update: "workspace.read", - archive_workspace_response: "workspace.manage", + "agent.timeline.replacement": ["workspace.read", "hub.execute"], + "browser.host.register.response": ["workspace.write"], + "subscription.release.response": null, + "session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"], + "agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"], + agent_archived: ["workspace.read", "hub.execute"], + agent_attention_required: ["workspace.read", "hub.execute"], + agent_deleted: ["workspace.read", "hub.execute"], + agent_permission_request: ["workspace.read", "hub.execute"], + agent_permission_resolved: ["workspace.write", "hub.execute"], + agent_status: ["workspace.read", "hub.execute"], + agent_stream: ["workspace.read", "hub.execute"], + agent_update: ["workspace.read", "hub.execute"], + archive_workspace_response: ["workspace.manage", "hub.execute"], artifact: "workspace.read", assistant_chunk: "workspace.read", audio_output: "workspace.write", branch_suggestions_response: "workspace.read", "browser.automation.execute.request": "workspace.write", - cancel_agent_response: "workspace.write", + cancel_agent_response: ["workspace.write", "hub.execute"], capture_terminal_response: "workspace.read", "chat/create/response": "workspace.write", "chat/delete/response": "workspace.write", @@ -282,11 +301,11 @@ const OUTBOUND_PERMISSION = { dictation_stream_partial: "workspace.write", directory_suggestions_response: "workspace.read", fetch_agent_history_response: "workspace.read", - fetch_agent_response: "workspace.read", - fetch_agent_timeline_response: "workspace.read", - fetch_agents_response: "workspace.read", + fetch_agent_response: ["workspace.read", "hub.execute"], + fetch_agent_timeline_response: ["workspace.read", "hub.execute"], + fetch_agents_response: ["workspace.read", "hub.execute"], fetch_recent_provider_sessions_response: "workspace.read", - fetch_workspaces_response: "workspace.read", + fetch_workspaces_response: ["workspace.read", "hub.execute"], "file.upload.response": "workspace.write", file_download_token_response: "workspace.read", file_explorer_response: "workspace.read", @@ -341,6 +360,8 @@ const OUTBOUND_PERMISSION = { "plugin.source.install.response": "daemon.manage", "plugin.source.status.response": "daemon.manage", "plugin.source.update.response": "daemon.manage", + "plugin.source.update.preview.response": "daemon.manage", + "plugin.source.update.apply.response": "daemon.manage", pong: "daemon.read", "project.add.response": "workspace.manage", "project.create_directory.response": "workspace.manage", @@ -353,6 +374,8 @@ const OUTBOUND_PERMISSION = { "project.update": "workspace.read", project_icon_response: "workspace.read", "provider.usage.list.response": "daemon.read", + "usage.list_reports.update": "daemon.read", + "usage.list_reports.response": "daemon.read", provider_diagnostic_response: "daemon.read", providers_snapshot_update: ["daemon.read", "hub.execute"], pull_request_timeline_response: "workspace.read", @@ -370,7 +393,7 @@ const OUTBOUND_PERMISSION = { "schedule/run-once/response": "automation.manage", "schedule/update/response": "automation.manage", script_status_update: "workspace.read", - send_agent_message_response: "workspace.write", + send_agent_message_response: ["workspace.write", "hub.execute"], set_agent_feature_response: "workspace.write", set_agent_mode_response: "workspace.write", set_agent_model_response: "workspace.write", @@ -382,6 +405,7 @@ const OUTBOUND_PERMISSION = { stash_pop_response: "workspace.read", stash_save_response: "workspace.read", status: "daemon.read", + "checkout.diff.get.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", @@ -394,6 +418,7 @@ const OUTBOUND_PERMISSION = { voice_input_state: "workspace.write", wait_for_finish_response: "workspace.read", "workspace.clear_attention.response": "workspace.write", + "workspace.mark_unread.response": "workspace.write", "workspace.create.response": "workspace.manage", "workspace.github.search_repositories.response": "workspace.read", "workspace.label.assignment.set.response": "workspace.manage", @@ -403,16 +428,16 @@ const OUTBOUND_PERMISSION = { "workspace.label.update": "workspace.read", "workspace.label.update.response": "workspace.manage", "workspace.pin.set.response": "workspace.manage", - "workspace.recovery.inspect.response": "workspace.read", - "workspace.recovery.restore.response": "workspace.manage", + "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], + "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", "workspace.script.start.response": "workspace.write", "workspace.script.stop.response": "workspace.write", - "workspace.title.set.response": "workspace.manage", + "workspace.title.set.response": ["workspace.manage", "hub.execute"], workspace_setup_progress: "workspace.read", workspace_setup_status_response: "workspace.read", "workspace.setup.run.response": "workspace.write", - workspace_update: "workspace.read", + workspace_update: ["workspace.read", "hub.execute"], write_project_config_response: "workspace.write", } as const satisfies Record; @@ -420,6 +445,14 @@ export function requiredPermissionForInbound(operation: InboundOperation): Permi return INBOUND_PERMISSION[operation]; } -export function requiredPermissionForOutbound(operation: OutboundOperation): PermissionRequirement { - return OUTBOUND_PERMISSION[operation]; +export function requiredPermissionForOutbound( + message: SessionOutboundMessage, +): PermissionRequirement { + // The legacy status envelope carries both agent RPC results and daemon configuration. + if ( + message.type === "status" && + (message.payload.status === "agent_created" || message.payload.status === "agent_create_failed") + ) + return ["workspace.write", "hub.execute"]; + return OUTBOUND_PERMISSION[message.type]; } diff --git a/packages/server/src/server/bootstrap-auth.test.ts b/packages/server/src/server/bootstrap-auth.test.ts index 2fa7c860a6..4046bd6747 100644 --- a/packages/server/src/server/bootstrap-auth.test.ts +++ b/packages/server/src/server/bootstrap-auth.test.ts @@ -1,7 +1,12 @@ import { WebSocket } from "ws"; +import { hash } from "bcryptjs"; +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { DaemonAuthenticationError, DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { readLocalCredentialForTarget } from "./local-credential.js"; const originalEnv = { ...process.env }; const CORRECT_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; @@ -25,8 +30,10 @@ async function expectWebSocketCloses(params: { protocol?: string; code: number; reason: string; + hello?: Record; }): Promise { const { ws } = await connectWebSocket(params); + if (params.hello) ws.send(JSON.stringify(params.hello)); await expect( new Promise<{ code: number; reason: string }>((resolve) => { ws.once("close", (code, reason) => { @@ -77,6 +84,14 @@ describe("daemon bearer auth", () => { headers: { Authorization: "Bearer correct-password" }, }); expect(correct.status).toBe(200); + + const localToken = ( + await readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8") + ).trim(); + const local = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`, { + headers: { Authorization: `Bearer ${localToken}` }, + }); + expect(local.status).toBe(200); } finally { await daemonHandle.close(); } @@ -133,6 +148,7 @@ describe("daemon bearer auth", () => { port: daemonHandle.port, code: 4401, reason: "Password required", + hello: { type: "hello", clientId: "missing", clientType: "cli", protocolVersion: 1 }, }); await expectWebSocketCloses({ port: daemonHandle.port, @@ -146,9 +162,269 @@ describe("daemon bearer auth", () => { protocol: "paseo.bearer.correct-password", }); expect(protocol).toBe("paseo.bearer.correct-password"); + const serverInfo = new Promise((resolve) => { + ws.once("message", (data) => resolve(JSON.parse(data.toString()))); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: "legacy", + clientType: "cli", + protocolVersion: 1, + }), + ); + await expect(serverInfo).resolves.toMatchObject({ + type: "session", + message: { payload: { status: "server_info" } }, + }); ws.close(); } finally { await daemonHandle.close(); } }); + + test("accepts hello password and local credential and reports the negotiated protocol", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const token = ( + await readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8") + ).trim(); + if (process.platform !== "win32") { + expect((await stat(join(daemonHandle.paseoHome, "local-credential"))).mode & 0o777).toBe( + 0o600, + ); + } + for (const auth of [ + { kind: "password", password: "correct-password" }, + { kind: "localCredential", token }, + ]) { + const { ws } = await connectWebSocket({ port: daemonHandle.port }); + const serverInfo = new Promise>((resolve, reject) => { + ws.once("message", (data) => + resolve(JSON.parse(data.toString()) as Record), + ); + ws.once("close", () => reject(new Error("closed before server_info"))); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: `test-${auth.kind}`, + clientType: "cli", + protocolVersion: 2, + auth, + }), + ); + await expect(serverInfo).resolves.toMatchObject({ + type: "session", + message: { payload: { status: "server_info", protocolVersion: 1 } }, + }); + ws.close(); + } + } finally { + await daemonHandle.close(); + } + await expect( + readFile(join(daemonHandle.paseoHome, "local-credential"), "utf8"), + ).rejects.toThrow(); + }); + + test("uses the per-run credential in memory after the file changes", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const path = join(daemonHandle.paseoHome, "local-credential"); + const token = (await readFile(path, "utf8")).trim(); + await writeFile(path, "stale-on-disk\n"); + const { ws } = await connectWebSocket({ port: daemonHandle.port }); + const serverInfo = new Promise((resolve) => { + ws.once("message", (data) => resolve(JSON.parse(data.toString()))); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: "local-after-file-change", + clientType: "cli", + protocolVersion: 1, + auth: { kind: "localCredential", token }, + }), + ); + await expect(serverInfo).resolves.toMatchObject({ + type: "session", + message: { payload: { status: "server_info" } }, + }); + ws.close(); + } finally { + await daemonHandle.close(); + } + }); + + test("a desktop-style client connects to its password-protected local daemon without a saved password", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + const target = `127.0.0.1:${daemonHandle.port}`; + await writeFile(join(daemonHandle.paseoHome, "paseo.pid"), JSON.stringify({ listen: target })); + const client = new DaemonClient({ + url: `ws://${target}/ws`, + clientId: "desktop-managed-test", + clientType: "browser", + localCredential: async () => + readLocalCredentialForTarget(daemonHandle.paseoHome, target) ?? undefined, + webSocketFactory: (url, options) => + new WebSocket(url, options?.protocols, { headers: options?.headers }), + reconnect: { enabled: false }, + }); + try { + await client.connect(); + expect(client.getLastServerInfoMessage()?.serverId).toBe(daemonHandle.daemon.getServerId()); + } finally { + await client.close(); + await daemonHandle.close(); + } + }); + + test("rejects a wrong hello password with a protocol frame before close", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const { ws } = await connectWebSocket({ port: daemonHandle.port }); + const frames: unknown[] = []; + const closed = new Promise<{ code: number; reason: string }>((resolve) => { + ws.on("message", (data) => frames.push(JSON.parse(data.toString()))); + ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: "wrong", + clientType: "cli", + protocolVersion: 1, + auth: { kind: "password", password: "wrong" }, + }), + ); + await expect(closed).resolves.toEqual({ code: 4401, reason: "Incorrect password" }); + expect(frames).toEqual([ + { type: "hello.rejected", reason: "incorrect_password", accepts: ["password"] }, + ]); + } finally { + await daemonHandle.close(); + } + }); + + test("surfaces a typed auth reason from a real client with the wrong password", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemonHandle.port}/ws`, + clientId: "typed-wrong-password", + clientType: "browser", + password: "wrong password", + webSocketFactory: (url, options) => + new WebSocket(url, options?.protocols, { headers: options?.headers }), + reconnect: { enabled: false }, + }); + try { + await expect(client.connect()).rejects.toMatchObject({ + name: "DaemonAuthenticationError", + reason: "incorrect_password", + } satisfies Partial); + } finally { + await client.close(); + await daemonHandle.close(); + } + }); + + test("authenticates a real browser client when its password is invalid as a WebSocket subprotocol", async () => { + const password = "two words"; + const daemonHandle = await createTestPaseoDaemon({ + auth: { password: await hash(password, 4) }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemonHandle.port}/ws`, + clientId: "browser-password-with-spaces", + clientType: "browser", + password, + webSocketFactory: (url, options) => + new WebSocket(url, options?.protocols, { headers: options?.headers }), + reconnect: { enabled: false }, + }); + try { + await client.connect(); + expect(client.getLastServerInfoMessage()?.serverId).toBeTruthy(); + } finally { + await client.close(); + await daemonHandle.close(); + } + }); + + test("ignores a proxy Basic header and admits the hello password", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const ws = new WebSocket(`ws://127.0.0.1:${daemonHandle.port}/ws`, undefined, { + headers: { Authorization: "Basic proxy-credential" }, + }); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + const info = new Promise((resolve) => { + ws.once("message", (frame) => resolve(JSON.parse(frame.toString()))); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: "behind-basic-proxy", + clientType: "cli", + protocolVersion: 1, + auth: { kind: "password", password: "correct-password" }, + }), + ); + await expect(info).resolves.toMatchObject({ + type: "session", + message: { payload: { status: "server_info" } }, + }); + ws.close(); + } finally { + await daemonHandle.close(); + } + }); + + test("sends password_required to a capable client without a credential", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const { ws } = await connectWebSocket({ port: daemonHandle.port }); + const frames: unknown[] = []; + const closed = new Promise((resolve) => { + ws.on("message", (data) => frames.push(JSON.parse(data.toString()))); + ws.once("close", (code) => resolve(code)); + }); + ws.send( + JSON.stringify({ + type: "hello", + clientId: "capable", + clientType: "cli", + protocolVersion: 1, + capabilities: { hello_rejection: true }, + }), + ); + await expect(closed).resolves.toBe(4401); + expect(frames).toEqual([ + { type: "hello.rejected", reason: "password_required", accepts: ["password"] }, + ]); + } finally { + await daemonHandle.close(); + } + }); + + test("closes a pre-hello ping without sending server data", async () => { + const daemonHandle = await createTestPaseoDaemon({ auth: { password: CORRECT_PASSWORD_HASH } }); + try { + const { ws } = await connectWebSocket({ port: daemonHandle.port }); + const frames: unknown[] = []; + const closed = new Promise((resolve) => { + ws.on("message", (data) => frames.push(JSON.parse(data.toString()))); + ws.once("close", (code) => resolve(code)); + }); + ws.send(JSON.stringify({ type: "ping" })); + await expect(closed).resolves.toBe(4002); + expect(frames).toEqual([]); + } finally { + await daemonHandle.close(); + } + }); }); diff --git a/packages/server/src/server/bootstrap-web-ui.test.ts b/packages/server/src/server/bootstrap-web-ui.test.ts index 348a6cf0eb..81afc3c166 100644 --- a/packages/server/src/server/bootstrap-web-ui.test.ts +++ b/packages/server/src/server/bootstrap-web-ui.test.ts @@ -2,10 +2,17 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, test } from "vitest"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +const expectedHostName = + process.platform === "darwin" + ? execFileSync("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8" }).trim() + : os.hostname(); + interface InitialDaemonConnectionHint { listen: string; useTls: boolean; @@ -77,6 +84,23 @@ describe("daemon web UI bootstrap", () => { } }); + test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => { + daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false }); + const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`); + expect(await response.json()).toMatchObject({ hostname: expectedHostName }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemonHandle.port}/ws`, + clientId: "host-name-test", + clientType: "mobile", + }); + try { + await client.connect(); + expect(client.getLastServerInfoMessage()?.hostname).toBe(expectedHostName); + } finally { + await client.close(); + } + }); + test("injects a TLS initial connection hint only for HTTPS forwarded by a trusted proxy", async () => { const distDir = await createWebUiDist(); @@ -101,15 +125,53 @@ describe("daemon web UI bootstrap", () => { expect(httpHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: false, - label: os.hostname(), + label: expectedHostName, }); expect(httpsHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: true, - label: os.hostname(), + label: expectedHostName, }); }); + test("spells out the scheme default port when the Host header omits it", async () => { + const distDir = await createWebUiDist(); + + daemonHandle = await createTestPaseoDaemon({ + mcpEnabled: false, + webUi: { + enabled: true, + distDir, + }, + }); + + // A browser reaching a proxy that terminates TLS on 443 sends a Host header + // with no port at all. The app rejects a portless `listen`, so the daemon + // has to supply the default itself. + const httpsHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test", "x-forwarded-proto": "https" }, + }), + ); + const httpHint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "paseo.example.test" }, + }), + ); + const ipv6Hint = readInjectedConnectionHint( + await fetchDaemonWebUi({ + port: daemonHandle.port, + headers: { host: "[::1]", "x-forwarded-proto": "https" }, + }), + ); + + expect(httpsHint.listen).toBe("paseo.example.test:443"); + expect(httpHint.listen).toBe("paseo.example.test:80"); + expect(ipv6Hint.listen).toBe("[::1]:443"); + }); + test("ignores forwarded HTTPS when proxy trust is disabled", async () => { const distDir = await createWebUiDist(); @@ -132,7 +194,7 @@ describe("daemon web UI bootstrap", () => { expect(httpsHint).toEqual({ listen: `daemon.example.test:${daemonHandle.port}`, useTls: false, - label: os.hostname(), + label: expectedHostName, }); }); }); diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 40ea69e863..697dfda8a7 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,3 +1,4 @@ +import { resolveDaemonVersion } from "./daemon-version.js"; import os from "node:os"; import http from "node:http"; import path from "node:path"; @@ -672,7 +673,10 @@ describe("paseo daemon bootstrap", () => { await mkdir(pluginDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "startup-rollback" }), + JSON.stringify({ + id: "startup-rollback", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 627a93d4e3..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1,9 +1,11 @@ +import type { PluginRegistries } from "@getpaseo/protocol/plugin-registry"; +import { describeHookWorkspace } from "./plugins/lifecycle/index.js"; import express from "express"; import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http"; import { constants, existsSync, unlinkSync } from "fs"; -import { open, rm } from "fs/promises"; +import { open, rm, stat } from "fs/promises"; import { randomUUID } from "node:crypto"; -import { hostname as getHostname } from "node:os"; +import { getHostName } from "./host-name.js"; import path from "node:path"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { Logger } from "pino"; @@ -79,12 +81,12 @@ export function parseListenString(listen: string): ListenTarget { throw new Error(`Invalid listen string: ${listen}`); } -function formatListenTarget(listenTarget: ListenTarget | null): string | null { +export function formatListenTarget(listenTarget: ListenTarget | null): string | null { if (!listenTarget) { return null; } if (listenTarget.type === "tcp") { - return `${listenTarget.host}:${listenTarget.port}`; + return `${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`; } return listenTarget.path; } @@ -124,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { createPaseoWorktreeWorkflow } from "./worktree-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; +import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js"; import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js"; import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js"; import type { RequestedSpeechProviders } from "./speech/speech-types.js"; @@ -150,6 +153,7 @@ import { ScheduleService } from "./schedule/service.js"; import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js"; import { createOrchestrationSkills } from "./orchestration-skills/index.js"; import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js"; +import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js"; import { BrowserToolsBroker } from "./browser-tools/broker.js"; import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; @@ -197,12 +201,14 @@ import { type ManagedProcessRegistry, } from "./managed-processes/managed-processes.js"; import { terminateWithTreeKill } from "../utils/tree-kill.js"; +import { withTimeout } from "../utils/promise-timeout.js"; import { isHostnameAllowed, type HostnamesConfig } from "./hostnames.js"; import { createRequireBearerMiddleware, isAgentMcpRequestAuthorized, type DaemonAuthConfig, } from "./auth.js"; +import { deleteLocalCredential, writeLocalCredential } from "./local-credential.js"; import { createWebUiMiddleware } from "./web-ui.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js"; @@ -227,6 +233,7 @@ import { } from "./hub/relationship-remote.js"; import { DaemonExecutions } from "./hub/daemon-executions.js"; import { PluginService } from "./plugins/index.js"; +import { BuiltinPluginLoader } from "./plugins/builtin/index.js"; import { ManagedPluginSources } from "./plugins/managed-source.js"; const MCP_DEBUG_BATCH_LIMIT = 10; @@ -406,6 +413,8 @@ export interface PaseoDaemonConfig { skillSelection?: AgentSkillSelection; pluginsEnabled?: boolean; plugins?: Record; + pluginRegistries?: PluginRegistries; + pluginRegistryUrl?: string; staticDir: string; mcpDebug: boolean; isDev?: boolean; @@ -468,9 +477,11 @@ export interface PaseoDaemon { start(): Promise; stop(): Promise; getListenTarget(): ListenTarget | null; + getServerId(): string; } export interface PaseoDaemonDependencies { + builtinPlugins?: BuiltinPluginLoader; hubRelationshipRemote?: HubRelationshipRemote; hubRelationshipClock?: HubRelationshipClock; hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy; @@ -481,6 +492,10 @@ export interface PaseoDaemonDependencies { }; } +function resolveBuiltinPluginLoader(dependencies: PaseoDaemonDependencies): BuiltinPluginLoader { + return dependencies.builtinPlugins ?? new BuiltinPluginLoader(); +} + function createBootstrapManagedProcessRegistry( config: Pick, logger: Logger, @@ -512,7 +527,7 @@ function mountWebUi(app: express.Application, config: PaseoDaemonConfig, logger: createWebUiMiddleware({ enabled: config.webUi?.enabled ?? false, distDir: config.webUi?.distDir ?? null, - label: getHostname(), + label: getHostName(), logger, }), ); @@ -604,8 +619,17 @@ export async function createPaseoDaemon( }); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); - const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, { - managedSources: new ManagedPluginSources(config.paseoHome), + const pluginRuntime: PluginService = new PluginService(logger, daemonConfigStore, daemonVersion, { + usageAgents: { + hasAgent: (id) => agentManager.getAgent(id) !== null, + usageSession: (id) => agentManager.usageSession(id), + }, + managedSources: new ManagedPluginSources(config.paseoHome, { + registries: config.pluginRegistries, + defaultUrl: config.pluginRegistryUrl, + }), + builtinPlugins: resolveBuiltinPluginLoader(dependencies), + settingsDirectory: path.join(config.paseoHome, "plugin-settings"), }); const serverId = getOrCreateServerId(config.paseoHome, { logger }); @@ -759,8 +783,10 @@ export async function createPaseoDaemon( // remain protected. mountWebUi(app, config, logger); + let localCredential: string | null = null; + const daemonAuth = { ...config.auth, localCredential: () => localCredential }; app.use( - createRequireBearerMiddleware(config.auth, (context) => { + createRequireBearerMiddleware(daemonAuth, (context) => { logger.warn(context, "Rejected HTTP request with invalid daemon password"); }), ); @@ -779,7 +805,7 @@ export async function createPaseoDaemon( res.json({ status: "server_info", serverId, - hostname: getHostname(), + hostname: getHostName(), version: daemonVersion, listen: formatListenTarget(boundListenTarget ?? listenTarget), }); @@ -806,18 +832,21 @@ export async function createPaseoDaemon( try { fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await fileHandle.stat(); + if (res.destroyed) { + return; + } if (!fileStats.isFile()) { res.status(404).json({ error: "File not found" }); return; } - const safeFileName = entry.fileName.replace(/["\r\n]/g, "_"); res.setHeader("Content-Type", entry.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`); + res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName)); res.setHeader("Content-Length", fileStats.size.toString()); const stream = fileHandle.createReadStream(); fileHandle = null; + res.once("close", () => stream.destroy()); stream.on("error", (err) => { logger.error({ err }, "Failed to stream download"); if (!res.headersSent) { @@ -875,11 +904,20 @@ export async function createPaseoDaemon( forgeOverrides: { github }, }, }); + workspaceRegistry.subscribeToMutations((mutation) => { + if (mutation.kind === "archive" && mutation.workspace) { + pluginRuntime.emit("workspace.archived", { + workspace: describeHookWorkspace(mutation.workspace), + }); + } + }); const workspaceProvisioning = createWorkspaceProvisioningService({ + lifecycle: pluginRuntime, serverId, projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false, logger, }); const agentProviderRuntime = await createAgentProviderRuntime({ @@ -909,6 +947,7 @@ export async function createPaseoDaemon( }); const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState(); const agentManager = new AgentManager({ + pluginLifecycle: pluginRuntime, clients: initialAgentManagerState.clients, providerDefinitions: initialAgentManagerState.providerDefinitions, registry: agentStorage, @@ -917,8 +956,17 @@ export async function createPaseoDaemon( workspaceGitService.onWorkspaceStateMayHaveChanged(cwd); }, mcpAuthToken: agentMcpAuthToken, + resolvePaseoToolPolicy: (provider) => + resolvePaseoToolPolicy(provider, daemonConfigStore.get().providers), logger, }); + const syncPluginProviders = () => { + agentManager.updateProviderRegistry( + providerSnapshotManager.replacePluginProviders(pluginRuntime.getProviderRegistrations()), + ); + }; + const unsubscribePluginProviders = + pluginRuntime.subscribeProviderRegistrations(syncPluginProviders); const detachAgentStoragePersistence = attachAgentStoragePersistence( logger, @@ -987,10 +1035,13 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, resolveFirstAgentPromptTitle(firstAgentContext), + undefined, + context, ); if (firstAgentContext) { workspaceAutoName.scheduleForDirectory({ @@ -1037,8 +1088,9 @@ export async function createPaseoDaemon( const ensureWorkspaceForCreateAndBroadcastExternal = async ( cwd: string, firstAgentContext?: FirstAgentContext, + context?: { callerWorkspaceId?: string }, ): Promise => { - const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext); + const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context); await emitWorkspaceUpdatesExternal([workspaceId]); return workspaceId; }; @@ -1126,7 +1178,6 @@ export async function createPaseoDaemon( emit: emitExternalSessionMessage, sessionLogger: logger, terminalManager, - archiveWorkspaceRecord: archiveWorkspaceRecordExternal, serviceProxy, scriptRuntimeStore, getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null), @@ -1201,7 +1252,7 @@ export async function createPaseoDaemon( }); const hubRelationships = new HubRelationshipController({ paseoHome: config.paseoHome, - hostname: getHostname(), + hostname: getHostName(), serverId, daemonPublicKey: daemonKeyPair.publicKeyB64, logger, @@ -1356,11 +1407,12 @@ export async function createPaseoDaemon( emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, workspaceRegistry, projectRegistry, - createDirectoryWorkspace: async (cwd, title, projectId) => { + createDirectoryWorkspace: async (cwd, title, projectId, context) => { const workspace = await workspaceProvisioning.createWorkspaceForDirectory( cwd, title, projectId, + context, ); await emitWorkspaceUpdatesExternal([workspace.workspaceId]); return workspace; @@ -1377,9 +1429,8 @@ export async function createPaseoDaemon( serviceProxyPublicBaseUrl, resolveScriptHealth: (hostname) => scriptHealthMonitor.getHealthForHostname(hostname), logger, - // MCP operations do not belong to one WebSocket session, so lifecycle - // status updates fan out to every connected client. emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)), + publishStatusUpdate: (message) => wsServer?.publishScriptStatusUpdate(message), spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId), @@ -1391,6 +1442,9 @@ export async function createPaseoDaemon( createPaseoWorktree: createAgentCommandDependencies.createPaseoWorktree, browserToolsEnabled: browserToolsPolicy.isEnabled(), browserToolsBroker, + paseoToolPolicy: + runtime.paseoToolPolicy ?? + (runtime.callerAgentId ? agentManager.getPaseoToolPolicy(runtime.callerAgentId) : undefined), paseoHome: config.paseoHome, worktreesRoot: config.worktreesRoot, callerAgentId: runtime.callerAgentId, @@ -1416,7 +1470,12 @@ export async function createPaseoDaemon( const createAgentMcpSession = async (callerAgentId?: string) => { const agentMcpServer = await createAgentMcpServer( - createAgentToolHostDependencies({ callerAgentId }), + createAgentToolHostDependencies({ + callerAgentId, + paseoToolPolicy: callerAgentId + ? agentManager.getPaseoToolPolicy(callerAgentId) + : undefined, + }), ); // Stateless mode: each HTTP request builds a fresh server + transport that is @@ -1544,6 +1603,7 @@ export async function createPaseoDaemon( const start = async () => { let mainStarted = false; try { + localCredential = await writeLocalCredential(config.paseoHome); if (serviceProxyListenTarget) { const boundServiceProxyTarget = await serviceProxy.startStandalone({ listenTarget: serviceProxyListenTarget, @@ -1637,7 +1697,7 @@ export async function createPaseoDaemon( startPaused: true, }, workspaceAutoName, - config.auth, + daemonAuth, speechService, terminalManager, { @@ -1691,6 +1751,7 @@ export async function createPaseoDaemon( ); pluginRuntime.bindPaseoSessionHost(wsServer); await pluginRuntime.start(); + providerSnapshotManager.settlePluginProviders(); wsServer.beginAcceptingConnections(); relayRuntime = createRelayRuntime({ config: { @@ -1734,6 +1795,9 @@ export async function createPaseoDaemon( speechService.start(); scriptHealthMonitor.start(); } catch (error) { + localCredential = null; + await deleteLocalCredential(config.paseoHome); + unsubscribePluginProviders(); await pluginRuntime.stopAllPlugins().catch(() => undefined); await serviceProxy.stopStandalone().catch(() => undefined); await agentProviderRuntime.shutdown().catch(() => undefined); @@ -1746,7 +1810,13 @@ export async function createPaseoDaemon( }; const stop = async () => { - await pluginRuntime.stopAllPlugins(); + localCredential = null; + await deleteLocalCredential(config.paseoHome); + // Stop tracking plugin provider registrations before anything tears plugins + // down, so plugin shutdown cannot withdraw a provider from under an agent + // that is still open. Plugins themselves are stopped once every session + // they serve has been closed, further down. + unsubscribePluginProviders(); await hubRelationships.stop(); workspaceReconciliation.dispose(); scriptHealthMonitor.stop(); @@ -1754,10 +1824,16 @@ export async function createPaseoDaemon( wsServer?.prepareForShutdown(); agentManager.prepareForShutdown(); await closeAllAgents(logger, agentManager); + await withTimeout({ + promise: pluginRuntime.drainEvents(), + timeoutMs: AGENT_CLOSE_TIMEOUT_MS, + label: "drain plugin lifecycle events", + }).catch((error) => logger.warn({ err: error }, "Plugin lifecycle events did not finish")); await agentManager.flushForShutdown().catch(() => undefined); detachAgentStoragePersistence(); await agentStorage.flush().catch(() => undefined); await agentProviderRuntime.shutdown(); + await pluginRuntime.stopAllPlugins(); terminalManager.killAll(); await speechService.stop(); await scheduleService.stop().catch(() => undefined); @@ -1794,15 +1870,28 @@ export async function createPaseoDaemon( start, stop, getListenTarget: () => boundListenTarget, + getServerId: () => serverId, }; } +/** + * Closing an agent asks its provider to close the session and waits for the + * answer. A provider that never answers must not hold the daemon open, so a + * close that outlives this deadline is abandoned; `agentProviderRuntime` + * shutdown runs next and rejects the request that was still pending. + */ +const AGENT_CLOSE_TIMEOUT_MS = 5_000; + async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise { - const agents = agentManager.listAgents(); + const agents = agentManager.listAgents({ includeInternal: true }); await Promise.all( agents.map(async (agent) => { try { - await agentManager.closeAgent(agent.id); + await withTimeout({ + promise: agentManager.closeAgent(agent.id), + timeoutMs: AGENT_CLOSE_TIMEOUT_MS, + label: `close agent ${agent.id}`, + }); } catch (err) { logger.error({ err, agentId: agent.id }, "Failed to close agent"); } diff --git a/packages/server/src/server/browser-tools/broker.ts b/packages/server/src/server/browser-tools/broker.ts index dc1eca7f2d..b59094de20 100644 --- a/packages/server/src/server/browser-tools/broker.ts +++ b/packages/server/src/server/browser-tools/broker.ts @@ -158,7 +158,10 @@ export class BrowserToolsBroker { }); } - public receiveResponse(response: BrowserAutomationExecuteResponse): boolean { + public receiveResponse( + response: BrowserAutomationExecuteResponse, + clientIds?: readonly string[], + ): boolean { const parsed = BrowserAutomationExecuteResponseSchema.safeParse(response); if (!parsed.success) { const requestId = getBrowserAutomationResponseRequestId(response); @@ -167,7 +170,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } @@ -184,7 +187,7 @@ export class BrowserToolsBroker { } const pending = this.pending.get(parsed.data.payload.requestId); - if (!pending) { + if (!pending || (clientIds && !clientIds.includes(pending.clientId))) { return false; } diff --git a/packages/server/src/server/checkout-diff-cache.test.ts b/packages/server/src/server/checkout-diff-cache.test.ts new file mode 100644 index 0000000000..e2ac4f31ba --- /dev/null +++ b/packages/server/src/server/checkout-diff-cache.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "vitest"; +import { CheckoutDiffCache } from "@server/server/checkout-diff-cache.js"; + +function createSequencedLoader(first: Promise, second: Promise) { + let calls = 0; + return { + load: () => (++calls === 1 ? first : second), + get calls() { + return calls; + }, + }; +} + +async function fillCompletedCache(cache: CheckoutDiffCache) { + for (let i = 0; i < 70; i++) { + await cache.read({ + cwd: String(i), + compare: { mode: "base" }, + load: async () => ({ diff: "" }), + }); + } +} + +test("forced reads after a mutation share a fresh build after the active build finishes", async () => { + const cache = new CheckoutDiffCache(() => 0); + const first = Promise.withResolvers<{ diff: string }>(); + const second = Promise.withResolvers<{ diff: string }>(); + const loader = createSequencedLoader(first.promise, second.promise); + const { load } = loader; + const pending = cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + await Promise.resolve(); + const options = { force: true, reason: "repository-mutation" }; + const fresh = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load }); + const another = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load }); + expect(loader.calls).toBe(1); + first.resolve({ diff: "before-mutation" }); + expect(await pending).toEqual({ diff: "before-mutation" }); + await Promise.resolve(); + await Promise.resolve(); + expect(loader.calls).toBe(2); + second.resolve({ diff: "after-mutation" }); + expect(await fresh).toEqual({ diff: "after-mutation" }); + expect(await another).toEqual({ diff: "after-mutation" }); + expect(await cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toEqual({ + diff: "after-mutation", + }); + expect(loader.calls).toBe(2); +}); + +test("reads after invalidation wait for fresh work without overlapping the original read", async () => { + const cache = new CheckoutDiffCache(() => 0); + const first = Promise.withResolvers<{ diff: string }>(); + const second = Promise.withResolvers<{ diff: string }>(); + const loader = createSequencedLoader(first.promise, second.promise); + const { load } = loader; + const read = () => cache.read({ cwd: "repo", compare: { mode: "base" }, load }); + const pending = read(); + await Promise.resolve(); + cache.invalidate("repo", "base"); + cache.invalidate("repo", "base"); + const fresh = read(); + const another = read(); + expect(loader.calls).toBe(1); + first.resolve({ diff: "old" }); + expect(await pending).toEqual({ diff: "old" }); + await Promise.resolve(); + await Promise.resolve(); + expect(loader.calls).toBe(2); + second.resolve({ diff: "new" }); + expect(await fresh).toEqual({ diff: "new" }); + expect(await another).toEqual({ diff: "new" }); + expect(await read()).toEqual({ diff: "new" }); + expect(loader.calls).toBe(2); +}); + +test("edits during a read do not delay its caller or cache its outdated snapshot", async () => { + const cache = new CheckoutDiffCache(() => 0); + const deferred = Promise.withResolvers<{ diff: string }>(); + let calls = 0; + const load = () => { + calls += 1; + return deferred.promise; + }; + const pending = cache.read({ cwd: "repo", compare: { mode: "uncommitted" }, load }); + await Promise.resolve(); + cache.invalidate("repo", "uncommitted"); + deferred.resolve({ diff: "snapshot" }); + expect(await pending).toEqual({ diff: "snapshot" }); + expect(calls).toBe(1); + expect( + await cache.read({ + cwd: "repo", + compare: { mode: "uncommitted" }, + load: async () => ({ diff: "fresh" }), + }), + ).toEqual({ diff: "fresh" }); +}); + +test("active reads survive completed-payload eviction and failed reads can retry", async () => { + const cache = new CheckoutDiffCache(() => 0); + const deferred = Promise.withResolvers<{ diff: string }>(); + const load = () => deferred.promise; + const first = cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load }); + const rejected = expect(first).rejects.toThrow("read failed"); + await fillCompletedCache(cache); + expect(cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load })).toBe(first); + deferred.reject(new Error("read failed")); + await rejected; + expect( + await cache.read({ + cwd: "active", + compare: { mode: "uncommitted" }, + load: async () => ({ diff: "retry" }), + }), + ).toEqual({ diff: "retry" }); +}); diff --git a/packages/server/src/server/checkout-diff-cache.ts b/packages/server/src/server/checkout-diff-cache.ts new file mode 100644 index 0000000000..38675bea37 --- /dev/null +++ b/packages/server/src/server/checkout-diff-cache.ts @@ -0,0 +1,92 @@ +import { LRUCache } from "lru-cache"; +import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-git.js"; + +interface ActiveRead { + promise: Promise; + invalidated: boolean; +} + +interface CachedDiff { + value: CheckoutDiffResult; + loadedAt: number; + lastReadStarted: number; +} + +interface CheckoutDiffReadInput { + cwd: string; + compare: CheckoutDiffCompare; + force?: boolean; + reason?: string; + load: () => Promise; +} + +/** Completed payloads are bounded; active work survives invalidation and eviction. */ +export class CheckoutDiffCache { + private readonly values = new LRUCache({ + max: 64, + }); + private readonly active = new Map(); + + constructor(private readonly now: () => number) {} + + read(input: CheckoutDiffReadInput): Promise { + const { cwd, compare, force, reason, load } = input; + if (force && !reason) throw new Error("WorkspaceGitService forced read requires a reason"); + const key = JSON.stringify([ + cwd, + compare.mode, + compare.mode === "base" ? (compare.baseRef ?? null) : null, + compare.ignoreWhitespace === true, + compare.includeStructured === true, + ]); + const cached = this.values.get(key); + const now = this.now(); + if ( + !force && + cached && + (now - cached.loadedAt <= 15_000 || now - cached.lastReadStarted < 2_000) + ) + return Promise.resolve(cached.value); + const pending = this.active.get(key); + if (pending) { + if (force) { + pending.invalidated = true; + this.values.delete(key); + } + if (!pending.invalidated) return pending.promise; + // A caller arriving after a change needs a fresh read, once the old one + // finishes. The original caller can still receive its snapshot promptly. + // The older result cannot enter the cache. Drop force when retrying so + // callers waiting on that same result share the next fresh build. + const reload = () => this.read({ ...input, force: false }); + return pending.promise.then(reload, reload); + } + if (cached) cached.lastReadStarted = now; + + // Defer load until ownership is installed, including synchronous invalidations. + const read: ActiveRead = { + invalidated: false, + promise: Promise.resolve() + .then(load) + .then((value) => { + // Never repopulate the cache with a snapshot invalidated during its build. + if (!read.invalidated) + this.values.set(key, { value, loadedAt: this.now(), lastReadStarted: now }); + return value; + }) + .finally(() => this.active.delete(key)), + }; + this.active.set(key, read); + return read.promise; + } + + invalidate(cwd: string, mode: CheckoutDiffCompare["mode"]): void { + for (const key of new Set([...this.values.keys(), ...this.active.keys()])) { + const [cachedCwd, cachedMode] = JSON.parse(key) as string[]; + if (cachedCwd !== cwd || cachedMode !== mode) continue; + this.values.delete(key); + const pending = this.active.get(key); + if (pending) pending.invalidated = true; + } + } +} diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 9bf0439aff..628bb32a0a 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -73,6 +73,12 @@ export class CheckoutDiffManager { this.workspaceGitService = options.workspaceGitService; } + async read( + params: Omit, + ): Promise { + return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare)); + } + async subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, diff --git a/packages/server/src/server/cli-agent-logs.test.ts b/packages/server/src/server/cli-agent-logs.test.ts new file mode 100644 index 0000000000..634d0615c0 --- /dev/null +++ b/packages/server/src/server/cli-agent-logs.test.ts @@ -0,0 +1,283 @@ +import { test } from "vitest"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import type { Readable } from "node:stream"; +import { join } from "node:path"; +import { createDaemonTestContext } from "./test-utils/daemon-test-context.js"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import type { AgentStreamEvent } from "@getpaseo/protocol/agent-types"; + +interface CliResult { + exitCode: number | null; + stdout: string; + stderr: string; +} +type CliProcess = Promise & { stdout: Readable; kill: (signal: NodeJS.Signals) => void }; + +function waitForCliOutput(child: CliProcess, text: string): Promise { + return new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes(text)) { + cleanup(); + resolve(); + } + }; + const cleanup = () => { + clearTimeout(timer); + child.stdout.off("data", onData); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`CLI did not print ${text}:\n${output}`)); + }, 20_000); + child.stdout.on("data", onData); + void child.then( + (result) => { + cleanup(); + return reject(new Error(`CLI exited before ${text}:\n${result.stderr}`)); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + +test("logs --since filters history and live output through the CLI and daemon", async () => { + const cliHome = await mkdtemp(join(tmpdir(), "paseo-logs-cli-home-")); + const port = 1; // Invalid dates must fail before trying this unavailable endpoint. + const runLocalPaseo = ( + args: string[], + env: NodeJS.ProcessEnv = {}, + cwd = cliHome, + ): CliProcess => { + const child = spawn( + process.execPath, + [ + "--no-warnings", + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + ...env, + HOME: cliHome, + USERPROFILE: cliHome, + }, + stdio: ["ignore", "pipe", "pipe"], + timeout: 25_000, + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const done = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (exitCode) => resolve({ exitCode, stdout, stderr })); + }); + return Object.assign(done, { + stdout: child.stdout, + kill: (signal: NodeJS.Signals) => { + child.kill(signal); + }, + }); + }; + // Follow assertions check output; Windows forcibly terminates Node on SIGINT. + try { + console.log("--since filters logs through the real CLI and daemon"); + const historyEvents: AgentStreamEvent[] = []; + const ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ historyEvents }), + }); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd: ctx.daemon.paseoHome, + title: "logs since", + }); + const manager = ctx.daemon.daemon.agentManager; + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "OLD_USER" }); + await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "OLD_REPLY" }); + const host = `127.0.0.1:${ctx.daemon.port}`; + const logs = (args: string[]) => + runLocalPaseo(["--host", host, ...args], {}, ctx.daemon.paseoHome); + + const baseline = await logs(["logs", agent.id]); + assert.strictEqual(baseline.exitCode, 0, baseline.stderr); + assert.strictEqual(baseline.stdout, "[User] OLD_USER\nOLD_REPLY\n"); + + for (const command of [["logs"], ["agent", "logs"]]) { + const result = await logs([...command, agent.id, "--since", "2100-01-01T00:00:00Z"]); + console.log(`paseo ${command.join(" ")} --since 2100-01-01T00:00:00Z`); + console.log(`exit=${result.exitCode}\nstdout:\n${result.stdout}stderr:\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, "No activity to display.\n"); + } + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const firstTimestamp = timeline.entries[0]?.timestamp; + assert(firstTimestamp !== undefined, "the fixture should have timestamped activity"); + for (const since of ["2000-01-01T00:00:00Z", firstTimestamp]) { + const result = await logs(["logs", agent.id, "--since", since]); + assert.strictEqual(result.exitCode, 0, result.stderr); + assert.strictEqual(result.stdout, baseline.stdout); + } + + const offset = await logs(["logs", agent.id, "--since", "2100-01-01T08:00:00+08:00"]); + assert.strictEqual(offset.exitCode, 0, offset.stderr); + assert.strictEqual(offset.stdout, "No activity to display.\n"); + + const tail = await logs([ + "logs", + agent.id, + "--since", + "2000-01-01T00:00:00Z", + "--filter", + "text", + "--tail", + "1", + ]); + assert.strictEqual(tail.exitCode, 0, tail.stderr); + assert.strictEqual(tail.stdout, "OLD_REPLY\n"); + + const empty = await logs(["logs", agent.id, "--since", firstTimestamp, "--tail", "0"]); + assert.strictEqual(empty.exitCode, 0, empty.stderr); + assert.strictEqual(empty.stdout, ""); + + for (const followArgs of [[], ["--follow"]]) { + const invalid = await runLocalPaseo([ + "--host", + `127.0.0.1:${port}`, + "logs", + "missing-agent", + "--since", + "not-a-date", + "--json", + ...followArgs, + ]); + assert.strictEqual(invalid.exitCode, 1, invalid.stderr); + assert.strictEqual(invalid.stdout, ""); + assert.deepStrictEqual(JSON.parse(invalid.stderr), { + error: { + code: "INVALID_TIMESTAMP", + message: "Invalid --since value: not-a-date", + details: "Use a timestamp such as 2026-01-01T00:00:00Z.", + }, + }); + } + + console.log("--since filters initial history in follow mode"); + const follow = logs(["logs", agent.id, "--follow", "--since", "2100-01-01T00:00:00Z"]); + try { + await waitForCliOutput(follow, "--- Following logs"); + } finally { + await follow.kill("SIGINT"); + } + const followed = await follow; + console.log(`follow exit=${followed.exitCode}\nstdout:\n${followed.stdout}`); + assert.strictEqual(followed.stdout.includes("OLD_USER"), false); + assert.strictEqual(followed.stdout.includes("OLD_REPLY"), false); + + console.log("--since filters live events in follow mode"); + const futureFollow = logs([ + "agent", + "logs", + agent.id, + "-f", + "--tail", + "0", + "--since", + "2100-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(futureFollow, "--- Following logs"); + // A timestamped history replay follows the live item on the same socket. + // Its visible output proves the CLI consumed the earlier item too. + await Promise.all([ + waitForCliOutput(futureFollow, "AFTER_CUTOFF"), + (async () => { + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_BEFORE_CUTOFF", + }); + historyEvents.push({ + type: "timeline", + provider: "codex", + timestamp: "2110-01-01T00:00:00Z", + item: { type: "user_message", text: "AFTER_CUTOFF" }, + }); + await ctx.client.refreshAgent(agent.id); + })(), + ]); + } finally { + await futureFollow.kill("SIGINT"); + } + const futureOutput = await futureFollow; + console.log(`future follow exit=${futureOutput.exitCode}\nstdout:\n${futureOutput.stdout}`); + assert.strictEqual(futureOutput.stdout.includes("LIVE_BEFORE_CUTOFF"), false); + + console.log("follow still prints live events after --since"); + const pastFollow = logs([ + "logs", + agent.id, + "--follow", + "--tail", + "0", + "--filter", + "text", + "--since", + "2000-01-01T00:00:00Z", + ]); + try { + await waitForCliOutput(pastFollow, "--- Following logs"); + await Promise.all([ + // Wait for a later visible item to close the streamed reply. + // Windows SIGINT forcibly exits without running shutdown handlers. + waitForCliOutput(pastFollow, "[User] LIVE_AFTER_REPLY\n"), + (async () => { + await manager.appendTimelineItem(agent.id, { type: "user_message", text: "LIVE_USER" }); + await manager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "LIVE_REPLY", + }); + await manager.appendTimelineItem(agent.id, { + type: "user_message", + text: "LIVE_AFTER_REPLY", + }); + })(), + ]); + } finally { + await pastFollow.kill("SIGINT"); + } + const pastOutput = await pastFollow; + console.log(`past follow exit=${pastOutput.exitCode}\nstdout:\n${pastOutput.stdout}`); + assert(pastOutput.stdout.includes("[User] LIVE_USER\nLIVE_REPLY\n"), pastOutput.stdout); + } finally { + await ctx.cleanup(); + } + } finally { + await rm(cliHome, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts index e0524c8948..0247e7af97 100644 --- a/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts +++ b/packages/server/src/server/cli-run-workspace-precedence.e2e.test.ts @@ -40,7 +40,7 @@ test("daemon resolves human and managed CLI workspace ownership", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); // A bare run mints a fresh local workspace for the cwd, then the agent is // stamped with that workspace's id. diff --git a/packages/server/src/server/client-activity.e2e.test.ts b/packages/server/src/server/client-activity.e2e.test.ts index 5348ea44d8..316fca8d85 100644 --- a/packages/server/src/server/client-activity.e2e.test.ts +++ b/packages/server/src/server/client-activity.e2e.test.ts @@ -84,14 +84,11 @@ describe("client activity tracking", () => { reject(new Error(`Timeout waiting for attention_required (${timeout}ms)`)); }, timeout); - const cleanup = client.on("agent_stream", (msg) => { - if (msg.type !== "agent_stream") return; - if (msg.payload.agentId !== agentId) return; - if (msg.payload.event.type !== "attention_required") return; - + const cleanup = client.onAgentAttentionRequired((notification) => { + if (notification.agentId !== agentId) return; clearTimeout(timer); cleanup(); - resolve(msg.payload.event); + resolve({ type: "attention_required", ...notification }); }); }); } @@ -107,7 +104,7 @@ describe("client activity tracking", () => { reject(new Error(`Timeout waiting for assistant timeline (${timeout}ms)`)); }, timeout); - const cleanup = client.on("agent_stream", (msg) => { + const cleanup = client.subscribeAgentTimeline(agentId, (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.agentId !== agentId) return; if (msg.payload.event.type !== "timeline") return; diff --git a/packages/server/src/server/config-auth.test.ts b/packages/server/src/server/config-auth.test.ts index 0951a2438b..d7a9f25110 100644 --- a/packages/server/src/server/config-auth.test.ts +++ b/packages/server/src/server/config-auth.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { loadConfig } from "./config.js"; -import { isBearerTokenValid } from "./auth.js"; +import { isBearerTokenValidAsync } from "./auth.js"; const roots: string[] = []; const CONFIG_PASSWORD_HASH = "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW"; @@ -34,9 +34,9 @@ describe("daemon auth config", () => { const config = loadConfig(paseoHome, { env: {} }); expect(config.auth?.password).toBe(CONFIG_PASSWORD_HASH); - expect(isBearerTokenValid({ password: config.auth?.password, token: "correct-password" })).toBe( - true, - ); + expect( + await isBearerTokenValidAsync({ password: config.auth?.password, token: "correct-password" }), + ).toBe(true); }); test("lets PASEO_PASSWORD override config.json auth password hash", async () => { @@ -53,6 +53,8 @@ describe("daemon auth config", () => { expect(config.auth?.password).not.toBe(CONFIG_PASSWORD_HASH); expect(config.auth?.password).toMatch(/^\$2[aby]\$12\$/); - expect(isBearerTokenValid({ password: config.auth?.password, token: "from-env" })).toBe(true); + expect( + await isBearerTokenValidAsync({ password: config.auth?.password, token: "from-env" }), + ).toBe(true); }); }); diff --git a/packages/server/src/server/config-environment.ts b/packages/server/src/server/config-environment.ts new file mode 100644 index 0000000000..24134035de --- /dev/null +++ b/packages/server/src/server/config-environment.ts @@ -0,0 +1,91 @@ +// Daemon configuration inputs. General provider credentials and executable/runtime +// controls remain available to managed launches and their agent processes. +export const DAEMON_SETTING_ENV_KEYS = [ + "MCP_DEBUG", + "OPENAI_STT_BASE_URL", + "OPENAI_TTS_BASE_URL", + "PASEO_ALLOWED_HOSTS", + "PASEO_APP_BASE_URL", + "PASEO_CORS_ORIGINS", + "PASEO_DICTATION_ENABLED", + "PASEO_DICTATION_LANGUAGE", + "PASEO_DICTATION_LOCAL_STT_MODEL", + "PASEO_DICTATION_STT_PROVIDER", + "PASEO_GIT_CONCURRENCY", + "PASEO_GIT_MAX_PROCESSES_PER_SECOND", + "PASEO_GIT_MAX_PROCESS_CONCURRENCY", + "PASEO_HOSTNAMES", + "PASEO_LISTEN", + "PASEO_LOCAL_MODELS_DIR", + "PASEO_LOG", + "PASEO_LOG_CONSOLE_FORMAT", + "PASEO_LOG_CONSOLE_LEVEL", + "PASEO_LOG_FILE_LEVEL", + "PASEO_LOG_FILE_PATH", + "PASEO_LOG_FILE_ROTATE_COUNT", + "PASEO_LOG_FILE_ROTATE_SIZE", + "PASEO_LOG_FORMAT", + "PASEO_LOG_LEVEL", + "PASEO_LOG_ROTATE_COUNT", + "PASEO_LOG_ROTATE_SIZE", + "PASEO_PASSWORD", + "PASEO_PLUGIN_REGISTRY", + "PASEO_RELAY_ENABLED", + "PASEO_RELAY_ENDPOINT", + "PASEO_RELAY_PUBLIC_ENDPOINT", + "PASEO_RELAY_PUBLIC_USE_TLS", + "PASEO_RELAY_USE_TLS", + "PASEO_SERVICE_PROXY_ENABLED", + "PASEO_SERVICE_PROXY_LISTEN", + "PASEO_SERVICE_PROXY_PUBLIC_BASE_URL", + "PASEO_TRUSTED_PROXIES", + "PASEO_VOICE_LANGUAGE", + "PASEO_VOICE_LLM_PROVIDER", + "PASEO_VOICE_LOCAL_STT_MODEL", + "PASEO_VOICE_LOCAL_TTS_MODEL", + "PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", + "PASEO_VOICE_LOCAL_TTS_SPEED", + "PASEO_VOICE_MODE_ENABLED", + "PASEO_VOICE_STT_PROVIDER", + "PASEO_VOICE_TTS_PROVIDER", + "PASEO_VOICE_TURN_DETECTION_PROVIDER", + "PASEO_WEB_UI_DIST_DIR", + "PASEO_WEB_UI_ENABLED", + "PORT", + "STT_CONFIDENCE_THRESHOLD", + "STT_MODEL", + "TTS_MODEL", + "TTS_VOICE", +] as const; + +const CONFIG_CONTEXT_ENV_KEYS = [ + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_STT_API_KEY", + "OPENAI_TTS_API_KEY", +] as const; + +export function configurationEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return Object.fromEntries( + [...DAEMON_SETTING_ENV_KEYS, ...CONFIG_CONTEXT_ENV_KEYS].map((key) => [key, env[key]]), + ); +} + +export function daemonLaunchEnvironment(input: { + env: NodeJS.ProcessEnv; + home: string; + mode: "managed" | "deployment"; + desktopManaged?: boolean; +}): NodeJS.ProcessEnv { + const env = { ...input.env }; + if (input.mode === "managed") { + for (const key of DAEMON_SETTING_ENV_KEYS) delete env[key]; + } + delete env.PASEO_HOST; + delete env.PASEO_DESKTOP_MANAGED; + env.PASEO_HOME = input.home; + if (input.desktopManaged) env.PASEO_DESKTOP_MANAGED = "1"; + return env; +} diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 80045a4454..a71b5621e8 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -200,3 +200,18 @@ describe("server config", () => { ).toBe(path.join(packageRoot, "app-dist")); }); }); + +test("loads private plugin registry settings through the configuration boundary", async () => { + const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-")); + try { + const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } }; + await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries })); + const config = loadConfig(home, { + env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" }, + }); + expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal"); + expect(config.pluginRegistries).toEqual(pluginRegistries); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7ed5f16519..7b2d06560e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -1,3 +1,4 @@ +import { configurationEnvironment } from "./config-environment.js"; import { existsSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -25,6 +26,15 @@ import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js"; import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js"; +export { + loadPersistedConfig, + readPersistedConfig, + savePersistedConfig, + getPersistedConfigValue, + editPersistedConfig, + type PersistedConfig, +} from "./persisted-config.js"; + const DEFAULT_PORT = 6767; const DEFAULT_RELAY_ENDPOINT = "relay.paseo.sh:443"; const DEFAULT_APP_BASE_URL = "https://app.paseo.sh"; @@ -554,7 +564,7 @@ export function resolveConfigFromPersisted( options?: ResolveConfigFromPersistedOptions, ): PaseoDaemonConfig { const resolvedOptions = options ?? {}; - const env = resolvedOptions.env ?? process.env; + const env = configurationEnvironment(resolvedOptions.env ?? process.env); const cli = resolvedOptions.cli; const relayEnabledFallback = resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined; @@ -616,6 +626,8 @@ export function resolveConfigFromPersisted( skillSelection: persisted.agents?.skills?.selection, pluginsEnabled: persisted.pluginsEnabled ?? false, plugins: persisted.plugins, + pluginRegistries: persisted.pluginRegistries, + pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY, mcpDebug: env.MCP_DEBUG === "1", isDev: resolvePaseoNodeEnv(env) === "development", agentStoragePath: path.join(paseoHome, "agents"), diff --git a/packages/server/src/server/creation/creation.e2e.test.ts b/packages/server/src/server/creation/creation.e2e.test.ts new file mode 100644 index 0000000000..f4d35bd3f3 --- /dev/null +++ b/packages/server/src/server/creation/creation.e2e.test.ts @@ -0,0 +1,397 @@ +import { execFileSync } from "node:child_process"; +import type { z } from "zod"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; +import { WebSocket } from "ws"; +import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import pino from "pino"; +import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +function createCreationDaemon(options: Parameters[0] = {}) { + const agentClients = options.agentClients ?? createTestAgentClients(); + return createTestPaseoDaemon({ + ...options, + agentClients, + // Installed provider CLIs must not change when background naming starts. + providerOverrides: Object.fromEntries( + BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: id in agentClients }]), + ), + }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => { + const provider = deferred(); + const ready = deferred(); + const disconnected = deferred(); + const cleaned = deferred(); + const directory = await mkdtemp(join(tmpdir(), "creation-wire-")); + let agents = 0; + let prompts = 0; + const daemon = await createCreationDaemon({ + logger: pino( + { level: "trace" }, + { + write(line) { + const { msg } = JSON.parse(line); + if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve(); + if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve(); + }, + }, + ), + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + agents++; + await provider.promise; + }, + onStartTurn: (_prompt, config) => { + if (config.internal) return; + prompts++; + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-subscriber", + }); + const observer = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.7.2", + clientId: "creation-independent-observer", + }); + const observerMessages: SessionOutboundMessage[] = []; + const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message)); + const workspaceId = "wks_0123456789abcdef"; + const agentId = randomUUID(); + const request = { + idempotencyKey: "workspace-one", + workspaceId, + source: { kind: "directory" as const, path: directory }, + agent: { + agentId, + provider: "codex", + cwd: directory, + initialPrompt: "Create once", + clientMessageId: "initial-one", + }, + }; + try { + await client.connect(); + await observer.connect(); + const first = client.createWorkspace({ + ...request, + onEvent: (snapshot) => { + if (snapshot.phase === "workspace_ready") ready.resolve(snapshot); + }, + }); + void first.catch(() => undefined); + const snapshot = await ready.promise; + expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" }); + expect((await observer.fetchAgents()).entries).toHaveLength(0); + expect( + observerMessages.filter((message) => message.type === "workspace.create.update"), + ).toHaveLength(0); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await client.close(); + await disconnected.promise; + await vi.advanceTimersByTimeAsync(90_000); + await cleaned.promise; + vi.useRealTimers(); + await expect(first).rejects.toThrow("closed"); + // A disconnected form cannot prevent the provider or initial prompt from starting. + provider.resolve(); + await expect.poll(() => prompts).toBe(1); + const replay = await observer.createWorkspace(request); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + expect(replay.agent?.id).toBe(agentId); + expect(replay.agent?.title).toBe("Create once"); + expect(agents).toBe(1); + expect(prompts).toBe(1); + const conflict = await observer.createWorkspace({ + ...request, + idempotencyKey: "another-intent", + }); + expect(conflict.error).toBe("workspace_id_conflict"); + expect(agents).toBe(1); + } finally { + vi.useRealTimers(); + provider.resolve(); + unsubscribe(); + await client.close(); + await observer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); + +async function connectCreationPeer(port: number) { + const timeout = 30_000; + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const frames: SessionOutboundMessage[] = []; + socket.on("message", (data) => { + const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString())); + if (frame.type === "session") frames.push(frame.message); + }); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId: randomUUID(), + protocolVersion: 1, + appVersion: "0.8.0", + }), + ); + await expect + .poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"), { + timeout, + }) + .toBe(true); + return { + close: () => socket.close(), + request: async ( + message: z.input & { requestId: string }, + ) => { + socket.send(JSON.stringify({ type: "session", message })); + const response = () => + frames.find( + (m) => + "payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId, + ); + await expect.poll(response, { timeout }).toBeDefined(); + return response()!; + }, + }; +} + +test.each([false, true])( + "workspace identity does not depend on subscribing (first subscribe=%s)", + async (subscribe) => { + const directory = await mkdtemp(join(tmpdir(), "creation-identity-")); + const daemon = await createCreationDaemon(); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + type: "workspace.create.request" as const, + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "same-workspace", + }; + const first = await peer.request({ ...request, requestId: "first", subscribe }); + const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe }); + if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response") + throw new Error("Expected workspace responses"); + expect(first.payload.error).toBeNull(); + expect(replay.payload.error).toBeNull(); + expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); + +test.each(["create_agent_request", "agent.create.request"] as const)( + "legacy and modern agent RPCs share one creation identity (first %s)", + async (type) => { + const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-")); + let creations = 0; + const daemon = await createCreationDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + creations++; + // A real provider or Git checkout may take longer than Vitest's default 1s poll budget. + await new Promise((resolve) => setTimeout(resolve, 1_200)); + }, + }), + }); + const peer = await connectCreationPeer(daemon.port); + try { + const request = { + config: { provider: "codex", cwd: directory }, + idempotencyKey: "same-agent", + }; + const first = await peer.request({ ...request, type, requestId: "first" }); + const replay = await peer.request({ + ...request, + type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request", + requestId: "replay", + }); + const createdAgent = (message: SessionOutboundMessage) => { + if (message.type === "agent.create.response") { + expect(message.payload.error).toBeNull(); + return message.payload.agent; + } + if (message.type === "status" && message.payload.status === "agent_created") + return message.payload.agent; + throw new Error(`Unexpected creation result ${JSON.stringify(message)}`); + }; + expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id); + const agentId = createdAgent(first)!.id; + await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation"); + await daemon.daemon.agentManager.flush(); + const legacyReplay = await peer.request({ + ...request, + type: "create_agent_request", + requestId: "legacy-after-update", + }); + expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation"); + expect(creations).toBe(1); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); + +test("legacy keyed creation preserves checkout error codes", async () => { + const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-")); + execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: directory, stdio: "pipe" }, + ); + const daemon = await createCreationDaemon({ agentClients: createTestAgentClients() }); + const peer = await connectCreationPeer(daemon.port); + try { + const result = await peer.request({ + type: "create_agent_request", + requestId: "missing-branch", + idempotencyKey: "missing-branch", + config: { provider: "codex", cwd: directory }, + worktree: { mode: "checkout-branch", branch: "does-not-exist" }, + }); + expect(result).toMatchObject({ + type: "status", + payload: { status: "agent_create_failed", errorCode: "unknown_branch" }, + }); + } finally { + peer.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60000); + +test.each(["agent", "workspace"] as const)( + "%s creation succeeds when the provider rejects its initial prompt", + async (kind) => { + const directory = await mkdtemp(join(tmpdir(), "creation-prompt-rejected-")); + const rejection = "Input exceeds the maximum length of 1048576 characters."; + let sessions = 0; + let prompts = 0; + const daemon = await createCreationDaemon({ + agentClients: createTestAgentClients({ + beforeCreateSession: async (config) => { + if (config.internal) return; + sessions++; + }, + onStartTurn: (_prompt, config) => { + if (config.internal) return; + prompts++; + throw new Error(rejection); + }, + }), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.1", + }); + const snapshots: CreationSnapshot[] = []; + const agent = { + config: { provider: "codex", cwd: directory }, + initialPrompt: "Continue the attached conversation.", + clientMessageId: "draft:initial-message", + }; + const streamed: AgentTimelineItem[] = []; + const unsubscribe = daemon.daemon.agentManager.subscribe((event) => { + if (event.type === "agent_stream" && event.event.type === "timeline") { + streamed.push(event.event.item); + } + }); + try { + await client.connect(); + const submit = async () => { + const common = { + idempotencyKey: "rejected-first-prompt", + onEvent: (snapshot: CreationSnapshot) => snapshots.push(snapshot), + }; + if (kind === "agent") return client.createAgent({ ...common, ...agent }); + const result = await client.createWorkspace({ + ...common, + source: { kind: "directory", path: directory }, + agent, + }); + expect(result.error).toBeNull(); + return result.agent!; + }; + const created = await submit(); + expect(created).toMatchObject({ status: "error", lastError: rejection }); + expect(snapshots.map((snapshot) => snapshot.phase)).not.toContain("prompt_started"); + expect(snapshots.at(-1)).toMatchObject({ + phase: "completed", + error: null, + agent: { id: created.id }, + }); + const expectedTimeline = [ + { + type: "user_message", + text: agent.initialPrompt, + clientMessageId: agent.clientMessageId, + messageId: agent.clientMessageId, + }, + { type: "assistant_message", text: `[System Error] ${rejection}` }, + ]; + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect(streamed).toEqual(expectedTimeline); + expect((await submit()).id).toBe(created.id); + expect( + (await client.fetchAgentTimeline(created.id)).entries.map((entry) => entry.item), + ).toEqual(expectedTimeline); + expect((await client.fetchAgents()).entries).toHaveLength(1); + expect({ sessions, prompts }).toEqual({ sessions: 1, prompts: 1 }); + } finally { + unsubscribe(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60000, +); diff --git a/packages/server/src/server/creation/index.test.ts b/packages/server/src/server/creation/index.test.ts new file mode 100644 index 0000000000..a165110ef8 --- /dev/null +++ b/packages/server/src/server/creation/index.test.ts @@ -0,0 +1,324 @@ +import pino from "pino"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import type { + AgentSnapshotPayload, + CreationSnapshot, + WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { CreationService, type CreationInput } from "./index.js"; + +const silentLogger = pino({ level: "silent" }); +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +const workspace: WorkspaceDescriptorPayload = { + id: "wks_0123456789abcdef", + projectId: "project", + projectDisplayName: "Project", + projectRootPath: "/project", + workspaceDirectory: "/project/worktree", + projectKind: "git", + workspaceKind: "worktree", + name: "Worktree", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + scripts: [], +}; +const agent: AgentSnapshotPayload = { + id: "00000000-0000-4000-8000-000000000001", + workspaceId: workspace.id, + provider: "codex", + cwd: "/project/worktree", + model: null, + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Start once", + labels: {}, +}; +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-")); + directories.push(directory); + const service = new CreationService(directory, silentLogger); + const provider = deferred(); + const ready = deferred(); + const calls: string[] = []; + const input: CreationInput = { + kind: "workspace", + key: "submit-one", + request: { source: "/project", prompt: "Start once" }, + workspaceId: workspace.id, + agentId: agent.id, + hasAgent: true, + hasPrompt: true, + exists: async () => false, + provision: async () => { + calls.push("workspace"); + return { workspace }; + }, + createAgent: async (_id, _workspace, onReady) => { + await provider.promise; + calls.push("agent"); + await onReady(agent); + calls.push("prompt"); + return { agent, initialPromptStarted: true }; + }, + }; + const updates: CreationSnapshot[] = []; + const observe = (snapshot: CreationSnapshot) => { + updates.push(snapshot); + if (snapshot.phase === "workspace_ready") ready.resolve(); + }; + return { directory, service, input, provider, ready, calls, updates, observe }; +} + +test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + expect(f.calls).toEqual(["workspace"]); + expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]); + expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id }); + const duplicate = f.service.create(f.input); + f.provider.resolve(); + expect(await first).toMatchObject({ phase: "completed", error: null, workspace, agent }); + expect(await duplicate).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("a failed agent startup retries only that stage with the reserved IDs", async () => { + const f = await fixture(); + let attempts = 0; + f.input.createAgent = async (id, readyWorkspace, onReady) => { + attempts++; + expect(id).toBe(agent.id); + expect(readyWorkspace).toEqual(workspace); + if (attempts === 1) throw new Error("provider unavailable"); + await onReady(agent); + return { agent, initialPromptStarted: true }; + }; + expect(await f.service.create(f.input)).toMatchObject({ + phase: "failed", + failedStage: "agent", + outcomeUnknown: false, + workspace, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({ + phase: "completed", + agent, + }); + expect(f.calls).toEqual(["workspace"]); + expect(attempts).toBe(2); +}); + +test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => { + const f = await fixture(); + let prompts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + await onReady(agent); + prompts++; + throw new Error("connection lost after provider accepted prompt"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ + phase: "failed", + failedStage: "prompt", + outcomeUnknown: true, + workspace, + agent, + }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(prompts).toBe(1); +}); + +test("conflicting intents and resource IDs cannot start more work", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow( + "workspace_request_key_conflict", + ); + await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow( + "workspace_id_conflict", + ); + f.provider.resolve(); + expect(await first).toMatchObject({ phase: "completed", error: null }); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); +}); + +test("late subscribers recover readiness and removing an observer cannot stop creation", async () => { + const f = await fixture(); + const first = f.service.create(f.input, f.observe); + await f.ready.promise; + const updates: CreationSnapshot[] = []; + const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) => + updates.push(snapshot), + ); + expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace }); + subscription.unsubscribe(); + f.provider.resolve(); + await first; + expect(updates).toEqual([]); + const completed = await new CreationService(f.directory, silentLogger).subscribe( + "workspace", + f.input.key, + () => {}, + ); + expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace }); + completed.unsubscribe(); +}); + +test("omitted resource IDs are generated once and preserved on replay", async () => { + const f = await fixture(); + f.input.workspaceId = undefined; + f.input.agentId = undefined; + f.input.provision = async (id) => ({ workspace: { ...workspace, id } }); + f.input.createAgent = async (id, createdWorkspace, onReady) => { + const created = { ...agent, id, workspaceId: createdWorkspace!.id }; + await onReady(created); + return { agent: created, initialPromptStarted: true }; + }; + const result = await f.service.create(f.input); + expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/); + expect(result.agentId).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/, + ); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); +}); + +test("restart after a committed agent without a prompt completes without recreating the agent", async () => { + const f = await fixture(); + const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-")); + directories.push(recoveredDirectory); + f.input.hasPrompt = false; + let starts = 0; + f.input.createAgent = async (_id, _workspace, onReady) => { + starts++; + await onReady(agent); + // Capture the durable state at the exact milestone a process could stop after. + await cp(f.directory, recoveredDirectory, { recursive: true }); + return { agent, initialPromptStarted: false }; + }; + await f.service.create(f.input); + const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input); + expect(recovered).toMatchObject({ phase: "completed", workspace, agent }); + expect(starts).toBe(1); +}); + +test("partial provisioning without a registered workspace is not repeated", async () => { + const f = await fixture(); + let provisions = 0; + f.input.provision = async () => { + provisions++; + await writeFile(join(f.directory, "partial-checkout"), "disk was changed"); + throw new Error("Git failed after creating the checkout"); + }; + const result = await f.service.create(f.input); + expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true }); + expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result); + expect(provisions).toBe(1); +}); + +test.each(["pending", "completed"])( + "imports a %s legacy agent receipt without creating another agent", + async (state) => { + const f = await fixture(); + const legacyDirectory = join(f.directory, "legacy"); + await mkdir(legacyDirectory); + const request = { config: { cwd: "/project", provider: "codex" } }; + const hash = (value: unknown) => + createHash("sha256").update(JSON.stringify(value)).digest("hex"); + await writeFile( + join(legacyDirectory, `${hash(["create", "old-key"])}.json`), + JSON.stringify({ + fingerprint: hash({ ...request, type: "create_agent_request" }), + state, + agentId: agent.id, + }), + ); + const input: CreationInput = { + kind: "agent", + key: "old-key", + request, + hasAgent: true, + hasPrompt: false, + exists: async () => true, + readAgent: async () => agent, + createAgent: async () => { + throw new Error("Must not create again"); + }, + }; + const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory); + await expect( + service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }), + ).rejects.toThrow("agent_request_key_conflict"); + expect(await service.create(input)).toMatchObject({ phase: "completed", agent }); + expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({ + phase: "completed", + agent, + }); + }, +); + +test("observer failures are logged without failing creation", async () => { + const f = await fixture(); + const logs: unknown[] = []; + const logger = pino( + { level: "warn" }, + { + write: (line) => { + logs.push(JSON.parse(line)); + }, + }, + ); + const service = new CreationService(f.directory, logger); + f.provider.resolve(); + const result = await service.create(f.input, () => { + throw new Error("delivery failed"); + }); + expect(result.phase).toBe("completed"); + expect(f.calls).toEqual(["workspace", "agent", "prompt"]); + expect(logs).toContainEqual( + expect.objectContaining({ + level: 40, + kind: "workspace", + idempotencyKey: f.input.key, + phase: "workspace_ready", + err: expect.objectContaining({ message: "delivery failed" }), + }), + ); +}); diff --git a/packages/server/src/server/creation/index.ts b/packages/server/src/server/creation/index.ts new file mode 100644 index 0000000000..e0b28b8069 --- /dev/null +++ b/packages/server/src/server/creation/index.ts @@ -0,0 +1,377 @@ +import type { Logger } from "pino"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { + CreationSnapshotSchema, + type AgentSnapshotPayload, + type CreationSnapshot, + type WorkspaceDescriptorPayload, +} from "@getpaseo/protocol/messages"; +import { writeFileAtomic } from "../atomic-file.js"; +import { generateWorkspaceId } from "../workspace-registry-model.js"; + +type Observer = (snapshot: CreationSnapshot) => void; +export interface CreatedAgent { + agent: AgentSnapshotPayload; + initialPromptStarted: boolean; +} +interface CreationRequest { + key: string; + request: { [key: string]: unknown }; + workspaceId?: string; + agentId?: string; + hasAgent: boolean; + hasPrompt: boolean; + exists: (kind: "workspace" | "agent", id: string) => Promise; + provision?: ( + workspaceId: string, + ) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>; + createAgent?: ( + agentId: string, + workspace: WorkspaceDescriptorPayload | undefined, + onReady: (agent: AgentSnapshotPayload) => Promise, + ) => Promise; +} +export type CreationInput = CreationRequest & + ( + | { kind: "workspace" } + | { kind: "agent"; readAgent: (id: string) => Promise } + ); +const RecordSchema = z.object({ + fingerprint: z.string(), + snapshot: CreationSnapshotSchema, + inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(), +}); +type Record = z.infer; + +/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */ +export class CreationService { + private admission: Promise = Promise.resolve(); + private readonly active = new Map>(); + private readonly receiptOperations = new Map>(); + private readonly observers = new Map>(); + constructor( + private readonly directory: string, + private readonly logger: Logger, + private readonly validateCompleted: ( + snapshot: CreationSnapshot, + ) => Promise = async () => {}, + private readonly legacyDirectory?: string, + ) {} + + async create(input: CreationInput, observer?: Observer): Promise { + const identity = identityFor(input.kind, input.key); + const admitted = this.admission.then(async () => { + await mkdir(this.directory, { recursive: true }); + const fingerprint = digest(input.request); + let record = await this.read(identity); + if (!record) record = await this.readLegacyAgent(input, fingerprint); + if (record && record.fingerprint !== fingerprint) + throw new Error(`${input.kind}_request_key_conflict`); + const running = this.active.get(identity); + if (running && record) return { completion: running, snapshot: record.snapshot }; + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) { + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (record?.inFlight) { + await this.publish(identity, record, { + phase: "failed", + error: `${input.kind}_request_outcome_unknown`, + failedStage: record.inFlight, + outcomeUnknown: true, + }); + return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot }; + } + if (!record) { + record = initialRecord(input, fingerprint); + // Persist identity before claiming resources. A failed admission can only retry these IDs. + await this.write(identity, record); + } + await this.claimResources(identity, record.snapshot, input); + if (record.snapshot.phase === "failed") { + await this.publish(identity, record, { + phase: record.snapshot.workspace ? "workspace_ready" : "accepted", + error: null, + errorCode: undefined, + failedStage: undefined, + outcomeUnknown: undefined, + }); + } + const snapshot = record.snapshot; + // Defer the runner until admission has installed the initiating observer. + const completion = Promise.resolve().then(() => this.run(identity, record, input)); + this.active.set(identity, completion); + void completion.finally(() => this.active.delete(identity)).catch(() => undefined); + return { completion, snapshot }; + }); + this.admission = admitted.then( + () => undefined, + () => undefined, + ); + // Install before admission starts, so even a fast local provision cannot outrun observation. + const unsubscribe = observer ? this.observe(identity, observer) : () => {}; + try { + const { completion, snapshot } = await admitted; + if (observer) this.notify(observer, snapshot); + return await completion; + } finally { + unsubscribe(); + } + } + + async subscribe( + kind: CreationSnapshot["kind"], + key: string, + observer: Observer, + ): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> { + const identity = identityFor(kind, key); + const unsubscribe = this.observe(identity, observer); + try { + await this.admission; + const record = await this.read(identity); + if (record?.inFlight && !this.active.has(identity)) { + await this.publish(identity, record, { + phase: "failed", + failedStage: record.inFlight, + outcomeUnknown: true, + error: `${kind}_request_outcome_unknown`, + }); + } + if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot); + return { snapshot: record?.snapshot ?? null, unsubscribe }; + } catch (error) { + unsubscribe(); + throw error; + } + } + + private async run( + identity: string, + record: Record, + input: CreationInput, + ): Promise { + try { + if (input.provision && !record.snapshot.workspace) { + record.inFlight = "workspace"; + await this.write(identity, record); + const { workspace, setupSkippedReason } = await input.provision( + record.snapshot.workspaceId!, + ); + record.inFlight = null; + await this.publish(identity, record, { + phase: "workspace_ready", + workspace, + workspaceId: workspace.id, + setupSkippedReason, + }); + } + if (input.hasAgent && input.createAgent && !record.snapshot.agent) { + record.inFlight = "agent"; + await this.write(identity, record); + const { agent, initialPromptStarted } = await input.createAgent( + record.snapshot.agentId!, + record.snapshot.workspace, + async (readyAgent) => { + record.inFlight = input.hasPrompt ? "prompt" : null; + await this.publish(identity, record, { + phase: "agent_ready", + agent: readyAgent, + workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId, + }); + }, + ); + record.inFlight = null; + if (initialPromptStarted) + await this.publish(identity, record, { phase: "prompt_started", agent }); + else record.snapshot = { ...record.snapshot, agent }; + } + record.inFlight = null; + await this.publish(identity, record, { phase: "completed" }); + } catch (error) { + const stage = record.inFlight ?? "agent"; + const resourceId = + stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId; + const code = + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined; + const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required"; + const unknown = + stage === "prompt" || + (stage === "workspace" && !rejectedBeforeProvision) || + (resourceId !== null && (await input.exists(stage, resourceId))); + // A failed provider startup with no registered resource can retry under the same ID. + // A partially provisioned resource or an attempted prompt needs reconciliation. + if (!unknown) record.inFlight = null; + await this.publish(identity, record, { + phase: "failed", + error: error instanceof Error ? error.message : String(error), + errorCode: code, + failedStage: stage, + outcomeUnknown: unknown, + }); + } + return record.snapshot; + } + + // COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire. + // Import the previous receipt format; it is never a second execution path. + private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise { + if (input.kind !== "agent" || !this.legacyDirectory) return null; + const text = await readOptional( + join(this.legacyDirectory, `${digest(["create", input.key])}.json`), + ); + if (text === null) return null; + const receipt = z + .object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), + }) + .parse(JSON.parse(text)); + if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request })) + throw new Error("agent_request_key_conflict"); + const agent = await input.readAgent(receipt.agentId); + if (!agent && receipt.state === "completed") + throw new Error("Previously created agent no longer exists"); + const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint); + record.snapshot = { + ...record.snapshot, + phase: agent ? "completed" : "failed", + agent: agent ?? undefined, + workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId, + error: agent ? null : "agent_request_outcome_unknown", + ...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }), + }; + await this.write(identityFor(input.kind, input.key), record); + return record; + } + + private async claimResources( + identity: string, + snapshot: CreationSnapshot, + input: CreationInput, + ): Promise { + if (input.kind === "workspace" && snapshot.workspaceId) + await this.claim(identity, "workspace", snapshot.workspaceId, input.exists); + if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists); + } + + private async claim( + identity: string, + kind: "workspace" | "agent", + id: string, + exists: CreationInput["exists"], + ): Promise { + const file = join(this.directory, `${digest([kind, id])}.claim`); + const owner = await readOptional(file); + if (owner !== null) { + if (owner !== identity) throw new Error(`${kind}_id_conflict`); + return; + } + if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`); + await writeFile(file, identity, { flag: "wx", mode: 0o600 }); + } + private observe(identity: string, observer: Observer): () => void { + const observers = this.observers.get(identity) ?? new Set(); + observers.add(observer); + this.observers.set(identity, observers); + return () => { + observers.delete(observer); + if (!observers.size) this.observers.delete(identity); + }; + } + private notify(observer: Observer, snapshot: CreationSnapshot): void { + try { + observer(snapshot); + } catch (err) { + this.logger.warn( + { + err, + kind: snapshot.kind, + idempotencyKey: snapshot.idempotencyKey, + phase: snapshot.phase, + }, + "Creation observer failed", + ); + } + } + private async publish( + identity: string, + record: Record, + update: Partial, + ): Promise { + record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 }; + await this.write(identity, record); + for (const observer of this.observers.get(identity) ?? []) + this.notify(observer, record.snapshot); + } + private write(identity: string, record: Record): Promise { + // Serialize now: the runner mutates the record after queueing this write. + const contents = JSON.stringify(record, null, 2); + return this.accessReceipt(identity, () => + writeFileAtomic(join(this.directory, `${identity}.json`), contents), + ); + } + private read(identity: string): Promise { + return this.accessReceipt(identity, async () => { + const text = await readOptional(join(this.directory, `${identity}.json`)); + return text === null ? null : RecordSchema.parse(JSON.parse(text)); + }); + } + private accessReceipt(identity: string, access: () => Promise): Promise { + // Admission and the runner progress independently. Keep this owner from + // holding a receipt read open while atomically replacing the same file. + const previous = this.receiptOperations.get(identity) ?? Promise.resolve(); + const result = previous.then(access); + const settled = result.then( + () => undefined, + () => undefined, + ); + this.receiptOperations.set(identity, settled); + void settled.finally(() => { + if (this.receiptOperations.get(identity) === settled) this.receiptOperations.delete(identity); + }); + return result; + } +} +function initialRecord(input: CreationInput, fingerprint: string): Record { + return { + fingerprint, + inFlight: null, + snapshot: { + kind: input.kind, + idempotencyKey: input.key, + revision: 0, + phase: "accepted", + error: null, + workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null), + agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null, + }, + }; +} +function identityFor(kind: string, key: string): string { + return digest([kind, key]); +} +function digest(input: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(input, (_key, value: unknown) => { + if (value && typeof value === "object" && !Array.isArray(value)) + return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))); + return value; + }), + ) + .digest("hex"); +} +async function readOptional(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 1c65316bdc..0bc3310b41 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -121,7 +121,7 @@ test("createAgent without an initial prompt returns an idle snapshot", async () try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-no-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -185,7 +185,7 @@ test("createAgent with background initialPrompt returns a running snapshot befor try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-background-prompt" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", @@ -415,7 +415,7 @@ test("createAgent fails when the initial turn cannot start", async () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "create-start-failure" } }); + await client.fetchAgents({ subscribe: {} }); await expect( client.createAgent({ @@ -831,6 +831,29 @@ test("send_agent_message auto-unarchives archived agents", async () => { } }, 180000); +test("send_agent_message leaves an archived agent archived when its directory is gone", async () => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd, + }, + }); + const archived = await ctx.client.archiveAgent(created.id); + rmSync(cwd, { recursive: true, force: true }); + + await expect(ctx.client.sendMessage(created.id, "hello")).rejects.toThrow( + "Working directory does not exist", + ); + + const afterSend = await ctx.client.fetchAgent({ agentId: created.id }); + expect(afterSend?.agent.archivedAt).toBe(archived.archivedAt); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 30000); + test("refresh_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { @@ -1325,7 +1348,7 @@ test("creates agent and exercises lifecycle", async () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "daemon-client-lifecycle" }, + subscribe: {}, }); const agentUpdatePromise = waitForSignal(15000, (resolve) => { @@ -1473,7 +1496,7 @@ test("creates agent and exercises lifecycle", async () => { sawAssistantMessage = true; } }); - const unsubscribeRawStream = ctx.client.on("agent_stream", (message) => { + const unsubscribeRawStream = ctx.client.subscribeAgentTimeline(agent.id, (message) => { if (message.type !== "agent_stream") { return; } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index a8593a2c02..3629a0fd18 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -400,6 +400,48 @@ describe("DaemonConfigStore", () => { }); }); + test("patch persists provider Paseo-tool policy without changing availability", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ agents: { providers: { claude: { enabled: false } } } }), + ); + const store = new DaemonConfigStore(paseoHome, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: { claude: { enabled: false } }, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + + store.patch({ + providers: { + claude: { + paseoTools: { enabled: true, disabledTools: ["list_agents"] }, + }, + }, + }); + store.patch({ + providers: { + claude: { + paseoTools: { disabledTools: ["create_agent"] }, + }, + }, + }); + + expect(store.get().providers.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["create_agent"] }, + }); + expect(loadPersistedConfig(paseoHome).agents?.providers?.claude).toEqual({ + enabled: false, + paseoTools: { enabled: true, disabledTools: ["create_agent"] }, + }); + }); + test("patch removes provider entries from config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 922d0dac1c..3e285bded0 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -289,9 +289,19 @@ export function applyMutableProviderConfigToOverrides( const nextOverrides: Record = { ...baseOverrides }; for (const [providerId, providerConfig] of Object.entries(mutableProviders ?? {})) { + const previousOverride = nextOverrides[providerId]; + const parsedOverride = ProviderOverrideSchema.strip().parse(providerConfig); nextOverrides[providerId] = { - ...nextOverrides[providerId], - ...ProviderOverrideSchema.strip().parse(providerConfig), + ...previousOverride, + ...parsedOverride, + ...(parsedOverride.paseoTools + ? { + paseoTools: { + ...previousOverride?.paseoTools, + ...parsedOverride.paseoTools, + }, + } + : {}), }; } diff --git a/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts new file mode 100644 index 0000000000..c3e58f8b94 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-archive.native.real.e2e.test.ts @@ -0,0 +1,110 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean): Promise { + const root = mkdtempSync(path.join(tmpdir(), "paseo-archive-codex-")); + const cwd = path.join(root, "repo"); + mkdirSync(cwd); + execFileSync("git", ["init", "-b", "main", cwd]); + execFileSync( + "git", + [ + "-c", + "user.name=QA", + "-c", + "user.email=qa@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "QA", + ], + { cwd }, + ); + const logger = pino({ level: "warn" }); + const provider = new CodexAppServerAgentClient(logger); + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + try { + daemon = await createTestPaseoDaemon({ + agentClients: { codex: provider }, + logger, + pluginsEnabled: false, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + config: { + provider: "codex", + cwd, + model: "gpt-5.6-sol", + modeId: "full-access", + thinkingOptionId: "low", + }, + worktree: { mode: "branch-off", base: "main", newBranch: "archive-qa" }, + }); + const manager = daemon.daemon.agentManager; + const token = "ARCHIVE_CEDAR_7429"; + const first = await manager.runAgent( + agent.id, + `Remember ${token}. Reply with only that token. Do not use tools.`, + ); + expect(first.finalText).toContain(token); + const handle = manager.getAgent(agent.id)!.persistence!; + await client.archiveWorkspace(agent.workspaceId!); + expect(existsSync(agent.cwd)).toBe(false); + // Older daemons marked Paseo archived while native archive failed against its writer. + if (legacyNativeArchive) await provider.unarchiveNativeSession(handle); + await client.restoreWorkspace(agent.workspaceId!); + expect(existsSync(agent.cwd)).toBe(true); + await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 }); + await client.sendAgentMessage( + agent.id, + "What token did I ask you to remember? Reply with RECALLED=. Do not use tools.", + { activeTurnBehavior: "steer" }, + ); + const connectedClient = client; + await expect + .poll( + async () => { + const timeline = await connectedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + return timeline.entries + .map(({ item }) => (item.type === "assistant_message" ? item.text : "")) + .join(""); + }, + { timeout: 30_000 }, + ) + .toContain(`RECALLED=${token}`); + expect(manager.getAgent(agent.id)?.persistence?.sessionId).toBe(handle.sessionId); + process.stdout.write( + `archived -> restored; same agent=${agent.id}; same session=${handle.sessionId}; two real replies recall ${token}; legacy=${legacyNativeArchive}\n`, + ); + await client.archiveWorkspace(agent.workspaceId!); + } finally { + await client?.close(); + await daemon?.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +// Opt in: real Codex requests using local authentication. +test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([false, true])( + "Codex recalls context after workspace restore (legacy native archive: %s)", + async (legacyNativeArchive) => { + await expectContextAfterWorkspaceRestore(legacyNativeArchive); + }, + 90_000, +); 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 c5618b9f09..e8bb548688 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 @@ -130,7 +130,7 @@ describe("cancelAgent", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-cancel" }, + subscribe: {}, }); // Create Codex agent @@ -203,7 +203,7 @@ describe("setAgentMode", () => { const cwd = tmpCwd(); await ctx.client.fetchAgents({ - subscribe: { subscriptionId: "agent-operations-mode" }, + subscribe: {}, }); // Create a Codex agent with default mode ("auto") diff --git a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts index 44e774d7a3..301df35e4b 100644 --- a/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-refresh-rehydrates-timeline.e2e.test.ts @@ -113,7 +113,7 @@ describe("daemon E2E - refresh rehydrates timeline from on-disk session", () => client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "refresh-rehydrate-test" }, + subscribe: {}, }); const imported = await client.importAgent({ provider: "claude", sessionId, cwd }); diff --git a/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts new file mode 100644 index 0000000000..cbbacde969 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-reload.native.real.e2e.test.ts @@ -0,0 +1,128 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; + +import type { AgentClient, AgentSessionConfig } from "../agent/agent-sdk-types.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js"; +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { + getRealProviderConfig, + getRealProviderRuntimeSettings, +} from "./real-provider-test-config.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +type NativeProvider = "claude" | "codex" | "opencode"; + +interface ReloadConversation { + rememberToken: () => Promise; + reloadAndRecallToken: (sessionId: string) => Promise; +} + +async function withNativeConversation( + provider: NativeProvider, + run: (conversation: ReloadConversation) => Promise, +): Promise { + const secret = `RELOAD_${provider.toUpperCase()}_7F31`; + const root = mkdtempSync(path.join(tmpdir(), `paseo-reload-${provider}-`)); + const cwd = path.join(root, "workspace"); + mkdirSync(cwd); + const logger = pino({ level: "warn" }); + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + let openCode: OpenCodeAgentClient | undefined; + let openCodeRuntimeRoot: string | undefined; + try { + let providerClient: AgentClient; + let config: Partial; + if (provider === "claude") { + providerClient = new ClaudeAgentClient({ logger }); + config = { model: "haiku", modeId: "bypassPermissions" }; + } else if (provider === "codex") { + providerClient = new CodexAppServerAgentClient(logger); + config = { modeId: "full-access", thinkingOptionId: "low" }; + } else { + const settings = getRealProviderRuntimeSettings("opencode"); + openCodeRuntimeRoot = path.dirname(settings.env!.XDG_CONFIG_HOME!); + openCode = new OpenCodeAgentClient(logger, settings, { + serverManager: new OpenCodeServerManager({ logger, runtimeSettings: settings }), + }); + providerClient = openCode; + config = getRealProviderConfig("opencode"); + } + daemon = await createTestPaseoDaemon({ + agentClients: { [provider]: providerClient }, + logger, + pluginsEnabled: false, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider, cwd, ...config }); + process.stdout.write(`${provider}: model=${agent.model ?? "default"}\n`); + const manager = daemon.daemon.agentManager; + const connectedClient = client; + await run({ + rememberToken: async () => { + const first = await manager.runAgent( + agent.id, + `Remember this token for this conversation: ${secret}. Reply with exactly that token. Do not use tools.`, + ); + expect(first.finalText).toContain(secret); + const handle = manager.getAgent(agent.id)?.persistence; + expect(handle?.sessionId).toBeTruthy(); + process.stdout.write(`${provider}: first real turn passed; session=${handle!.sessionId}\n`); + return handle!.sessionId; + }, + reloadAndRecallToken: async (sessionId: string) => { + for (let round = 1; round <= 3; round++) { + await connectedClient.refreshAgent(agent.id); + expect(manager.getAgent(agent.id)?.persistence?.sessionId).toBe(sessionId); + const response = await manager.runAgent( + agent.id, + "What token did I ask you to remember? Reply with only that token. Do not use tools.", + ); + expect(response.finalText).toContain(secret); + const timeline = await connectedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + timeline.entries.some( + ({ item }) => item.type === "assistant_message" && item.text.includes(secret), + ), + ).toBe(true); + process.stdout.write( + `${provider}: refresh ${round}/3 passed; same session; memory recalled; timeline present\n`, + ); + } + }, + }); + await manager.closeAgent(agent.id); + } finally { + await client?.close(); + await daemon?.close(); + await openCode?.shutdown(); + if (openCodeRuntimeRoot) rmSync(openCodeRuntimeRoot, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +} + +// Opt in: real requests using local Claude/Codex auth and the OpenRouter test setup. +test + .runIf(process.env.PASEO_NATIVE_RELOAD_QA === "1") + .each(["claude", "codex", "opencode"] as const)( + "%s retains its session and conversation through repeated refresh RPCs", + async (provider) => { + await withNativeConversation(provider, async (conversation) => { + const sessionId = await conversation.rememberToken(); + await conversation.reloadAndRecallToken(sessionId); + }); + }, + 180_000, +); diff --git a/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts index ad56ac382e..b8a9cd7cfc 100644 --- a/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-rpc-durability.e2e.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -74,6 +74,49 @@ test("agent fetch RPCs tolerate an agent whose workspace project record is gone" } }); +test("history search filters before pagination and keeps newest matches first", async () => { + const fixture = seedStaleAgentFixture(); + let daemon: TestPaseoDaemon | null = null; + let client: DaemonClient | null = null; + try { + const agentsDir = path.join(fixture.paseoHomeRoot, ".paseo", "agents"); + const template = JSON.parse( + readFileSync(path.join(agentsDir, `${fixture.healthyAgentId}.json`), "utf8"), + ); + for (const [id, title, updatedAt] of [ + ["newer-partial", "Unbilled usage", "2026-06-29T13:00:00.000Z"], + ["unrelated", "Terminal resizing", "2026-06-29T12:00:00.000Z"], + ["older-exact", "bill", "2026-06-28T12:00:00.000Z"], + ]) { + writeJson(path.join(agentsDir, `${id}.json`), { + ...template, + id, + title, + updatedAt, + lastActivityAt: updatedAt, + }); + } + daemon = await createTestPaseoDaemon({ paseoHomeRoot: fixture.paseoHomeRoot, cleanup: false }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + const first = await client.fetchAgentHistory({ search: "bill", page: { limit: 1 } }); + expect(first.entries.map((entry) => entry.agent.id)).toEqual(["newer-partial"]); + expect(first.pageInfo.hasMore).toBe(true); + expect(first.pageInfo.nextCursor).toBeTypeOf("string"); + const second = await client.fetchAgentHistory({ + search: "bill", + page: { limit: 1, cursor: first.pageInfo.nextCursor! }, + }); + expect(second.entries.map((entry) => entry.agent.id)).toEqual(["older-exact"]); + expect(second.pageInfo.hasMore).toBe(false); + expect(second.entries[0].searchMatches).toBeUndefined(); + } finally { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + for (const target of fixture.cleanupPaths) rmSync(target, { recursive: true, force: true }); + } +}); + function seedStaleAgentFixture(): StaleAgentFixture { const healthyCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-healthy-agent-")); const orphanCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-orphan-agent-")); diff --git a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts index b3a3d4854f..67d39b3134 100644 --- a/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/auto-archive-unrelated-github-merge.real.e2e.test.ts @@ -126,7 +126,7 @@ githubTest( throw new Error("Failed to register both worktrees"); } await context.client.fetchWorkspaces({ - subscribe: { subscriptionId: "real-unrelated-merge" }, + subscribe: {}, }); writeFileSync(path.join(merged.worktreePath, "feature.txt"), "merged\n"); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts new file mode 100644 index 0000000000..f0cd4495e1 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -0,0 +1,87 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { createDaemonTestContext } from "../test-utils/index.js"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +test.each(["uncommitted", "base", "commit"] as const)( + "%s diff preserves removed SQL comments and their deletion counts", + async (mode) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-content-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "query.sql"), "-- old comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 1;\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + writeFileSync(join(cwd, "query.sql"), "-- new comment\nSELECT 1;\n"); + writeFileSync(join(cwd, "control.sql"), "SELECT 2;\n"); + + expect(git(cwd, "diff", "--numstat", "HEAD", "--", "query.sql").trim()).toBe( + "1\t1\tquery.sql", + ); + + if (mode !== "uncommitted") { + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const files = + mode === "commit" + ? [ + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "query.sql", + ) + ).file, + ( + await ctx.client.getCommitFileDiff( + cwd, + git(cwd, "rev-parse", "HEAD").trim(), + "control.sql", + ) + ).file, + ] + : (await ctx.client.getCheckoutDiff(cwd, { mode, baseRef: "main" })).files; + + const control = files.find((file) => file?.path === "control.sql"); + expect(control).toMatchObject({ additions: 1, deletions: 1 }); + expect(control?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "SELECT 1;" }), + expect.objectContaining({ type: "add", content: "SELECT 2;" }), + ]), + ); + + const query = files.find((file) => file?.path === "query.sql"); + expect + .soft(query) + .toMatchObject({ additions: 1, deletions: 1, isNew: false, isDeleted: false }); + expect + .soft(query?.hunks.flatMap((hunk) => hunk.lines)) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "-- old comment" }), + expect.objectContaining({ type: "add", content: "-- new comment" }), + expect.objectContaining({ type: "context", content: "SELECT 1;" }), + ]), + ); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 7ce8cbb3a3..3af53a9133 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -77,12 +77,9 @@ test("pushes file-level checkout diff updates with deterministic path order", as initGitRepo(cwd); commitFile(cwd, "base.txt", "base\n"); - const subscriptionId = "checkout-diff-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -98,7 +95,7 @@ test("pushes file-level checkout diff updates with deterministic path order", as expect(update.error).toBeNull(); expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -114,12 +111,9 @@ test("pushes updates when subscribed from a subdirectory and files change outsid const nestedDir = path.join(cwd, "nested", "dir"); mkdirSync(nestedDir, { recursive: true }); - const subscriptionId = "checkout-diff-subdir-e2e-subscription"; - const initial = await ctx.client.subscribeCheckoutDiff( - nestedDir, - { mode: "uncommitted" }, - { subscriptionId }, - ); + const subscription = ctx.client.observeCheckoutDiff(nestedDir, { mode: "uncommitted" }); + const initial = await subscription.ready; + const subscriptionId = initial.subscriptionId; expect(initial.error).toBeNull(); expect(initial.files).toEqual([]); @@ -133,7 +127,7 @@ test("pushes updates when subscribed from a subdirectory and files change outsid expect(update.error).toBeNull(); expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true); - ctx.client.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -146,15 +140,14 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy initGitRepo(cwd); commitFile(cwd, "large-a.js", "const value = 0;\n"); commitFile(cwd, "large-b.js", "const value = 0;\n"); - const denseExpression = `const value = ${"a+".repeat(450_000)}a;\n`; + // Short lines stay eligible for highlighting; a giant single line is skipped + // before token expansion and cannot exercise the structured-payload limit. + const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000); writeFileSync(path.join(cwd, "large-a.js"), denseExpression); writeFileSync(path.join(cwd, "large-b.js"), denseExpression); - const initial = await ctx.client.subscribeCheckoutDiff( - cwd, - { mode: "uncommitted" }, - { subscriptionId: "oversized-checkout-diff" }, - ); + const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" }); + const initial = await subscription.ready; expect(initial).toMatchObject({ cwd, @@ -165,6 +158,7 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy const status = await ctx.client.getCheckoutStatus(cwd); expect(status).toMatchObject({ cwd, isGit: true }); + await subscription.release(); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts index 77bd164732..0631cfd867 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake-simple.real.e2e.test.ts @@ -45,7 +45,7 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-simple-real" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index fe482d00a5..5899a91bc8 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -451,7 +451,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-a" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -524,7 +524,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-b" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -597,7 +597,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-abc-c" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -663,7 +663,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-wake-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -721,7 +721,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-followup-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -767,7 +767,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-hang-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -869,7 +869,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-background-repro-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -941,7 +941,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-autonomous-race-stress-real" }, + subscribe: {}, }); const agent = await client.createAgent({ @@ -1027,7 +1027,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-transcript-parity-race" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts new file mode 100644 index 0000000000..00b365881f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Claude Code resumes a session by id from any working directory and keeps writing to the +// transcript under the project folder it started in. The agent's cwd can name a different +// folder: a project that moved, a daemon migrated between machines or containers, or Claude +// Code running under WSL interop and seeing the cwd as a Windows path. +// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one. +const PROVIDER_ID = "claude-real"; +const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + ( + entry, + ): entry is { + item: { type: "user_message" | "assistant_message"; text: string }; + } => entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history stored under another project folder", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let configDir: string; + let transcriptCwd: string; + let agentCwd: string; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + configDir = path.join(tempRoot, "claude-config"); + transcriptCwd = path.join(tempRoot, "where-claude-started"); + agentCwd = path.join(tempRoot, "agent-cwd"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(agentCwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(transcriptCwd, { configDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { role: "user", content: "hello from the original folder" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd: transcriptCwd, + message: { + role: "assistant", + content: "reply from the original folder", + }, + }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + }, 60_000); + + async function startDaemon(): Promise { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude", + env: { CLAUDE_CONFIG_DIR: configDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd: agentCwd, + }); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the original folder"); + expect(text).toContain("reply from the original folder"); + }, 60_000); +}); diff --git a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts index 4d4ceb3a57..39b3d7d176 100644 --- a/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-live-usage.e2e.test.ts @@ -150,7 +150,7 @@ describe("daemon E2E (claude live usage)", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-live-usage" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "claude", diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts new file mode 100644 index 0000000000..29779fd1ef --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +const PROVIDER_ID = "claude-secondary"; +const SESSION_ID = "provider-config-dir-session"; + +function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string { + return entries + .filter( + (entry): entry is { item: { type: "user_message" | "assistant_message"; text: string } } => + entry.item.type === "user_message" || entry.item.type === "assistant_message", + ) + .map((entry) => entry.item.text) + .join("\n"); +} + +describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR", () => { + let tempRoot: string; + let paseoHomeRoot: string; + let providerConfigDir: string; + let cwd: string; + let prevClaudeConfigDir: string | undefined; + let daemon: TestPaseoDaemon | undefined; + let client: DaemonClient | undefined; + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(tmpdir(), "claude-provider-config-dir-")); + paseoHomeRoot = path.join(tempRoot, "paseo-home"); + providerConfigDir = path.join(tempRoot, "custom-claude-dir"); + cwd = path.join(tempRoot, "repo"); + mkdirSync(paseoHomeRoot, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + + const projectDir = claudeProjectDirSync(cwd, { configDir: providerConfigDir }); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + path.join(projectDir, `${SESSION_ID}.jsonl`), + [ + { + type: "user", + uuid: "user-uuid-1", + sessionId: SESSION_ID, + cwd, + message: { role: "user", content: "hello from the custom config dir" }, + }, + { + type: "assistant", + sessionId: SESSION_ID, + cwd, + message: { role: "assistant", content: "reply from the custom config dir" }, + }, + ] + .map((entry) => `${JSON.stringify(entry)}\n`) + .join(""), + "utf8", + ); + + // The daemon's own environment does not name the directory; only the provider's env does. + prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(async () => { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + client = undefined; + daemon = undefined; + rmSync(tempRoot, { recursive: true, force: true }); + if (prevClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir; + } + }, 60_000); + + async function startDaemon(): Promise { + daemon = await createTestPaseoDaemon({ + paseoHomeRoot, + cleanup: false, + providerOverrides: { + [PROVIDER_ID]: { + extends: "claude", + label: "Claude (secondary)", + env: { CLAUDE_CONFIG_DIR: providerConfigDir }, + }, + }, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + } + + async function stopDaemon(): Promise { + await client?.close(); + await daemon?.close(); + client = undefined; + daemon = undefined; + } + + test("an agent's timeline survives a daemon restart", async () => { + const firstClient = await startDaemon(); + const agent = await firstClient.importAgent({ + provider: PROVIDER_ID, + sessionId: SESSION_ID, + cwd, + }); + await stopDaemon(); + + const restartedClient = await startDaemon(); + const timeline = await restartedClient.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + + const text = timelineText(timeline.entries); + expect(text).toContain("hello from the custom config dir"); + expect(text).toContain("reply from the custom config dir"); + }, 60_000); + + test("the import list finds the provider's sessions", async () => { + const daemonClient = await startDaemon(); + const recent = await daemonClient.fetchRecentProviderSessions({ + cwd, + providers: [PROVIDER_ID], + }); + + expect(recent.entries.map((entry) => entry.providerHandleId)).toContain(SESSION_ID); + }, 60_000); +}); diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts index c362529e23..23ab49e67d 100644 --- a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -234,7 +234,7 @@ describe("daemon E2E (real claude) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts index 937f3d0c6a..c1640a7d2d 100644 --- a/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-thinking-memory.real.e2e.test.ts @@ -72,7 +72,7 @@ describe("daemon E2E (real claude) - thinking effort memory", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-thinking-memory-real" }, + subscribe: {}, }); const modelList = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts new file mode 100644 index 0000000000..d8388f9dd2 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/cli-logs-follow.e2e.test.ts @@ -0,0 +1,89 @@ +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +// A real daemon and CLI with a deterministic provider emitting two text chunks. +// The live Claude reproduction is recorded in the PR; this locks its CLI behavior. +test("logs --follow preserves the same assistant text as one-shot logs", async () => { + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let follow: ReturnType | undefined; + let stdout = ""; + let stderr = ""; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + cwd: daemon.paseoHome, + title: "Follow transcript regression", + modeId: "bypassPermissions", + }); + follow = spawn( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${daemon.port}`, + "logs", + "--follow", + agent.id, + "--tail", + "0", + "--filter", + "assistant_message", + ], + { + env: { ...process.env, PASEO_HOME: daemon.paseoHome }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const exited = once(follow, "exit"); + follow.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + follow.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + await expect + .poll( + () => { + if (follow?.exitCode !== null) { + throw new Error(`CLI exited before following logs: ${stderr}`); + } + return stdout; + }, + { timeout: 20_000 }, + ) + .toContain("Following logs"); + await client.sendMessage(agent.id, "respond with exactly: paseo-pr-cycle 한국어 보고서"); + await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle", 20_000); + const transcript = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 100, + projection: "projected", + }); + const assistantText = transcript.entries + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item.type === "assistant_message" ? row.item.text : "")) + .join(""); + expect(assistantText).not.toBe(""); + // Completion closes the message while follow mode is still running. + // Then stop through the CLI's normal signal path. + await expect.poll(() => stdout, { timeout: 10_000 }).toContain(`${assistantText.trim()}\n`); + follow.kill("SIGINT"); + await exited; + const output = stdout.slice(stdout.indexOf("---\n") + 4).trim(); + expect(output, stderr).toBe(assistantText.trim()); + } finally { + follow?.kill("SIGTERM"); + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts index c0e9bc6154..8237ce9ef7 100644 --- a/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-auto-review.local.e2e.test.ts @@ -288,7 +288,7 @@ async function runScenario(modeId: "auto" | "auto-review") { try { writeMockCodexConfig(codexHome, mockServer.url); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `codex-auto-review-${modeId}` } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ provider: "codex", diff --git a/packages/server/src/server/daemon-e2e/codex-custom-provider-archive.local.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-custom-provider-archive.local.e2e.test.ts new file mode 100644 index 0000000000..9917ba573f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/codex-custom-provider-archive.local.e2e.test.ts @@ -0,0 +1,135 @@ +import { once } from "node:events"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Runs the real `codex` binary against a local Responses endpoint standing in +// for a custom provider's OPENAI_BASE_URL, so no login is needed. + +function sse(events: Array>): string { + return events + .map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`) + .join(""); +} + +function finalAnswer(): string { + return sse([ + { type: "response.created", response: { id: "resp-1" } }, + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: "msg-1", + content: [{ type: "output_text", text: "done" }], + }, + }, + { + type: "response.completed", + response: { + id: "resp-1", + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }, + ]); +} + +async function startResponsesEndpoint(): Promise<{ + baseUrl: string; + server: Server; + requestCount: () => number; +}> { + let requests = 0; + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.statusCode = 404; + res.end(); + return; + } + requests += 1; + res.setHeader("content-type", "text/event-stream"); + res.end(finalAnswer()); + }); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no server address"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + server, + requestCount: () => requests, + }; +} + +async function closeServer(server: Server): Promise { + server.close(); + await once(server, "close"); +} + +describe("Codex custom provider archive", () => { + const cleanups: Array<() => void | Promise> = []; + + afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); + }); + + test("archiving hides the session from Import session and unarchiving lists it again", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "codex-custom-archive-")); + cleanups.push(() => rmSync(root, { recursive: true, force: true })); + const codexHome = path.join(root, "codex-home"); + mkdirSync(codexHome); + const cwd = path.join(root, "project"); + mkdirSync(cwd); + const { baseUrl, server } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); + + const daemon = await createTestPaseoDaemon({ + logger: createTestLogger(), + pluginsEnabled: false, + providerOverrides: { + "my-codex": { + extends: "codex", + label: "My Codex", + env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" }, + }, + }, + }); + cleanups.push(() => daemon.close()); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + cleanups.push(() => client.close()); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const manager = daemon.daemon.agentManager; + + const agent = await client.createAgent({ + config: { provider: "my-codex", cwd, model: "mock-model", modeId: "full-access" }, + }); + await manager.runAgent(agent.id, "hello from the custom provider"); + const importable = async () => + (await manager.listImportableSessions({ cwd, providerFilter: new Set(["my-codex"]) })) + .sessions; + expect(await importable()).toHaveLength(1); + + await client.archiveAgent(agent.id); + expect(await importable()).toEqual([]); + + await client.refreshAgent(agent.id); + expect(await importable()).toHaveLength(1); + }, 120_000); +}); diff --git a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts index 9e4722ea26..6f43929f2d 100644 --- a/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-goal-mid-turn.real.e2e.test.ts @@ -116,7 +116,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -170,7 +170,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -247,7 +247,7 @@ describe("daemon E2E (real codex) - /goal command", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts index e61346a6a2..c55322de0e 100644 --- a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -164,7 +164,7 @@ describe("daemon E2E (real codex) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts index 1c5fed39ed..6b2e0bef4b 100644 --- a/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/empty-project-persists.e2e.test.ts @@ -108,7 +108,7 @@ test("archiving the last workspace leaves the project parent with no workspaces" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } }); expect(created.error).toBeNull(); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index f9d7fce7b4..862923dec6 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -1,5 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + readdirSync, + readlinkSync, + realpathSync, + symlinkSync, +} from "fs"; +import { once } from "node:events"; +import { get } from "node:http"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -8,6 +18,41 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +// Linux exposes the real open descriptors without replacing file or HTTP methods. +function openDownloadDescriptors(filePath: string): string[] { + const realFilePath = realpathSync(filePath); + return readdirSync("/proc/self/fd").filter((fd) => { + try { + return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath; + } catch (error) { + // A descriptor can close between listing it and reading its link. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); +} + +async function expectDownloadClosed(filePath: string): Promise { + await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]); +} + +async function cancelDownloadAfterFirstChunk(url: string): Promise { + const request = get(url); + try { + const [response] = await once(request, "response"); + expect(response.statusCode).toBe(200); + const [chunk] = await once(response, "data"); + expect(chunk.length).toBeGreaterThan(0); + expect(chunk.length).toBeLessThan(64 * 1024 * 1024); + const closed = once(response, "close"); + response.destroy(); + request.destroy(); + await closed; + } finally { + request.destroy(); + } +} + // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -24,42 +69,110 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { - test("issues token over WS and downloads via HTTP", async () => { - const cwd = tmpCwd(); - const filePath = path.join(cwd, "download.txt"); - const fileContents = "download test payload"; - writeFileSync(filePath, fileContents, "utf-8"); + test.skipIf(process.platform !== "linux")( + "releases file descriptors after repeated HTTP download cancellations", + async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "linked-files"); + symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir"); + const filePath = path.join(cwd, "large.bin"); + const fileSize = 64 * 1024 * 1024; + writeFileSync(filePath, Buffer.alloc(fileSize)); + try { + const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(completedToken.error).toBeNull(); + const completed = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`, + ); + expect(completed.status).toBe(200); + expect((await completed.arrayBuffer()).byteLength).toBe(fileSize); + await expectDownloadClosed(filePath); - const agent = await ctx.client.createAgent({ - provider: "codex", - model: CODEX_TEST_MODEL, - thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, - cwd, - title: "Download Token Test Agent", - }); + for (let attempt = 0; attempt < 3; attempt++) { + const token = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(token.error).toBeNull(); + await cancelDownloadAfterFirstChunk( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`, + ); + await expectDownloadClosed(filePath); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); - expect(agent.id).toBeTruthy(); + test.each([ + { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "報告書.md", + disposition: + "attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md", + }, + { + fileName: "보고서.txt", + disposition: + "attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + ])( + "issues token over WS and downloads $fileName via HTTP", + async ({ fileName, disposition }) => { + const cwd = tmpCwd(); + try { + const filePath = path.join(cwd, fileName); + const fileContents = "download test payload"; + writeFileSync(filePath, fileContents, "utf-8"); - const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt"); + const agent = await ctx.client.createAgent({ + provider: "codex", + model: CODEX_TEST_MODEL, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, + cwd, + title: "Download Token Test Agent", + }); - expect(tokenResponse.error).toBeNull(); - expect(tokenResponse.token).toBeTruthy(); - expect(tokenResponse.fileName).toBe("download.txt"); + expect(agent.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); - const response = await fetch( - `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, - ); + const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName); - expect(response.status).toBe(200); - expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); - const disposition = response.headers.get("content-disposition") ?? ""; - expect(disposition).toContain("download.txt"); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.token).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(tokenResponse.fileName).toBe(fileName); - const body = await response.text(); - expect(body).toBe(fileContents); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`, + ); - rmSync(cwd, { recursive: true, force: true }); - }, 60000); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType); + expect(response.headers.get("content-disposition")).toBe(disposition); + + const body = await response.text(); + expect(body).toBe(fileContents); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60000, + ); test("rejects invalid token", async () => { const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 4bb4736133..c0e14d72b8 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs"; +import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -23,6 +23,370 @@ afterEach(async () => { }, 60000); describe("file explorer", () => { + test.each(["..", "..."])( + "workspace suggestions resolve exact paths starting with %s", + async (prefix) => { + const cwd = tmpCwd(); + const directoryName = `${prefix}docs`; + const nestedPath = `${directoryName}/notes.md`; + const fileName = `${prefix}notes.md`; + try { + mkdirSync(path.join(cwd, directoryName)); + writeFileSync(path.join(cwd, nestedPath), "nested payload\n"); + writeFileSync(path.join(cwd, fileName), "direct payload\n"); + const file = await ctx.client.readFile(cwd, nestedPath); + expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n"); + + const nestedEntry = { path: nestedPath, kind: "file" }; + const entries = [ + nestedEntry, + { path: directoryName, kind: "directory" }, + { path: fileName, kind: "file" }, + ]; + const cases = entries.flatMap((entry) => [ + { query: `./${entry.path}`, entry }, + { query: path.resolve(cwd, entry.path), entry }, + ]); + cases.unshift({ query: nestedPath, entry: nestedEntry }); + for (const { query, entry } of cases) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([entry]); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test("workspace suggestions exclude exact parent and sibling paths", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const sibling = path.join(directory, "workspace-other"); + try { + mkdirSync(cwd); + mkdirSync(sibling); + writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n"); + writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n"); + for (const query of [ + "./..", + "../workspace-other/outside.txt", + path.join(sibling, "outside.txt"), + ]) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([]); + } + const control = await ctx.client.getDirectorySuggestions({ + cwd, + query: "./visible.txt", + limit: 1, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + expect(control.error).toBeNull(); + expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test.each([ + { description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` }, + { description: "an ordinary name", fileName: "notes.txt" }, + { description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` }, + ])("saves text files with $description", async ({ fileName }) => { + const cwd = tmpCwd(); + try { + const created = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(created).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "original payload\n"); + const initial = await ctx.client.readFile(cwd, fileName); + + const saved = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "saved payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(saved).toMatchObject({ status: "written", size: 14 }); + const after = await ctx.client.readFile(cwd, fileName); + expect(after.path).toBe(fileName); + expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n"); + + const staleSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "stale payload\n", + expectedModifiedAt: initial.modifiedAt, + expectedRevision: initial.revision, + }); + expect(staleSave).toMatchObject({ + status: "conflict", + version: { + status: "ready", + path: fileName, + size: after.size, + modifiedAt: after.modifiedAt, + revision: after.revision, + }, + }); + const afterConflict = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n"); + + const secondSave = await ctx.client.writeFile({ + cwd, + path: fileName, + content: "second payload\n", + expectedModifiedAt: afterConflict.modifiedAt, + expectedRevision: afterConflict.revision, + }); + expect(secondSave).toMatchObject({ status: "written", size: 15 }); + const final = await ctx.client.readFile(cwd, fileName); + expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n"); + expect(readdirSync(cwd)).toEqual([fileName]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test.each([ + { + query: "implementation", + expectedPaths: [ + ".cursor/plans/implementation-plan.md", + ".codex/plans/implementation-plan.md", + ".claude/plans/implementation-plan.md", + ], + }, + { + query: ".cursor/plans/implementation", + expectedPaths: [".cursor/plans/implementation-plan.md"], + }, + ])( + "workspace suggestions find hidden agent plans for '$query'", + async ({ query, expectedPaths }) => { + const cwd = tmpCwd(); + try { + for (const directory of [".cursor", ".codex", ".claude", ".hidden"]) { + mkdirSync(path.join(cwd, directory, "plans"), { recursive: true }); + writeFileSync(path.join(cwd, directory, "plans", "implementation-plan.md"), "plan\n"); + } + + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 50, + includeFiles: true, + includeDirectories: true, + }); + expect(response.error).toBeNull(); + expect(response.entries).toHaveLength(expectedPaths.length); + expect(response.entries).toEqual( + expect.arrayContaining( + expectedPaths.map((entryPath) => ({ path: entryPath, kind: "file" })), + ), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => { + const cwd = tmpCwd(); + const fileName = `${prefix}notes.txt`; + const directoryName = `${prefix}drafts`; + try { + const createdFile = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: fileName, + kind: "file", + }); + expect(createdFile).toMatchObject({ path: fileName, success: true, error: null }); + writeFileSync(path.join(cwd, fileName), "notes payload\n"); + writeFileSync(path.join(cwd, ".notes.txt"), "dotfile control\n"); + writeFileSync(path.join(cwd, "notes.txt"), "ordinary control\n"); + + const createdDirectory = await ctx.client.createFileEntry({ + cwd, + parentPath: ".", + name: directoryName, + kind: "directory", + }); + expect(createdDirectory).toMatchObject({ path: directoryName, success: true, error: null }); + const createdChild = await ctx.client.createFileEntry({ + cwd, + parentPath: directoryName, + name: "inside.txt", + kind: "file", + }); + expect(createdChild).toMatchObject({ + path: `${directoryName}/inside.txt`, + success: true, + error: null, + }); + writeFileSync(path.join(cwd, directoryName, "inside.txt"), "nested payload\n"); + + const directory = await ctx.client.listDirectory(cwd, "."); + expect( + Object.fromEntries(directory.entries.map((entry) => [entry.path, entry.kind])), + ).toEqual({ + [directoryName]: "directory", + [fileName]: "file", + ".notes.txt": "file", + "notes.txt": "file", + }); + const children = await ctx.client.listDirectory(cwd, directoryName); + expect(children.entries.map((entry) => entry.path)).toEqual([`${directoryName}/inside.txt`]); + const file = await ctx.client.readFile(cwd, fileName); + expect(file.path).toBe(fileName); + expect(new TextDecoder().decode(file.bytes)).toBe("notes payload\n"); + const nestedFile = await ctx.client.readFile(cwd, `${directoryName}/inside.txt`); + expect(nestedFile.path).toBe(`${directoryName}/inside.txt`); + expect(new TextDecoder().decode(nestedFile.bytes)).toBe("nested payload\n"); + + const dotfile = await ctx.client.readFile(cwd, ".notes.txt"); + expect(new TextDecoder().decode(dotfile.bytes)).toBe("dotfile control\n"); + const ordinaryFile = await ctx.client.readFile(cwd, "notes.txt"); + expect(new TextDecoder().decode(ordinaryFile.bytes)).toBe("ordinary control\n"); + + const download = await ctx.client.requestDownloadToken(cwd, fileName); + expect(download.error).toBeNull(); + expect(download.fileName).toBe(fileName); + const response = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${download.token}`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("notes payload\n"); + + const renamedFileName = `${prefix}renamed.txt`; + const renamed = await ctx.client.renameFileEntry({ + cwd, + path: fileName, + name: renamedFileName, + }); + expect(renamed).toMatchObject({ renamedPath: renamedFileName, success: true, error: null }); + const renamedFile = await ctx.client.readFile(cwd, renamedFileName); + expect(new TextDecoder().decode(renamedFile.bytes)).toBe("notes payload\n"); + + const copiedDirectoryName = `${directoryName} copy`; + const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: directoryName }); + expect(duplicated).toMatchObject({ + duplicatedPath: copiedDirectoryName, + success: true, + error: null, + }); + const copiedFile = await ctx.client.readFile(cwd, `${copiedDirectoryName}/inside.txt`); + expect(new TextDecoder().decode(copiedFile.bytes)).toBe("nested payload\n"); + + const deletedDirectory = await ctx.client.deleteFileEntry({ cwd, path: directoryName }); + expect(deletedDirectory).toMatchObject({ success: true, error: null }); + const deletedFile = await ctx.client.deleteFileEntry({ cwd, path: renamedFileName }); + expect(deletedFile).toMatchObject({ success: true, error: null }); + const remaining = await ctx.client.listDirectory(cwd, "."); + expect(remaining.entries.map((entry) => entry.path).sort()).toEqual( + [".notes.txt", "notes.txt", copiedDirectoryName].sort(), + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + test("rejects parent traversal and absolute paths to a sibling workspace", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(cwd); + mkdirSync(outside); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + for (const requestedPath of ["..", "../workspace-sibling", outside]) { + await expect(ctx.client.listDirectory(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + } + for (const requestedPath of [ + "../workspace-sibling/secret.txt", + path.join(outside, "secret.txt"), + ]) { + await expect(ctx.client.readFile(cwd, requestedPath)).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, requestedPath); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + const deleted = await ctx.client.deleteFileEntry({ cwd, path: requestedPath }); + expect(deleted).toMatchObject({ + success: false, + error: "Access outside of workspace is not allowed", + }); + } + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + test("accepts symlinks to double-dot names inside the workspace and rejects outside targets", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const outside = path.join(directory, "workspace-sibling"); + try { + mkdirSync(path.join(cwd, "..drafts"), { recursive: true }); + mkdirSync(outside); + writeFileSync(path.join(cwd, "..drafts", "notes.txt"), "inside payload\n"); + writeFileSync(path.join(outside, "secret.txt"), "outside payload\n"); + // Junctions also work on Windows without requiring symlink privileges. + symlinkSync(path.join(cwd, "..drafts"), path.join(cwd, "inside-link"), "junction"); + symlinkSync(outside, path.join(cwd, "outside-link"), "junction"); + + const listing = await ctx.client.listDirectory(cwd, "."); + expect(listing.entries.map((entry) => entry.path).sort()).toEqual([ + "..drafts", + "inside-link", + ]); + const file = await ctx.client.readFile(cwd, "inside-link/notes.txt"); + expect(file.path).toBe("inside-link/notes.txt"); + expect(new TextDecoder().decode(file.bytes)).toBe("inside payload\n"); + await expect(ctx.client.readFile(cwd, "outside-link/secret.txt")).rejects.toThrow( + "Access outside of workspace is not allowed", + ); + const download = await ctx.client.requestDownloadToken(cwd, "outside-link/secret.txt"); + expect(download).toMatchObject({ + token: null, + error: "Access outside of workspace is not allowed", + }); + expect(readdirSync(outside)).toEqual(["secret.txt"]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + test("lists directory contents", async () => { const cwd = tmpCwd(); 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 64ba85eb48..be225f171d 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 @@ -1,5 +1,14 @@ import { test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + existsSync, + rmSync, + readFileSync, + realpathSync, + mkdirSync, +} from "fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -152,6 +161,78 @@ afterEach(async () => { await ctx.cleanup(); }, 60000); +interface GitCommandOptions { + cwd: string; + args: string[]; +} + +async function mergeFeatureIntoBaseWorktree(baseName: string): Promise { + const directory = realpathSync(tmpCwd()); + const base = path.join(directory, baseName); + const feature = path.join(directory, "feature"); + function git({ cwd, args }: GitCommandOptions): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + mkdirSync(base); + git({ cwd: base, args: ["init", "-b", "main"] }); + git({ cwd: base, args: ["config", "user.name", "Paseo Test"] }); + git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] }); + git({ cwd: base, args: ["config", "commit.gpgsign", "false"] }); + git({ cwd: base, args: ["config", "core.autocrlf", "false"] }); + git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] }); + git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] }); + writeFileSync(path.join(feature, "feature.txt"), "feature payload\n"); + git({ cwd: feature, args: ["add", "feature.txt"] }); + git({ cwd: feature, args: ["commit", "-m", "feature"] }); + const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim(); + + const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit); + expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n"); + expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])( + "merges into a base worktree whose path preserves %j", + mergeFeatureIntoBaseWorktree, +); + +test.each(["base checkout", "中文 checkout"])( + "merges into a base worktree named %j", + mergeFeatureIntoBaseWorktree, +); + +test("merges into a local base branch without another worktree", async () => { + const cwd = realpathSync(tmpCwd()); + function git(args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }); + } + try { + git(["init", "-b", "main"]); + git(["config", "user.name", "Paseo Test"]); + git(["config", "user.email", "test@paseo.local"]); + git(["config", "commit.gpgsign", "false"]); + git(["commit", "--allow-empty", "-m", "base"]); + git(["checkout", "-b", "feature"]); + writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n"); + git(["add", "feature.txt"]); + git(["commit", "-m", "feature"]); + const featureCommit = git(["rev-parse", "HEAD"]).trim(); + + const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" }); + expect(merged).toMatchObject({ success: true, error: null }); + expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit); + expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + test("returns diff for modified file in git repo", async () => { const cwd = tmpCwd(); 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 25c74c62a0..9eb1ff929a 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 @@ -96,7 +96,7 @@ beforeEach(async () => { unsubscribe = ctx.client.subscribeRawMessages((message) => { messages.push(message); }); - await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } }); + await ctx.client.fetchAgents({ subscribe: {} }); }); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts index d1acba0e55..81d60901a7 100644 --- a/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/mode-switch-propagation.e2e.test.ts @@ -115,7 +115,7 @@ describe("mode-switch update propagation", () => { await client2.connect(); const bootstrapResult = await client2.fetchAgents({ - subscribe: { subscriptionId: "bootstrap-test" }, + subscribe: {}, }); const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id); diff --git a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts index 71e9f9ed73..52bee9f79f 100644 --- a/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-resolution-on-init.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - model resolution on init", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "model-init-test" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts index 83fe12e1e3..fba40a681c 100644 --- a/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/model-runtime-reconcile-claude.real.e2e.test.ts @@ -37,7 +37,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "claude-runtime-model-reconcile" }, + subscribe: {}, }); const modelsResult = await client.listProviderModels("claude", { cwd }); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 7691e63713..1f2086c879 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -1,4 +1,11 @@ import { describe, test, expect } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { findExecutable } from "../../executable-resolution/executable-resolution.js"; +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; import { execFileSync } from "node:child_process"; import { createDaemonTestContext } from "../test-utils/index.js"; @@ -96,3 +103,65 @@ describe("daemon E2E", () => { ); }); }); + +test("Antigravity creates an agent and answers a prompt with real agy", async ({ skip }) => { + const command = await findExecutable(process.env.AGY_COMMAND ?? "agy"); + if (command === null) { + skip("agy is not resolvable"); + return; + } + const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-antigravity-e2e-")); + const daemon = await createTestPaseoDaemon({ + agentClients: {}, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, ["antigravity-provider"]), + providerOverrides: { antigravity: { command: [command], paseoTools: { enabled: false } } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await expect + .poll( + async () => { + const snapshot = await client.getProvidersSnapshot({ cwd }); + return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status; + }, + { timeout: 30000 }, + ) + .toBe("ready"); + const agent = await client.createAgent({ + provider: "antigravity", + cwd, + title: "Antigravity E2E", + }); + expect(agent.currentModeId).toBe("full-access"); + expect(agent.availableModes?.map((mode) => mode.id)).toEqual(["full-access"]); + const openedTimeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect(openedTimeline.entries.map((entry) => entry.item)).toEqual([ + { + type: "notification", + level: "warning", + message: + "Antigravity is running with full access\nAntigravity's CLI cannot ask for permission when another app drives it, so Paseo starts it with --dangerously-skip-permissions. Every tool call, including shell commands, runs without asking.", + }, + ]); + await client.sendMessage(agent.id, "Reply with exactly ANTIGRAVITY_E2E_OK. No tools."); + const result = await client.waitForFinish(agent.id, 90000); + expect(result.status).toBe("idle"); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect( + timeline.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("ANTIGRAVITY_E2E_OK"), + ), + ).toBe(true); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 120000); diff --git a/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts new file mode 100644 index 0000000000..f8355065f4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { isCommandAvailable } from "../../executable-resolution/executable-resolution.js"; +import { loadPersistedConfig } from "../persisted-config.js"; +import { resolvePaseoHome } from "../paseo-home.js"; +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("Muse registers without a client settings bundle", async () => { + const daemon = await createTestPaseoDaemon({ + builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]), + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + const muse = (await client.getPluginCatalog()).find((plugin) => plugin.id === "muse-provider"); + expect(muse).toBeDefined(); + expect(muse?.clientBundle).toBe(""); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("Muse creates an agent, streams a text reply, and executes a real tool", async (context) => { + if (!(await isCommandAvailable("muse"))) { + context.skip(); + return; + } + const cwd = await mkdtemp(path.join(os.tmpdir(), "paseo-muse-e2e-")); + await writeFile(path.join(cwd, "f"), "MUSE_TOOL_E2E_OK\n"); + execFileSync("git", ["init", cwd], { stdio: "ignore" }); + const configured = loadPersistedConfig(resolvePaseoHome()).agents?.providers?.muse; + const daemon = await createTestPaseoDaemon({ + builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]), + providerOverrides: { muse: { ...configured, enabled: true } }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.10.0", + }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ provider: "muse", cwd, modeId: "allowAll" }); + await client.sendMessage(agent.id, "Reply exactly MUSE_TEXT_E2E_OK. Do not use tools."); + expect((await client.waitForFinish(agent.id, 120000)).status).toBe("idle"); + const reply = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + reply.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("MUSE_TEXT_E2E_OK"), + ), + ).toBe(true); + await client.sendMessage( + agent.id, + "Read the file f with the read_file tool and report its contents.", + ); + expect((await client.waitForFinish(agent.id, 120000)).status).toBe("idle"); + const tools = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + tools.entries.some( + (entry) => + entry.item.type === "tool_call" && + entry.item.name === "read_file" && + entry.item.status === "completed", + ), + ).toBe(true); + expect( + tools.entries.some( + (entry) => + entry.item.type === "assistant_message" && entry.item.text.includes("MUSE_TOOL_E2E_OK"), + ), + ).toBe(true); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 300000); diff --git a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts index a0634e2bd7..7149b16864 100644 --- a/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/omp.real.e2e.test.ts @@ -1,6 +1,5 @@ import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -11,6 +10,7 @@ import { afterEach, beforeAll, describe, expect, test } from "vitest"; import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; import { MIN_SUPPORTED_OMP_VERSION, + OmpAgentClient, formatOmpVersionSupport, } from "../agent/providers/omp/agent.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; @@ -49,14 +49,53 @@ async function preflight(): Promise { } } -async function createHarness(): Promise { +// OMP exits at startup when no model is usable at all, so the home offers one offline +// model, and an agent on any other provider's model has no credentials. +function createOfflineOmpHome(): string { + const home = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-offline-")); + roots.add(home); + const agentDir = path.join(home, ".omp", "agent"); + mkdirSync(agentDir, { recursive: true }); + writeFileSync( + path.join(agentDir, "models.yml"), + [ + "providers:", + " offline:", + " baseUrl: http://127.0.0.1:9/v1", + " api: openai-completions", + " auth: none", + " models:", + " - id: offline-1", + " name: Offline", + " api: openai-completions", + " reasoning: false", + " input: [text]", + " contextWindow: 128000", + " maxTokens: 4096", + " cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0}", + "", + ].join("\n"), + ); + return home; +} + +async function createHarness(options: { ompHome?: string } = {}): Promise { const cwd = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-")); const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-home-")); const staticDir = mkdtempSync(path.join(tmpdir(), "paseo-real-omp-static-")); for (const root of [cwd, paseoHomeRoot, staticDir]) roots.add(root); const logger = pino({ level: process.env.OMP_E2E_LOG_LEVEL ?? "silent" }); const daemon = await createTestPaseoDaemon({ - agentClients: createRealProviderClients(["omp"], logger), + agentClients: options.ompHome + ? { + omp: new OmpAgentClient({ + logger, + runtimeSettings: { + env: { HOME: options.ompHome, ANTHROPIC_API_KEY: "", ANTHROPIC_OAUTH_TOKEN: "" }, + }, + }), + } + : createRealProviderClients(["omp"], logger), providerOverrides: { omp: { enabled: true } }, logger, paseoHomeRoot, @@ -69,7 +108,7 @@ async function createHarness(): Promise { }); await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `omp-real-${randomUUID()}` }, + subscribe: {}, }); return { daemon, client, cwd, paseoHomeRoot, staticDir }; } @@ -92,6 +131,15 @@ async function timeline(client: DaemonClient, agentId: string): Promise entry.item); } +function compactionFiller(): string { + const teas = ["assam", "darjeeling", "sencha", "oolong"]; + return Array.from( + { length: 1200 }, + (_, index) => + `Record ${String(index).padStart(5, "0")}: tea=${teas[index % teas.length]} origin=region-${index % 37} notes=lorem ipsum dolor sit amet`, + ).join("\n"); +} + function latestTools(items: AgentTimelineItem[]) { const latest = new Map>(); for (const item of items) if (item.type === "tool_call") latest.set(item.callId, item); @@ -187,6 +235,29 @@ afterEach(() => { }); describe("daemon E2E (real OMP)", () => { + test( + "a prompt OMP rejects before its agent runs fails the turn with OMP's error", + async () => { + const harness = await createHarness({ ompHome: createOfflineOmpHome() }); + try { + const agent = await harness.client.createAgent({ + cwd: harness.cwd, + title: "no-credentials", + provider: "omp", + model: "anthropic/claude-haiku-4-5", + modeId: "full", + }); + await harness.client.sendMessage(agent.id, "Reply with ok."); + const finish = await harness.client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect(finish.error).toContain("No API key found for anthropic"); + } finally { + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "prompt, native tool, and resumed follow-up", async () => { @@ -448,6 +519,56 @@ describe("daemon E2E (real OMP)", () => { TIMEOUT_MS, ); + test( + "a compaction shows as a compaction row after a daemon restart", + async () => { + const harness = await createHarness(); + let restarted: TestPaseoDaemon | null = null; + let restartedClient: DaemonClient | null = null; + try { + const agent = await createAgent(harness, "compact-restart"); + await promptAndFinish( + harness, + agent.id, + `${compactionFiller()}\n\nReply exactly OMP_COMPACT_FIRST`, + ); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_SECOND"); + await harness.client.sendMessage(agent.id, "/compact"); + await harness.client.waitForFinish(agent.id, TIMEOUT_MS); + await promptAndFinish(harness, agent.id, "Reply exactly OMP_COMPACT_THIRD"); + + await harness.client.close(); + await harness.daemon.close(); + restarted = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["omp"], pino({ level: "silent" })), + providerOverrides: { omp: { enabled: true } }, + logger: pino({ level: "silent" }), + paseoHomeRoot: harness.paseoHomeRoot, + staticDir: harness.staticDir, + cleanup: false, + }); + restartedClient = new DaemonClient({ url: `ws://127.0.0.1:${restarted.port}/ws` }); + await restartedClient.connect(); + await restartedClient.fetchAgents({ subscribe: {} }); + const replayed = await timeline(restartedClient, agent.id); + + expect( + latestTools(replayed) + .map(toolResult) + .filter((text) => text.includes("Unsupported history record")), + ).toEqual([]); + expect(replayed.filter((item) => item.type === "compaction")).toEqual([ + expect.objectContaining({ type: "compaction", status: "completed" }), + ]); + } finally { + await restartedClient?.close().catch(() => undefined); + await restarted?.close().catch(() => undefined); + await closeHarness(harness); + } + }, + TIMEOUT_MS, + ); + test( "native branch rewinds conversation history and keeps the session usable", async () => { diff --git a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts index ebd93a4ff5..8989f9ebac 100644 --- a/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-missing-directory.e2e.test.ts @@ -48,7 +48,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } }); + await client.fetchAgents({ subscribe: {} }); const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = []; client.subscribe((event: DaemonEvent) => { @@ -57,7 +57,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i } workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId }); }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } }); + await client.fetchWorkspaces({ subscribe: {} }); const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-")); cleanupPaths.add(tempParent); @@ -77,8 +77,8 @@ test("openProject expands tilde before creating the workspace", async () => { const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } }); - await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } }); + await client.fetchAgents({ subscribe: {} }); + await client.fetchWorkspaces({ subscribe: {} }); const home = process.env.HOME || os.homedir(); const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-")); diff --git a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts index ab49ecf9d3..e463d2936a 100644 --- a/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/open-project-worktree-reclassification.e2e.test.ts @@ -106,7 +106,7 @@ test("openProject preserves a worktree's exact-root project without rehoming it" const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "worktree-reclassification" } }); + await client.fetchAgents({ subscribe: {} }); const response = await client.openProject(worktreeRoot); const persistedProjects = await readRegistry(projectsPath); diff --git a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts index 048a8dd0c6..93e227ecdd 100644 --- a/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-custom-agents.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts index d66ca028eb..a06880d612 100644 --- a/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-draft-features.real.e2e.test.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "node:crypto"; import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -30,7 +29,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-draft-features-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts index 3d1aec0a96..d518dac227 100644 --- a/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-import-persistence.real.e2e.test.ts @@ -36,7 +36,7 @@ async function withConnectedOpenCodeDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `opencode-import-persistence-${randomUUID()}` }, + subscribe: {}, }); await run({ client }); } finally { diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts index 9fa2f13cf6..e03c66bca0 100644 --- a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -29,7 +29,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts index 8235937f64..0291c5e338 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-mode.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-mode" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts index 455d099088..8cdc409c7f 100644 --- a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -23,7 +23,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts index 923431f9bf..70296864ed 100644 --- a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts +++ b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts @@ -187,7 +187,7 @@ export async function createOpenCodeOmoRealRuntime(): Promise { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); diff --git a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts index bdf5012f67..6fafc0f40c 100644 --- a/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts @@ -256,7 +256,7 @@ async function createHarness(): Promise<{ }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-send-interrupt-real" } }); + await client.fetchAgents({ subscribe: {} }); return { client, daemon }; } diff --git a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts index 3645b018f1..ebb603e0ee 100644 --- a/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-slow-plugin-readiness.real.e2e.test.ts @@ -66,7 +66,7 @@ describe("daemon E2E (real opencode) - slow plugin readiness", () => { }); const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "opencode-slow-plugin" } }); + await client.fetchAgents({ subscribe: {} }); try { const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index a3a84a17ea..16ba0da1f5 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -42,8 +42,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); @@ -81,17 +82,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received const queue = messages; - const hasPermissionResolved = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "allow" - ); - } - return false; - }); - expect(hasPermissionResolved).toBe(true); + const hasPermissionResolved = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "allow" + ); + } + return false; + }); + await expect.poll(hasPermissionResolved).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -107,8 +109,9 @@ describe("permission flow: Codex", () => { thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Deny Test", - modeId: "read-only", + modeId: "always-ask", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); @@ -145,17 +148,18 @@ describe("permission flow: Codex", () => { // Verify permission_resolved event was received with deny const queue = messages; - const hasPermissionDenied = queue.some((m) => { - if (m.type === "agent_stream" && m.payload.agentId === agent.id) { - return ( - m.payload.event.type === "permission_resolved" && - m.payload.event.requestId === permission.id && - m.payload.event.resolution.behavior === "deny" - ); - } - return false; - }); - expect(hasPermissionDenied).toBe(true); + const hasPermissionDenied = () => + queue.some((m) => { + if (m.type === "agent_stream" && m.payload.agentId === agent.id) { + return ( + m.payload.event.type === "permission_resolved" && + m.payload.event.requestId === permission.id && + m.payload.event.resolution.behavior === "deny" + ); + } + return false; + }); + await expect.poll(hasPermissionDenied).toBe(true); rmSync(cwd, { recursive: true, force: true }); }, 30_000); @@ -172,6 +176,7 @@ describe("permission flow: Codex", () => { title: "Codex Interrupt Test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; expect(agent.id).toBeTruthy(); expect(agent.currentModeId).toBe("full-access"); diff --git a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts index 9e9e185895..78161f842a 100644 --- a/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/persistence.e2e.test.ts @@ -61,6 +61,7 @@ describe("daemon E2E - persistence", () => { const resumed = await ctx.client.resumeAgent(handle); expect(resumed.provider).toBe("codex"); expect(resumed.cwd).toBe(cwd); + await ctx.client.subscribeAgentTimeline(resumed.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(resumed.id, "What did I ask you to say earlier?"); diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts index e013328a12..d198be9e09 100644 --- a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -1,4 +1,7 @@ +import { mkdtempSync, rmSync } from "node:fs"; import { readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import pino from "pino"; import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; @@ -63,14 +66,31 @@ function expectPiSessionId(value: string): void { expect(value.length).toBeGreaterThan(0); } -function piPrompt(input: { promptToken: string; doneToken: string }): string { +interface PiTurn { + promptToken: string; + doneToken: string; + filler?: string; +} + +function piPrompt(input: PiTurn): string { return [ `PASEO_PI_REWIND_PROMPT_${input.promptToken}.`, "Remember this marker for the conversation.", + ...(input.filler ? [`Ignore this filler: ${input.filler}`] : []), `Reply exactly: ${input.doneToken}`, ].join(" "); } +function turn(token: string): PiTurn { + return { promptToken: token, doneToken: `PI_${token}_DONE` }; +} + +// Pi keeps the most recent 20k estimated tokens (4 characters each) when it compacts. +// A turn larger than that makes /compact summarize every turn before it. +function turnOutlastingCompaction(token: string): PiTurn { + return { ...turn(token), filler: "lorem ipsum ".repeat(8_000) }; +} + function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") { return items.filter((item) => item.type === role); } @@ -91,10 +111,29 @@ async function expectNoCreatedFiles(session: PiRewindSession): Promise { await expect(readdir(session.cwd)).resolves.toEqual([]); } +async function compactPi(harness: PiRewindHarness, session: PiRewindSession): Promise { + await harness.client.sendMessage(session.agentId, "/compact"); + const deadline = Date.now() + TURN_TIMEOUT_MS; + while (Date.now() < deadline) { + const items = await fetchTimelineItems(harness.client, session.agentId); + const failure = items.find( + (item) => item.type === "assistant_message" && item.text.includes("Failed to compact"), + ); + if (failure) { + throw new Error(`Pi compaction failed: ${JSON.stringify(failure)}`); + } + if (items.some((item) => item.type === "compaction" && item.status === "completed")) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("Pi compaction did not complete"); +} + async function askPi( harness: PiRewindHarness, session: PiRewindSession, - input: { promptToken: string; doneToken: string }, + input: PiTurn, ): Promise { await harness.client.sendMessage(session.agentId, piPrompt(input)); const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS); @@ -122,7 +161,7 @@ describe("daemon E2E (real pi) - rewind", () => { }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "pi-rewind-real" } }); + await client.fetchAgents({ subscribe: {} }); harness = { client, daemon }; }, 30_000); @@ -204,4 +243,126 @@ describe("daemon E2E (real pi) - rewind", () => { await closePiRewindSession(session); } }, 420_000); + + test("rewinds a replayed row to the selected message after an earlier rewind branched the session", async () => { + const session = await launchPiRewindSession(harness, "pi-rewind-branched-replay-real"); + + try { + await askPi(harness, session, turn("ONE")); + await askPi(harness, session, turn("ABANDONED")); + const abandonedId = userMessageIdForToken( + await fetchTimelineItems(harness.client, session.agentId), + "PI_REWIND_PROMPT_ABANDONED", + ); + await harness.client.rewindAgent(session.agentId, abandonedId, "conversation"); + + await askPi(harness, session, turn("TWO")); + await askPi(harness, session, turn("THREE")); + await askPi(harness, session, turn("FOUR")); + const fourId = userMessageIdForToken( + await fetchTimelineItems(harness.client, session.agentId), + "PI_REWIND_PROMPT_FOUR", + ); + await harness.client.rewindAgent(session.agentId, fourId, "conversation"); + + const replayedTimeline = await fetchTimelineItems(harness.client, session.agentId); + expectTimeline(replayedTimeline, { + userTexts: [piPrompt(turn("ONE")), piPrompt(turn("TWO")), piPrompt(turn("THREE"))], + assistantCount: 3, + }); + const replayedThreeId = userMessageIdForToken(replayedTimeline, "PI_REWIND_PROMPT_THREE"); + await harness.client.rewindAgent(session.agentId, replayedThreeId, "conversation"); + + expectTimeline(await fetchTimelineItems(harness.client, session.agentId), { + userTexts: [piPrompt(turn("ONE")), piPrompt(turn("TWO"))], + assistantCount: 2, + }); + await expectNoCreatedFiles(session); + } finally { + await closePiRewindSession(session); + } + }, 900_000); + + test("rewinds a row whose entry an earlier compaction summarized away", async () => { + const session = await launchPiRewindSession(harness, "pi-rewind-compacted-real"); + + try { + await askPi(harness, session, turn("ONE")); + await askPi(harness, session, turn("TWO")); + await askPi(harness, session, turnOutlastingCompaction("THREE")); + const twoId = userMessageIdForToken( + await fetchTimelineItems(harness.client, session.agentId), + "PI_REWIND_PROMPT_TWO", + ); + await compactPi(harness, session); + + await harness.client.rewindAgent(session.agentId, twoId, "conversation"); + + expectTimeline(await fetchTimelineItems(harness.client, session.agentId), { + userTexts: [piPrompt(turn("ONE"))], + assistantCount: 1, + }); + await expectNoCreatedFiles(session); + } finally { + await closePiRewindSession(session); + } + }, 900_000); + + test("keeps a rewind after the daemon restarts", async () => { + const cwd = tmpRewindCwd("daemon-real-pi-rewind-"); + const paseoHomeRoot = mkdtempSync(path.join(tmpdir(), "daemon-real-pi-rewind-home-")); + const logger = pino({ level: "silent" }); + const startDaemon = async (): Promise => { + const daemon = await createTestPaseoDaemon({ + agentClients: createRealProviderClients(["pi"], logger), + logger, + paseoHomeRoot, + cleanup: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return { client, daemon }; + }; + const stopDaemon = async (restarted: PiRewindHarness): Promise => { + await restarted.client.close().catch(() => undefined); + await restarted.daemon.close().catch(() => undefined); + }; + let current = await startDaemon(); + + try { + const agent = await current.client.createAgent({ + cwd, + title: "pi-rewind-restart-real", + provider: "pi", + model: PI_REAL_TEST_MODEL, + thinkingOptionId: "medium", + }); + const session = { agentId: agent.id, cwd }; + for (const token of ["ONE", "TWO", "THREE", "FOUR"]) { + await askPi(current, session, turn(token)); + } + const threeId = userMessageIdForToken( + await fetchTimelineItems(current.client, agent.id), + "PI_REWIND_PROMPT_THREE", + ); + await current.client.rewindAgent(agent.id, threeId, "conversation"); + const rewound = { + userTexts: [piPrompt(turn("ONE")), piPrompt(turn("TWO"))], + assistantCount: 2, + }; + expectTimeline(await fetchTimelineItems(current.client, agent.id), rewound); + + await stopDaemon(current); + current = await startDaemon(); + + expectTimeline(await fetchTimelineItems(current.client, agent.id), rewound); + } finally { + await stopDaemon(current); + rmSync(paseoHomeRoot, { recursive: true, force: true }); + rmSync(cwd, { recursive: true, force: true }); + } + }, 900_000); }); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index 5a0510c1ea..984aa2fdcc 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -1,7 +1,6 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { randomUUID } from "node:crypto"; import { beforeAll, beforeEach, expect, test } from "vitest"; import pino from "pino"; @@ -118,7 +117,7 @@ async function withConnectedPiDaemon( try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `pi-real-${randomUUID()}` }, + subscribe: {}, }); await run({ client, daemon }); } finally { diff --git a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts index e7843f78ae..a4db7ce7bf 100644 --- a/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/project-becomes-git.e2e.test.ts @@ -62,7 +62,7 @@ test("an empty project becomes Git without changing its identity or creating a w const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); cleanupClients.add(client); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "project-becomes-git" } }); + await client.fetchAgents({ subscribe: {} }); const added = await client.addProject(projectRoot); diff --git a/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts new file mode 100644 index 0000000000..89385c67db --- /dev/null +++ b/packages/server/src/server/daemon-e2e/project-path-spaces.e2e.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, expect, onTestFinished, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +// Windows does not preserve trailing spaces in folder names. +const testOnPosix = test.skipIf(process.platform === "win32"); + +let client: DaemonClient; +let repoDir: string; +let projectId: string; + +beforeEach(async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-"))); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + repoDir = join(root, "repo "); + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync( + "git", + [ + "-c", + "user.name=Paseo Test", + "-c", + "user.email=test@getpaseo.local", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd: repoDir, stdio: "pipe" }, + ); + const daemon = await createTestPaseoDaemon(); + onTestFinished(() => daemon.close()); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const testClient = client; + onTestFinished(() => testClient.close()); + await client.connect(); + const added = await client.addProject(repoDir); + expect(added.error).toBeNull(); + expect(added.project?.projectRootPath).toBe(repoDir); + projectId = added.project!.projectId; +}); + +testOnPosix("a project folder ending in a space keeps its Git status", async () => { + const opened = await client.createWorkspace({ + source: { kind: "directory", path: repoDir, projectId }, + }); + expect(opened.error).toBeNull(); + expect(opened.workspace?.workspaceDirectory).toBe(repoDir); + const status = await client.getCheckoutStatus(repoDir); + expect(status.isGit).toBe(true); + expect(status.repoRoot).toBe(repoDir); + expect(status.currentBranch).toBe("main"); +}); + +testOnPosix("a project folder ending in a space can create a worktree", async () => { + const created = await client.createWorkspace({ + source: { + kind: "worktree", + projectId, + action: "branch-off", + branchName: "feature/spaces", + baseBranch: "main", + }, + }); + expect(created.error).toBeNull(); + expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces"); +}); diff --git a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts index aaea430b7f..bafb61e2b4 100644 --- a/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/rewind-user-message-dedupe-claude.real.e2e.test.ts @@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => { try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: "rewind-user-message-dedupe" }, + subscribe: {}, }); const agent = await client.createAgent({ diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts index 6638c425a0..5eea6dab24 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-claude.real.e2e.test.ts @@ -304,6 +304,166 @@ async function waitForRunningClaudeSleep( ); } +// Explicit point-in-time polls of agent status, on top of (not instead of) the pushed +// agent_update events the collector already records. A phantom "running" flip that starts and +// ends between two polls would still be caught by the pushed-event scan the callers pair this +// with; polling here additionally proves the daemon answers fetchAgent with "idle" throughout, +// not just that it eventually pushes one. +async function sampleAgentStatuses( + client: DaemonClient, + agentId: string, + samples: number, + intervalMs: number, +): Promise { + const statuses: string[] = []; + for (let i = 0; i < samples; i += 1) { + const snapshot = await client.fetchAgent({ agentId }); + statuses.push(snapshot?.agent.status ?? "missing"); + if (i < samples - 1) { + await sleep(intervalMs); + } + } + return statuses; +} + +// NOTE: agent_stream events on the wire (AgentStreamEventPayloadSchema in +// packages/protocol/src/messages.ts) do not include a "provider_subagent" variant. A backgrounded +// subagent's descriptor updates are forwarded exclusively as separate "agent.provider_subagents.update" +// messages (see forwardProviderSubagentUpdate in session.ts), and that push channel's delivery is +// gated by capabilities this test's plain DaemonClient does not declare. list_provider_subagents is a +// plain request/response RPC unaffected by that gating, so status history below polls it directly +// instead of trying to reconstruct status transitions from the stream. +type ProviderSubagentDescriptor = Awaited< + ReturnType +>["subagents"][number]; + +interface ProviderSubagentPoll { + subagent: ProviderSubagentDescriptor; + // Every status observed while polling, in order, so a transient cancel cannot hide behind a + // later "completed" read even though the store's status field is not expected to un-cancel. + history: string[]; +} + +// Polls agent.provider_subagents.list until the backgrounded helper is registered at all. +// "Main's turn is running" (waitForAgentUpsert) fires on generic turn-start bookkeeping, which +// can be true before Claude has actually issued the Agent tool call; canceling that early +// cancels a turn with no helper launched yet, which is not what "Stop spares a helper" means to +// test. This confirms the helper actually exists before the caller acts on it. +async function waitForProviderSubagentToExist( + client: DaemonClient, + agentId: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + if (subagents[0]) { + return subagents[0]; + } + await sleep(300); + } + throw new Error( + `Timed out waiting for a background helper to be registered for agent ${agentId}`, + ); +} + +// Claude starts the wake-up turn a moment after the helper's completion lands (0.1 to 5 s in +// recorded traces), so poll for it instead of checking the instant the helper reads completed. +// The daemon announces an autonomous turn to subscribers as a pushed "running" agent_update, +// not as an agent_stream turn_started (that stream reaches the client that started a run). +async function waitForWakeTurn( + collector: ReturnType, + agentId: string, + fromIndex: number, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const window = collector.messages.slice(fromIndex); + const woke = + getAgentStatuses(window, agentId).includes("running") || + window.some( + (message) => + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "turn_started", + ); + if (woke) return true; + await sleep(500); + } + return false; +} + +// The wake-up turn runs briefly after it opens, so the final state is read once it settles. +async function waitForAgentStatus( + client: DaemonClient, + agentId: string, + status: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: string | undefined; + while (Date.now() < deadline) { + last = (await client.fetchAgent({ agentId }))?.agent.status; + if (last === status) return last; + await sleep(500); + } + return last; +} + +// Polls agent.provider_subagents.list until the single backgrounded helper leaves "running". +// Scenarios in this file always launch exactly one background helper, so subagents[0] is it. +async function waitForProviderSubagentTerminal( + client: DaemonClient, + agentId: string, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + const history: string[] = []; + let lastSeen: ProviderSubagentDescriptor[] = []; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(agentId); + lastSeen = subagents; + const subagent = subagents[0]; + if (subagent) { + history.push(subagent.status); + if (subagent.status !== "running") { + return { subagent, history }; + } + } + await sleep(1_000); + } + throw new Error( + `Timed out waiting for the background helper to leave "running". history=${JSON.stringify(history)} lastSeen=${JSON.stringify(lastSeen)}`, + ); +} + +// Pulls the main agent's own assistant text via fetchAgentTimeline rather than the push +// collector. Assistant text delivery is paced (docs/agent-stream-performance.md), so checking +// the collector immediately after waitForFinish resolves can still miss the final text; the +// timeline fetch is authoritative and matches what the pre-existing tests in this file do. +async function fetchAssistantTexts( + client: DaemonClient, + agentId: string, + limit = 100, +): Promise { + const timeline = await client.fetchAgentTimeline(agentId, { limit }); + return timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => (entry.item as Extract).text); +} + +async function fetchSubagentAssistantTexts( + client: DaemonClient, + agentId: string, + subagentId: string, +): Promise { + const timeline = await client.fetchProviderSubagentTimeline(agentId, subagentId, { limit: 50 }); + return timeline.rows + .filter((row) => row.item.type === "assistant_message") + .map((row) => (row.item as Extract).text); +} + describe("daemon E2E (real claude) - send message during tool call", () => { let canRun = false; interface SteeringResources { @@ -345,7 +505,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe Claude steering test client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create Claude steering test agent", @@ -504,6 +664,141 @@ describe("daemon E2E (real claude) - send message during tool call", () => { } }, 210_000); + test("a background subagent keeps running after a message is sent while it works", async () => { + // The scenario the rest of this file explicitly rules out: every other test here tells Claude + // "Do not use a background task", so steering during a backgrounded subagent was never + // covered. A subagent streams on the parent's channel, so a parent that mistakes the child's + // frames for its own work reports "running" after it has already finished, and the message + // sent into that window is refused, or delivered by cancelling the turn and taking the + // subagent with it. Both outcomes are user-visible, and both are what this asserts against. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Claude background-subagent test client was not created"); + await within("connect background-subagent test client", 15_000, client.connect()); + await within( + "subscribe background-subagent test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create background-subagent test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-background-survives-send", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + await within( + "launch one background subagent and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly: sleep 40.`, + `Wait for its completion notification, then reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // The main session has ended its turn. It must read as idle even though the subagent it + // launched is still streaming on the shared channel. + const afterLaunch = await within( + "wait for the main session to go idle after launching the subagent", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect( + afterLaunch.status, + "a main session that finished its turn must not report busy because a subagent is running", + ).not.toBe("running"); + + // And a message sent in that window must be accepted rather than refused. + const followUpId = generateClientMessageId(); + await within( + "send a message to the idle main session while the subagent runs", + 30_000, + client.sendAgentMessage(agent.id, "Are you there? Reply exactly: MAIN_ALIVE.", { + messageId: followUpId, + }), + ); + await within( + "wait for the main session to answer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + + const timeline = await within( + "fetch background-subagent timeline", + 15_000, + client.fetchAgentTimeline(agent.id, { limit: 200 }), + ); + const assistantText = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map( + (entry) => (entry.item as Extract).text, + ) + .join("\\n"); + expect( + assistantText, + "the main session must be reachable while its background subagent still runs", + ).toContain("MAIN_ALIVE"); + + // The subagent must have survived the send rather than being cancelled with the turn. Its + // status is read from agent.provider_subagents.list (see the note above + // waitForProviderSubagentToExist): agent_stream carries no provider_subagent events. + const { subagents } = await within( + "list provider subagents after the send", + 15_000, + client.listProviderSubagents(agent.id), + ); + expect(subagents.length, "the background subagent must be tracked").toBeGreaterThan(0); + expect( + subagents.map((subagent) => subagent.status), + "a backgrounded subagent must not be canceled by a message sent to the main session", + ).not.toContain("canceled"); + const { subagent, history } = await within( + "wait for the background subagent to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect(history, "no status poll may read canceled").not.toContain("canceled"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Claude background-subagent E2E cleanup failures").toEqual([]); + } + }, 300_000); + test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => { const logger = pino({ level: "silent" }); const cwd = tmpCwd(); @@ -518,7 +813,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { await within( "subscribe queued-steer Stop client", 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "queued-steer-stop" } }), + client.fetchAgents({ subscribe: {} }), ); const agent = await within( "create queued-steer Stop agent", @@ -587,7 +882,7 @@ describe("daemon E2E (real claude) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -678,4 +973,430 @@ describe("daemon E2E (real claude) - send message during tool call", () => { rmSync(cwd, { recursive: true, force: true }); } }, 300_000); + + test("a helper's own nested background job does not phantom-run the parent or kill the helper", async () => { + // The exact reported bug: main backgrounds a helper, the helper itself starts its OWN + // background job (a grandchild from main's perspective) and then keeps working in the + // foreground. The grandchild's completion is bookkeeping that belongs to the helper, not to + // main, and must not make main phantom-report "running" or get treated as busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("nested background-job test client was not created"); + await within("connect nested background-job test client", 15_000, client.connect()); + await within( + "subscribe nested background-job test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create nested background-job test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-nested-background-job", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch a helper with its own nested background job and end the turn", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "nested bg survivor".`, + `prompt: "Use the Bash tool with run_in_background: true to run exactly:`, + `python3 -c 'import time; time.sleep(5)'.`, + `Immediately after starting it, without waiting for it, use the Bash tool again`, + `in the foreground to run exactly: python3 -c 'import time; time.sleep(50)'.`, + `After that foreground command finishes, reply exactly: CHILD_DONE."`, + "Immediately after launching it, reply with the single word LAUNCHED and end your turn.", + "Do not wait for it. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + const afterLaunch = await within( + "wait for the main session to go idle after launching the nested-background helper", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(afterLaunch.status, "the main session must be idle once it replied LAUNCHED").toBe( + "idle", + ); + + // The helper's own nested background job (5s) finishes while the helper is still busy + // in its unrelated foreground sleep (50s). Sample for 30s, well past the nested job's + // completion even when the helper is slow to start it, + // both by polling and by scanning every pushed agent_update in the window, so a brief + // phantom flip cannot slip between polls. + const sampleWindowStart = resources.collector.messages.length; + const polledStatuses = await sampleAgentStatuses(client, agent.id, 11, 3_000); + const pushedStatuses = getAgentStatuses( + resources.collector.messages.slice(sampleWindowStart), + agent.id, + ); + const observedRunning = [...polledStatuses, ...pushedStatuses].filter( + (status) => status === "running", + ); + expect( + observedRunning, + `the parent must stay idle across the nested job's completion; polled=${JSON.stringify(polledStatuses)} pushed=${JSON.stringify(pushedStatuses)}`, + ).toEqual([]); + + // A message sent in that window must still be answered normally. + await within( + "send PONG request while the nested job settles", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + }), + ); + await within("wait for the PONG reply", 60_000, client.waitForFinish(agent.id, 50_000)); + // Pulled via fetchAgentTimeline, not the push collector: assistant text delivery is paced + // (docs/agent-stream-performance.md) and can trail waitForFinish's own completion signal, + // so the collector can still be missing the final text the instant waitForFinish resolves. + const pongTexts = await within( + "fetch timeline for the PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect( + pongTexts.join("\n"), + "the agent must answer PONG while the helper is still running", + ).toContain("PONG"); + + const messagesBeforeWake = resources.collector.messages.length; + + // The helper itself must survive: never recorded canceled, and it eventually reports + // CHILD_DONE from its own foreground sleep finishing. + const { subagent, history } = await within( + "wait for the nested-background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + // The helper's completion should wake the main session with a fresh turn (the + // session-state-driven autonomous turn), separate from the PONG exchange above. + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeWake, + ); + if (!wokeMainAgent) { + const wakeWindowMessages = resources.collector.messages.slice(messagesBeforeWake); + const wakeWindowEventTypes = wakeWindowMessages + .filter( + (message) => message.type === "agent_stream" && message.payload.agentId === agent.id, + ) + .map((message) => + message.type === "agent_stream" ? message.payload.event.type : "unknown", + ); + const timeline = await client + .fetchAgentTimeline(agent.id, { limit: 100 }) + .catch(() => null); + throw new Error( + `the helper's completion must wake the main session with a new turn. ` + + `wakeWindowEventTypes=${JSON.stringify(wakeWindowEventTypes)} ` + + `recentTimeline=${JSON.stringify(timeline ? summarizeTimelineItems(timeline) : null)}`, + ); + } + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "nested background-job E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("the app's default steer survives a backgrounded helper while the main session works", async () => { + // appSettings.sendBehavior defaults to "steer" (packages/app/src/hooks/use-settings/storage.ts), + // so this is what the app actually sends when a user replies while an agent looks busy. + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("default-steer background-helper test client was not created"); + await within("connect default-steer test client", 15_000, client.connect()); + await within( + "subscribe default-steer test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create default-steer test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-default-steer-background-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor b".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(42)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(16)'.", + // Matching the proven pattern from the first test in this file: tell Claude to wait + // for a later message rather than relying on the sleep call itself to block, since a + // Bash call can resolve through Claude Code's own async task_notification path once a + // background subagent is already active instead of blocking the turn. + "Do not reply or finish your turn until a later user message arrives, no matter how", + "quickly that Bash command finishes. When that later message arrives, address it and", + "end your turn. Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the daemon considers main's own turn active before steering it. This does not + // wait for a specific "sleep 16" shell call to show as running: Claude Code 2.1.280 can + // report a slower Bash call through its own async task_notification tool instead of a + // classic blocking call once a background subagent is already active, so pinning to a + // literal running shell tool_call is not reliable here. Busy at the daemon level is what + // "while main is busy with its own sleep" actually needs. + await within( + "confirm main's own turn is active before steering", + 30_000, + client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 25_000), + ); + // The running status precedes the Agent tool call, so a steer sent on it alone can reach + // Claude before the helper exists and prove nothing about a helper surviving it. + await within( + "confirm the background helper is registered before steering", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + await within( + "send the app's default steer while main is busy with its own sleep", + 30_000, + client.sendMessage(agent.id, "Reply with exactly PONG.", { + messageId: generateClientMessageId(), + activeTurnBehavior: "steer", + }), + ); + + const finish = await within( + "wait for the main session to finish after the steer", + 120_000, + client.waitForFinish(agent.id, 110_000), + ); + expect(finish.status, "the main session must end idle after the steer").toBe("idle"); + // Pulled via fetchAgentTimeline, not the push collector: see fetchAssistantTexts. + const postSteerTexts = await within( + "fetch timeline for the post-steer PONG reply", + 15_000, + fetchAssistantTexts(client, agent.id, 20), + ); + expect(postSteerTexts.join("\n"), "PONG must be answered after the steer").toContain("PONG"); + + const { subagent, history } = await within( + "wait for the background helper to finish", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "the helper must complete, not be canceled or fail").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "default-steer background-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); + + test("Stop cancels the main session's own turn but spares its background helper", async () => { + const logger = pino({ level: "silent" }); + const resources: SteeringResources = { + cwd: tmpCwd(), + daemon: null, + client: null, + collector: null, + }; + try { + resources.daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + resources.client = new DaemonClient({ url: `ws://127.0.0.1:${resources.daemon.port}/ws` }); + const { client, cwd } = resources; + if (!client) throw new Error("Stop-spares-helper test client was not created"); + await within("connect Stop-spares-helper test client", 15_000, client.connect()); + await within( + "subscribe Stop-spares-helper test client", + 15_000, + client.fetchAgents({ subscribe: {} }), + ); + const agent = await within( + "create Stop-spares-helper test agent", + 30_000, + client.createAgent({ + cwd: cwd ?? process.cwd(), + title: "claude-stop-spares-helper", + ...getRealProviderConfig("claude"), + }), + ); + resources.collector = createMessageCollector(client); + + await within( + "launch the background helper and start the main session's own foreground sleep", + 30_000, + client.sendAgentMessage( + agent.id, + [ + "Use the Agent tool exactly once with run_in_background: true.", + "subagent_type: general-purpose.", + `description: "bg survivor c".`, + `prompt: "Use the Bash tool in the foreground to run exactly:`, + `python3 -c 'import time; time.sleep(43)'. After it finishes, reply exactly: CHILD_DONE."`, + "Do not wait for that background helper to finish before continuing.", + "After launching it, use the Bash tool one more time yourself, WITHOUT run_in_background,", + "to run exactly: python3 -c 'import time; time.sleep(23)'.", + "Do not call any other tools.", + ].join(" "), + { messageId: generateClientMessageId() }, + ), + ); + + // Confirm the helper has actually been launched (registered) before Stop, not just that + // main's turn shows "running": that status can flip true on generic turn-start bookkeeping + // before Claude has issued the Agent tool call, and canceling that early cancels a turn + // with no helper to spare yet. + await within( + "confirm the background helper is registered before Stop", + 60_000, + waitForProviderSubagentToExist(client, agent.id, 55_000), + ); + + const messagesBeforeStop = resources.collector.messages.length; + await within("Stop the main session's own turn", 30_000, client.cancelAgent(agent.id)); + const afterStop = await within( + "wait for the agent to go idle after Stop", + 60_000, + client.waitForFinish(agent.id, 50_000), + ); + expect(afterStop.status, "Stop must leave the agent idle").toBe("idle"); + + // The helper must survive Stop: it keeps running and later reports CHILD_DONE, which + // wakes the main session with a fresh autonomous turn. + const { subagent, history } = await within( + "wait for the background helper to finish after Stop", + 90_000, + waitForProviderSubagentTerminal(client, agent.id, 80_000), + ); + expect(subagent.status, "Stop must not cancel or fail the background helper").toBe( + "completed", + ); + expect( + history, + `no provider subagent status poll may ever read canceled; history=${JSON.stringify(history)}`, + ).not.toContain("canceled"); + const subagentTexts = await fetchSubagentAssistantTexts(client, agent.id, subagent.id); + expect( + subagentTexts.join("\n"), + "the helper's own transcript must contain its final CHILD_DONE reply", + ).toContain("CHILD_DONE"); + + const wokeMainAgent = await waitForWakeTurn( + resources.collector, + agent.id, + messagesBeforeStop, + ); + expect( + wokeMainAgent, + "the helper's completion after Stop must wake the main session with a new turn", + ).toBe(true); + + expect(await waitForAgentStatus(client, agent.id, "idle"), "the agent must end idle").toBe( + "idle", + ); + } finally { + const cleanup = await Promise.allSettled([ + Promise.resolve(resources.collector?.unsubscribe()), + resources.client?.close() ?? Promise.resolve(), + resources.daemon?.close() ?? Promise.resolve(), + ]); + if (resources.cwd) rmSync(resources.cwd, { recursive: true, force: true }); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + expect(failures, "Stop-spares-helper E2E cleanup failures").toEqual([]); + } + }, 300_000); }); diff --git a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts index 73ccc01988..39cc2554cc 100644 --- a/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-during-tool-call-codex.real.e2e.test.ts @@ -254,11 +254,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { const { client, cwd } = resources; if (!client) throw new Error("Codex steering test client was not created"); await within("connect steering test client", 15_000, client.connect()); - await within( - "subscribe steering test client", - 15_000, - client.fetchAgents({ subscribe: { subscriptionId: "steer" } }), - ); + await within("subscribe steering test client", 15_000, client.fetchAgents({ subscribe: {} })); const agent = await within( "create Codex steering test agent", 30_000, @@ -399,7 +395,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, @@ -478,7 +474,7 @@ describe("daemon E2E (real codex) - send message during tool call", () => { try { await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "primary" } }); + await client.fetchAgents({ subscribe: {} }); const agent = await client.createAgent({ cwd, 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 index f9f6eb6aff..310b7b5cd4 100644 --- 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 @@ -44,8 +44,8 @@ describe("daemon E2E (real claude) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -104,7 +104,7 @@ describe("daemon E2E (real claude) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, 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 index aa75a77ee2..4f179998ab 100644 --- 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 @@ -44,8 +44,8 @@ describe("daemon E2E (real codex) - send while running recovery", () => { try { await primary.connect(); await secondary.connect(); - await primary.fetchAgents({ subscribe: { subscriptionId: "primary" } }); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await primary.fetchAgents({ subscribe: {} }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await primary.createAgent({ cwd, @@ -101,7 +101,7 @@ describe("daemon E2E (real codex) - send while running recovery", () => { }); const initial = await reconnected.fetchAgents({ - subscribe: { subscriptionId: "reconnected" }, + subscribe: {}, }); const hydratedSnapshot = initial.entries.find( (candidate) => candidate.agent.id === agent.id, diff --git a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts index df1a522018..3ef308701d 100644 --- a/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/streaming.e2e.test.ts @@ -51,6 +51,7 @@ describe("daemon E2E - streaming", () => { title: "Streaming concat test", modeId: "bypassPermissions", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage( @@ -78,6 +79,7 @@ describe("daemon E2E - streaming", () => { title: "Overlap stream test", modeId: "full-access", }); + await ctx.client.subscribeAgentTimeline(agent.id, () => {}).ready; messages.length = 0; await ctx.client.sendMessage(agent.id, "Run: sleep 30"); diff --git a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts index f21f5f9b36..2b789788ad 100644 --- a/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal-byte-headless-parity.e2e.test.ts @@ -8,7 +8,7 @@ import { expect, test } from "vitest"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; -import type { TerminalStreamEvent } from "@getpaseo/client/internal/terminal-stream-router"; +import type { TerminalStreamEvent } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -48,7 +48,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac }); const initialSnapshot = waitForTerminalStreamEvent(client, terminal.id, "snapshot"); - await subscribeTerminal(client, terminal.id); + const liveSubscription = await subscribeTerminal(client, terminal.id); await initialSnapshot; client.sendTerminalInput(terminal.id, { type: "resize", ...BYTE_TEST_SIZE }); liveHeadless.resize(BYTE_TEST_SIZE); @@ -70,7 +70,7 @@ test("byte-stream headless terminal matches daemon state after high-output attac expect(liveHeadless.cursor()).toEqual(daemonSnapshot.cursor); expect(liveHeadless.visibleLines()).toEqual(capturedViewport.lines); - client.unsubscribeTerminal(terminal.id); + await liveSubscription.release(); stopLive(); const restoreHeadless = new ClientHeadlessTerminal(BYTE_TEST_SIZE); @@ -179,11 +179,12 @@ async function subscribeTerminal( client: DaemonClient, terminalId: string, options?: Parameters[1], -): Promise { +) { const response = await client.subscribeTerminal(terminalId, options); if (response.error) { throw new Error(response.error); } + return response.subscription; } async function readTerminalSnapshot(input: { diff --git a/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts new file mode 100644 index 0000000000..19ccdf45e8 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts @@ -0,0 +1,246 @@ +import { resolveDaemonVersion } from "../daemon-version.js"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { createPaseoClient, type PaseoClient } from "@getpaseo/client"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +let daemon: TestPaseoDaemon; +let client: DaemonClient; +let cwd: string; +let sdk: PaseoClient; + +beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), "terminal-workspace-sdk-")); + daemon = await createTestPaseoDaemon(); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + sdk = createPaseoClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await sdk.connect(); +}); + +afterEach(async () => { + await client.close(); + await sdk.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); +}); + +test("SDK and workspace handles preserve ownership and actual process directories", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + const nested = path.join(cwd, "nested"); + await mkdir(nested); + const a = await sdk.terminals.create({ workspaceId: first, name: "Main" }); + const b = await sdk.workspaces.ref(second).terminals.create({ name: "Feature", cwd: nested }); + const c = await sdk.terminals.create({ + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + expect(a.current()).toEqual({ id: a.id, workspaceId: first, cwd, name: "Main" }); + expect(b.current()).toEqual({ id: b.id, workspaceId: second, cwd: nested, name: "Feature" }); + expect(c.current()).toEqual({ + id: c.id, + workspaceId: second, + cwd: daemon.staticDir, + name: "Outside", + }); + const result = await sdk.workspaces.ref(second).terminals.list({ requestId: "feature-list" }); + expect(result.requestId).toBe("feature-list"); + expect(result.entries).toEqual(expect.arrayContaining([b.current(), c.current()])); + expect(result.entries).toHaveLength(2); + expect((await sdk.terminals.list()).entries).toHaveLength(3); + expect((await sdk.terminals.list({ cwd })).entries).toEqual([a.current(), b.current()]); + const ref = sdk.terminals.ref(b.id); + expect(ref.current()).toBeNull(); + expect(await ref.refresh()).toEqual(b.current()); + await ref.kill(); + expect(await ref.refresh()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId: second })).entries).toEqual([c.current()]); + expect((await sdk.terminals.list({ workspaceId: first })).entries).toEqual([a.current()]); +}); + +test("SDK creates a command terminal and sends literal input and key tokens", async () => { + const workspaceId = await createWorkspace("Input"); + const terminal = await sdk.terminals.create({ + workspaceId, + command: process.execPath, + args: [ + "-e", + "process.stdin.setRawMode(true); process.stdin.resume(); console.log('READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('HEX:' + hex); });", + ], + size: { rows: 35, cols: 120 }, + }); + const screen = async () => (await terminal.capture({ stripAnsi: true })).lines.join("\n"); + await expect.poll(screen).toContain("READY"); + expect(terminal.write("Enter")).toBe(5); + await expect.poll(screen).toContain("HEX:456e746572"); + expect(terminal.sendKeys(["Enter", "Tab", "Escape", "C-c"])).toBe(4); + await expect.poll(screen).toContain("HEX:456e7465720d091b03"); + const capture = await terminal.capture({ + start: 0, + end: 0, + stripAnsi: true, + requestId: "capture-first", + }); + expect(capture).toMatchObject({ terminalId: terminal.id, requestId: "capture-first" }); + expect(capture.lines).toHaveLength(1); + await terminal.kill(); + expect(terminal.current()).toBeNull(); + expect((await sdk.terminals.list({ workspaceId })).entries).toEqual([]); +}); + +test("terminal creation rejects unknown and archived owners, including explicit cwd overrides", async () => { + const workspaceId = await createWorkspace("Archived"); + await sdk.workspaces.ref(workspaceId).archive(); + for (const id of [workspaceId, "wks_missing"]) { + await expect(sdk.terminals.create({ workspaceId: id })).rejects.toThrow(/not active/); + await expect(sdk.terminals.create({ workspaceId: id, cwd })).rejects.toThrow(/not active/); + const raw = await client.createTerminal(cwd, undefined, undefined, { workspaceId: id }); + expect(raw).toMatchObject({ + terminal: null, + error: `Workspace ${id} is not active or does not exist`, + }); + } + expect((await sdk.terminals.list()).entries).toEqual([]); + expect((await client.fetchWorkspaces()).entries).toEqual([]); +}); + +test("plugin handlers operate terminals through their host-owned Paseo API", async () => { + const workspaceId = await createWorkspace("Plugin workspace"); + const pluginDirectory = path.join(cwd, "plugin"); + await mkdir(pluginDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "terminal-sdk", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(pluginDirectory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +const operate = defineRpc({ name: "operate", input: z.object({ workspaceId: z.string(), command: z.string() }), output: z.object({ workspaceIds: z.array(z.string()), lines: z.array(z.string()), remaining: z.number() }) }); +async function waitForTerminalOutput(terminal, text) { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + const capture = await terminal.capture({ stripAnsi: true }); + if (capture.lines.some(line => line.includes(text))) return capture.lines; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error("Timed out waiting for plugin terminal output: " + text); +} +export default function contribute(server) { + server.handle(operate, async ({ workspaceId, command }, { paseo }) => { + const workspace = paseo.workspaces.ref(workspaceId); + const terminal = await workspace.terminals.create({ command, args: ["-e", "process.stdin.setRawMode(true); process.stdin.resume(); console.log('PLUGIN READY'); let hex = ''; process.stdin.on('data', data => { hex += data.toString('hex'); console.log('PLUGIN:' + hex); });"] }); + try { + await waitForTerminalOutput(terminal, "PLUGIN READY"); + terminal.write("Enter"); + await waitForTerminalOutput(terminal, "PLUGIN:456e746572"); + terminal.sendKeys(["Enter"]); + const lines = await waitForTerminalOutput(terminal, "PLUGIN:456e7465720d"); + const listed = await workspace.terminals.list(); + await paseo.terminals.ref(terminal.id).kill(); + return { workspaceIds: listed.entries.map(entry => entry.workspaceId), lines, remaining: (await workspace.terminals.list()).entries.length }; + } finally { + await terminal.kill(); + } + }); + return () => {}; +}`, + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + try { + const result = await client.invokePluginRpc("terminal-sdk", "operate", { + workspaceId, + command: process.execPath, + }); + expect(result).toMatchObject({ + workspaceIds: [workspaceId], + remaining: 0, + lines: expect.arrayContaining([expect.stringContaining("PLUGIN:456e7465720d")]), + }); + } finally { + await client.removePlugin("terminal-sdk"); + } +}, 30_000); + +async function createWorkspace(title: string): Promise { + const result = await client.createWorkspace({ source: { kind: "directory", path: cwd }, title }); + if (!result.workspace) throw new Error(result.error ?? "Workspace creation failed"); + return result.workspace.id; +} + +test("listing by workspace ID keeps terminals in a shared directory separate", async () => { + const first = await createWorkspace("main"); + const second = await createWorkspace("feature-work"); + await client.createTerminal(cwd, "main terminal", undefined, { workspaceId: first }); + const created = await client.createTerminal(cwd, "feature terminal", undefined, { + workspaceId: second, + }); + expect(created.error).toBeNull(); + + const result = await client.listTerminals(undefined, undefined, { workspaceId: second }); + expect(result.terminals).toEqual([ + expect.objectContaining({ id: created.terminal?.id, workspaceId: second, cwd }), + ]); +}); + +test("script status started and stopped by one client reaches every subscribed client", async () => { + await writeFile( + path.join(cwd, "paseo.json"), + JSON.stringify({ + scripts: { sleeper: { type: "script", command: 'node -e "setInterval(() => {}, 60000)"' } }, + }), + ); + const observer = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const bystander = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await observer.connect(); + await bystander.connect(); + const bystanderEvents: string[] = []; + bystander.subscribeRawMessages((message) => bystanderEvents.push(message.type)); + const opened = await client.openProject(cwd); + expect(opened.error).toBeNull(); + const workspaceId = opened.workspace!.id; + const actorSeen = await observeScriptLifecycles(client, "sleeper"); + const observerSeen = await observeScriptLifecycles(observer, "sleeper"); + + expect((await client.startWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("running"); + await expect.poll(() => observerSeen.at(-1)).toBe("running"); + + expect((await client.stopWorkspaceScript(workspaceId, "sleeper")).error).toBeNull(); + await expect.poll(() => actorSeen.at(-1)).toBe("stopped"); + await expect.poll(() => observerSeen.at(-1)).toBe("stopped"); + expect(bystanderEvents).not.toContain("script_status_update"); + } finally { + await observer.close(); + await bystander.close(); + } +}, 60_000); + +async function observeScriptLifecycles( + daemonClient: DaemonClient, + scriptName: string, +): Promise { + const lifecycles: string[] = []; + const subscription = daemonClient.observeEvents(["script_status_update"]); + subscription.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type !== "script_status_update") return; + const script = message.payload.scripts.find((entry) => entry.scriptName === scriptName); + if (script) lifecycles.push(script.lifecycle); + }, + }); + await subscription.ready; + return lifecycles; +} 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 6d22b4695e..633bd3bc1c 100644 --- a/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/terminal.e2e.test.ts @@ -1048,6 +1048,9 @@ test("one client can stream two terminals concurrently", async () => { expect(firstSubscribe.error).toBeNull(); expect(secondSubscribe.error).toBeNull(); + if (firstSubscribe.error !== null || secondSubscribe.error !== null) { + throw new Error("Expected both terminal subscriptions to succeed"); + } expect(firstSubscribe.slot).not.toBe(secondSubscribe.slot); const firstOutput = waitForTerminalOutput(ctx.client, firstTerminalId, (text) => @@ -1077,8 +1080,8 @@ test("disconnect and reconnect both receive the current snapshot", async () => { const created = await createTerminalInWorkspace(ctx.client, { cwd }); const terminalId = created.terminal!.id; - await ctx.client.subscribeTerminal(terminalId); - ctx.client.unsubscribeTerminal(terminalId); + const observation = await ctx.client.subscribeTerminal(terminalId); + await observation.subscription.release(); ctx.client.sendTerminalInput(terminalId, { type: "input", diff --git a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts index 8a794b54b5..ae01a8539d 100644 --- a/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-reconnect-contract.e2e.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "vitest"; +import { afterEach, beforeEach, expect, onTestFinished, test } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -11,6 +11,23 @@ import { import { createMessageCollector } from "../test-utils/message-collector.js"; import type { SessionOutboundMessage } from "../messages.js"; +async function observeTimeline( + client: DaemonClient, + agentId: string, + messages: SessionOutboundMessage[], +): Promise { + const subscription = client.observeTimeline([agentId]); + onTestFinished( + subscription.subscribe({ + snapshot() {}, + update(message) { + messages.push(message); + }, + }), + ); + await subscription.ready; +} + function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } @@ -32,7 +49,6 @@ async function waitFor( function isLiveAssistantTimeline( message: SessionOutboundMessage, agentId: string, - epoch?: string, text?: string, ): boolean { return ( @@ -41,8 +57,7 @@ function isLiveAssistantTimeline( message.payload.event.type === "timeline" && message.payload.event.item.type === "assistant_message" && message.payload.seq === undefined && - typeof message.payload.epoch === "string" && - (epoch === undefined || message.payload.epoch === epoch) && + message.payload.epoch === undefined && (text === undefined || message.payload.event.item.text === text) ); } @@ -69,6 +84,8 @@ test("reconnect catches up committed rows without replaying a provisional seed", modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -91,7 +108,7 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -107,16 +124,15 @@ test("reconnect catches up committed rows without replaying a provisional seed", }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-a" }, + subscribe: {}, }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -133,11 +149,14 @@ test("reconnect catches up committed rows without replaying a provisional seed", expect(catchUp.entries).toHaveLength(1); expect(catchUp.startCursor).toEqual({ epoch, seq: 121 }); expect(catchUp.endCursor).toEqual({ epoch, seq: 121 }); - expect(catchUp.entries[0]?.seqStart).toBe(121); + expect(catchUp.projection).toBe("projected"); + expect(catchUp.entries[0]?.seqStart).toBe(1); expect(catchUp.entries[0]?.seqEnd).toBe(121); expect(catchUp.entries[0]?.item).toEqual({ type: "assistant_message", - text: "finalized while disconnected", + text: + Array.from({ length: 120 }, (_, index) => `committed row ${index + 1}`).join("") + + "finalized while disconnected", }); } finally { reconnectCollector.unsubscribe(); @@ -161,6 +180,8 @@ test("reconnect with no new committed rows resumes from future live provisional modeId: "full-access", }); + await observeTimeline(ctx.client, agent.id, primaryCollector.messages); + for (let seq = 1; seq <= 120; seq += 1) { await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, { type: "assistant_message", @@ -183,7 +204,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => primaryCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "partial before disconnect"), + isLiveAssistantTimeline(message, agent.id, "partial before disconnect"), ), ); @@ -194,16 +215,15 @@ test("reconnect with no new committed rows resumes from future live provisional }); await reconnectClient.connect(); const reconnectCollector = createMessageCollector(reconnectClient); + await observeTimeline(reconnectClient, agent.id, reconnectCollector.messages); try { await reconnectClient.fetchAgents({ - subscribe: { subscriptionId: "timeline-reconnect-b" }, + subscribe: {}, }); expect( - reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch), - ), + reconnectCollector.messages.some((message) => isLiveAssistantTimeline(message, agent.id)), ).toBe(false); const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, { @@ -228,7 +248,7 @@ test("reconnect with no new committed rows resumes from future live provisional }); await waitFor(() => reconnectCollector.messages.some((message) => - isLiveAssistantTimeline(message, agent.id, epoch, "fresh live after reconnect"), + isLiveAssistantTimeline(message, agent.id, "fresh live after reconnect"), ), ); } finally { diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts index 1e809314be..78134d7865 100644 --- a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -178,7 +178,7 @@ describe("daemon E2E - timeline window", () => { } }); - test("canonical after fetch returns only committed rows after the cursor", async () => { + test("legacy canonical requests return full projected items after the cursor", async () => { const cwd = tmpCwd(); try { const agent = await ctx.client.createAgent({ @@ -201,13 +201,16 @@ describe("daemon E2E - timeline window", () => { projection: "canonical", }); - expect(timeline.projection).toBe("canonical"); + expect(timeline.projection).toBe("projected"); expect(timeline.entries).toHaveLength(1); expect(timeline.startCursor?.seq).toBe(250); expect(timeline.endCursor?.seq).toBe(250); - expect(timeline.entries[0]?.seqStart).toBe(250); + expect(timeline.entries[0]?.seqStart).toBe(1); expect(timeline.entries[0]?.seqEnd).toBe(250); - expect(timeline.entries[0]?.sourceSeqRanges).toEqual([{ startSeq: 250, endSeq: 250 }]); + expect(timeline.entries[0]?.sourceSeqRanges).toEqual([ + { startSeq: 1, endSeq: 1 }, + { startSeq: 250, endSeq: 250 }, + ]); expect(timeline.entries[0]?.item.type).toBe("tool_call"); } finally { rmSync(cwd, { recursive: true, force: true }); @@ -301,7 +304,6 @@ describe("daemon E2E - timeline window", () => { }); } - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 100, @@ -311,11 +313,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(501); expect(timeline.endCursor?.seq).toBe(600); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -341,7 +338,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "after", cursor: { epoch, seq: 300 }, @@ -352,11 +348,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(301); expect(timeline.endCursor?.seq).toBe(400); expect(timeline.hasNewer).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } @@ -490,7 +481,6 @@ describe("daemon E2E - timeline window", () => { limit: 1, }).epoch; - const fetchSpy = vi.spyOn(ctx.daemon.daemon.agentManager, "fetchTimeline"); const timeline = await ctx.client.fetchAgentTimeline(agent.id, { direction: "before", cursor: { epoch, seq: 501 }, @@ -501,11 +491,6 @@ describe("daemon E2E - timeline window", () => { expect(timeline.startCursor?.seq).toBe(401); expect(timeline.endCursor?.seq).toBe(500); expect(timeline.hasOlder).toBe(true); - expect( - fetchSpy.mock.calls.some( - ([, options]) => options?.direction === "tail" && options.limit === 0, - ), - ).toBe(false); } finally { rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts index 925415a96e..7643138e0e 100644 --- a/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/ui-action-stress.real.e2e.test.ts @@ -466,7 +466,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-normal-${provider}` }, + subscribe: {}, }); const agent = await client.createAgent({ cwd, @@ -504,7 +504,7 @@ describe.each(realProviders)("daemon E2E (real %s) - UI action stress", (provide try { await client.connect(); await client.fetchAgents({ - subscribe: { subscriptionId: `ui-stress-overlap-${provider}` }, + subscribe: {}, }); for (const scenario of scenarios) { diff --git a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts index b22327c6ba..9e2d96f699 100644 --- a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts @@ -61,7 +61,7 @@ function tmpCwd(provider: ContractProvider): string { function collectUserMessageEvents(client: DaemonClient, agentId: string): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; - client.on("agent_stream", (message) => { + client.subscribeAgentTimeline(agentId, (message) => { if (message.type !== "agent_stream" || message.payload.agentId !== agentId) { return; } @@ -103,7 +103,7 @@ describe.each(CONTRACT_CASES)("daemon E2E (real $provider) - user_message contra appVersion: "0.1.80", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: `${entry.provider}-user-contract` } }); + await client.fetchAgents({ subscribe: {} }); }, 30_000); afterEach(async () => { diff --git a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts index e2220a4109..ad2a62c804 100644 --- a/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/wait-for-idle.e2e.test.ts @@ -130,7 +130,7 @@ test("waitForFinish resolves first idle edge even if a new run starts immediatel const secondary = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "wait-for-idle-secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); const agent = await ctx.client.createAgent({ provider: "codex", @@ -193,7 +193,7 @@ test("waitForFinish stays blocked when sendMessage transactionally replaces a ru try { await secondary.connect(); - await secondary.fetchAgents({ subscribe: { subscriptionId: "secondary" } }); + await secondary.fetchAgents({ subscribe: {} }); await ctx.client.sendMessage(agent.id, "Run: sleep 5"); await ctx.client.waitForAgentUpsert( diff --git a/packages/server/src/server/daemon-instance.test.ts b/packages/server/src/server/daemon-instance.test.ts new file mode 100644 index 0000000000..ee428b2e58 --- /dev/null +++ b/packages/server/src/server/daemon-instance.test.ts @@ -0,0 +1,135 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir, uptime } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js"; +import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js"; + +// A process cannot have started before the machine booted, so a lock stamped +// before this boot names a PID that some unrelated process now holds. +function bootedAt(): number { + return Date.now() - uptime() * 1000; +} + +async function writeLock(paseoHome: string, lock: PidLockInfo): Promise { + await writeFile(join(paseoHome, "paseo.pid"), JSON.stringify(lock)); +} + +function lockFor(pid: number, startedAt: Date): PidLockInfo { + return { + pid, + startedAt: startedAt.toISOString(), + hostname: "old-host", + uid: process.getuid?.() ?? 0, + listen: "127.0.0.1:6767", + desktopManaged: true, + heartbeat: true, + }; +} + +describe("daemon instance identity across a reboot", () => { + let paseoHome: string; + let bystander: ChildProcess | undefined; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-")); + }); + + afterEach(async () => { + bystander?.kill("SIGKILL"); + bystander = undefined; + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a lock stamped before this boot has no running owner", async () => { + await writeLock(paseoHome, lockFor(process.pid, new Date(bootedAt() - 60 * 60_000))); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); + + test("a supervisor started during this boot still holds the lock", async () => { + await writeLock(paseoHome, lockFor(process.pid, new Date())); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + }); + + test("a new supervisor takes over a lock stamped before this boot", async () => { + await writeLock(paseoHome, lockFor(process.pid, new Date(bootedAt() - 60 * 60_000))); + + await acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }); + + expect(await getPidLockInfo(paseoHome)).toMatchObject({ pid: process.pid + 10_000 }); + }); + + test("stopping a lock stamped before this boot leaves the process holding that pid alone", async () => { + // Records delivery rather than dying of it, so a signal cannot be missed by arriving late. + const signalMarker = join(paseoHome, "bystander-signalled"); + bystander = spawn( + process.execPath, + [ + "-e", + `process.on("SIGTERM", () => require("node:fs").writeFileSync(${JSON.stringify(signalMarker)}, "SIGTERM"));` + + `setTimeout(() => {}, 120_000);`, + ], + { stdio: "ignore" }, + ); + const bystanderPid = bystander.pid; + if (bystanderPid === undefined) throw new Error("bystander process did not start"); + let exited = false; + bystander.once("exit", () => { + exited = true; + }); + + await writeLock(paseoHome, lockFor(bystanderPid, new Date(bootedAt() - 60 * 60_000))); + + expect(await stopDaemonInstance(paseoHome)).toMatchObject({ action: "not_running" }); + + expect(existsSync(signalMarker)).toBe(false); + expect(exited).toBe(false); + await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/); + }); +}); + +// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock +// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and +// the supervisor's start puts the wall-clock boot instant after the lock's startedAt. +describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => { + let paseoHome: string; + + beforeEach(async () => { + paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-")); + }); + + afterEach(async () => { + vi.useRealTimers(); + await rm(paseoHome, { recursive: true, force: true }); + }); + + test("a supervisor still holds the lock after its paused VM resumes", async () => { + await acquirePidLock(paseoHome, null, { ownerPid: process.pid }); + + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000); + + expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid }); + expect(await isLocked(paseoHome)).toMatchObject({ locked: true }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Another Paseo daemon is already running"); + }); + + test("a lock written during another boot has no running owner", async () => { + await writeLock(paseoHome, { + ...lockFor(process.pid, new Date()), + bootId: "00000000-0000-0000-0000-000000000000", + }); + + expect(await readDaemonInstance(paseoHome)).toBeNull(); + expect(await isLocked(paseoHome)).toMatchObject({ locked: false }); + }); +}); diff --git a/packages/server/src/server/daemon-instance.ts b/packages/server/src/server/daemon-instance.ts new file mode 100644 index 0000000000..02f4446795 --- /dev/null +++ b/packages/server/src/server/daemon-instance.ts @@ -0,0 +1,310 @@ +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + getPidLockInfo, + isPidLockOwnerRunning, + isSamePidLock, + releasePidLock, + type PidLockInfo, +} from "./pid-lock.js"; +import { daemonLaunchEnvironment } from "./config-environment.js"; +import { readPersistedConfig } from "./persisted-config.js"; +import treeKill from "tree-kill"; +const killTree = (pid: number, signal: string): Promise => + new Promise((resolve, reject) => + treeKill(pid, signal, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + +// Process ownership only: desktop and CLI load this entry in their own processes. +// Keep daemon bootstrap and WebSocket schemas out of its dependency tree. +export { resolvePaseoHome } from "./paseo-home.js"; +export { ensurePrivateDirectory } from "./private-files.js"; +export { daemonLaunchEnvironment } from "./config-environment.js"; +export { readLocalCredentialForTarget } from "./local-credential.js"; +export { + isSamePidLock as isSameDaemonInstance, + type PidLockInfo as DaemonInstance, +} from "./pid-lock.js"; + +export class DaemonInstanceError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + } +} + +export async function readDaemonInstance(home: string): Promise { + const lock = await getPidLockInfo(home); + return lock && isPidLockOwnerRunning(lock) ? lock : null; +} + +export function daemonLogPath(home: string): string { + try { + return path.resolve(home, readPersistedConfig(home).log?.file?.path ?? "daemon.log"); + } catch { + return path.join(home, "daemon.log"); + } +} + +export async function waitForDaemonReady( + home: string, + options: { + timeoutMs?: number; + instance?: PidLockInfo; + signal?: AbortSignal; + } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 600_000); + while (true) { + options.signal?.throwIfAborted(); + const instance = await readDaemonInstance(home); + if (!instance) + throw new DaemonInstanceError( + "DAEMON_NOT_RUNNING", + `Daemon is not running for ${home}. Start with: paseo daemon start --home ${JSON.stringify(home)}`, + ); + if (options.instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to follow PID ${instance.pid}.`, + ); + } + if (instance.listen) return { ...instance, listen: instance.listen }; + if (Date.now() >= deadline) throw notReady(home, instance); + await delay(100, undefined, { signal: options.signal }); + } +} + +function notReady(home: string, instance: PidLockInfo): DaemonInstanceError { + return new DaemonInstanceError( + "DAEMON_NOT_READY", + `Daemon PID ${instance.pid} remains running but is not ready for ${home}.\nLogs: ${daemonLogPath(home)}\nStatus: paseo daemon status --home ${JSON.stringify(home)}\nStop: paseo daemon stop --home ${JSON.stringify(home)}`, + ); +} + +async function requestInstanceStop( + home: string, + instance: PidLockInfo, + options: { + force?: boolean; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + }, +) { + let forced = false; + let usedLifecycleRpc = false; + if (process.platform === "win32") { + if (instance.listen && options.requestShutdown) { + try { + await options.requestShutdown({ ...instance, listen: instance.listen }); + usedLifecycleRpc = true; + } catch (error) { + if (!options.force) throw error; + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing forced cleanup.`, + ); + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else if (!options.force) { + throw new DaemonInstanceError( + "STOP_NO_GRACEFUL_CHANNEL", + `PID ${instance.pid} for ${home} has no graceful shutdown channel. Use --force explicitly to terminate it.`, + ); + } else { + await killTree(instance.pid, "SIGKILL"); + forced = true; + } + } else { + try { + process.kill(instance.pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + return { forced, usedLifecycleRpc }; +} + +export async function stopDaemonInstance( + home: string, + options: { + instance?: PidLockInfo; + force?: boolean; + timeoutMs?: number; + killTimeoutMs?: number; + requestShutdown?: (instance: PidLockInfo & { listen: string }) => Promise; + } = {}, +): Promise<{ + action: "stopped" | "not_running"; + pid: number | null; + forced: boolean; + usedLifecycleRpc: boolean; +}> { + const { timeoutMs = 15_000, killTimeoutMs = 3_000 } = options; + const deadline = Date.now() + timeoutMs; + const instance = await getPidLockInfo(home); + if (options.instance && instance && !isSamePidLock(instance, options.instance)) { + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; refusing to stop PID ${instance.pid}.`, + ); + } + if (!instance || !isPidLockOwnerRunning(instance)) { + if (instance) + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { + action: "not_running", + pid: instance?.pid ?? null, + forced: false, + usedLifecycleRpc: false, + }; + } + if (instance.pid <= 1 || instance.pid === process.pid) + throw new Error("Refusing to stop invalid supervisor PID"); + let { forced, usedLifecycleRpc } = await requestInstanceStop(home, instance, options); + const waitForExit = async (waitMs: number) => { + const exitDeadline = Date.now() + waitMs; + while (isPidLockOwnerRunning(instance)) { + const current = await getPidLockInfo(home); + if (current && !isSamePidLock(instance, current)) + throw new DaemonInstanceError( + "DAEMON_REPLACED", + `Supervisor changed for ${home}; stop of PID ${instance.pid} was not confirmed.`, + ); + if (Date.now() >= exitDeadline) return false; + await delay(100); + } + return true; + }; + let stopped = await waitForExit(forced ? killTimeoutMs : Math.max(0, deadline - Date.now())); + if (!stopped && options.force && !forced) { + await killTree(instance.pid, "SIGKILL"); + forced = true; + stopped = await waitForExit(killTimeoutMs); + } + if (!stopped) + throw new DaemonInstanceError( + "STOP_NOT_CONFIRMED", + `Timed out waiting for supervisor PID ${instance.pid} in ${home} to exit${options.force ? "" : "; use --force to permit forced cleanup"}.`, + ); + await releasePidLock(home, { ownerPid: instance.pid, startedAt: instance.startedAt }); + return { action: "stopped", pid: instance.pid, forced, usedLifecycleRpc }; +} + +export async function startDaemonInstance(input: { + home: string; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + mode: "managed" | "deployment"; + desktopManaged?: boolean; + foreground?: boolean; + timeoutMs?: number; + signal?: AbortSignal; + onAcquired?: (instance: PidLockInfo) => void; + onReady?: (instance: PidLockInfo & { listen: string }) => void; +}): Promise<{ instance: PidLockInfo; spawned: boolean; exitCode?: number }> { + input.signal?.throwIfAborted(); + const existing = await readDaemonInstance(input.home); + if (existing) return { instance: existing, spawned: false }; + const { + foreground = false, + timeoutMs = 600_000, + signal, + onAcquired = () => {}, + onReady = () => {}, + } = input; + const child = spawn(input.command, input.args, { + env: daemonLaunchEnvironment(input), + detached: !foreground, + stdio: foreground ? "inherit" : "ignore", + windowsHide: true, + }); + let exit: { code: number; error?: Error } | undefined; + const exited = new Promise((resolve) => { + child.once("error", (error) => { + exit = { code: 1, error }; + resolve(1); + }); + child.once("exit", (code) => { + exit = { code: code ?? 1 }; + resolve(code ?? 1); + }); + }); + const cancel = () => { + if (child.pid && !exit) { + child.ref(); + child.kill("SIGTERM"); + } + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (!foreground) child.unref(); + const deadline = foreground ? Infinity : Date.now() + timeoutMs; + let acquired: PidLockInfo | undefined; + async function waitUntilReady() { + while (true) { + signal?.throwIfAborted(); + const instance = await readDaemonInstance(input.home); + if (instance && instance.pid !== child.pid) return { instance, spawned: false }; + if (instance && !acquired) { + acquired = instance; + onAcquired(instance); + } + if (exit) { + const logPath = daemonLogPath(input.home); + const log = await readFile(logPath, "utf8").catch(() => ""); + throw new DaemonInstanceError( + "DAEMON_START_FAILED", + `Daemon failed to start (${exit.error?.message ?? `exit ${exit.code}`}). Logs: ${logPath}\n${log.split("\n").slice(-30).join("\n")}`, + ); + } + if (instance?.listen) { + const ready = { ...instance, listen: instance.listen }; + onReady(ready); + return { + instance: ready, + spawned: true, + ...(foreground ? { exitCode: await exited } : {}), + }; + } + if (Date.now() >= deadline) { + if (acquired) throw notReady(input.home, acquired); + throw new DaemonInstanceError( + "DAEMON_NOT_READY", + `Supervisor PID ${child.pid} remains running but has not published its lock for ${input.home}. Logs: ${daemonLogPath(input.home)}. Check paseo daemon status --home ${JSON.stringify(input.home)}. Stop with paseo daemon stop --home ${JSON.stringify(input.home)} once its lock is published, or signal this PID.`, + ); + } + await delay(100, undefined, { signal }); + } + } + try { + return await waitUntilReady(); + } catch (error) { + if (!(error instanceof DaemonInstanceError && error.code === "DAEMON_NOT_READY")) { + cancel(); + const stopped = await Promise.race([ + exited.then(() => true), + delay(15_000, false, { ref: false }), + ]); + if (!stopped && child.pid) { + await killTree(child.pid, "SIGKILL"); + await exited; + } + } + throw error; + } finally { + signal?.removeEventListener("abort", cancel); + } +} diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index f7a770d8be..bc5acbc404 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -1,6 +1,6 @@ import { appendFileSync, mkdirSync } from "node:fs"; import path from "node:path"; -import { createPaseoDaemon } from "./bootstrap.js"; +import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js"; import { loadConfig } from "./config.js"; import { resolvePaseoHome } from "./paseo-home.js"; import { createRootLogger } from "./logger.js"; @@ -17,6 +17,7 @@ type SupervisorLifecycleMessage = | { type: "paseo:ready"; listen: string; + serverId: string; } | { type: "paseo:restart"; @@ -323,14 +324,15 @@ async function main() { try { await daemon.start(); const listenTarget = daemon.getListenTarget(); - const listen = - listenTarget?.type === "tcp" - ? `${listenTarget.host}:${listenTarget.port}` - : listenTarget?.path; + const listen = formatListenTarget(listenTarget); if (!listen) { throw new Error("Daemon did not expose a listen target after startup"); } - sendSupervisorLifecycleMessage({ type: "paseo:ready", listen }); + sendSupervisorLifecycleMessage({ + type: "paseo:ready", + listen, + serverId: daemon.getServerId(), + }); } catch (err) { logger.fatal({ err }, "Daemon failed to start listening"); throw err; diff --git a/packages/server/src/server/dictation/dictation-stream-manager.test.ts b/packages/server/src/server/dictation/dictation-stream-manager.test.ts index 6d389a17e8..975d6677b8 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.test.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.test.ts @@ -534,3 +534,54 @@ describe("DictationStreamManager (provider-agnostic provider)", () => { } }); }); + +it("cancellation during STT bootstrap closes the producer and never acknowledges a late connection", async () => { + let connected!: () => void; + const gate = new Promise((resolve) => { + connected = resolve; + }); + class ConnectingSession extends FakeRealtimeSession { + override async connect(): Promise { + await gate; + } + } + const session = new ConnectingSession(); + const messages: Array<{ type: string }> = []; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + emit: (message) => messages.push(message), + sessionId: "cancel-bootstrap", + stt: new FakeSttProvider(session), + }); + const starting = manager.handleStart("dictation", "audio/pcm;rate=24000;bits=16"); + manager.handleCancel("dictation"); + expect(session.closed).toBe(true); + connected(); + await starting; + expect(messages).toEqual([]); + manager.cleanupAll(); +}); + +it("closes every dictation stream when one provider cleanup fails", async () => { + class FailingCloseSession extends FakeRealtimeSession { + override close(): void { + super.close(); + throw new Error("provider cleanup failed"); + } + } + const first = new FailingCloseSession(); + const second = new FakeRealtimeSession(); + const sessions = [first, second]; + const manager = new DictationStreamManager({ + logger: pino({ level: "silent" }), + sessionId: "cleanup-failure", + emit: () => {}, + stt: { id: "controlled", createSession: () => sessions.shift()! }, + }); + await manager.handleStart("first", "audio/pcm;rate=24000;bits=16"); + await manager.handleStart("second", "audio/pcm;rate=24000;bits=16"); + expect(() => manager.cleanupAll()).toThrow(); + expect(first.closed).toBe(true); + expect(second.closed).toBe(true); + expect(manager.hasDemand).toBe(false); +}); diff --git a/packages/server/src/server/dictation/dictation-stream-manager.ts b/packages/server/src/server/dictation/dictation-stream-manager.ts index e911db3d6e..b1b061501f 100644 --- a/packages/server/src/server/dictation/dictation-stream-manager.ts +++ b/packages/server/src/server/dictation/dictation-stream-manager.ts @@ -134,6 +134,7 @@ export class DictationStreamManager { private readonly language: string; private readonly finalTimeoutMs: number; private readonly autoCommitSeconds: number; + private readonly onIdle: (() => void) | undefined; private readonly streams = new Map(); constructor(params: { @@ -144,7 +145,9 @@ export class DictationStreamManager { language?: string; finalTimeoutMs?: number; autoCommitSeconds?: number; + onIdle?: () => void; }) { + this.onIdle = params.onIdle; this.logger = params.logger.child({ component: "dictation-stream-manager" }); this.emit = params.emit; this.sessionId = params.sessionId; @@ -157,10 +160,20 @@ export class DictationStreamManager { DEFAULT_DICTATION_AUTO_COMMIT_SECONDS; } + get hasDemand(): boolean { + return this.streams.size > 0; + } + public cleanupAll(): void { + const failures: unknown[] = []; for (const dictationId of this.streams.keys()) { - this.cleanupDictationStream(dictationId); + try { + this.cleanupDictationStream(dictationId); + } catch (error) { + failures.push(error); + } } + if (failures.length) throw new AggregateError(failures, "Dictation cleanup failed"); } public async handleStart(dictationId: string, format: string): Promise { @@ -189,79 +202,6 @@ export class DictationStreamManager { return; } - stt.on("committed", ({ segmentId }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - state.committedSegmentIds.push(segmentId); - - if (state.finishRequested && state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("transcript", ({ segmentId, transcript, isFinal }) => { - const state = this.streams.get(dictationId); - if (!state) { - return; - } - state.transcriptsBySegmentId.set(segmentId, transcript); - if (isFinal) { - state.finalTranscriptSegmentIds.add(segmentId); - } - - if (state.finishRequested && state.awaitingFinalCommit && isFinal) { - state.awaitingFinalCommit = false; - } - - const orderedIds = state.committedSegmentIds.includes(segmentId) - ? state.committedSegmentIds - : [...state.committedSegmentIds, segmentId]; - const partialText = orderedIds - .map((id) => state.transcriptsBySegmentId.get(id) ?? "") - .join(" ") - .trim(); - this.emitDictationPartial(dictationId, partialText); - - this.maybeSealDictationStreamFinish(dictationId); - this.maybeFinalizeDictationStream(dictationId); - }); - - stt.on("error", (err) => { - const message = err instanceof Error ? err.message : String(err); - const state = this.streams.get(dictationId); - if (state && state.finishRequested && isBufferTooSmallError(message)) { - if (state.inFlightCommitCount > 0) { - state.inFlightCommitCount -= 1; - } - if (state.awaitingFinalCommit) { - state.awaitingFinalCommit = false; - } - this.maybeFinalizeDictationStream(dictationId); - return; - } - void this.failAndCleanupDictationStream(dictationId, message, true); - }); - - try { - await stt.connect(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.failDictationStream(dictationId, message, true); - try { - stt.close(); - } catch { - // no-op - } - return; - } - const inputRate = parsePcmRateFromFormat(format, 16000) ?? 16000; if (!Number.isFinite(inputRate) || inputRate <= 0) { this.failDictationStream( @@ -322,6 +262,78 @@ export class DictationStreamManager { finalTimeout: null, }); + stt.on("committed", ({ segmentId }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + state.committedSegmentIds.push(segmentId); + + if (state.finishRequested && state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("transcript", ({ segmentId, transcript, isFinal }) => { + const state = this.streams.get(dictationId); + if (state?.stt !== stt) { + return; + } + state.transcriptsBySegmentId.set(segmentId, transcript); + if (isFinal) { + state.finalTranscriptSegmentIds.add(segmentId); + } + + if (state.finishRequested && state.awaitingFinalCommit && isFinal) { + state.awaitingFinalCommit = false; + } + + const orderedIds = state.committedSegmentIds.includes(segmentId) + ? state.committedSegmentIds + : [...state.committedSegmentIds, segmentId]; + const partialText = orderedIds + .map((id) => state.transcriptsBySegmentId.get(id) ?? "") + .join(" ") + .trim(); + this.emitDictationPartial(dictationId, partialText); + + this.maybeSealDictationStreamFinish(dictationId); + this.maybeFinalizeDictationStream(dictationId); + }); + + stt.on("error", (err) => { + const message = err instanceof Error ? err.message : String(err); + const state = this.streams.get(dictationId); + if (state?.stt !== stt) return; + if (state.finishRequested && isBufferTooSmallError(message)) { + if (state.inFlightCommitCount > 0) { + state.inFlightCommitCount -= 1; + } + if (state.awaitingFinalCommit) { + state.awaitingFinalCommit = false; + } + this.maybeFinalizeDictationStream(dictationId); + return; + } + void this.failAndCleanupDictationStream(dictationId, message, true); + }); + + try { + await stt.connect(); + } catch (error) { + if (this.streams.get(dictationId)?.stt !== stt) return; + const message = error instanceof Error ? error.message : String(error); + this.failDictationStream(dictationId, message, true); + this.cleanupDictationStream(dictationId); + return; + } + if (this.streams.get(dictationId)?.stt !== stt) return; + this.emitDictationAck(dictationId, -1); } @@ -528,7 +540,9 @@ export class DictationStreamManager { error: string, retryable: boolean, ): Promise { + const state = this.streams.get(dictationId); const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (!state || this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_error", payload: { @@ -561,12 +575,12 @@ export class DictationStreamManager { if (state.finalTimeout) { clearTimeout(state.finalTimeout); } + this.streams.delete(dictationId); try { state.stt.close(); - } catch { - // no-op + } finally { + if (this.streams.size === 0) this.onIdle?.(); } - this.streams.delete(dictationId); } private estimateFinalizationTimeout(state: DictationStreamState): { @@ -731,6 +745,7 @@ export class DictationStreamManager { if (orderedSegmentIds.length === 0) { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { @@ -770,6 +785,7 @@ export class DictationStreamManager { void (async () => { const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId); + if (this.streams.get(dictationId) !== state) return; this.emit({ type: "dictation_stream_final", payload: { diff --git a/packages/server/src/server/directory-sync/index.ts b/packages/server/src/server/directory-sync/index.ts index 1b08360cb7..e16e20302f 100644 --- a/packages/server/src/server/directory-sync/index.ts +++ b/packages/server/src/server/directory-sync/index.ts @@ -69,27 +69,29 @@ export class DirectorySyncService { synchronizeWorkspaces( snapshot: Iterable, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick { this.workspaces.replaceAll(snapshot); - const read = this.read(this.workspaces, cursor); + const read = this.read(this.workspaces, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), emptyProjects: [], pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } synchronizeAgents( snapshot: Iterable, cursor: DirectorySyncCursor, + includeBackground = false, ): Pick { this.agents.replaceAll(snapshot); - const read = this.read(this.agents, cursor); + const read = this.read(this.agents, cursor, includeBackground); return { entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })), pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - sync: this.metadata(read), + sync: this.metadata(read, includeBackground), }; } @@ -111,13 +113,19 @@ export class DirectorySyncService { value: WorkspaceDescriptorPayload | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial { if (value) { this.workspaces.replace(value); } else { this.workspaces.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.workspaces, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.workspaces, id), + includeBackground, + ); } sequenceAgentUpdate( @@ -125,18 +133,24 @@ export class DirectorySyncService { value: AgentDirectoryEntry | null, id: string, includeSequence: boolean, + includeBackground = false, ): T & Partial { if (value) { this.agents.replace(value); } else { this.agents.remove(id); } - return this.withVersion(payload, includeSequence, this.version(this.agents, id)); + return this.withVersion( + payload, + includeSequence, + this.version(this.agents, id), + includeBackground, + ); } - private metadata(read: CollectionRead) { + private metadata(read: CollectionRead, includeBackground = false) { return { - generation: this.generation, + generation: this.generationFor(includeBackground), headSeq: read.headSeq, mode: read.mode, ...(read.reason ? { reason: read.reason } : {}), @@ -148,15 +162,19 @@ export class DirectorySyncService { payload: T, includeSequence: boolean, version: DirectoryVersion | null, + includeBackground = false, ): T & Partial { - return version && includeSequence ? { ...payload, ...version } : payload; + return version && includeSequence + ? { ...payload, ...version, generation: this.generationFor(includeBackground) } + : payload; } private read( collection: VersionedCollection, cursor: DirectorySyncCursor, + includeBackground = false, ): CollectionRead { - if (cursor.generation !== this.generation) { + if (cursor.generation !== this.generationFor(includeBackground)) { return collection.readSnapshot( cursor.generation === undefined ? "no_cursor" : "generation_changed", ); @@ -167,6 +185,10 @@ export class DirectorySyncService { return collection.readAfter(cursor.afterSeq); } + private generationFor(includeBackground: boolean): string { + return includeBackground ? `${this.generation}:background` : this.generation; + } + private version(collection: VersionedCollection, id: string): DirectoryVersion | null { const seq = collection.sequenceFor(id); return seq === null ? null : { generation: this.generation, seq }; diff --git a/packages/server/src/server/exports.test.ts b/packages/server/src/server/exports.test.ts index 9c9a5af9bb..1edf1049c9 100644 --- a/packages/server/src/server/exports.test.ts +++ b/packages/server/src/server/exports.test.ts @@ -17,3 +17,58 @@ test("keeps daemon-client APIs out of the server public entry", async () => { expect(serverExports).not.toHaveProperty(name); } }); + +// Keep the emitted dependency tree intact: production runs unbundled modules, +// so tree-shaking must not hide the cost of an unused re-export. +test.each([ + ["desktop daemon management", "../../../desktop/src/daemon/daemon-manager.ts"], + ["supervisor", "../../scripts/supervisor-entrypoint.ts"], + ["daemon control", "@getpaseo/server/daemon-control"], + ["configuration without the session admission resolver", "@getpaseo/server/configuration"], +])("%s does not load the daemon runtime or wire schemas", async (_name, entry) => { + const inputs = await runtimeDependencies(entry); + expect(inputs.filter((file) => /\/(?:bootstrap|messages)\.[jt]s$/.test(file))).toEqual([]); +}); + +test("CLI command registration does not load the daemon implementation", async () => { + const inputs = await runtimeDependencies("../../../cli/src/cli.ts"); + expect( + inputs.filter((file) => /\/(?:bootstrap|agent-manager|session)\.[jt]s$/.test(file)), + ).toEqual([]); +}); + +async function runtimeDependencies(entry: string): Promise { + const { build } = await import("esbuild"); + const { fileURLToPath } = await import("node:url"); + const { isAbsolute } = await import("node:path"); + const result = await build({ + stdin: { + contents: `import ${JSON.stringify(entry.startsWith("@") ? entry : fileURLToPath(new URL(entry, import.meta.url)))};`, + resolveDir: fileURLToPath(new URL(".", import.meta.url)), + }, + bundle: true, + write: false, + metafile: true, + treeShaking: false, + platform: "node", + // Server unit CI builds dependency workspaces, then tests server source. + conditions: ["source"], + format: "esm", + logLevel: "silent", + plugins: [ + { + name: "external-vendors", + setup(builder) { + builder.onResolve({ filter: /^[^./]/ }, ({ path }) => { + if (!isAbsolute(path) && !path.startsWith("@getpaseo/")) { + return { path, external: true }; + } + }); + }, + }, + ], + }); + const inputs = Object.keys(result.metafile.inputs); + expect(inputs.length).toBeGreaterThan(1); + return inputs; +} diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 0961e08b11..2f5e2a8243 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -9,7 +9,7 @@ export { savePersistedConfig, type PersistedConfig, } from "./persisted-config.js"; -export { hashDaemonPassword, isBearerTokenValid } from "./auth.js"; +export { hashDaemonPassword, isBearerTokenValidAsync } from "./auth.js"; export { generateLocalPairingOffer, type LocalPairingOffer } from "./pairing-offer.js"; export { ConnectionOfferSchema, @@ -96,3 +96,23 @@ export type { AgentStreamEventPayload, AgentStreamMessage, } from "@getpaseo/protocol/messages"; + +export { + readDaemonInstance, + startDaemonInstance, + stopDaemonInstance, + waitForDaemonReady, + daemonLogPath, + daemonLaunchEnvironment, + isSameDaemonInstance, + DaemonInstanceError, + type DaemonInstance, +} from "./daemon-instance.js"; +export { + readPersistedConfig, + getPersistedConfigValue, + editPersistedConfig, +} from "./persisted-config.js"; +export { resolveConfigFromPersisted } from "./config.js"; + +export { ensurePrivateDirectory } from "./private-files.js"; diff --git a/packages/server/src/server/file-download/content-disposition.test.ts b/packages/server/src/server/file-download/content-disposition.test.ts new file mode 100644 index 0000000000..46748c21ef --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { formatAttachmentContentDisposition } from "./content-disposition.js"; + +describe("formatAttachmentContentDisposition", () => { + test.each([ + { fileName: "report.txt", disposition: 'attachment; filename="report.txt"' }, + { + fileName: "café.txt", + disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt", + }, + { + fileName: "Résumé Ångström.pdf", + disposition: + "attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf", + }, + { + fileName: "中文报告 (最终版).txt", + disposition: + "attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt", + }, + { + fileName: "report-📄.txt", + disposition: + "attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt", + }, + { + fileName: 'say "hi" \\ bye.txt', + disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"', + }, + { + fileName: "100%25 done.txt", + disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt", + }, + { + fileName: "l'été (1)*.txt", + disposition: + "attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt", + }, + ])("$fileName", ({ fileName, disposition }) => { + expect(formatAttachmentContentDisposition(fileName)).toBe(disposition); + }); +}); diff --git a/packages/server/src/server/file-download/content-disposition.ts b/packages/server/src/server/file-download/content-disposition.ts new file mode 100644 index 0000000000..1ad7997b7c --- /dev/null +++ b/packages/server/src/server/file-download/content-disposition.ts @@ -0,0 +1,32 @@ +const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g; +const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/; +const QUOTED_STRING_SPECIALS = /[\\"]/g; +const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g; + +/** + * Content-Disposition for a file download (RFC 6266). + * + * Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because + * browsers do not read raw Latin-1 bytes in `filename` and fall back to a name + * taken from the URL. A name with a percent escape gets it too, so browsers do + * not decode the escape in `filename`. + */ +export function formatAttachmentContentDisposition(fileName: string): string { + const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?"); + const disposition = `attachment; filename=${quote(asciiFileName)}`; + if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) { + return disposition; + } + return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`; +} + +function quote(value: string): string { + return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`; +} + +function encodeExtendedValue(value: string): string { + return encodeURIComponent(value).replace( + NON_ATTR_CHARS_LEFT_BY_URI_ENCODING, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} diff --git a/packages/server/src/server/file-explorer/service.test.ts b/packages/server/src/server/file-explorer/service.test.ts index ea22ac24b9..a82e1a8f80 100644 --- a/packages/server/src/server/file-explorer/service.test.ts +++ b/packages/server/src/server/file-explorer/service.test.ts @@ -12,8 +12,8 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { runGitCommand } from "../../utils/run-git-command.js"; +import { describe, expect, it, vi } from "vitest"; +import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js"; import { createExplorerEntry, deleteExplorerEntry, @@ -517,6 +517,129 @@ describe("file explorer service", () => { } }); + it("renames an untracked bracketed filename without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-entry-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i.tsx"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8"); + + snapshotGitCommandRuntimeMetrics(); + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([ + ["ls-files", 1], + ["rev-parse", 1], + ]); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("renames an untracked bracketed folder without matching a tracked sibling", async () => { + const root = await createTempDir("paseo-folder-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.join(root, "i")); + await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8"); + await runGitCommand(["add", "i"], { cwd: root }); + await mkdir(path.join(root, "[id]")); + await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8"); + + const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" }); + + expect(result).toEqual({ status: "ok", path: "route" }); + expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n"); + await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => { + const root = await createTempDir("paseo-tracked-literal-rename-"); + try { + await runGitCommand(["init"], { cwd: root }); + await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: "[id].tsx", + name: "renamed.tsx", + }); + + expect(result).toEqual({ status: "ok", path: "renamed.tsx" }); + expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + "i.tsx\0renamed.tsx\0", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it.each([ + { + sourcePath: "[id].tsx", + sourceFile: "[id].tsx", + targetName: "renamed.tsx", + targetFile: "renamed.tsx", + }, + { + sourcePath: "[id]", + sourceFile: "[id]/route.tsx", + targetName: "route", + targetFile: "route/route.tsx", + }, + ])( + "keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)", + async ({ sourcePath, sourceFile, targetName, targetFile }) => { + const root = await createTempDir("paseo-inherited-literal-rename-"); + try { + vi.stubEnv("GIT_LITERAL_PATHSPECS", "1"); + await runGitCommand(["init"], { cwd: root }); + await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true }); + await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8"); + await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8"); + await runGitCommand(["add", "."], { cwd: root }); + + const result = await renameExplorerEntry({ + root, + relativePath: sourcePath, + name: targetName, + }); + + expect(result).toEqual({ status: "ok", path: targetName }); + expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n"); + await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n"); + expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe( + `i.tsx\0${targetFile}\0`, + ); + expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1"); + } finally { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); + } + }, + ); + it("supports case-only renames for tracked and untracked files", async () => { const root = await createTempDir("paseo-entry-case-rename-"); try { diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 5aa4efe974..b371a5e4af 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -489,7 +489,7 @@ export async function writeExplorerFile({ const temporaryPath = path.join( path.dirname(filePath.resolvedPath), - `.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`, + `.paseo-${randomUUID()}.tmp`, ); let temporaryHandle: FileHandle | null = null; try { @@ -724,7 +724,11 @@ export async function renameExplorerEntry({ }); const tracked = repository.exitCode === 0 - ? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot }) + ? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], { + cwd: realRoot, + // Inherited literal mode disables the :(literal) pathspec magic. + envOverlay: { GIT_LITERAL_PATHSPECS: undefined }, + }) : null; if (tracked?.stdout) { await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot }); @@ -820,8 +824,9 @@ async function resolveScopedPath({ function assertWithinWorkspace(root: string, candidate: string): void { const relative = path.relative(root, candidate); - if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; - throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); + const isOutsideWorkspace = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE); } async function openFileForRead(filePath: string): Promise { diff --git a/packages/server/src/server/file-observer/index.test.ts b/packages/server/src/server/file-observer/index.test.ts index 0427cd823b..711b826348 100644 --- a/packages/server/src/server/file-observer/index.test.ts +++ b/packages/server/src/server/file-observer/index.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, sep } from "node:path"; +import { basename, dirname, join, sep } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { createFileObserver, @@ -212,51 +212,59 @@ test("observes a thousand concurrent writes and remains healthy after delete and const root = await createRoot(); const directories = Array.from({ length: 20 }, (_, index) => join(root, `dir-${index}`)); await Promise.all(directories.map((directory) => mkdir(directory))); - const observed = new Map>(); - const subscription = await subscribeToFileChanges(root, (error, events) => { - expect(error).toBeNull(); - for (const event of events) { - const types = observed.get(event.path) ?? new Set(); - types.add(event.type); - observed.set(event.path, types); - } - }); const paths = Array.from({ length: 1_000 }, (_, index) => join(directories[index % directories.length], `file-${index}.txt`), ); - const populatedDirectory = join(root, "populated", "nested"); - await mkdir(populatedDirectory, { recursive: true }); const populatedPaths = Array.from({ length: 200 }, (_, index) => join(populatedDirectory, `nested-${index}.txt`), ); + const removedPaths = paths.slice(0, 100); + const moves = directories.slice(0, 10).map((from, index) => ({ + from, + to: join(root, `renamed-${index}`), + })); + const movedPaths = moves.flatMap(({ from, to }) => + paths + .slice(100) + .filter((path) => dirname(path) === from) + .map((path) => join(to, basename(path))), + ); + const sentinel = join(directories[15], "still-observed.txt"); + const population = expectFileChanges(populatedPaths); + const writes = expectFileChanges(paths); + const deletions = expectFileChanges(removedPaths, "delete"); + const movedFiles = expectFileChanges(movedPaths); + const recovery = expectFileChanges([sentinel]); + const subscription = await subscribeToFileChanges(root, (error, events) => { + expect(error).toBeNull(); + for (const event of events) { + population.record(event); + writes.record(event); + deletions.record(event); + movedFiles.record(event); + recovery.record(event); + } + }); + + await mkdir(populatedDirectory, { recursive: true }); await Promise.all(populatedPaths.map((path, index) => writeFile(path, `${index}`))); - await expect - .poll(() => populatedPaths.filter((path) => !observed.has(path)), { timeout: 10_000 }) - .toEqual([]); + await population.complete; await Promise.all(paths.map((path, index) => writeFile(path, `${index}`))); - await expect - .poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 }) - .toEqual([]); + await writes.complete; - const removedPaths = paths.slice(0, 100); + // A native watcher may recover coalesced paths through its safety audit. + // Completion comes from the delivered deletions, not an idle diagnostic or + // a deadline shorter than that recovery cycle. await Promise.all(removedPaths.map((path) => rm(path))); - await expect - .poll(() => removedPaths.filter((path) => !observed.get(path)?.has("delete")), { - timeout: 10_000, - }) - .toEqual([]); + await deletions.complete; - for (let index = 0; index < 10; index += 1) { - const from = directories[index]; - const to = join(root, `renamed-${index}`); - await rename(from, to); - await rm(to, { recursive: true, force: true }); - } - const sentinel = join(directories[15], "still-observed.txt"); + await Promise.all(moves.map(({ from, to }) => rename(from, to))); + await movedFiles.complete; + await Promise.all(moves.map(({ to }) => rm(to, { recursive: true, force: true }))); await writeFile(sentinel, "alive"); - await expect.poll(() => observed.has(sentinel)).toBe(true); + await recovery.complete; await subscription.unsubscribe(); }, 90_000); @@ -312,6 +320,7 @@ test("closing the observer service releases every subscription and rejects reuse nativeTrackedFileCount: 0, pendingEventCount: 0, pendingReconciliationWorkCount: 0, + pendingClassificationCount: 0, reconciliationInFlightCount: 0, reconciliationCount: 0, scopedReconciliationCount: 0, @@ -360,3 +369,16 @@ async function createRoot(): Promise { roots.add(root); return root; } + +function expectFileChanges(paths: string[], type?: FileChange["type"]) { + const pending = new Set(paths); + const { promise: complete, resolve } = Promise.withResolvers(); + return { + complete, + record(event: FileChange) { + if (type && event.type !== type) return; + pending.delete(event.path); + if (pending.size === 0) resolve(); + }, + }; +} diff --git a/packages/server/src/server/file-observer/index.ts b/packages/server/src/server/file-observer/index.ts index 785161bea5..05c9fdac50 100644 --- a/packages/server/src/server/file-observer/index.ts +++ b/packages/server/src/server/file-observer/index.ts @@ -29,6 +29,7 @@ export interface FileObserverDiagnostics { nativeTrackedFileCount: number; pendingEventCount: number; pendingReconciliationWorkCount: number; + pendingClassificationCount: number; reconciliationInFlightCount: number; reconciliationCount: number; scopedReconciliationCount: number; @@ -120,6 +121,7 @@ class FileObserverService implements FileObserver { let nativeTrackedFileCount = 0; let pendingEventCount = 0; let pendingReconciliationWorkCount = 0; + let pendingClassificationCount = 0; let reconciliationInFlightCount = 0; for (const observation of observations) { const diagnostics = observation.getDiagnostics(); @@ -127,6 +129,7 @@ class FileObserverService implements FileObserver { nativeTrackedFileCount += diagnostics.nativeTrackedFileCount; pendingEventCount += diagnostics.pendingEventCount; pendingReconciliationWorkCount += diagnostics.pendingReconciliationWorkCount; + pendingClassificationCount += diagnostics.pendingClassificationCount; reconciliationInFlightCount += Number(diagnostics.reconciliationInFlight); } return { @@ -135,6 +138,7 @@ class FileObserverService implements FileObserver { nativeTrackedFileCount, pendingEventCount, pendingReconciliationWorkCount, + pendingClassificationCount, reconciliationInFlightCount, ...metrics, }; @@ -178,6 +182,7 @@ function emptyDiagnostics(): FileObserverDiagnostics { nativeTrackedFileCount: 0, pendingEventCount: 0, pendingReconciliationWorkCount: 0, + pendingClassificationCount: 0, reconciliationInFlightCount: 0, ...createObserverMetrics(), }; diff --git a/packages/server/src/server/file-observer/internal/contracts.ts b/packages/server/src/server/file-observer/internal/contracts.ts index fa7a6ddb0f..1d38e14a19 100644 --- a/packages/server/src/server/file-observer/internal/contracts.ts +++ b/packages/server/src/server/file-observer/internal/contracts.ts @@ -7,6 +7,7 @@ export interface ObserverMetrics extends Omit< | "nativeTrackedFileCount" | "pendingEventCount" | "pendingReconciliationWorkCount" + | "pendingClassificationCount" | "reconciliationInFlightCount" > {} @@ -14,6 +15,7 @@ export interface BackendDiagnostics { nativeHandleCount: number; nativeTrackedFileCount: number; pendingReconciliationWorkCount: number; + pendingClassificationCount: number; reconciliationInFlight: boolean; } diff --git a/packages/server/src/server/file-observer/internal/linux.test.ts b/packages/server/src/server/file-observer/internal/linux.test.ts new file mode 100644 index 0000000000..3c8d6666b7 --- /dev/null +++ b/packages/server/src/server/file-observer/internal/linux.test.ts @@ -0,0 +1,122 @@ +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { createFileObserver } from "../index.js"; +import { createLinuxBackend } from "./linux.js"; +import { createObserverPaths } from "./paths.js"; + +// This backend keeps no file-level inventory and runs no periodic full-tree +// safety audit (see the comment on MAX_QUEUED_CLASSIFICATIONS in linux.ts), so +// a shed classification could never be recovered later the way the native +// backend's directory audit recovers one. Overflow must fail the observation +// loudly instead of silently dropping a create or delete. +test("the linux backend fails the observation when the classification queue overflows", async () => { + const root = await mkdtemp(join(tmpdir(), "linux-classify-")); + const paths = createObserverPaths(process.platform); + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + let failure: Error | null = null; + const backend = createLinuxBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: () => {}, + fail: (error) => { + // Mirror the real Observation host: a failure ends the subscription, + // so isActive() must flip immediately and further events must not be + // processed. + active = false; + failure = error; + }, + }, + paths, + (_directory, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event: string, onError: (error: Error) => void) => notifications.on(event, onError), + } as never; + }, + ); + try { + await backend.start(); + // Emitted synchronously: no stat can settle before the last event lands, + // so the queue grows monotonically until it overflows. Once it does, + // isActive() flips false and the backend's own listener guard skips the + // rest of the burst, so it's safe to keep emitting unconditionally. + for (let index = 0; index < 20_000; index += 1) { + notifications.emit("change", "rename", `module-${index}.js`); + } + expect(active).toBe(false); + expect(failure).toBeInstanceOf(Error); + expect(failure?.message).toMatch(/exceeded \d+ queued classifications/); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("an ignore update drains classifications queued below the new excluded root", async () => { + const root = await mkdtemp(join(tmpdir(), "linux-ignore-queue-")); + const ignored = join(root, "generated"); + await mkdir(ignored); + const tracked = join(root, "tracked.txt"); + await writeFile(tracked, "tracked"); + // The backend is injected for deterministic queue admission, but its real + // temporary root uses the host filesystem's path format. + const paths = createObserverPaths(process.platform); + let onRootChange: ((eventType: string, filename: string | null) => void) | null = null; + const observer = createFileObserver(); + let active = true; + let ignoreGenerated = false; + const delivered: string[] = []; + const backend = createLinuxBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path), + isPathInside: paths.isInside, + queueEvent: (type, path) => { + if (type === "create") delivered.push(path); + }, + fail: (error) => { + throw error; + }, + }, + paths, + (directory, listener) => { + if (directory === root) onRootChange = listener; + return { + close: () => {}, + on: () => {}, + } as never; + }, + ); + try { + await backend.start(); + delivered.length = 0; + for (let index = 0; index < 6_000; index += 1) { + onRootChange?.("rename", `generated/file-${index}.js`); + } + onRootChange?.("rename", "tracked.txt"); + expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0); + ignoreGenerated = true; + await backend.updateIgnore(); + expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1); + await expect.poll(() => delivered.includes(tracked)).toBe(true); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/linux.ts b/packages/server/src/server/file-observer/internal/linux.ts index 70b1e549b1..d67517a006 100644 --- a/packages/server/src/server/file-observer/internal/linux.ts +++ b/packages/server/src/server/file-observer/internal/linux.ts @@ -7,22 +7,46 @@ import { isMissingPathError, toError } from "./paths.js"; const MAX_WATCHED_DIRECTORIES = 5_000; const RECONCILIATION_DELAY_MS = 50; +// fs.stat runs on the libuv threadpool, which is four threads and shared by the +// whole daemon. One stat per native event pins it and unrelated filesystem work +// stalls. The concurrency limit bounds that. +// +// Unlike the native backend, this backend keeps no file-level inventory +// (nativeTrackedFileCount is always 0, and its own reconcile() diffs +// directories, never files) and runs no periodic full-tree safety audit. A +// classification shed here can never be recovered later, so overflow must +// fail the observation loudly instead of silently dropping a create or +// delete. WorkspaceGit already handles this failure mode: it catches the +// existing MAX_WATCHED_DIRECTORIES overflow and falls back to bounded +// polling. The cap is generous — it should only trip under genuine +// pathology, not an ordinary large burst. +const MAX_CONCURRENT_CLASSIFICATIONS = 32; +const MAX_QUEUED_CLASSIFICATIONS = 16_384; interface DirectorySnapshot { directories: Set; filesDiscoveredBeforeCoverage: Set; } +type WatchDirectory = ( + directory: string, + listener: (eventType: string, filename: string | null) => void, +) => FSWatcher; + +const watchDirectory: WatchDirectory = (directory, listener) => watch(directory, listener); + export function createLinuxBackend( host: ObservationHost, paths: ObserverPaths, + observe: WatchDirectory = watchDirectory, ): ObservationBackend { - return new LinuxBackend(host, paths); + return new LinuxBackend(host, paths, observe); } class LinuxBackend implements ObservationBackend { private readonly watchers = new Map(); private readonly classifications = new Set>(); + private readonly classificationQueue: Array<{ path: string }> = []; private readonly pendingScopes = new Set(); private reconcileTail: Promise = Promise.resolve(); private queueDepth = 0; @@ -33,6 +57,7 @@ class LinuxBackend implements ObservationBackend { constructor( private readonly host: ObservationHost, private readonly paths: ObserverPaths, + private readonly observe: WatchDirectory, ) {} async start(): Promise { @@ -41,6 +66,8 @@ class LinuxBackend implements ObservationBackend { } async updateIgnore(): Promise { + this.discardIgnoredClassifications(); + const inFlight = [...this.classifications]; for (const [directory, watcher] of this.watchers) { if (directory !== this.host.root && this.host.isIgnored(directory)) { watcher.close(); @@ -49,6 +76,7 @@ class LinuxBackend implements ObservationBackend { } this.cancelQueued(); await this.enqueue([this.host.root]); + await Promise.allSettled(inFlight); } close(): Promise { @@ -67,14 +95,17 @@ class LinuxBackend implements ObservationBackend { pendingReconciliationWorkCount: this.pendingScopes.size + this.classifications.size + + this.classificationQueue.length + this.queueDepth + Number(this.timer !== null), + pendingClassificationCount: this.classifications.size + this.classificationQueue.length, reconciliationInFlight: this.inFlight, }; } private async finishClose(): Promise { await this.reconcileTail; + this.classificationQueue.length = 0; await Promise.allSettled(this.classifications); for (const watcher of this.watchers.values()) watcher.close(); this.watchers.clear(); @@ -90,7 +121,7 @@ class LinuxBackend implements ObservationBackend { `Recursive file observation exceeded ${MAX_WATCHED_DIRECTORIES} directories under ${this.host.root}`, ); } - const watcher = watch(directory, (eventType, filename) => { + const watcher = this.observe(directory, (eventType, filename) => { if (!this.host.isActive()) return; const path = filename ? resolve(directory, filename.toString()) : directory; if (!this.host.isIgnored(path)) { @@ -247,6 +278,45 @@ class LinuxBackend implements ObservationBackend { } private classify(path: string): void { + if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) { + // This backend has no file-level inventory and no full-tree safety + // audit to fall back on (see the comment on MAX_QUEUED_CLASSIFICATIONS + // above), so a shed here would silently and permanently lose this + // create/delete. Fail loudly instead, the same degradation path as + // MAX_WATCHED_DIRECTORIES overflow. + this.host.fail( + new Error( + `Recursive file observation exceeded ${MAX_QUEUED_CLASSIFICATIONS} queued classifications under ${this.host.root}`, + ), + ); + return; + } + this.classificationQueue.push({ path }); + this.pumpClassifications(); + } + + private discardIgnoredClassifications(): void { + let kept = 0; + for (const classification of this.classificationQueue) { + if (!this.host.isIgnored(classification.path)) { + this.classificationQueue[kept++] = classification; + } + } + this.classificationQueue.length = kept; + } + + private pumpClassifications(): void { + while ( + this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS && + this.classificationQueue.length > 0 + ) { + const next = this.classificationQueue.shift(); + if (!next) return; + this.startClassification(next.path); + } + } + + private startClassification(path: string): void { this.host.metrics.nativeClassificationCount += 1; let classification!: Promise; classification = stat(path) @@ -258,7 +328,10 @@ class LinuxBackend implements ObservationBackend { } this.host.fail(toError(error)); }) - .finally(() => this.classifications.delete(classification)); + .finally(() => { + this.classifications.delete(classification); + if (this.host.isActive()) this.pumpClassifications(); + }); this.classifications.add(classification); } diff --git a/packages/server/src/server/file-observer/internal/native-recursive.test.ts b/packages/server/src/server/file-observer/internal/native-recursive.test.ts new file mode 100644 index 0000000000..12ab1b18f3 --- /dev/null +++ b/packages/server/src/server/file-observer/internal/native-recursive.test.ts @@ -0,0 +1,489 @@ +import { EventEmitter } from "node:events"; +import { writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test, vi } from "vitest"; +import { createFileObserver, type FileChange } from "../index.js"; +import { createNativeRecursiveBackend } from "./native-recursive.js"; +import { createObserverPaths } from "./paths.js"; + +// Native watchers may coalesce file removals into change notifications. Keep the +// filesystem real while controlling which notifications reach reconciliation. +test.each(["existing", "new"])( + "shallow scans recover coalesced deletes of %s files in nested scopes", + async (population) => { + const root = await mkdtemp(join(tmpdir(), "native-scopes-")); + const paths = createObserverPaths(process.platform); + const removed = [ + join(root, "root.txt"), + join(root, "child", "child.txt"), + join(root, "child", "deep", "deep.txt"), + ]; + await mkdir(join(root, "child", "deep"), { recursive: true }); + if (population === "existing") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + } + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => { + notifications.removeAllListeners(); + }, + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + if (population === "new") { + await Promise.all(removed.map((path) => writeFile(path, "before"))); + for (const path of removed) notifications.emit("change", "change", path); + } + await Promise.all(removed.map((path) => rm(path))); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + for (const path of removed) notifications.emit("change", "change", path); + await vi.advanceTimersByTimeAsync(8_000); + vi.useRealTimers(); + await expect + .poll(() => + events + .filter((event) => event.type === "delete") + .map((event) => event.path) + .sort(), + ) + .toEqual([...removed].sort()); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } + }, +); + +test("a file announced only as changed remains visible to coalesced deletion scans", async () => { + const root = await mkdtemp(join(tmpdir(), "native-change-only-")); + const directory = join(root, "nested"); + await mkdir(directory); + const paths = createObserverPaths(process.platform); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + let active = true; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + const path = join(directory, "changed.txt"); + await writeFile(path, "created"); + notifications.emit("change", "change", path); + await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + + await rm(path); + notifications.emit("change", "change", directory); + await expect + .poll(() => events.filter((event) => event.type === "delete"), { timeout: 10_000 }) + .toEqual([{ path, type: "delete" }]); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +test("a native ignore update drains classifications queued below the new excluded root", async () => { + const root = await mkdtemp(join(tmpdir(), "native-ignore-queue-")); + const ignored = join(root, "generated"); + await mkdir(ignored); + const tracked = join(root, "tracked.txt"); + await writeFile(tracked, "tracked"); + const paths = createObserverPaths(process.platform); + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + let ignoreGenerated = false; + const delivered: string[] = []; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: (path) => ignoreGenerated && paths.isInside(ignored, path), + isPathInside: paths.isInside, + queueEvent: (type, path) => { + if (type === "create") delivered.push(path); + }, + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + for (let index = 0; index < 2_000; index += 1) { + notifications.emit("change", "rename", join(ignored, `file-${index}.js`)); + } + notifications.emit("change", "rename", tracked); + expect(backend.getDiagnostics().pendingClassificationCount).toBeGreaterThan(0); + ignoreGenerated = true; + await backend.updateIgnore(); + expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(1); + await expect.poll(() => delivered.includes(tracked)).toBe(true); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +// One fs.stat per native event is how a single dependency install pins the +// libuv threadpool and the daemon stops answering. +test("a rename burst does not spawn one stat per event", async () => { + const root = await mkdtemp(join(tmpdir(), "native-classify-")); + const paths = createObserverPaths(process.platform); + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: () => {}, + fail: () => {}, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); + // Emitted synchronously: no stat can settle before the last event lands. + for (let index = 0; index < 5_000; index += 1) { + notifications.emit("change", "rename", join(root, `package-${index}`, "index.js")); + } + expect(backend.getDiagnostics().pendingClassificationCount).toBeLessThanOrEqual(2_080); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +// The shed path is only safe because this backend has a real files/entries +// inventory to diff against. Prove it does the work: force a shed onto an +// already-tracked directory and confirm the create still surfaces. +test("a classification shed onto the scoped audit still recovers the file", async () => { + const root = await mkdtemp(join(tmpdir(), "native-shed-")); + const paths = createObserverPaths(process.platform); + const shedDirectory = join(root, "shed"); + await mkdir(shedDirectory); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); // The initial full audit tracks "shed" as known. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + + // Jam the queue exactly as in the burst test above: 32 in flight, 2,048 + // queued, all against a directory that never existed. + const fillerDirectory = join(root, "filler"); + for (let index = 0; index < 2_048 + 32; index += 1) { + notifications.emit("change", "rename", join(fillerDirectory, `file-${index}.js`)); + } + expect(backend.getDiagnostics().pendingClassificationCount).toBe(2_080); + + // A real file lands in the already-tracked "shed" directory while the + // queue is full, so this event must be shed rather than stat-ed. Written + // synchronously so no queued stat can settle and free a slot before this + // event is classified -- that would let it enqueue normally instead of + // exercising the shed branch. + const shedPath = join(shedDirectory, "recovered.txt"); + writeFileSync(shedPath, "content"); + notifications.emit("change", "rename", shedPath); + + // 2s clears the mandatory-audit deadline (500ms quiet / 5s max-dirty) + // that the shed forces by adding "shed" to localScopes, but stays well + // under the optional-only deadline (8s) that the ambient per-event + // change-scope audit alone would use. Every event -- shed or not -- + // already schedules that ambient audit, so advancing 8s would recover + // this file even if the shed branch were a no-op. The short window is + // what actually proves the shed's own requestAudit call did the work. + await vi.advanceTimersByTimeAsync(2_000); + vi.useRealTimers(); + + await expect + .poll(() => events.some((event) => event.type === "create" && event.path === shedPath)) + .toBe(true); + } finally { + vi.useRealTimers(); + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +// C1 regression: removeSubtree unlinks its root from the parent's +// DirectoryEntry as part of tearing down the old subtree. reconcileSubtree +// re-populates the index below `directory` via mergeInventory but must also +// re-link `directory` itself back into its parent -- walkSubtree only +// descends through that link, so without the re-link a later ancestor +// removal can no longer reach anything below the reconciled directory. +test("a recursive-scope reconcile re-links its directory so an ancestor removal still finds nested files", async () => { + const root = await mkdtemp(join(tmpdir(), "native-relink-")); + const paths = createObserverPaths(process.platform); + const deepDirectory = join(root, "a", "b", "c"); + const deepFile = join(deepDirectory, "deep.txt"); + await mkdir(deepDirectory, { recursive: true }); + await writeFile(deepFile, "content"); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + const settled = () => + expect + .poll( + () => { + const diagnostics = backend.getDiagnostics(); + return ( + diagnostics.pendingReconciliationWorkCount === 0 && !diagnostics.reconciliationInFlight + ); + }, + { timeout: 10_000 }, + ) + .toBe(true); + try { + await backend.start(); // Tracks a, a/b, a/b/c, and deep.txt. + + // A rename event landing on the already-known directory a/b produces a + // local scope on its parent "a" (rebuilding a's DirectoryEntry from a + // fresh shallow scan) and, once classify()'s real stat() confirms a/b is + // still a directory, a recursive scope on a/b itself -- the same "known + // directory changes in place" shape the directory-churn benchmark + // exercises via atomic replace. + notifications.emit("change", "rename", join(root, "a", "b")); + await settled(); + + // Remove the ancestor while the native event names only a deleted file. + // The scoped scan must report its missing directory as well as that file. + await rm(join(root, "a"), { recursive: true, force: true }); + notifications.emit("change", "rename", deepFile); + await settled(); + + expect(events.filter((event) => event.type === "delete").map((event) => event.path)).toContain( + deepFile, + ); + expect(events).toContainEqual({ type: "delete", path: deepDirectory }); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}, 15_000); + +test("a full native inventory audit reports a removed directory", async () => { + const root = await mkdtemp(join(tmpdir(), "native-full-audit-")); + const directory = join(root, "removed"); + await mkdir(directory); + await writeFile(join(directory, "tracked.txt"), "before"); + const paths = createObserverPaths(process.platform); + const events: FileChange[] = []; + const observer = createFileObserver(); + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => true, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + () => ({ close: () => {}, on: () => {} }), + ); + try { + await backend.start(); + await rm(directory, { recursive: true, force: true }); + await backend.updateIgnore(); // Uses the same full inventory diff as the safety audit. + expect(events).toContainEqual({ type: "delete", path: directory }); + } finally { + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +// I2 regression: startClassification queues an unconditional "create" event +// before onPresent runs. When a file's parent directory has no entries +// bucket yet (a coalesced directory-creation event that never arrived), +// declining to track the path at all announces a create it can never +// retract -- no future scan can rediscover a path that is already gone from +// disk to emit its matching delete. +test("a file classified with no parent bucket yet still gets its delete emitted", async () => { + const root = await mkdtemp(join(tmpdir(), "native-orphan-")); + const paths = createObserverPaths(process.platform); + const newDirectory = join(root, "newdir"); + const events: FileChange[] = []; + const notifications = new EventEmitter(); + const observer = createFileObserver(); + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: observer.getDiagnostics(), + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => events.push({ type, path }), + fail: (error) => { + throw error; + }, + }, + paths, + (_root, listener) => { + notifications.on("change", listener); + return { + close: () => notifications.removeAllListeners(), + on: (event, onError) => notifications.on(event, onError), + }; + }, + ); + try { + await backend.start(); // Tracks the (empty) root only. + + // A brand-new directory with no directory-created event processed for + // it yet (coalesced away) has no entries bucket: entries.get(scope) is + // undefined for a file inside it. + await mkdir(newDirectory); + const filePath = join(newDirectory, "orphan.txt"); + await writeFile(filePath, "content"); + notifications.emit("change", "rename", filePath); + + // The file must stay tracked so a later removal can still find and + // retract the create that startClassification already queued. + await expect.poll(() => backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + expect(events.some((event) => event.type === "create" && event.path === filePath)).toBe(true); + + // Remove the whole directory before any audit ever scans it. Nothing + // will see filePath as "on disk and gone from a known listing" through + // the normal directory-scan diff, since newdir never had one. + await rm(newDirectory, { recursive: true, force: true }); + + await expect + .poll(() => events.some((event) => event.type === "delete" && event.path === filePath), { + timeout: 10_000, + }) + .toBe(true); + expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(0); + } finally { + active = false; + await backend.close(); + await observer.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-observer/internal/native-recursive.ts b/packages/server/src/server/file-observer/internal/native-recursive.ts index e5ef077535..4df86d7745 100644 --- a/packages/server/src/server/file-observer/internal/native-recursive.ts +++ b/packages/server/src/server/file-observer/internal/native-recursive.ts @@ -1,4 +1,4 @@ -import { type Dirent, type FSWatcher, watch } from "node:fs"; +import { type Dirent, watch } from "node:fs"; import { readdir, stat } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import type { BackendDiagnostics, ObservationBackend, ObservationHost } from "./contracts.js"; @@ -6,6 +6,12 @@ import type { ObserverPaths } from "./paths.js"; import { isMissingPathError, toError } from "./paths.js"; const MAX_TRACKED_ENTRIES = 250_000; +// fs.stat runs on the libuv threadpool, which is four threads and shared by the +// whole daemon. One stat per native event pins it and unrelated filesystem work +// stalls. Beyond the queue bound, fall back to a directory scan, which finds the +// same paths at a fraction of the cost. +const MAX_CONCURRENT_CLASSIFICATIONS = 32; +const MAX_QUEUED_CLASSIFICATIONS = 2_048; const AUDIT_QUIET_MS = 500; const AUDIT_MAX_DIRTY_MS = 5_000; const OPTIONAL_AUDIT_QUIET_MS = 8_000; @@ -25,19 +31,42 @@ interface Inventory { files: Set; } +type WatchDirectory = ( + root: string, + listener: (eventType: string, filename: string | null) => void, +) => { + close(): void; + on(event: "error", listener: (error: Error) => void): unknown; +}; + +const watchDirectory: WatchDirectory = (root, listener) => + watch(root, { recursive: true }, listener); + export function createNativeRecursiveBackend( host: ObservationHost, paths: ObserverPaths, + observe: WatchDirectory = watchDirectory, ): ObservationBackend { - return new NativeRecursiveBackend(host, paths); + return new NativeRecursiveBackend(host, paths, observe); } class NativeRecursiveBackend implements ObservationBackend { - private watcher: FSWatcher | null = null; + private watcher: ReturnType | null = null; private files = new Set(); + // Files classified before their parent directory has an entries bucket. + // walkSubtree can only reach files through that bucket, so these stay + // tracked directly until a scoped audit builds the bucket and adopts them. + // Bounded by every file classified within one full-audit window (up to + // FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose parent never got a bucket, not + // by the size of the tracked set. + private orphanFiles = new Set(); private directories = new Set(); private entries = new Map(); private readonly classifications = new Set>(); + private readonly classificationQueue: Array<{ + path: string; + onPresent: (isDirectory: boolean) => void; + }> = []; private readonly localScopes = new Set(); private readonly changeScopes = new Set(); private readonly recursiveScopes = new Set(); @@ -62,6 +91,7 @@ class NativeRecursiveBackend implements ObservationBackend { constructor( private readonly host: ObservationHost, private readonly paths: ObserverPaths, + private readonly observe: WatchDirectory, ) {} async start(): Promise { @@ -72,11 +102,14 @@ class NativeRecursiveBackend implements ObservationBackend { } async updateIgnore(): Promise { + this.discardIgnoredClassifications(); + const inFlight = [...this.classifications]; this.generation += 1; this.cancelAudits(); this.fullAuditRequested = true; this.auditQueued = true; await this.enqueueAudit(true, this.generation); + await Promise.allSettled(inFlight); } close(): Promise { @@ -97,24 +130,28 @@ class NativeRecursiveBackend implements ObservationBackend { this.changeScopes.size + this.recursiveScopes.size + this.classifications.size + + this.classificationQueue.length + this.queueDepth + Number(this.auditTimer !== null || this.auditQueued || this.auditDirty), + pendingClassificationCount: this.classifications.size + this.classificationQueue.length, reconciliationInFlight: this.inFlight, }; } private async finishClose(): Promise { await this.reconcileTail; + this.classificationQueue.length = 0; await Promise.allSettled(this.classifications); this.watcher?.close(); this.watcher = null; this.files.clear(); + this.orphanFiles.clear(); this.directories.clear(); this.entries.clear(); } private watchRoot(): void { - const watcher = watch(this.host.root, { recursive: true }, (eventType, filename) => { + const watcher = this.observe(this.host.root, (eventType, filename) => { if (!this.host.isActive()) return; this.host.metrics.nativeEventCount += 1; if (!filename) { @@ -126,22 +163,41 @@ class NativeRecursiveBackend implements ObservationBackend { const path = resolve(this.host.root, filename.toString()); if (this.host.isIgnored(path)) return; const scope = path === this.host.root ? this.host.root : dirname(path); + const knownDirectory = this.directories.has(path); + this.requestChangeAudit(scope); if (eventType === "change") { this.host.metrics.nativeChangeEventCount += 1; this.host.queueEvent("update", path); - this.requestChangeAudit(scope); - if (this.directories.has(path)) this.requestAudit(path, true); - return; + if (knownDirectory) this.requestAudit(path, true); + if (knownDirectory || this.files.has(path)) return; + // A coalesced creation can arrive only as a change. Classify unknown + // paths so later deletion scans can find them in the inventory. + } else { + this.host.metrics.nativeRenameEventCount += 1; + if (knownDirectory) this.requestAudit(scope); } - this.host.metrics.nativeRenameEventCount += 1; - const knownDirectory = this.directories.has(path); this.classify(path, (isDirectory) => { - if (!isDirectory) return; + if (!isDirectory) { + const entry = this.entries.get(scope); + if (entry) { + // Remember files as soon as we announce them. A coalesced delete must + // still be found by the next audit, even before the first directory scan. + this.files.add(path); + entry.files.add(path); + } else { + // No inventory for the parent yet. startClassification already + // announced this as a create, so it must stay findable for a + // matching delete: track it directly until a scoped audit builds + // the bucket and adopts it (see orphanFiles above). + this.files.add(path); + this.orphanFiles.add(path); + this.requestAudit(scope); + } + return; + } if (knownDirectory) this.requestAudit(path, true); else this.requestAudit(scope); }); - this.requestChangeAudit(scope); - if (knownDirectory) this.requestAudit(scope); }); watcher.on("error", (error) => { if (this.host.isActive()) this.host.fail(toError(error)); @@ -182,7 +238,10 @@ class NativeRecursiveBackend implements ObservationBackend { if (fullAudit) { const next = await this.scanTree(this.host.root); if (!this.canCommit(generation)) return; - if (emitDiff) this.queueDiff(next.files, this.files); + if (emitDiff) { + this.queueDiff(next.files, this.files); + this.queueRemovedDirectories(next.directories, this.directories); + } this.replaceInventory(next); this.lastFullAuditAt = performance.now(); this.host.metrics.fullReconciliationCount += 1; @@ -257,18 +316,22 @@ class NativeRecursiveBackend implements ObservationBackend { for (const directory of new Set(localScopes)) { await this.reconcileDirectory(directory, generation); if (!this.canCommit(generation)) return; + await new Promise((done) => setImmediate(done)); } for (const directory of new Set(recursiveScopes)) { await this.reconcileSubtree(directory, generation); if (!this.canCommit(generation)) return; + await new Promise((done) => setImmediate(done)); } - for (const directory of this.paths.collapse(changeScopes)) { + // Parent scans are shallow, so a queued parent cannot cover its children. + for (const directory of new Set(changeScopes)) { const alreadyCovered = forcedLocalScopes.has(directory) || forcedRecursiveScopes.some((scope) => this.host.isPathInside(scope, directory)); if (alreadyCovered) continue; await this.reconcileDirectory(directory, generation); if (!this.canCommit(generation)) return; + await new Promise((done) => setImmediate(done)); } } @@ -301,6 +364,7 @@ class NativeRecursiveBackend implements ObservationBackend { if (!next.directories.has(path)) this.removeSubtree(path, true); } for (const path of next.files) { + this.orphanFiles.delete(path); if (!previous.files.has(path)) { this.files.add(path); this.host.queueEvent("create", path); @@ -355,11 +419,23 @@ class NativeRecursiveBackend implements ObservationBackend { if (!this.directories.has(directory) || this.host.isIgnored(directory)) return; const inventory = await this.scanTree(directory); if (!this.canCommit(generation)) return; - const previousFiles = new Set( - [...this.files].filter((path) => this.host.isPathInside(directory, path)), - ); + const previousFiles = new Set(); + for (const tracked of this.walkSubtree(directory)) { + const entry = this.entries.get(tracked); + if (!entry) continue; + for (const file of entry.files) previousFiles.add(file); + } this.queueDiff(inventory.files, previousFiles); + this.queueRemovedDirectories(inventory.directories, this.walkSubtree(directory)); this.removeSubtree(directory, false); + // removeSubtree unlinks `directory` from its own parent's directories set + // as part of tearing down the old subtree. Mirror reconcileUnknownSubtree + // and re-link it, guarded on the directory still existing -- it can + // vanish between the `this.directories.has` check above and this scan -- + // or walkSubtree can no longer reach anything under it from the root. + if (inventory.directories.has(directory)) { + this.entries.get(dirname(directory))?.directories.add(directory); + } this.mergeInventory(inventory, false); } @@ -372,8 +448,16 @@ class NativeRecursiveBackend implements ObservationBackend { } } + private queueRemovedDirectories(next: Set, previous: Iterable): void { + for (const directory of previous) { + if (!next.has(directory)) this.host.queueEvent("delete", directory); + } + } + private replaceInventory(inventory: Inventory): void { this.files = inventory.files; + // A full scan gives every existing file a directory-index home. + this.orphanFiles.clear(); this.directories = inventory.directories; this.entries = inventory.entries; } @@ -382,6 +466,7 @@ class NativeRecursiveBackend implements ObservationBackend { for (const directory of inventory.directories) this.directories.add(directory); for (const [directory, entry] of inventory.entries) this.entries.set(directory, entry); for (const path of inventory.files) { + this.orphanFiles.delete(path); const added = !this.files.has(path); this.files.add(path); if (emitCreates && added) this.host.queueEvent("create", path); @@ -389,17 +474,50 @@ class NativeRecursiveBackend implements ObservationBackend { this.assertWithinLimit(this.files.size + this.directories.size); } + // Depends on the invariant that every tracked directory has an `entries` + // bucket, and that `this.directories` and `this.entries` are written and + // cleared together. A root with no bucket yields nothing here, which makes + // `removeSubtree` a near no-op for that root — it deletes nothing from + // `entries`/`directories` and only cleans up matching `orphanFiles` — so + // nothing enforces the invariant beyond this comment. + private *walkSubtree(root: string): Generator { + const stack = [root]; + while (stack.length > 0) { + const directory = stack.pop(); + if (directory === undefined) continue; + const entry = this.entries.get(directory); + if (!entry) continue; + yield directory; + for (const child of entry.directories) stack.push(child); + } + } + private removeSubtree(root: string, emitDeletes: boolean): void { - for (const path of this.files) { - if (!this.host.isPathInside(root, path)) continue; - this.files.delete(path); - if (emitDeletes) this.host.queueEvent("delete", path); - } - for (const directory of this.directories) { - if (!this.host.isPathInside(root, directory)) continue; - this.directories.delete(directory); + for (const directory of this.walkSubtree(root)) { + const entry = this.entries.get(directory); + if (entry) { + for (const file of entry.files) { + if (!this.files.delete(file)) continue; + if (emitDeletes) this.host.queueEvent("delete", file); + } + // A native rename can name only a deleted file. The scoped scan still + // knows its parent disappeared, so report that topology change too. + if (emitDeletes) this.host.queueEvent("delete", directory); + } this.entries.delete(directory); + this.directories.delete(directory); } + // Orphaned files have no directory-index home, so walkSubtree cannot + // find them. This set is bounded by every file classified within one + // full-audit window (up to FULL_AUDIT_MAX_DIRTY_MS, 5 minutes) whose + // parent never got a bucket, not by the tracked-set size. + for (const path of this.orphanFiles) { + if (!this.host.isPathInside(root, path)) continue; + this.orphanFiles.delete(path); + if (this.files.delete(path) && emitDeletes) this.host.queueEvent("delete", path); + } + // `root` can be a tracked file rather than a directory. + if (this.files.delete(root) && emitDeletes) this.host.queueEvent("delete", root); const parent = this.entries.get(dirname(root)); parent?.directories.delete(root); parent?.files.delete(root); @@ -556,6 +674,38 @@ class NativeRecursiveBackend implements ObservationBackend { } private classify(path: string, onPresent: (isDirectory: boolean) => void): void { + if (this.classificationQueue.length >= MAX_QUEUED_CLASSIFICATIONS) { + // Shed load onto the scoped audit, which reads the directory once instead + // of stat-ing every entry in it. + this.requestAudit(dirname(path)); + return; + } + this.classificationQueue.push({ path, onPresent }); + this.pumpClassifications(); + } + + private discardIgnoredClassifications(): void { + let kept = 0; + for (const classification of this.classificationQueue) { + if (!this.host.isIgnored(classification.path)) { + this.classificationQueue[kept++] = classification; + } + } + this.classificationQueue.length = kept; + } + + private pumpClassifications(): void { + while ( + this.classifications.size < MAX_CONCURRENT_CLASSIFICATIONS && + this.classificationQueue.length > 0 + ) { + const next = this.classificationQueue.shift(); + if (!next) return; + this.startClassification(next.path, next.onPresent); + } + } + + private startClassification(path: string, onPresent: (isDirectory: boolean) => void): void { this.host.metrics.nativeClassificationCount += 1; let classification!: Promise; classification = stat(path) @@ -571,7 +721,10 @@ class NativeRecursiveBackend implements ObservationBackend { } this.host.fail(toError(error)); }) - .finally(() => this.classifications.delete(classification)); + .finally(() => { + this.classifications.delete(classification); + if (this.host.isActive()) this.pumpClassifications(); + }); this.classifications.add(classification); } diff --git a/packages/server/src/server/file-observer/internal/paths.test.ts b/packages/server/src/server/file-observer/internal/paths.test.ts new file mode 100644 index 0000000000..8399760ef6 --- /dev/null +++ b/packages/server/src/server/file-observer/internal/paths.test.ts @@ -0,0 +1,42 @@ +import { win32 } from "node:path"; +import { describe, expect, test } from "vitest"; +import { createObserverPaths } from "./paths.js"; + +// m3 regression: plain lexicographic sort does not put a parent immediately +// before its descendants. A sibling whose name extends the parent's with a +// character that sorts below the separator (POSIX "/" = 0x2F, so "-" and "." +// qualify) lands between them, so the single backward look collapse() +// relies on misses the real parent and keeps a redundant descendant scope. +// This is this repo's own layout: packages/app, packages/app-web, +// packages/app/src. +describe("collapse", () => { + test("drops a redundant descendant but keeps a sibling whose name extends the parent's", () => { + const paths = createObserverPaths("linux"); + const parent = "/repo/packages/app"; + const sibling = "/repo/packages/app-web"; + const descendant = "/repo/packages/app/src"; + + expect(paths.collapse([parent, sibling, descendant]).sort()).toEqual([parent, sibling].sort()); + }); + + test("keeps unrelated roots and drops nested duplicates regardless of input order", () => { + const paths = createObserverPaths("linux"); + const a = "/repo/a"; + const aChild = "/repo/a/child"; + const b = "/repo/b"; + + expect(paths.collapse([aChild, b, a]).sort()).toEqual([a, b].sort()); + }); + + // The selected path strategy supplies separators and case comparison, so + // Windows paths can be checked on every CI host. + test("collapses win32 paths case-insensitively even with mismatched casing", () => { + const paths = createObserverPaths("win32"); + const sep = win32.sep; + const parent = `C:${sep}repo${sep}packages${sep}app`; + const sibling = `C:${sep}repo${sep}packages${sep}APP-web`; + const descendant = `c:${sep}repo${sep}packages${sep}app${sep}src`; + + expect(paths.collapse([parent, sibling, descendant]).sort()).toEqual([parent, sibling].sort()); + }); +}); diff --git a/packages/server/src/server/file-observer/internal/paths.ts b/packages/server/src/server/file-observer/internal/paths.ts index 71ffffb7d2..004b7ef298 100644 --- a/packages/server/src/server/file-observer/internal/paths.ts +++ b/packages/server/src/server/file-observer/internal/paths.ts @@ -1,4 +1,4 @@ -import { resolve, sep } from "node:path"; +import { posix, win32 } from "node:path"; export interface ObserverPaths { isInside(root: string, path: string): boolean; @@ -8,6 +8,8 @@ export interface ObserverPaths { } export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths { + const pathApi = platform === "win32" ? win32 : posix; + function comparable(path: string): string { return platform === "win32" ? path.toLowerCase() : path; } @@ -15,13 +17,34 @@ export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths { function isInside(root: string, path: string): boolean { const comparedRoot = comparable(root); const comparedPath = comparable(path); - return comparedPath === comparedRoot || comparedPath.startsWith(`${comparedRoot}${sep}`); + return ( + comparedPath === comparedRoot || comparedPath.startsWith(`${comparedRoot}${pathApi.sep}`) + ); } function collapse(paths: string[]): string[] { - return [...new Set(paths)] - .sort((left, right) => left.length - right.length) - .filter((path, index, all) => !all.slice(0, index).some((parent) => isInside(parent, path))); + // Plain lexicographic sort does not put a parent immediately before its + // descendants: a sibling whose name extends the parent's with a + // character that sorts below the separator (e.g. "app" vs "app-web") + // lands between them. Sort on a key with the separator swapped for a + // character below everything instead, so "app" < "app/src" < "app-web" + // holds and one backward look is enough. + const decorated = [...new Set(paths)].map((path) => ({ + path, + key: comparable(path).split(pathApi.sep).join("\0"), + })); + decorated.sort((left, right) => { + if (left.key < right.key) return -1; + if (left.key > right.key) return 1; + return 0; + }); + const kept: string[] = []; + for (const { path } of decorated) { + const previous = kept[kept.length - 1]; + if (previous !== undefined && isInside(previous, path)) continue; + kept.push(path); + } + return kept; } return { @@ -29,7 +52,7 @@ export function createObserverPaths(platform: NodeJS.Platform): ObserverPaths { collapse, normalizeIgnoredRoots(root, paths) { const inside = paths - .map((path) => resolve(path)) + .map((path) => pathApi.resolve(path)) .filter((path) => path !== root && isInside(root, path)); return collapse(inside); }, diff --git a/packages/server/src/server/file-observer/native-recursive.test.ts b/packages/server/src/server/file-observer/native-recursive.test.ts new file mode 100644 index 0000000000..cb442729fc --- /dev/null +++ b/packages/server/src/server/file-observer/native-recursive.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import type { FileChange } from "./index.js"; +import { createNativeRecursiveBackend } from "./internal/native-recursive.js"; +import { createObserverPaths } from "./internal/paths.js"; + +test("the recursive watcher remembers newly announced files before reconciliation", async () => { + const root = await mkdtemp(join(tmpdir(), "paseo-observer-native-")); + const paths = createObserverPaths(process.platform); + const changes: FileChange[] = []; + let active = true; + const backend = createNativeRecursiveBackend( + { + root, + metrics: { + reconciliationCount: 0, + scopedReconciliationCount: 0, + fullReconciliationCount: 0, + reconciliationFailureCount: 0, + observerFailureCount: 0, + directoryLimitFailureCount: 0, + nativeEventCount: 0, + nativeChangeEventCount: 0, + nativeRenameEventCount: 0, + nativePathlessEventCount: 0, + nativeClassificationCount: 0, + nativeShallowScanCount: 0, + lastReconciliationDurationMs: 0, + maxReconciliationDurationMs: 0, + }, + isActive: () => active, + isIgnored: () => false, + isPathInside: paths.isInside, + queueEvent: (type, path) => { + changes.push({ type, path }); + }, + fail: (error) => { + throw error; + }, + }, + paths, + ); + try { + await backend.start(); + const path = join(root, "new.txt"); + await writeFile(path, "created"); + await expect + .poll(() => changes.some((event) => event.path === path && event.type === "create")) + .toBe(true); + // Consumers have seen this file. A later audit needs it in its comparison + // inventory even if the native watcher coalesces the subsequent delete. + expect(backend.getDiagnostics().nativeTrackedFileCount).toBe(1); + await rm(path); + await backend.updateIgnore(); + await expect + .poll(() => changes.some((event) => event.path === path && event.type === "delete")) + .toBe(true); + } finally { + active = false; + await backend.close(); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index b338d7dd1c..8761529ec6 100644 --- a/packages/server/src/server/file-upload/index.test.ts +++ b/packages/server/src/server/file-upload/index.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -37,14 +37,14 @@ describe("file uploads", () => { await expect(uploads.receiveFrame(uploadChunk("req-upload", "hello"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-upload", " world"))).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-upload", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-upload"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-upload", file: { type: "uploaded_file", - id: "upload_req-upload", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -56,6 +56,49 @@ describe("file uploads", () => { expect(readFileSync(path, "utf8")).toBe("hello world"); }); + it("keeps the original file name for non-ASCII and punctuated names", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + for (const fileName of [ + "2026年9月绩效计划表.xlsx", + "테스트 파일 (1).xlsx", + "résumé [final] & notes, v2.pdf", + "cafe\u0301 हिंदी.txt", + ]) { + const file = await uploadNamed(uploads, fileName); + expect(file?.fileName).toBe(fileName); + expect(basename(file!.path)).toBe(fileName); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + } + }); + + it("replaces path separators, control characters, and characters Windows rejects", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({ + fileName: "passwd", + }); + const backslashed = await uploadNamed(uploads, "dir\\name.txt"); + expect(backslashed?.fileName).not.toContain("\\"); + expect(backslashed?.fileName).toMatch(/name\.txt$/); + await expect(uploadNamed(uploads, 'a:"c|?*.txt')).resolves.toMatchObject({ + fileName: "a_b___c___.txt", + }); + await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({ + fileName: "line_break.txt", + }); + }); + + it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`); + + expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`); + expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + }); + it("rejects chunks beyond the declared size and removes the partial file", async () => { const paseoHome = makePaseoHome(); const uploads = new FileUploadStore({ paseoHome }); @@ -70,8 +113,8 @@ describe("file uploads", () => { }); await expect(uploads.receiveFrame(uploadBegins("req-overflow"))).resolves.toBeNull(); - const uploadDir = join(paseoHome, "uploads", "upload_req-overflow"); - const path = join(uploadDir, "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); + const uploadDir = dirname(path); await expect(uploads.receiveFrame(uploadChunk("req-overflow", "hello!"))).resolves.toEqual({ type: "file.upload.response", payload: { @@ -106,9 +149,7 @@ describe("file uploads", () => { expect(results.slice(0, 3)).toEqual([null, null, null]); expect(results[3]?.payload.error).toBeNull(); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-queued", "notes.txt"), "utf8")).toBe( - "hello world", - ); + expect(readFileSync(uploadedPath(paseoHome, "notes.txt"), "utf8")).toBe("hello world"); }); it("replaces duplicate upload starts without letting the old stale timeout evict the replacement", async () => { @@ -139,16 +180,16 @@ describe("file uploads", () => { }); await vi.advanceTimersByTimeAsync(30); - const path = join(paseoHome, "uploads", "upload_req-duplicate_2", "new.txt"); await expect(uploads.receiveFrame(uploadBegins("req-duplicate"))).resolves.toBeNull(); await expect(uploads.receiveFrame(uploadChunk("req-duplicate", "new"))).resolves.toBeNull(); + const path = uploadedPath(paseoHome, "new.txt"); await expect(uploads.receiveFrame(uploadEnds("req-duplicate"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-duplicate", file: { type: "uploaded_file", - id: "upload_req-duplicate_2", + id: expect.any(String), fileName: "new.txt", mimeType: "text/plain", size: 3, @@ -184,14 +225,14 @@ describe("file uploads", () => { uploads.receiveFrame(uploadChunk("req-slow-active", " world")), ).resolves.toBeNull(); - const path = join(paseoHome, "uploads", "upload_req-slow-active", "notes.txt"); + const path = uploadedPath(paseoHome, "notes.txt"); await expect(uploads.receiveFrame(uploadEnds("req-slow-active"))).resolves.toEqual({ type: "file.upload.response", payload: { requestId: "req-slow-active", file: { type: "uploaded_file", - id: "upload_req-slow-active", + id: expect.any(String), fileName: "notes.txt", mimeType: "text/plain", size: 11, @@ -204,6 +245,25 @@ describe("file uploads", () => { }); }); +let uploadCount = 0; + +async function uploadNamed(uploads: FileUploadStore, fileName: string) { + const requestId = `req-named-${uploadCount++}`; + uploads.beginUpload({ + type: "file.upload.request", + fileName, + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId, + }); + await uploads.receiveFrame(uploadBegins(requestId)); + await uploads.receiveFrame(uploadChunk(requestId, "hello world")); + const response = await uploads.receiveFrame(uploadEnds(requestId)); + expect(response?.payload.error).toBeNull(); + return response?.payload.file; +} + function makePaseoHome(): string { const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-"))); tempDirs.push(root); @@ -252,3 +312,12 @@ function decodeUploadFrame(bytes: Uint8Array): FileTransferFrame { } return frame; } + +function uploadedPath(paseoHome: string, fileName: string): string { + const root = join(paseoHome, "uploads"); + const file = readdirSync(root) + .map((id) => join(root, id, fileName)) + .find((candidate) => existsSync(candidate)); + if (!file) throw new Error(`Upload file ${fileName} is missing`); + return file; +} diff --git a/packages/server/src/server/file-upload/index.ts b/packages/server/src/server/file-upload/index.ts index 929e8e8a52..979130d875 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,5 +1,6 @@ +import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, extname, join } from "node:path"; import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; @@ -13,7 +14,9 @@ interface FileUploadStoreOptions { interface PendingUpload { requestId: string; id: string; - attempt: number; + source: object; + completed: boolean; + finished(response: FileUploadResponse | null): void; fileName: string; mimeType: string; size: number; @@ -22,6 +25,7 @@ interface PendingUpload { started: boolean; staleTimeout: ReturnType; queue: Promise; + cleanup?: Promise; } export class FileUploadStore { @@ -29,7 +33,8 @@ export class FileUploadStore { private readonly paseoHome: string; private readonly staleUploadTimeoutMs: number; - private readonly pending = new Map(); + private readonly defaultSource = {}; + private readonly pending = new Map>(); constructor(options: FileUploadStoreOptions) { this.paseoHome = options.paseoHome; @@ -37,41 +42,55 @@ export class FileUploadStore { options.staleUploadTimeoutMs ?? FileUploadStore.defaultStaleUploadTimeoutMs; } - beginUpload(request: FileUploadRequest): void { - const existingUpload = this.pending.get(request.requestId); - if (existingUpload) { - this.clearPendingUpload(existingUpload); - void existingUpload.queue.then(() => this.removeUploadDirectory(existingUpload)); - } - + beginUpload( + request: FileUploadRequest, + source: object = this.defaultSource, + finished: (response: FileUploadResponse | null) => void = () => {}, + ): () => Promise { + const existingUpload = this.pending.get(source)?.get(request.requestId); + if (existingUpload) void this.cancel(existingUpload).catch(() => {}); const fileName = sanitizeFileName(request.fileName); - const attempt = existingUpload ? existingUpload.attempt + 1 : 1; - const id = buildUploadId(request.requestId, attempt); + const id = `upload_${randomUUID()}`; const uploadDir = join(this.paseoHome, "uploads", id); const upload: PendingUpload = { requestId: request.requestId, id, - attempt, + source, + completed: false, + finished, fileName, mimeType: request.mimeType, size: request.size, path: join(uploadDir, fileName), receivedBytes: 0, started: false, - staleTimeout: this.createStaleUploadTimeout(request.requestId), + staleTimeout: this.createStaleUploadTimeout(source, request.requestId), queue: Promise.resolve(), }; - this.pending.set(request.requestId, upload); + const uploads = this.pending.get(source) ?? new Map(); + uploads.set(request.requestId, upload); + this.pending.set(source, uploads); + return () => this.cancel(upload); } - async receiveFrame(frame: FileTransferFrame): Promise { - const upload = this.pending.get(frame.requestId); + async receiveFrame( + frame: FileTransferFrame, + source: object = this.defaultSource, + ): Promise { + const upload = this.pending.get(source)?.get(frame.requestId); if (!upload) { return null; } this.refreshStaleUploadTimeout(upload); const operation = upload.queue.then(() => this.applyFrame(upload, frame)); + void operation.then( + (response) => { + if (response) upload.finished(response); + return undefined; + }, + () => upload.finished(null), + ); upload.queue = operation.then( () => undefined, () => undefined, @@ -83,7 +102,7 @@ export class FileUploadStore { upload: PendingUpload, frame: FileTransferFrame, ): Promise { - if (this.pending.get(upload.requestId) !== upload) { + if (this.pending.get(upload.source)?.get(upload.requestId) !== upload) { return null; } @@ -132,12 +151,17 @@ export class FileUploadStore { `Upload size mismatch: expected ${upload.size}, received ${upload.receivedBytes}.`, ); } + upload.completed = true; return buildUploadResponse(upload, null); } - private createStaleUploadTimeout(requestId: string): ReturnType { + private createStaleUploadTimeout( + source: object, + requestId: string, + ): ReturnType { const timeout = setTimeout(() => { - this.expireStaleUpload(requestId); + const upload = this.pending.get(source)?.get(requestId); + if (upload) void this.cancel(upload).catch(() => {}); }, this.staleUploadTimeoutMs); timeout.unref?.(); return timeout; @@ -145,30 +169,25 @@ export class FileUploadStore { private refreshStaleUploadTimeout(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - upload.staleTimeout = this.createStaleUploadTimeout(upload.requestId); + upload.staleTimeout = this.createStaleUploadTimeout(upload.source, upload.requestId); } - private expireStaleUpload(requestId: string): void { - const upload = this.pending.get(requestId); - if (!upload) { - return; - } + private cancel(upload: PendingUpload): Promise { + if (upload.cleanup) return upload.cleanup; this.clearPendingUpload(upload); - const cleanup = upload.queue.then( - () => this.removeUploadDirectory(upload), - () => this.removeUploadDirectory(upload), - ); - upload.queue = cleanup.then( - () => undefined, - () => undefined, - ); + upload.cleanup = upload.queue.then(async () => { + if (!upload.completed) await this.removeUploadDirectory(upload); + return undefined; + }); + upload.finished(null); + return upload.cleanup; } private clearPendingUpload(upload: PendingUpload): void { clearTimeout(upload.staleTimeout); - if (this.pending.get(upload.requestId) === upload) { - this.pending.delete(upload.requestId); - } + const uploads = this.pending.get(upload.source); + if (uploads?.get(upload.requestId) === upload) uploads.delete(upload.requestId); + if (uploads?.size === 0) this.pending.delete(upload.source); } private async removeFailedUpload(upload: PendingUpload): Promise { @@ -177,9 +196,7 @@ export class FileUploadStore { } private async removeUploadDirectory(upload: PendingUpload): Promise { - await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }).catch( - () => undefined, - ); + await rm(join(this.paseoHome, "uploads", upload.id), { recursive: true, force: true }); } } @@ -203,18 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU }; } -function sanitizeUploadId(value: string): string { - return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "file"; -} - -function buildUploadId(requestId: string, attempt: number): string { - const baseId = `upload_${sanitizeUploadId(requestId)}`; - return attempt === 1 ? baseId : `${baseId}_${attempt}`; -} +// Most file systems cap a single file name at 255 bytes. +const MAX_FILE_NAME_BYTES = 255; +// Keeps the client's file name, replacing only what cannot appear in a single +// file name on Linux, macOS, or Windows. function sanitizeFileName(value: string): string { const name = basename(value) - .replace(/[^a-zA-Z0-9._ -]/g, "_") + .replace(/[\p{Cc}\\/:*?"<>|]/gu, "_") .trim(); - return name.length > 0 && name !== "." && name !== ".." ? name : "upload"; + return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload"); +} + +function fitFileNameLength(name: string): string { + if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name; + const extension = extname(name); + const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : ""; + let stem = ""; + for (const char of name.slice(0, name.length - keptExtension.length)) { + if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break; + stem += char; + } + return stem + keptExtension; } diff --git a/packages/server/src/server/host-name.ts b/packages/server/src/server/host-name.ts new file mode 100644 index 0000000000..5f36dcc368 --- /dev/null +++ b/packages/server/src/server/host-name.ts @@ -0,0 +1,14 @@ +import { execFileSync } from "node:child_process"; +import { hostname } from "node:os"; + +let hostName: string | undefined; + +// macOS's kernel hostname comes from DHCP/reverse DNS and can be "UNKNOWN"; +// ComputerName is the name the user gave the Mac. +export function getHostName(): string { + hostName ??= + process.platform === "darwin" + ? execFileSync("/usr/sbin/scutil", ["--get", "ComputerName"], { encoding: "utf8" }).trim() + : hostname(); + return hostName; +} diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index a96dc8cfdd..d78b29d732 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -1,3 +1,7 @@ +import type { AgentClient } from "../agent/agent-sdk-types.js"; +import { validateProviderOptions } from "../agent/provider-options.js"; +import { CodexProviderOptionsSchema } from "../agent/providers/codex/options.js"; +import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; import { afterEach, expect, test } from "vitest"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; @@ -8,8 +12,12 @@ afterEach(async () => { relationship = null; }); -async function launchRelationship(): Promise { - const launched = await HubRelationshipHarness.start(); +async function launchRelationship( + clients?: Record, +): Promise { + const launched = clients + ? await HubRelationshipHarness.startWithRealAgentClients(clients) + : await HubRelationshipHarness.start(); await launched.beginConnect().result; launched.connectLatestSocket(); relationship = launched; @@ -127,8 +135,22 @@ test("Hub can preapprove only tools on MCP servers injected in the same request" expect(hub.providerCreations()).toBe(0); }); -test("Hub returns path-specific structured provider option feedback", async () => { - const hub = await launchRelationship(); +test("Hub returns path-specific feedback thrown by the provider", async () => { + const clients = createTestAgentClients(); + const inner = clients.codex; + const provider: AgentClient = { + provider: inner.provider, + capabilities: inner.capabilities, + isAvailable: (signal, options) => inner.isAvailable(signal, options), + fetchCatalog: (options, context) => inner.fetchCatalog(options, context), + async createSession(config, launch, options) { + validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions); + return inner.createSession(config, launch, options); + }, + resumeSession: (handle, overrides, launch, options) => + inner.resumeSession(handle, overrides, launch, options), + }; + const hub = await launchRelationship({ ...clients, codex: provider }); hub.beginOwnedCreate("invalid-options", "invalid-options-execution", { providerOptions: { sandbox_workspace_write: { writable_roots: ["/tmp", 42] }, diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index a619be18a8..ec629c3fc9 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -205,7 +205,6 @@ export class DaemonExecutions implements HubExecutionAgents { } : {}), worktree: toCreateAgentWorktree(input.worktree), - background: true, notifyOnFinish: false, owner, onWorktreeCreated: (worktree) => { diff --git a/packages/server/src/server/hub/execution-controller.test.ts b/packages/server/src/server/hub/execution-controller.test.ts index 4f2836d1ce..95c6555035 100644 --- a/packages/server/src/server/hub/execution-controller.test.ts +++ b/packages/server/src/server/hub/execution-controller.test.ts @@ -34,6 +34,7 @@ function deferred(): Deferred { } class ControlledHubExecutionAgents implements HubExecutionAgents { + observerCount = 0; private readonly createObserved = deferred(); private readonly createGate = deferred(); @@ -45,7 +46,10 @@ class ControlledHubExecutionAgents implements HubExecutionAgents { async control(): Promise {} subscribe(_listener: (event: OwnedAgentEvent) => void): () => void { - return () => undefined; + this.observerCount++; + return () => { + this.observerCount--; + }; } async invalidateAuthority(): Promise {} @@ -224,3 +228,28 @@ describe("HubExecutionController", () => { ]); }); }); + +test("Hub observation is acquired and released explicitly without closing its RPC controller", async () => { + const agents = new ControlledHubExecutionAgents(); + const messages: SessionOutboundMessage[] = []; + const controller = new HubExecutionController({ + agents, + validateAgentConfiguration: async () => [], + send: (message) => messages.push(message), + }); + expect(agents.observerCount).toBe(0); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(true); + expect(agents.observerCount).toBe(1); + controller.setObserving(false); + expect(agents.observerCount).toBe(0); + await controller.validateAgent({ + type: "hub.execution.agent.validate.request", + requestId: "plain-validation", + provider: "codex", + }); + expect(messages).toHaveLength(1); + expect(agents.observerCount).toBe(0); + await controller.cleanup(); +}); diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index 025b05a48b..4199433d3d 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -26,7 +26,7 @@ export class HubExecutionController { private readonly agents: HubExecutionAgents; private readonly send: (message: SessionOutboundMessage) => void; private readonly validateAgentConfiguration: HubExecutionControllerOptions["validateAgentConfiguration"]; - private readonly unsubscribe: () => void; + private unsubscribe: (() => void) | null = null; private readonly pendingCreates = new Set>(); private readonly pendingControls = new Set>(); private readonly pendingValidations = new Set>(); @@ -37,7 +37,19 @@ export class HubExecutionController { this.agents = options.agents; this.validateAgentConfiguration = options.validateAgentConfiguration; this.send = options.send; - this.unsubscribe = this.agents.subscribe((event) => this.sendOwnedEvent(event)); + } + + get isObserving(): boolean { + return this.unsubscribe !== null; + } + + setObserving(observing: boolean): void { + if (observing && !this.closed) + this.unsubscribe ??= this.agents.subscribe((event) => this.sendOwnedEvent(event)); + else { + this.unsubscribe?.(); + this.unsubscribe = null; + } } cleanup(): Promise { @@ -47,7 +59,7 @@ export class HubExecutionController { private async cleanupOnce(): Promise { this.closed = true; - this.unsubscribe(); + this.setObserving(false); await Promise.allSettled([ ...this.pendingCreates, ...this.pendingControls, diff --git a/packages/server/src/server/hub/execution-session.websocket.test.ts b/packages/server/src/server/hub/execution-session.websocket.test.ts index 925f1a6780..88f376b3ba 100644 --- a/packages/server/src/server/hub/execution-session.websocket.test.ts +++ b/packages/server/src/server/hub/execution-session.websocket.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "vitest"; +import type { SessionOutboundMessage } from "../messages.js"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; let relationship: HubRelationshipHarness | null = null; @@ -16,6 +17,13 @@ async function launchRelationship(): Promise { return launched; } +function createdAgentWorkspaceId(message: SessionOutboundMessage): string { + if (message.type !== "status" || message.payload.status !== "agent_created") + throw new Error("Agent was not created"); + if (!message.payload.agent.workspaceId) throw new Error("Workspace was not created"); + return message.payload.agent.workspaceId; +} + test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => { const hub = await launchRelationship(); @@ -35,30 +43,213 @@ test("Hub retries one durable daemon execution across concurrency and reconstruc expect(reconstructed.durableAgentCount).toBe(1); }); -test("Hub session applies its scoped authorization to ordinary protocol messages", async () => { +test("Hub execute can steer ordinary agents while unrelated administration stays denied", async () => { const hub = await launchRelationship(); - const localAgentId = await hub.createUnrelatedLocalAgent(); - - const steeringDenial = await hub.deniedSteering(localAgentId); - const browserDenial = await hub.deniedBrowserDispatch(); - - expect(steeringDenial).toEqual({ - requestId: "denied-steer", - requestType: "send_agent_message_request", - error: "Session is not authorized for send_agent_message_request", - code: "access_denied", + const agentId = await hub.createUnrelatedLocalAgent(); + const response = await hub.requestOrdinary({ + type: "send_agent_message_request", + requestId: "ordinary-steer", + agentId, + text: "hello", + messageId: "first-arrival", + activeTurnBehavior: "steer", }); - expect(browserDenial).toEqual({ - requestId: "browser-1", - requestType: "browser.automation.execute.response", - error: "Session is not authorized for browser.automation.execute.response", - code: "access_denied", + expect(response).toMatchObject({ + type: "send_agent_message_response", + payload: { accepted: true }, }); - expect(hub.observedAgentIds()).not.toContain(localAgentId); - expect(hub.observedTrustedLifecycleMessages()).toEqual(["server_info"]); + expect(await hub.deniedBrowserDispatch()).toMatchObject({ code: "access_denied" }); expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]); }); +test("Hub execute can title the workspace created with an agent", async () => { + const hub = await launchRelationship(); + const created = await hub.requestOrdinary({ + type: "create_agent_request", + requestId: "create-for-title", + config: { provider: "codex", cwd: hub.repoRoot() }, + }); + expect(created).toMatchObject({ + type: "status", + payload: { status: "agent_created", agent: { workspaceId: expect.any(String) } }, + }); + const workspaceId = createdAgentWorkspaceId(created); + + expect( + await hub.requestOrdinary({ + type: "workspace.title.set.request", + requestId: "title-created-workspace", + workspaceId, + title: "Hub execution", + }), + ).toMatchObject({ + type: "workspace.title.set.response", + payload: { workspaceId, accepted: true, title: "Hub execution", error: null }, + }); + + expect( + await hub.requestOrdinary({ + type: "fetch_workspaces_request", + requestId: "read-created-workspace", + }), + ).toMatchObject({ + type: "fetch_workspaces_response", + payload: { + entries: expect.arrayContaining([ + expect.objectContaining({ id: workspaceId, title: "Hub execution" }), + ]), + }, + }); +}); + +test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => { + const hub = await launchRelationship(); + const create = { + type: "create_agent_request", + idempotencyKey: "ordinary-create", + config: { provider: "codex", cwd: hub.repoRoot() }, + }; + const responses = await Promise.all([ + hub.requestOrdinary({ ...create, requestId: "create-first" }), + hub.requestOrdinary({ ...create, requestId: "create-duplicate" }), + ]); + const first = responses[0]; + expect(first).toEqual({ + type: "status", + payload: { + status: "agent_created", + requestId: "create-first", + agentId: expect.any(String), + agent: expect.objectContaining({ + id: expect.any(String), + workspaceId: expect.any(String), + provider: "codex", + cwd: hub.repoRoot(), + status: "idle", + }), + }, + }); + if (first?.type !== "status" || first.payload.status !== "agent_created") + throw new Error("Agent was not created"); + const agentId = first.payload.agentId; + expect(first.payload.agent.id).toBe(agentId); + expect(responses[1]).toEqual({ + type: "status", + payload: { + status: "agent_created", + requestId: "create-duplicate", + agentId, + agent: first.payload.agent, + }, + }); + expect( + await hub.requestOrdinary({ + type: "fetch_agents_request", + requestId: "observe-agents", + subscribe: { subscriptionId: "hub-agents" }, + }), + ).toMatchObject({ type: "fetch_agents_response" }); + expect( + await hub.requestOrdinary({ + type: "agent.timeline.set_subscription.request", + requestId: "observe-timeline", + agentIds: [agentId], + }), + ).toMatchObject({ type: "agent.timeline.set_subscription.response" }); + const message = { + type: "send_agent_message_request", + agentId, + messageId: "ordinary-arrival", + text: "hello", + activeTurnBehavior: "steer", + }; + expect(await hub.requestOrdinary({ ...message, requestId: "message-first" })).toEqual({ + type: "send_agent_message_response", + payload: { requestId: "message-first", agentId, accepted: true, error: null }, + }); + expect(await hub.requestOrdinary({ ...message, requestId: "message-duplicate" })).toEqual({ + type: "send_agent_message_response", + payload: { requestId: "message-duplicate", agentId, accepted: true, error: null }, + }); + expect( + hub + .hubMessages() + .some((event) => event.type === "agent_stream" && event.payload.agentId === agentId), + ).toBe(true); + expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1); + expect( + await hub.requestOrdinary({ ...message, text: "changed", requestId: "message-conflict" }), + ).toMatchObject({ payload: { accepted: false } }); +}); + +test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => { + const hub = await launchRelationship(); + const create = { + type: "create_agent_request", + idempotencyKey: "restorable-agent", + config: { provider: "codex", cwd: hub.repoRoot() }, + worktree: { mode: "branch-off", newBranch: "ordinary-restoration" }, + }; + const response = await hub.requestOrdinary({ ...create, requestId: "restorable-create" }); + if (response.type !== "status" || response.payload.status !== "agent_created") + throw new Error("Agent was not created"); + const { agentId, agent } = response.payload; + if (!agent?.workspaceId) throw new Error("Workspace was not created"); + const workspaceId = agent.workspaceId; + const message = { + type: "send_agent_message_request", + agentId, + messageId: "before-restart", + text: "hello", + activeTurnBehavior: "steer", + }; + expect(await hub.requestOrdinary({ ...message, requestId: "before-restart-send" })).toMatchObject( + { payload: { accepted: true } }, + ); + await hub.restartDaemon(); + await hub.socketDialed(); + hub.connectLatestSocket(); + expect(await hub.requestOrdinary({ ...create, requestId: "replayed-create" })).toMatchObject({ + payload: { agentId }, + }); + expect(await hub.requestOrdinary({ ...message, requestId: "replayed-send" })).toMatchObject({ + payload: { accepted: true }, + }); + expect(hub.providerPromptTexts().filter((text) => text === "hello")).toHaveLength(1); + expect( + await hub.requestOrdinary({ + type: "archive_workspace_request", + workspaceId, + requestId: "ordinary-archive", + }), + ).toMatchObject({ payload: { error: null, archivedAt: expect.any(String) } }); + expect((await hub.worktreeState(agent.cwd)).exists).toBe(false); + expect( + await hub.requestOrdinary({ + type: "workspace.recovery.inspect.request", + workspaceId, + requestId: "inspect-recovery", + }), + ).toMatchObject({ payload: { state: { kind: "recoverable" } } }); + expect( + await hub.requestOrdinary({ + type: "workspace.recovery.restore.request", + workspaceId, + requestId: "restore-workspace", + }), + ).toMatchObject({ payload: { accepted: true } }); + expect((await hub.worktreeState(agent.cwd)).exists).toBe(true); + expect( + await hub.requestOrdinary({ + ...message, + text: "follow up", + messageId: "after-restore", + requestId: "after-restore-send", + }), + ).toMatchObject({ payload: { agentId, accepted: true } }); + expect(hub.providerPromptTexts().filter((text) => text === "follow up")).toHaveLength(1); +}, 20_000); + test("Hub completes the standard hello before rejecting a second hello", async () => { const hub = await launchRelationship(); @@ -66,7 +257,7 @@ test("Hub completes the standard hello before rejecting a second hello", async ( expect(hub.probeTrustedHello()).toBe(4002); }); -test("legacy Hub wire behavior still enters the common Session bootstrap", async () => { +test("legacy Hub wire behavior immediately enters the common Session bootstrap", async () => { const launched = await HubRelationshipHarness.start(); await launched.beginConnect().result; launched.connectLatestLegacySocket(); diff --git a/packages/server/src/server/hub/hub-cli-contract.test.ts b/packages/server/src/server/hub/hub-cli-contract.test.ts index 32aa866aed..03f1ab96a2 100644 --- a/packages/server/src/server/hub/hub-cli-contract.test.ts +++ b/packages/server/src/server/hub/hub-cli-contract.test.ts @@ -10,13 +10,21 @@ afterEach(async () => { test("the Hub CLI connects, reports status, and disconnects through the daemon", async () => { relationship = await HubRelationshipHarness.start(); - const connected = await relationship.beginConnect().result; + const connected = await relationship.runCli([ + "hub", + "connect", + "https://hub.test", + "--api-key", + "hub-contract-api-key:ceremony-token", + "--permission", + "hub.execute", + ]); relationship.connectLatestSocket(); - const status = await relationship.status(); + const status = await relationship.runCli(["hub", "status"]); const enrollment = relationship.enrollmentAttempts()[0]; const secret = relationship.relationshipFile()?.credential?.secret; - const disconnected = await relationship.disconnect(); + const disconnected = await relationship.runCli(["hub", "disconnect"]); expect(connected.state).toBe("connecting"); expect(status.state).toBe("connected"); diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index 0d091d66c1..51596b2384 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; -import { hostname, platform } from "node:os"; +import { platform } from "node:os"; import { afterEach, describe, expect, test } from "vitest"; +import { getHostName } from "../host-name.js"; import { HubRelationshipHarness } from "./test-utils/relationship-harness.js"; async function captureUnhandledRejections(action: () => Promise): Promise { @@ -88,7 +89,7 @@ describe("Hub relationship", () => { .update(secret ?? "") .digest("base64url"), ); - expect(enrollment.hostname).toBe(hostname()); + expect(enrollment.hostname).toBe(getHostName()); relationship.completeEnrollment(); await connecting.result; await relationship.socketDialed(); @@ -297,7 +298,7 @@ describe("Hub relationship", () => { version: 2, relationship: { permissions: ["hub.execute"] }, }); - expect(await relationship.status()).toMatchObject({ permissions: "hub.execute" }); + expect(await relationship.status()).toMatchObject({ permissions: ["hub.execute"] }); }); test.each([ @@ -419,7 +420,7 @@ describe("Hub relationship", () => { expect(disconnected).toMatchObject({ state: "not_connected", - hub: null, + hubOrigin: null, warning: expect.stringContaining("server-side revocation may remain pending"), }); expect(reconnected.state).toBe("connecting"); @@ -483,14 +484,14 @@ describe("Hub relationship", () => { const disconnected = await relationship.disconnect(true); - expect(disconnected).toMatchObject({ state: "not_connected", hub: null }); + expect(disconnected).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.relationshipFile()).toBeNull(); relationship.completeEnrollment(); await connecting.result; expect(relationship.socketAttempts()).toBe(0); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); }); test("daemon restart reconnects the same durable relationship", async () => { @@ -811,9 +812,9 @@ describe("Hub relationship", () => { expect(status).toMatchObject({ state: "revoked", daemonId: enrollment.daemonId, - hub: "https://hub.test", - permissions: "hub.execute", - error: reason, + hubOrigin: "https://hub.test", + permissions: ["hub.execute"], + lastError: reason, }); expect(persisted?.state).toBe("revoked"); expect(persisted?.relationship).toMatchObject({ @@ -848,10 +849,10 @@ describe("Hub relationship", () => { expect(disconnected).toMatchObject({ state: "not_connected", - hub: null, + hubOrigin: null, warning: expect.stringContaining("server-side revocation may remain pending"), }); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.revocationAttempts()).toBe(1); expect(relationship.socketAttempts()).toBe(1); expect(relationship.pendingRelationshipRetries()).toBe(0); @@ -864,10 +865,14 @@ describe("Hub relationship", () => { const disconnected = await relationship.disconnect(); - expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null }); + expect(disconnected).toMatchObject({ + state: "not_connected", + hubOrigin: null, + lastError: null, + }); expect(disconnected).not.toHaveProperty("warning"); expect(relationship.revocationAttempts()).toBe(1); - expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", lastError: null }); }); test("force disconnect removes local authority without notifying the Hub", async () => { @@ -876,7 +881,7 @@ describe("Hub relationship", () => { const forced = await relationship.disconnect(true); - expect(forced).toMatchObject({ state: "not_connected", hub: null }); + expect(forced).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(forced).not.toHaveProperty("warning"); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.loggableValues(forced)).not.toContain( @@ -895,7 +900,7 @@ describe("Hub relationship", () => { await relationship.startStoppedDaemon(); - expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null }); + expect(await relationship.status()).toMatchObject({ state: "not_connected", hubOrigin: null }); expect(relationship.relationshipFile()).toBeNull(); expect(relationship.revocationAttempts()).toBe(0); expect(relationship.pendingRelationshipRetries()).toBe(0); diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index d29dfe8062..84726d0b02 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -160,7 +160,9 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo if (typeof data !== "string") return; const frame = JSON.parse(data) as { type: "session"; message: SessionOutboundMessage }; this.sent.push(frame.message); - this.messageObserved.resolve(); + const observed = this.messageObserved; + this.messageObserved = deferred(); + observed.resolve(); } close(code = 1000): void { @@ -192,7 +194,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo ); if (message) return message; await this.messageObserved.promise; - this.messageObserved = deferred(); } } @@ -203,7 +204,6 @@ class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketCo const message = this.sent.find(predicate); if (message) return message; await this.messageObserved.promise; - this.messageObserved = deferred(); } } } @@ -425,7 +425,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote { } } -interface CliProcess { +interface RelationshipOperation { result: Promise>; } @@ -454,7 +454,7 @@ export class HubRelationshipHarness { private host = ""; private readonly logs: string[] = []; private readonly providerPrompts: AgentPromptInput[] = []; - private readonly cliProcesses = new Set>(); + private readonly operations = new Set>(); private readonly claimedCliSockets = new Set(); private readonly promptsToFail = new Set(); private failNextSessionClose = false; @@ -514,37 +514,44 @@ export class HubRelationshipHarness { this.remote.holdEnrollment(); } - beginConnect(token = "ceremony-token", hubUrl = HUB_ORIGIN, execute = true): CliProcess { + beginConnect( + token = "ceremony-token", + hubUrl = HUB_ORIGIN, + execute = true, + ): RelationshipOperation { return { - result: this.runCli([ - "hub", - "connect", - hubUrl, - "--api-key", - `hub-contract-api-key:${token}`, - ...(execute ? ["--permission", "hub.execute"] : []), - ]), + result: this.withClient( + async (client) => + (await client.connectHub(hubUrl, token, execute ? ["hub.execute"] : [])).status, + ), }; } async status(): Promise> { - return this.runCli(["hub", "status"]); + return this.withClient(async (client) => (await client.getHubStatus()).status); } async disconnect(force = false): Promise> { - return this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]); + return this.withClient(async (client) => { + const { status, warning } = await client.disconnectHub(force); + return { ...status, ...(warning ? { warning } : {}) }; + }); } async grantPermission(permission: string): Promise> { - return this.runCli(["hub", "permissions", "grant", permission]); + return this.withClient( + async (client) => (await client.updateHubPermissions({ grant: [permission] })).status, + ); } async revokePermission(permission: string): Promise> { - return this.runCli(["hub", "permissions", "revoke", permission]); + return this.withClient( + async (client) => (await client.updateHubPermissions({ revoke: [permission] })).status, + ); } - beginDisconnect(force = false): CliProcess { - return { result: this.runCli(["hub", "disconnect", ...(force ? ["--force"] : [])]) }; + beginDisconnect(force = false): RelationshipOperation { + return { result: this.disconnect(force) }; } async relationshipStateBecomes(expected: string | null): Promise { @@ -728,6 +735,16 @@ export class HubRelationshipHarness { socket.socket.close(code); } + async requestOrdinary(message: { + type: string; + requestId: string; + [key: string]: unknown; + }): Promise { + const socket = this.latestSocket().socket; + socket.receive(message); + return socket.messageFor(message.requestId); + } + sendHubRequestOnLatest(message: unknown): SessionOutboundMessage[] { const socket = this.latestSocket().socket; socket.receive(message); @@ -1362,7 +1379,7 @@ export class HubRelationshipHarness { async close(): Promise { await this.stopDaemon(); - await Promise.allSettled(this.cliProcesses); + await Promise.allSettled(this.operations); await Promise.all( [...this.claimedCliSockets].map((socket) => this.closeClaimedCliSocket(socket)), ); @@ -1423,8 +1440,21 @@ export class HubRelationshipHarness { this.daemon = null; } - private runCli(args: string[]): Promise> { - return this.trackCli(this.executeCli(args)); + private withClient(action: (client: DaemonClient) => Promise): Promise { + return this.trackOperation( + (async () => { + const client = await this.trustedClient(); + try { + return await action(client); + } finally { + await client.close(); + } + })(), + ); + } + + runCli(args: string[]): Promise> { + return this.trackOperation(this.executeCli(args)); } private async executeCli(args: string[]): Promise> { @@ -1448,11 +1478,11 @@ export class HubRelationshipHarness { return parsed as Record; } - private trackCli(process: Promise): Promise { - this.cliProcesses.add(process); + private trackOperation(process: Promise): Promise { + this.operations.add(process); void process.then( - () => this.cliProcesses.delete(process), - () => this.cliProcesses.delete(process), + () => this.operations.delete(process), + () => this.operations.delete(process), ); return process; } @@ -1572,7 +1602,6 @@ export class HubRelationshipHarness { appVersion: "0.1.106", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "hub-relationship-trusted" } }); return client; } diff --git a/packages/server/src/server/local-credential.ts b/packages/server/src/server/local-credential.ts new file mode 100644 index 0000000000..91818212ca --- /dev/null +++ b/packages/server/src/server/local-credential.ts @@ -0,0 +1,81 @@ +import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { chmod, rename, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + normalizeLoopbackToLocalhost, + parseConnectionUri, +} from "@getpaseo/protocol/daemon-endpoints"; + +const FILE_NAME = "local-credential"; + +export async function writeLocalCredential(home: string): Promise { + const token = randomBytes(32).toString("base64url"); + const temporary = join(home, `${FILE_NAME}.${randomUUID()}.tmp`); + try { + await writeFile(temporary, `${token}\n`, { mode: 0o600, flag: "wx" }); + await chmod(temporary, 0o600); + await rename(temporary, join(home, FILE_NAME)); + return token; + } finally { + await unlink(temporary).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + } +} + +export async function deleteLocalCredential(home: string): Promise { + await unlink(join(home, FILE_NAME)).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); +} + +export function readLocalCredential(home: string): string | null { + try { + const token = readFileSync(join(home, FILE_NAME), "utf8").trim(); + return /^[A-Za-z0-9_-]{43}$/.test(token) ? token : null; + } catch { + return null; + } +} + +export function matchesLocalCredential(expected: string, candidate: string): boolean { + const actual = Buffer.from(candidate); + const reference = Buffer.from(expected); + return actual.length === reference.length && timingSafeEqual(actual, reference); +} + +function normalizeTarget(target: string): string | null { + try { + const trimmed = target.trim(); + if (trimmed.startsWith("tcp://")) { + const parsed = parseConnectionUri(trimmed); + const endpoint = parsed.isIpv6 + ? `[${parsed.host}]:${parsed.port}` + : `${parsed.host}:${parsed.port}`; + return normalizeLoopbackToLocalhost(endpoint); + } + if (trimmed.startsWith("unix://") || trimmed.startsWith("pipe://")) return trimmed; + if (trimmed.startsWith("/")) return `unix://${trimmed}`; + if (trimmed.startsWith("\\\\.\\pipe\\")) return `pipe://${trimmed}`; + if (/^\d+$/.test(trimmed)) return `localhost:${trimmed}`; + return normalizeLoopbackToLocalhost(trimmed); + } catch { + return null; + } +} + +export function readLocalCredentialForTarget(home: string, target: string): string | null { + try { + const lock = JSON.parse(readFileSync(join(home, "paseo.pid"), "utf8")) as { + listen?: unknown; + }; + if (typeof lock.listen !== "string") return null; + const selected = normalizeTarget(target); + const listening = normalizeTarget(lock.listen); + if (!selected || !listening || selected !== listening) return null; + return readLocalCredential(home); + } catch { + return null; + } +} diff --git a/packages/server/src/server/local-service-connection.ts b/packages/server/src/server/local-service-connection.ts new file mode 100644 index 0000000000..a96a0a7094 --- /dev/null +++ b/packages/server/src/server/local-service-connection.ts @@ -0,0 +1,27 @@ +import http from "node:http"; +import net from "node:net"; + +// A host can support IPv6 loopback without listing ::1 for localhost in DNS. +const lookupLoopback: net.LookupFunction = (_hostname, _options, callback) => { + callback(null, [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ]); +}; + +export function connectToLocalService(port: number): net.Socket { + return net.connect({ host: "localhost", port, autoSelectFamily: true, lookup: lookupLoopback }); +} + +export function requestLocalService( + options: Pick, + onResponse: (response: http.IncomingMessage) => void, +): http.ClientRequest { + const requestOptions = { + ...options, + hostname: "localhost", + autoSelectFamily: true, + lookup: lookupLoopback, + }; + return http.request(requestOptions, onResponse); +} diff --git a/packages/server/src/server/message-receipts/index.test.ts b/packages/server/src/server/message-receipts/index.test.ts new file mode 100644 index 0000000000..10c1615a6a --- /dev/null +++ b/packages/server/src/server/message-receipts/index.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { MessageReceipts } from "./index.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +async function fixture() { + const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-")); + directories.push(directory); + return { directory, requests: new MessageReceipts(directory) }; +} + +test("message retries survive reconstruction without submitting twice", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: { text: "hello" }, + send: async () => { + deliveries++; + }, + }; + await Promise.all([requests.send(input), requests.send(input)]); + await new MessageReceipts(directory).send(input); + expect(deliveries).toBe(1); + await requests.send({ ...input, agentId: "another" }); + expect(deliveries).toBe(2); +}); + +test("ambiguous provider delivery is never blindly replayed after restart", async () => { + const { requests, directory } = await fixture(); + let deliveries = 0; + const input = { + agentId: "agent", + messageId: "arrival", + request: {}, + send: async () => { + deliveries++; + throw new Error("connection lost"); + }, + }; + await expect(requests.send(input)).rejects.toThrow("connection lost"); + await expect(new MessageReceipts(directory).send(input)).rejects.toThrow( + "agent_request_outcome_unknown", + ); + expect(deliveries).toBe(1); +}); + +test("failed local message preparation does not leave an ambiguous receipt", async () => { + const { requests, directory } = await fixture(); + let available = false; + let sends = 0; + const input = { + agentId: "agent", + messageId: "message", + request: {}, + prepare: async () => { + if (!available) throw new Error("load failed"); + }, + send: async () => { + sends++; + }, + }; + await expect(requests.send(input)).rejects.toThrow("load failed"); + available = true; + await new MessageReceipts(directory).send(input); + available = false; + await requests.send(input); + expect(sends).toBe(1); +}); diff --git a/packages/server/src/server/message-receipts/index.ts b/packages/server/src/server/message-receipts/index.ts new file mode 100644 index 0000000000..6103160b12 --- /dev/null +++ b/packages/server/src/server/message-receipts/index.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; +import { writeJsonFileAtomic } from "../atomic-file.js"; + +const ReceiptSchema = z.object({ + fingerprint: z.string(), + state: z.enum(["pending", "completed"]), + agentId: z.string(), +}); +interface SendMessageInput { + agentId: string; + messageId: string; + request: unknown; + send: () => Promise; + prepare?: () => Promise; +} + +/** Owns message delivery receipts; creation is owned by CreationService. */ +export class MessageReceipts { + private readonly pending = new Map>(); + constructor(private readonly directory: string) {} + + send(input: SendMessageInput): Promise { + // Preserve the existing on-disk identity and shape across daemon upgrades. + const key = digest(["send", input.agentId, input.messageId]); + const previous = this.pending.get(key); + const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() => + this.sendOnce(key, input), + ); + this.pending.set(key, result); + void result + .finally(() => { + if (this.pending.get(key) === result) this.pending.delete(key); + }) + .catch(() => undefined); + return result; + } + + private async sendOnce(key: string, input: SendMessageInput): Promise { + const file = path.join(this.directory, `${key}.json`); + const fingerprint = digest(input.request); + const existing = await readReceipt(file); + if (existing) { + if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict"); + if (existing.state === "completed") return; + // A provider may have accepted the message before its receipt was committed. + throw new Error("agent_request_outcome_unknown"); + } + await input.prepare?.(); + const receipt = { fingerprint, agentId: input.agentId }; + await writeJsonFileAtomic(file, { ...receipt, state: "pending" }); + await input.send(); + await writeJsonFileAtomic(file, { ...receipt, state: "completed" }); + } +} + +async function readReceipt(file: string): Promise | null> { + try { + return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8"))); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function digest(value: unknown): string { + return createHash("sha256") + .update( + JSON.stringify(value, (_key, candidate: unknown) => { + if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) { + return Object.fromEntries( + Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)), + ); + } + return candidate; + }), + ) + .digest("hex"); +} diff --git a/packages/server/src/server/orchestration-skills/internal/controller.test.ts b/packages/server/src/server/orchestration-skills/internal/controller.test.ts index d3dab5aa78..23b541e67d 100644 --- a/packages/server/src/server/orchestration-skills/internal/controller.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/controller.test.ts @@ -210,7 +210,7 @@ async function blockAgentsDir(targets: SkillTargets): Promise { } async function isInstalled(targets: SkillTargets, name: string): Promise { - const dirs = [targets.agentsDir, targets.claudeDir, targets.codexDir]; + const dirs = [targets.agentsDir, targets.claudeDir]; const present = await Promise.all( dirs.map((dir) => access(path.join(dir, name)) @@ -454,7 +454,7 @@ describe("skills controller", () => { expect(await installedEverywhere(readOnly.targets)).toEqual([ ["paseo", "paseo-loop"], ["paseo", "paseo-loop"], - ["paseo", "paseo-loop"], + ["paseo-loop"], ]); expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/mine.md")).toEqual([ "hand written", @@ -575,6 +575,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); const livePaths = [ harness.targets.agentsDir, harness.targets.claudeDir, @@ -601,6 +602,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ { kind: "delete", name: "paseo-loop" }, @@ -666,6 +668,7 @@ describe("skills controller", () => { }; const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "SKILL.md", "paseo-loop-v1"); await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "staged notes"); const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ @@ -1115,7 +1118,7 @@ describe("skills controller", () => { expect(await installedEverywhere(harness.targets)).toEqual([ ["paseo", "paseo-advisor", "paseo-loop"], ["paseo", "paseo-advisor", "paseo-loop"], - ["paseo", "paseo-advisor", "paseo-loop"], + ["paseo-advisor"], ]); expect(result.selection).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); }); @@ -1131,7 +1134,7 @@ describe("skills controller", () => { expect(result.confirmationRequired).toBeNull(); expect(result.selection).toEqual({ mode: "custom", skills: ["paseo"] }); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); }); it("asks again when another directory appears before the retry", async () => { @@ -1150,7 +1153,7 @@ describe("skills controller", () => { expect(await installedEverywhere(harness.targets)).toEqual([ ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], - ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], + ["paseo-chat"], ]); }); @@ -1179,7 +1182,7 @@ describe("skills controller", () => { const result = await harness.controller.save({ mode: "custom", skills: ["paseo"] }); expect(result.confirmationRequired).toBeNull(); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); }); it("preserves a regular file at a skill path when save convergence fails", async () => { @@ -1211,7 +1214,7 @@ describe("skills controller", () => { ]); expect(saved.selection).toEqual({ mode: "custom", skills: ["paseo"] }); - expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], []]); expect(await harness.controller.status()).toEqual({ state: "up-to-date", ops: [], diff --git a/packages/server/src/server/orchestration-skills/internal/operations.test.ts b/packages/server/src/server/orchestration-skills/internal/operations.test.ts index 6b188d24b0..14363c4b42 100644 --- a/packages/server/src/server/orchestration-skills/internal/operations.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/operations.test.ts @@ -1,4 +1,5 @@ import { promises as fs } from "node:fs"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -298,8 +299,8 @@ describe("custom skill selection", () => { available: ["paseo", "paseo-advisor", "paseo-loop"], installed: ["paseo", "paseo-loop"], }); - expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); - expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, false]); + expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, false]); expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); }); @@ -322,7 +323,7 @@ describe("custom skill selection", () => { const status = await installSkills(sandbox.targets, only("paseo")); expect(status.state).toBe("up-to-date"); - expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, false]); expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]); expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); }); @@ -395,7 +396,7 @@ describe("installSkills / updateSkills", () => { await fs.rm(sandbox.root, { recursive: true, force: true }); }); - it("installs from a clean machine, populates all three targets, and leaves user dirs alone", async () => { + it("installs into shared and Claude roots without duplicating Codex discovery", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" }); @@ -411,9 +412,7 @@ describe("installSkills / updateSkills", () => { expect( await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"), ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); - expect( - await fs.readFile(path.join(sandbox.targets.codexDir, name, "SKILL.md"), "utf-8"), - ).toBe(name === "paseo" ? "paseo-v1" : "loop-v1"); + expect(await pathExists(path.join(sandbox.targets.codexDir, name, "SKILL.md"))).toBe(false); expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(true); } expect( @@ -421,6 +420,100 @@ describe("installSkills / updateSkills", () => { ).toBe("user-unslop"); }); + it("retires unchanged managed Codex copies while preserving personal files", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await installSkills(sandbox.targets, ALL_SKILLS); + const legacy = path.join(sandbox.targets.codexDir, "paseo"); + const managed = { "SKILL.md": "paseo-v1", "references/old.md": "bundled reference" }; + const hashes = Object.fromEntries( + Object.entries(managed).map(([name, content]) => [ + name, + createHash("sha256").update(content).digest("hex"), + ]), + ); + await writeFiles(legacy, { + ...managed, + "notes/mine.md": "personal notes", + ".paseo-managed-files.json": JSON.stringify({ version: 1, files: hashes }), + }); + const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + expect(status.state).toBe("up-to-date"); + expect(status.ops).toEqual([]); + expect(await pathExists(path.join(legacy, "SKILL.md"))).toBe(false); + expect(await pathExists(path.join(legacy, "references/old.md"))).toBe(false); + expect(await fs.readFile(path.join(legacy, "notes/mine.md"), "utf8")).toBe("personal notes"); + expect(await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS)).toEqual(status); + }); + + it("keeps edited or untracked Codex skill definitions", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await installSkills(sandbox.targets, ALL_SKILLS); + const originalHash = createHash("sha256").update("paseo-v1").digest("hex"); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { + "SKILL.md": "personalized skill", + ".paseo-managed-files.json": JSON.stringify({ + version: 1, + files: { "SKILL.md": originalHash }, + }), + }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { + "SKILL.md": "untracked skill", + }); + expect((await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS)).state).toBe("up-to-date"); + expect( + await fs.readFile(path.join(sandbox.targets.codexDir, "paseo", "SKILL.md"), "utf8"), + ).toBe("personalized skill"); + expect( + await fs.readFile(path.join(sandbox.targets.codexDir, "paseo-loop", "SKILL.md"), "utf8"), + ).toBe("untracked skill"); + }); + + it.each([ + { alias: "codex", target: "agents" }, + { alias: "codex", target: "claude" }, + { alias: "claude", target: "codex" }, + ] as const)( + "keeps installed skills when the $alias root links to the $target root", + async ({ alias, target }) => { + await writeCurrentBundle(sandbox.targets.sourceDir); + const rootOf = { agents: "agentsDir", claude: "claudeDir", codex: "codexDir" } as const; + const aliasRoot = sandbox.targets[rootOf[alias]]; + const targetRoot = sandbox.targets[rootOf[target]]; + await fs.mkdir(targetRoot, { recursive: true }); + await fs.mkdir(path.dirname(aliasRoot), { recursive: true }); + await fs.symlink(targetRoot, aliasRoot, "dir"); + + const installed = await installSkills(sandbox.targets, ALL_SKILLS); + const updated = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + + expect(installed.state).toBe("up-to-date"); + expect(updated.state).toBe("up-to-date"); + for (const name of ["paseo", "paseo-loop"]) { + expect(await installedIn(sandbox.targets, name)).toEqual([true, true, true]); + } + }, + ); + + it("keeps an installed skill when its Codex directory links to the shared copy", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await fs.mkdir(path.join(sandbox.targets.agentsDir, "paseo"), { recursive: true }); + await fs.mkdir(sandbox.targets.codexDir, { recursive: true }); + await fs.symlink( + path.join(sandbox.targets.agentsDir, "paseo"), + path.join(sandbox.targets.codexDir, "paseo"), + "dir", + ); + + const installed = await installSkills(sandbox.targets, ALL_SKILLS); + const updated = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); + + expect(installed.state).toBe("up-to-date"); + expect(updated.state).toBe("up-to-date"); + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf8"), + ).toBe("paseo-v1"); + }); + it("repairs missing and edited skills without deleting a legacy directory", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); diff --git a/packages/server/src/server/orchestration-skills/internal/operations.ts b/packages/server/src/server/orchestration-skills/internal/operations.ts index a3b0831122..e45878a953 100644 --- a/packages/server/src/server/orchestration-skills/internal/operations.ts +++ b/packages/server/src/server/orchestration-skills/internal/operations.ts @@ -2,7 +2,13 @@ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; -import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js"; +import { + isLegacyCodexCopy, + listFilesRecursive, + readManagedFilesManifest, + removeSkill, + syncSkills, +} from "./sync.js"; export type SkillsState = "not-installed" | "up-to-date" | "drift"; @@ -163,11 +169,23 @@ export async function getSkillsStatus( hashSkills(targets.claudeDir, names), hashSkills(targets.codexDir, names), ]); - const disks = [agentsDisk, claudeDisk, codexDisk]; - const ops = diff(bundle, disks, names, resolveDesiredSkills(selection, available)); - const installed = installedSkillNames(disks, names); + const disks = [agentsDisk, claudeDisk]; + const desired = resolveDesiredSkills(selection, available); + const ops = diff(bundle, disks, names, desired); + for (const name of codexDisk.keys()) { + if (ops.some((op) => op.name === name)) continue; + if (!(await isLegacyCodexCopy(name, targets))) continue; + if (!desired.has(name)) ops.push({ kind: "delete", name }); + else if (await readManagedFilesManifest(path.join(targets.codexDir, name))) { + ops.push({ kind: "update", name }); + } + } + ops.sort((a, b) => compareStrings(a.name, b.name)); + const allDisks = [...disks, codexDisk]; + const installed = installedSkillNames(allDisks, names); - if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops, available, installed }; + if (!hasInstalledPaseoSkill(allDisks)) + return { state: "not-installed", ops, available, installed }; if (ops.length === 0) return { state: "up-to-date", ops, available, installed }; return { state: "drift", ops, available, installed }; } diff --git a/packages/server/src/server/orchestration-skills/internal/sync.test.ts b/packages/server/src/server/orchestration-skills/internal/sync.test.ts index da5b1c8765..1848330fdb 100644 --- a/packages/server/src/server/orchestration-skills/internal/sync.test.ts +++ b/packages/server/src/server/orchestration-skills/internal/sync.test.ts @@ -75,11 +75,9 @@ describe("syncSkills", () => { "utf-8", ); expect(claudeContent).toBe("new paseo content"); - const codexContent = await fs.readFile( - path.join(sandbox.codexDir, "paseo", "SKILL.md"), - "utf-8", - ); - expect(codexContent).toBe("new paseo content"); + await expect(fs.stat(path.join(sandbox.codexDir, "paseo", "SKILL.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); }); it("installs new bundled skills, including references/, when not present on disk", async () => { @@ -105,12 +103,9 @@ describe("syncSkills", () => { "utf-8", ), ).toBe("roles content"); - expect( - await fs.readFile( - path.join(sandbox.codexDir, "paseo-committee", "references", "roles.md"), - "utf-8", - ), - ).toBe("roles content"); + await expect(fs.stat(path.join(sandbox.codexDir, "paseo-committee"))).rejects.toMatchObject({ + code: "ENOENT", + }); const claudeSkillDir = path.join(sandbox.claudeDir, "paseo-committee"); expect((await fs.lstat(claudeSkillDir)).isDirectory()).toBe(true); diff --git a/packages/server/src/server/orchestration-skills/internal/sync.ts b/packages/server/src/server/orchestration-skills/internal/sync.ts index 9233365549..255c61a274 100644 --- a/packages/server/src/server/orchestration-skills/internal/sync.ts +++ b/packages/server/src/server/orchestration-skills/internal/sync.ts @@ -51,7 +51,9 @@ export async function listFilesRecursive(rootDir: string): Promise { return out; } -async function readManagedFilesManifest(dstDir: string): Promise { +export async function readManagedFilesManifest( + dstDir: string, +): Promise { const raw = await fs .readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8") .catch(() => null); @@ -185,6 +187,50 @@ export async function removeSkill(skillName: string, targets: RemoveSkillTargets } } +async function isSameDirectory(a: string, b: string): Promise { + const [realA, realB] = await Promise.all([a, b].map((p) => fs.realpath(p).catch(() => null))); + return realA !== null && realA === realB; +} + +/** + * Codex also discovers .agents/skills, so its dedicated copy is a leftover. When + * the Codex skill directory resolves to the shared or Claude copy, it is that + * copy, and retiring it would delete the install. + */ +export async function isLegacyCodexCopy( + skillName: string, + targets: RemoveSkillTargets, +): Promise { + const codexSkillDir = path.join(targets.codexDir, skillName); + for (const root of [targets.agentsDir, targets.claudeDir]) { + if (await isSameDirectory(codexSkillDir, path.join(root, skillName))) return false; + } + return true; +} + +async function retireManagedSkill(dstDir: string): Promise { + const info = await fs.lstat(dstDir).catch(() => null); + if (!info?.isDirectory()) return 0; + const manifest = await readManagedFilesManifest(dstDir); + if (!manifest) return 0; + const files = Object.keys(manifest.files); + await assertManagedPathsStayInsideSkill(dstDir, [MANAGED_FILES_MANIFEST, ...files]); + const removed: string[] = []; + for (const [rel, previousHash] of Object.entries(manifest.files)) { + const filePath = path.join(dstDir, rel); + const currentHash = await hashFile(filePath).catch(() => null); + if (currentHash !== previousHash) continue; + await fs.rm(filePath); + removed.push(rel); + } + await pruneEmptyParentDirs(dstDir, removed); + await fs.rm(path.join(dstDir, MANAGED_FILES_MANIFEST)); + await fs.rmdir(dstDir).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOTEMPTY" && error.code !== "ENOENT") throw error; + }); + return removed.length + 1; +} + export async function syncSkills(options: SkillSyncOptions): Promise { let changedFiles = 0; let processedSkills = 0; @@ -206,10 +252,10 @@ export async function syncSkills(options: SkillSyncOptions): Promise { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const app = createPaseoApi(client); + const plugin = createPaseoApi(client); + try { + await admin.connect(); + await client.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + const appUpdates: unknown[] = []; + const pluginUpdates: unknown[] = []; + app.agents.subscribe((update) => appUpdates.push(update)); + plugin.agents.subscribe((update) => pluginUpdates.push(update)); + const broad = await app.agents.list({ scope: "active", subscribe: {} }); + const filtered = await plugin.agents.list({ + scope: "active", + filter: { labels: { role: "orchestrator" } }, + page: { limit: 100 }, + subscribe: {}, + }); + expect(broad.subscriptionId).toEqual(expect.any(String)); + expect(filtered.subscriptionId).toEqual(expect.any(String)); + expect(broad.subscriptionId).not.toBe(filtered.subscriptionId); + expect(filtered.entries).toEqual([]); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: broad.subscriptionId, + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + ]), + ); + expect(appUpdates).not.toEqual( + expect.arrayContaining([expect.objectContaining({ kind: "remove" })]), + ); + expect(pluginUpdates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "remove", + subscriptionId: filtered.subscriptionId, + agentId: agent.id, + }), + ]), + ); + await plugin.dispose(); + const before = pluginUpdates.length; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll(() => appUpdates) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ agent: expect.objectContaining({ title: "Still observed" }) }), + ]), + ); + expect(pluginUpdates).toHaveLength(before); + } finally { + await client.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public SDK scope cancellation during bootstrap releases the returned ID", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const lifetime = new AbortController(); + const api = createPaseoApi(client, { signal: lifetime.signal }); + try { + await client.connect(); + const listing = api.agents.list({ subscribe: {} }); + lifetime.abort(); + await expect(listing).rejects.toThrow("released"); + await api.dispose(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Source registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + await expect(api.agents.list({ subscribe: {} })).rejects.toThrow("disposed"); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("a restored public SDK timeline leaves missed history to the consumer", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const sockets: WebSocket[] = []; + const makeClient = (observer: boolean) => + new PublicDaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: observer ? "timeline-observer" : "timeline-actor", + appVersion: "0.8.0", + reconnect: { enabled: false }, + webSocketFactory: (url) => { + const socket = new WebSocket(url); + if (observer) { + sockets.push(socket); + } + return socket as unknown as WebSocketLike; + }, + }); + const observer = makeClient(true); + const actor = makeClient(false); + const api = createPaseoApi(observer); + const received: unknown[] = []; + try { + await actor.connect(); + await observer.connect(); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Reconnect review", + }); + const handle = api.agents.ref(agent.id); + const subscription = handle.timeline.subscribe((event) => received.push(event)); + await subscription.ready; + await actor.sendMessage(agent.id, "before-outage"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("before-outage"); + const cached = await handle.timeline.refetch(); + expect(cached.endCursor).not.toBeNull(); + const oldId = subscription.subscriptionId; + sockets[0]!.terminate(); + await expect.poll(() => observer.getConnectionState().status).not.toBe("connected"); + await actor.sendMessage(agent.id, "during-outage"); + await actor.waitForFinish(agent.id); + await observer.connect(); + await expect + .poll(() => subscription.subscriptionId) + .toSatisfy((id: unknown) => typeof id === "string" && id !== oldId); + await expect.poll(() => JSON.stringify(received)).toContain("subscription_restored"); + expect(JSON.stringify(received)).not.toContain("during-outage"); + await actor.sendMessage(agent.id, "after-outage"); + await actor.waitForFinish(agent.id); + await expect.poll(() => JSON.stringify(received)).toContain("after-outage"); + const history = await handle.timeline.refetch({ + direction: "after", + cursor: cached.endCursor!, + projection: "projected", + }); + expect(JSON.stringify(history)).toContain("during-outage"); + expect(JSON.stringify(received)).not.toContain("during-outage"); + } finally { + await api.dispose(); + await observer.close(); + await actor.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts new file mode 100644 index 0000000000..68437daf70 --- /dev/null +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -0,0 +1,1755 @@ +import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { createPaseoApi } from "@getpaseo/client"; +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "vitest"; +import type { z } from "zod"; +import { WebSocket, WebSocketServer } from "ws"; +import { createServer } from "node:http"; +import { + decodeTerminalStreamFrame, + encodeFileTransferFrame, + FileTransferOpcode, + TerminalStreamOpcode, +} from "@getpaseo/protocol/binary-frames/index"; +import { DaemonClient } from "./test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; +import { + SessionInboundMessageSchema, + type SessionInboundMessage, + type SessionOutboundMessage, + type WSOutboundMessage, +} from "./messages.js"; + +class SubscriptionPeer { + readonly frames: WSOutboundMessage[] = []; + readonly binaryFrames: Uint8Array[] = []; + + private constructor(private readonly socket: WebSocket) { + socket.on("message", (data, binary) => { + if (!binary) this.frames.push(JSON.parse(data.toString())); + else this.binaryFrames.push(new Uint8Array(data as Buffer)); + }); + } + + static async connect( + port: number, + clientId: string, + capabilities: Record = {}, + ): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const peer = new SubscriptionPeer(socket); + await new Promise((resolve, reject) => { + socket.once("open", resolve); + socket.once("error", reject); + }); + socket.send( + JSON.stringify({ + type: "hello", + clientType: "browser", + clientId, + protocolVersion: 1, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true, ...capabilities }, + }), + ); + await expect + .poll(() => + peer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "server_info", + ), + ) + .toBe(true); + peer.frames.length = 0; + return peer; + } + + async request(message: z.input): Promise { + this.socket.send(JSON.stringify({ type: "session", message })); + await expect + .poll(() => + this.frames.some( + (frame) => + frame.type === "session" && + "payload" in frame.message && + "requestId" in frame.message.payload && + "requestId" in message && + frame.message.payload.requestId === message.requestId, + ), + ) + .toBe(true); + } + + send(message: z.input): void { + this.socket.send( + JSON.stringify({ type: "session", message: SessionInboundMessageSchema.parse(message) }), + ); + } + + upload(requestId: string, text: string, fileName: string): void { + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: "text/plain", + size: new TextEncoder().encode(text).byteLength, + encoding: "binary", + modifiedAt: "2026-09-09T00:00:00Z", + fileName, + }, + }), + ); + this.socket.send( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: new TextEncoder().encode(text), + }), + ); + this.socket.send(encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId })); + } + + close(): void { + this.socket.terminate(); + } +} + +test("a pure list reply belongs only to its source socket in a shared logical session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(idle); + const reader = await SubscriptionPeer.connect(daemon.port, "shared-owned-session"); + peers.push(reader); + await reader.request({ type: "fetch_agents_request", requestId: "reader-snapshot" }); + await idle.request({ type: "ping", requestId: "idle-barrier", clientSentAt: 1 }); + expect( + idle.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "fetch_agents_response", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("combined filtered agent lists create independent server-owned observations", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Before", + }); + peer = await SubscriptionPeer.connect(daemon.port, "independent-agent-views"); + await peer.request({ type: "fetch_agents_request", requestId: "all", subscribe: {} }); + await peer.request({ + type: "fetch_agents_request", + requestId: "filtered", + filter: { labels: { role: "orchestrator" } }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const [all, filtered] = snapshots; + expect(typeof all.subscriptionId).toBe("string"); + expect(typeof filtered.subscriptionId).toBe("string"); + expect(all.subscriptionId).not.toBe(filtered.subscriptionId); + await admin.updateAgent(agent.id, { name: "After" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "After" }), + }), + expect.objectContaining({ + subscriptionId: filtered.subscriptionId, + kind: "remove", + agentId: agent.id, + }), + ]), + ); + await peer.request({ + type: "subscription.release.request", + requestId: "release-filtered", + subscriptionId: filtered.subscriptionId!, + }); + peer.frames.length = 0; + await admin.updateAgent(agent.id, { name: "Still observed" }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: all.subscriptionId, + kind: "upsert", + agent: expect.objectContaining({ id: agent.id, title: "Still observed" }), + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("workspace filters and identical queries coexist until their own release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const aDir = path.join(daemon.staticDir, "a"); + const bDir = path.join(daemon.staticDir, "b"); + await mkdir(aDir); + await mkdir(bDir); + const a = (await admin.createWorkspace({ source: { kind: "directory", path: aDir } })) + .workspace!; + const b = (await admin.createWorkspace({ source: { kind: "directory", path: bDir } })) + .workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "independent-workspace-views"); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "a", + filter: { projectId: a.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + await peer.request({ + type: "fetch_workspaces_request", + requestId: "b-copy", + filter: { projectId: b.projectId }, + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_workspaces_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + const ids = snapshots.map((snapshot) => snapshot.subscriptionId!); + expect(new Set(ids).size).toBe(3); + await peer.request({ + type: "subscription.release.request", + requestId: "release-b", + subscriptionId: ids[1], + }); + await admin.setWorkspaceTitle(a.id, "A updated"); + await admin.setWorkspaceTitle(b.id, "B updated"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace_update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[0], + kind: "upsert", + workspace: expect.objectContaining({ id: a.id, name: "A updated" }), + }), + expect.objectContaining({ + subscriptionId: ids[2], + kind: "upsert", + workspace: expect.objectContaining({ id: b.id, name: "B updated" }), + }), + ]), + ); + expect( + peer.frames.filter( + (frame) => + frame.type === "session" && + frame.message.type === "workspace_update" && + "subscriptionId" in frame.message.payload && + frame.message.payload.subscriptionId === ids[1], + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("label lists are pure and label observers have independent lifetimes", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "label-views"); + await peer.request({ type: "workspace.label.list.request", requestId: "plain" }); + await peer.request({ type: "workspace.label.list.request", requestId: "first", subscribe: {} }); + await peer.request({ + type: "workspace.label.list.request", + requestId: "second", + subscribe: {}, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.list.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(3); + expect(snapshots[0]).not.toHaveProperty("subscriptionId"); + const first = snapshots[1] as (typeof snapshots)[number] & { subscriptionId: string }; + const second = snapshots[2] as (typeof snapshots)[number] & { subscriptionId: string }; + expect(typeof first.subscriptionId).toBe("string"); + expect(first.subscriptionId).not.toBe(second.subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release", + subscriptionId: first.subscriptionId, + }); + peer.frames.length = 0; + await peer.request({ + type: "workspace.label.assignment.set.request", + requestId: "assign", + workspaceId: workspace.id, + label: { name: "QA", color: "blue" }, + assigned: true, + }); + await expect + .poll(() => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "workspace.label.update" + ? [frame.message.payload] + : [], + ), + ) + .toEqual([ + expect.objectContaining({ + kind: "upsert", + subscriptionId: second.subscriptionId, + label: { name: "QA", color: "blue" }, + }), + ]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("identical file observations get server IDs and release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + await writeFile(path.join(daemon.staticDir, "observed.txt"), "before"); + peer = await SubscriptionPeer.connect(daemon.port, "file-observers"); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-a", + cwd: daemon.staticDir, + path: "observed.txt", + }); + await peer.request({ + type: "fs.file.subscribe.request", + requestId: "file-b", + cwd: daemon.staticDir, + path: "observed.txt", + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.subscribe.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-file-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(daemon.staticDir, "observed.txt"), "after"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fs.file.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + version: expect.objectContaining({ status: "ready" }), + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("checkout diff reads are snapshots and identical observers release independently", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + const cwd = daemon.staticDir; + execFileSync("git", ["init", "-q", "--initial-branch=main", cwd]); + await writeFile(path.join(cwd, "tracked.txt"), "before\n"); + execFileSync("git", ["-C", cwd, "add", "tracked.txt"]); + execFileSync("git", [ + "-C", + cwd, + "-c", + "user.name=QA", + "-c", + "user.email=qa@example.invalid", + "commit", + "-qm", + "Initial", + ]); + peer = await SubscriptionPeer.connect(daemon.port, "diff-observers"); + await peer.request({ + type: "checkout.diff.get.request", + requestId: "read", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-a", + cwd, + compare: { mode: "uncommitted" }, + }); + await peer.request({ + type: "subscribe_checkout_diff_request", + requestId: "diff-b", + cwd, + compare: { mode: "uncommitted" }, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "subscribe_checkout_diff_response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + expect(snapshots[0].subscriptionId).not.toBe(snapshots[1].subscriptionId); + await peer.request({ + type: "subscription.release.request", + requestId: "release-diff-a", + subscriptionId: snapshots[0].subscriptionId, + }); + peer.frames.length = 0; + await writeFile(path.join(cwd, "tracked.txt"), "after\n"); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "checkout_diff_update" && + frame.message.payload.files.length > 0 + ? [frame.message.payload] + : [], + ), + { timeout: 10000 }, + ) + .toEqual([ + expect.objectContaining({ + subscriptionId: snapshots[1].subscriptionId, + error: null, + files: [expect.objectContaining({ path: "tracked.txt" })], + }), + ]); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("event-category observations use independent IDs without enabling other categories", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + peer = await SubscriptionPeer.connect(daemon.port, "event-observers"); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-a", + events: ["project.update"], + }); + await peer.request({ + type: "session.events.set_subscription.request", + requestId: "events-b", + events: ["project.update"], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-events-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "project.update" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ subscriptionId: ids[1], kind: "upsert" }), + ]), + ); + expect( + peer.frames.filter( + (frame) => frame.type === "session" && frame.message.type !== "project.update", + ), + ).toEqual([]); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("timeline observers for the same agent survive an independent release", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Timeline", + }); + peer = await SubscriptionPeer.connect(daemon.port, "timeline-observers"); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-a", + agentIds: [agent.id], + }); + await peer.request({ + type: "agent.timeline.set_subscription.request", + requestId: "timeline-b", + agentIds: [agent.id], + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent.timeline.set_subscription.response" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-timeline-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + await admin.sendMessage(agent.id, "An observed turn"); + await expect + .poll( + () => + peer!.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_stream", + ).length, + { timeout: 5000 }, + ) + .toBeGreaterThan(0); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_stream" + ? [frame.message.payload] + : [], + ); + expect( + updates.every( + (update) => + "subscriptionId" in update && + update.subscriptionId === ids[1] && + update.agentId === agent.id, + ), + ).toBe(true); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("same-workspace terminal directory observers do not share a teardown slot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let peer: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + peer = await SubscriptionPeer.connect(daemon.port, "terminal-directory-observers"); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-a", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + await peer.request({ + type: "subscribe_terminals_request", + requestId: "terminals-b", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const snapshots = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect(snapshots).toHaveLength(2); + const ids = snapshots.map( + (snapshot) => (snapshot as typeof snapshot & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-terminals-a", + subscriptionId: ids[0], + }); + peer.frames.length = 0; + const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + await expect + .poll( + () => + peer!.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ), + { timeout: 5000 }, + ) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + subscriptionId: ids[1], + workspaceId: workspace.id, + terminals: [expect.objectContaining({ id: result.terminal!.id })], + }), + ]), + ); + const updates = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "terminals_changed" + ? [frame.message.payload] + : [], + ); + expect( + updates.every((update) => "subscriptionId" in update && update.subscriptionId === ids[1]), + ).toBe(true); + await admin.killTerminal(result.terminal!.id); + } finally { + peer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("terminal output has separate server IDs and binary slots, including in a shared session", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, { + workspaceId: workspace.id, + command: "/bin/cat", + }); + expect(result.error).toBe(null); + const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(idle); + const peer = await SubscriptionPeer.connect(daemon.port, "terminal-output-session"); + peers.push(peer); + for (const requestId of ["output-a", "output-b"]) { + await peer.request({ + type: "subscribe_terminal_request", + requestId, + terminalId: result.terminal!.id, + }); + } + const replies = peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "subscribe_terminal_response" && + frame.message.payload.error === null + ? [frame.message.payload] + : [], + ); + expect(replies).toHaveLength(2); + const ids = replies.map( + (reply) => (reply as typeof reply & { subscriptionId: string }).subscriptionId, + ); + expect(ids.every((id) => typeof id === "string")).toBe(true); + expect(ids[0]).not.toBe(ids[1]); + expect(replies[0].slot).not.toBe(replies[1].slot); + await expect + .poll(() => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Snapshot) + .map((frame) => frame!.slot), + ) + .toEqual(expect.arrayContaining(replies.map((reply) => reply.slot))); + expect(idle.binaryFrames).toEqual([]); + await peer.request({ + type: "subscription.release.request", + requestId: "release-output-a", + subscriptionId: ids[0], + }); + peer.binaryFrames.length = 0; + admin.sendTerminalInput(result.terminal!.id, { type: "input", data: "remaining-observer\n" }); + await expect + .poll( + () => + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => Buffer.from(frame!.payload).toString()) + .join(""), + { timeout: 5000 }, + ) + .toContain("remaining-observer"); + expect( + peer.binaryFrames + .map(decodeTerminalStreamFrame) + .every((frame) => frame?.slot === replies[1].slot), + ).toBe(true); + expect(idle.binaryFrames).toEqual([]); + await admin.killTerminal(result.terminal!.id); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("a modern connection and pure reads retain no client observation producers", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + let peer: SubscriptionPeer | undefined; + try { + peer = await SubscriptionPeer.connect(daemon.port, "no-demand-producers"); + await peer.request({ type: "fetch_agents_request", requestId: "plain-agents" }); + await peer.request({ type: "fetch_workspaces_request", requestId: "plain-workspaces" }); + await peer.request({ type: "workspace.label.list.request", requestId: "plain-labels" }); + await peer.request({ type: "list_terminals_request", requestId: "plain-terminals" }); + await peer.request({ type: "diagnostics.request", requestId: "producer-diagnostics" }); + const diagnostic = peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(diagnostic).toContain("Session observations"); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + expect(diagnostic).toContain("Git observations: 0"); + } finally { + peer?.close(); + await daemon.close(); + } +}); + +test("config changes require their own event demand and cannot escape to an idle socket", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const idle = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(idle); + const observer = await SubscriptionPeer.connect(daemon.port, "config-session"); + peers.push(observer); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "config-feed", + events: ["status.daemon_config_changed"], + }); + await admin.patchDaemonConfig({ autoArchiveAfterMerge: true }); + await expect + .poll(() => + observer.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "daemon_config_changed", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const changes = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "status" ? [frame.message.payload] : [], + ); + expect( + changes.every( + (change) => + change.status === "daemon_config_changed" && typeof change.subscriptionId === "string", + ), + ).toBe(true); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("status-shaped operation replies use actual source request provenance", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const idle = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(idle); + const actor = await SubscriptionPeer.connect(daemon.port, "status-reply-session"); + peers.push(actor); + await actor.request({ + type: "create_agent_request", + requestId: "created-status", + config: { provider: "codex", cwd: daemon.staticDir }, + }); + expect(actor.frames).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "session", + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + status: "agent_created", + requestId: "created-status", + }), + }), + }), + ]), + ); + expect(idle.frames).toEqual([]); + await actor.request({ type: "schedule/list", requestId: "schedule-list" }); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("browser capability advertisement is passive and explicit hosts own command delivery", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const capability = { hostKind: "QA browser", supportedCommands: ["list_tabs"] }; + const idle = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(idle); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + const host = await SubscriptionPeer.connect(daemon.port, "browser-host-session", { + browser_host: capability, + }); + peers.push(host); + await host.request({ + type: "browser.host.register.request", + requestId: "host-register", + hostKind: capability.hostKind, + supportedCommands: ["list_tabs"], + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(1); + const pending = daemon.daemon.browserToolsBroker.execute({ + command: { command: "list_tabs", args: {} }, + }); + await expect + .poll(() => + host.frames.some( + (frame) => + frame.type === "session" && frame.message.type === "browser.automation.execute.request", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + const registration = host.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "browser.host.register.response" + ? [frame.message.payload] + : [], + )[0]; + await host.request({ + type: "subscription.release.request", + requestId: "release-host", + subscriptionId: registration.subscriptionId, + }); + expect(daemon.daemon.browserToolsBroker.getRegisteredClientCount()).toBe(0); + expect(daemon.daemon.browserToolsBroker.getPendingRequestCount()).toBe(0); + await expect(pending).resolves.toMatchObject({ ok: false, error: { code: "browser_no_host" } }); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("adding raw client listeners does not create server observation demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + capabilities: { owned_subscriptions: true }, + }); + try { + await client.connect(); + const off = client.on("project.update", () => {}); + const all = client.subscribe(() => {}); + await client.ping(); + const { diagnostic } = await client.collectDiagnostics(); + expect(diagnostic).toContain("Registrations: 0"); + expect(diagnostic).toContain("Producer listeners: 0"); + off(); + all(); + } finally { + await client.close(); + await daemon.close(); + } +}); + +test("upload operations with identical request IDs belong to their source sockets", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const a = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(a); + const b = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(b); + const idle = await SubscriptionPeer.connect(daemon.port, "upload-shared-session"); + peers.push(idle); + a.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "a.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + b.send({ + type: "file.upload.request", + requestId: "same-upload", + fileName: "b.txt", + mimeType: "text/plain", + size: 5, + modifiedAt: "2026-09-09T00:00:00Z", + }); + await a.request({ type: "ping", requestId: "a-upload-begun", clientSentAt: 1 }); + await b.request({ type: "ping", requestId: "b-upload-begun", clientSentAt: 1 }); + a.upload("same-upload", "alpha", "a.txt"); + b.upload("same-upload", "bravo", "b.txt"); + const uploads = (peer: SubscriptionPeer) => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "file.upload.response" + ? [frame.message.payload] + : [], + ); + await expect + .poll(() => uploads(a)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "a.txt" }), + }), + ]); + await expect + .poll(() => uploads(b)) + .toEqual([ + expect.objectContaining({ + error: null, + file: expect.objectContaining({ fileName: "b.txt" }), + }), + ]); + expect(uploads(a)[0]?.file?.id).not.toBe(uploads(b)[0]?.file?.id); + expect(uploads(idle)).toEqual([]); + } finally { + peers.forEach((peer) => peer.close()); + await daemon.close(); + } +}); + +test("notification ownership is separate from data demand and another socket's focus", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + title: "Attention", + }); + const observer = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + const passive = await SubscriptionPeer.connect(daemon.port, "attention-sources"); + peers.push(observer, passive); + for (const requestId of ["data-a", "data-b", "notifications"]) { + await observer.request({ + type: "session.events.set_subscription.request", + requestId, + events: ["agent_attention_required"], + notifications: requestId === "notifications", + }); + } + const snapshots = observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "session.events.set_subscription.response" + ? [frame.message.payload] + : [], + ); + const notificationId = snapshots.find( + (snapshot) => snapshot.requestId === "notifications", + )!.subscriptionId; + observer.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + passive.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: agent.id, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await passive.request({ type: "ping", requestId: "focused-barrier", clientSentAt: 1 }); + observer.frames.length = 0; + passive.frames.length = 0; + await admin.sendMessage(agent.id, "Complete a turn"); + const updates = () => + observer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload] + : [], + ); + await expect.poll(() => updates().length, { timeout: 10000 }).toBe(3); + expect( + updates() + .filter((update) => update.shouldNotify) + .map((update) => update.subscriptionId), + ).toEqual([notificationId]); + expect(new Set(updates().map((update) => update.subscriptionId))).toEqual( + new Set(snapshots.map((snapshot) => snapshot.subscriptionId)), + ); + expect( + passive.frames.filter( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("protocol rejection is a source-only control reply on modern connections", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const sender = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "invalid-message-sources"); + peers.push(sender, idle); + await sender.request({ + type: "fetch_agents_request", + requestId: "invalid-query", + scope: 42, + } as unknown as SessionInboundMessage); + const replies = sender.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "rpc_error" ? [frame.message.payload] : [], + ); + expect(replies).toEqual([ + expect.objectContaining({ requestId: "invalid-query", requestType: "fetch_agents_request" }), + ]); + expect(idle.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("dictation failure belongs only to its requesting source and releases speech demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const peers: SubscriptionPeer[] = []; + try { + const requester = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + const idle = await SubscriptionPeer.connect(daemon.port, "speech-sources"); + peers.push(requester, idle); + requester.send({ + type: "dictation_stream_start", + dictationId: "same-dictation", + format: "audio/pcm;rate=16000;bits=16", + }); + await expect + .poll(() => + requester.frames.some( + (frame) => frame.type === "session" && frame.message.type === "dictation_stream_error", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + await requester.request({ type: "diagnostics.request", requestId: "speech-producers" }); + const report = requester.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "diagnostics.response" + ? [frame.message.payload.diagnostic] + : [], + )[0]; + expect(report).toContain("Registrations: 0"); + expect(report).toContain("Producer listeners: 0"); + } finally { + for (const peer of peers) peer.close(); + await daemon.close(); + } +}); + +test("a legacy socket cannot borrow a modern sibling's directory observation", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + if (!workspace) throw new Error("Workspace creation failed"); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + const modern = await SubscriptionPeer.connect(daemon.port, "mixed-notifications"); + const legacy = await SubscriptionPeer.connect(daemon.port, "mixed-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + peers.push(modern, legacy); + await modern.request({ + type: "fetch_agents_request", + requestId: "modern-directory", + subscribe: {}, + }); + await modern.request({ + type: "session.events.set_subscription.request", + requestId: "modern-attention", + events: ["agent_attention_required"], + }); + await admin.sendMessage(agent.id, "Finish a turn"); + await expect + .poll(() => + modern.frames.some( + (frame) => frame.type === "session" && frame.message.type === "agent_attention_required", + ), + ) + .toBe(true); + expect( + legacy.frames.filter( + (frame) => + frame.type === "session" && + (frame.message.type === "agent_attention_required" || + (frame.message.type === "agent_stream" && + frame.message.payload.event.type === "attention_required")), + ), + ).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("archive and delete replies retain their historical names and reach only the requester", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + const requester = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + const sibling = await SubscriptionPeer.connect(daemon.port, "mutation-replies"); + peers.push(requester, sibling); + await requester.request({ + type: "archive_agent_request", + requestId: "archive", + agentId: agent.id, + }); + await requester.request({ + type: "delete_agent_request", + requestId: "delete", + agentId: agent.id, + }); + expect( + requester.frames.flatMap((frame) => (frame.type === "session" ? [frame.message.type] : [])), + ).toEqual(["agent_archived", "agent_deleted"]); + expect(sibling.frames).toEqual([]); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("Hub bootstrap sends its server ID before the producer's synchronous snapshot", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const frames: WSOutboundMessage[] = []; + const hub = createServer(async (request, response) => { + let body = ""; + for await (const chunk of request) body += chunk; + response.setHeader("content-type", "application/json"); + if (request.url !== "/api/daemons/enroll") { + response.end("{}"); + return; + } + const enrollment = JSON.parse(body); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + response.end( + JSON.stringify({ + daemonId: enrollment.daemonId, + permissions: enrollment.permissions, + webSocketUrl: `ws://127.0.0.1:${address.port}/socket`, + }), + ); + }); + const wss = new WebSocketServer({ server: hub }); + wss.on("headers", (headers) => headers.push("x-paseo-session-protocol: 1")); + const connected = new Promise((resolve) => + wss.once("connection", (socket) => { + socket.on("message", (data) => frames.push(JSON.parse(data.toString()))); + resolve(socket); + }), + ); + let socket: WebSocket | undefined; + try { + await new Promise((resolve) => hub.listen(0, "127.0.0.1", resolve)); + const address = hub.address(); + if (!address || typeof address === "string") throw new Error("Missing local Hub address"); + await admin.connect(); + await admin.connectHub(`http://127.0.0.1:${address.port}`, "local-fixture-token", [ + "hub.execute", + ]); + socket = await connected; + socket.send( + JSON.stringify({ + type: "hello", + clientId: "local-hub", + clientType: "hub", + protocolVersion: 1, + capabilities: { owned_subscriptions: true }, + }), + ); + await expect.poll(() => frames.length).toBeGreaterThan(0); + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "hub.execution.agent.create.request", + requestId: "create", + executionId: "bootstrap", + provider: "codex", + cwd: daemon.staticDir, + prompt: "hello", + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "hub.execution.agent.create.response" && + frame.message.payload.success, + ), + ) + .toBe(true); + frames.length = 0; + socket.send( + JSON.stringify({ + type: "session", + message: { + type: "session.events.set_subscription.request", + requestId: "observe", + events: ["hub.execution.agent.update"], + }, + }), + ); + await expect + .poll(() => + frames.some( + (frame) => + frame.type === "session" && frame.message.type === "hub.execution.agent.update", + ), + ) + .toBe(true); + expect(frames[0]).toMatchObject({ + type: "session", + message: { + type: "session.events.set_subscription.response", + payload: { requestId: "observe", subscriptionId: expect.any(String) }, + }, + }); + } finally { + await admin.disconnectHub(true).catch(() => {}); + socket?.terminate(); + await admin.close(); + await daemon.close(); + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => hub.close(() => resolve())); + } +}); + +test("provider child changes require explicit event ownership and leave an idle peer quiet", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let idle: SubscriptionPeer | undefined; + let observer: SubscriptionPeer | undefined; + try { + await admin.connect(); + idle = await SubscriptionPeer.connect(daemon.port, "idle-provider-child"); + observer = await SubscriptionPeer.connect(daemon.port, "provider-child-observer"); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "children", + events: ["agent.provider_subagents.update"], + }); + const response = observer.frames.find( + (frame) => + frame.type === "session" && + frame.message.type === "session.events.set_subscription.response", + ); + expect(response).toBeDefined(); + const agent = await admin.createAgent({ provider: "codex", cwd: daemon.staticDir }); + await admin.sendMessage(agent.id, "Emit a provider child"); + await expect + .poll(() => + observer!.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "agent.provider_subagents.update" && + frame.message.payload.kind === "upsert" && + frame.message.payload.subagent.id === "fixture-child", + ), + ) + .toBe(true); + expect(idle.frames).toEqual([]); + expect(idle.binaryFrames).toEqual([]); + } finally { + idle?.close(); + observer?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("request outcomes for import and attention reach only their requesting socket", async () => { + const daemon = await createTestPaseoDaemon({ + mcpEnabled: false, + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared"); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await admin.connect(); + await requester.request({ + type: "import_agent_request", + requestId: "missing-import", + provider: "codex", + sessionId: "does-not-exist", + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "missing-import", + status: "agent_create_failed", + }), + }), + }), + ); + await requester.request({ + type: "import_agent_request", + requestId: "successful-import", + provider: "mock", + sessionId: "saved-provider-session", + cwd: daemon.staticDir, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "status", + payload: expect.objectContaining({ + requestId: "successful-import", + status: "agent_resumed", + }), + }), + }), + ); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Attention", + }); + await requester.request({ + type: "clear_agent_attention", + requestId: "clear-attention", + agentId: agent.id, + }); + expect(requester.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ type: "clear_agent_attention_response" }), + }), + ); + expect(sibling.frames).toEqual([]); + expect(sibling.binaryFrames).toEqual([]); + } finally { + requester.close(); + sibling.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("SDK import failure settles without waiting for the request timeout", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await expect( + client.importAgent({ provider: "codex", sessionId: "does-not-exist" }), + ).rejects.toThrow(); + } finally { + await client.close(); + await daemon.close(); + } +}, 5000); + +test("permission outcomes and domain observations keep independent source ownership", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const clients = [ + "permission-shared", + "permission-shared", + "permission-control", + "permission-shared", + ].map((clientId) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId })); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId: "permission-shared", + capabilities: { owned_subscriptions: false, explicit_event_subscriptions: false }, + }); + clients.push(legacy); + const actor = clients[0]; + const observed: SessionOutboundMessage[][] = clients.map(() => []); + const raw: SessionOutboundMessage[][] = clients.map(() => []); + try { + for (const [index, client] of clients.entries()) { + await client.connect(); + client.subscribeRawMessages((message) => raw[index].push(message)); + } + const owners = clients.slice(0, 3).map((client, index) => { + const owner = client.observeEvents(["agent_permission_request", "agent_permission_resolved"]); + owner.subscribe({ snapshot: () => {}, update: (message) => observed[index].push(message) }); + return owner; + }); + await Promise.all(owners.map((owner) => owner.ready)); + const agent = await actor.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + modeId: "always-ask", + }); + for (let round = 0; round < 3; round += 1) { + if (round === 1) await owners[1].release(); + observed.forEach((messages) => { + messages.length = 0; + }); + raw.forEach((messages) => { + messages.length = 0; + }); + await actor.sendMessage( + agent.id, + 'Request approval to run the command `printf "ok" > permission.txt`. If denied, stop.', + ); + const state = await actor.waitForFinish(agent.id, 5000); + const permission = state.final?.pendingPermissions[0]; + expect(permission).toBeDefined(); + if (!permission) throw new Error("Expected a pending permission"); + await (round === 2 ? legacy : actor).respondToPermission(agent.id, permission.id, { + behavior: "deny", + message: "Owned observation regression", + }); + const resolutions = (messages: SessionOutboundMessage[]) => + messages.filter( + ( + message, + ): message is Extract => + message.type === "agent_permission_resolved" && + message.payload.requestId === permission.id, + ); + await expect.poll(() => resolutions(observed[2]).length).toBe(1); + await actor.waitForFinish(agent.id, 5000); + await expect.poll(() => resolutions(observed[0]).length).toBe(1); + for (const index of [0, 1, 2]) { + const expected = index === 1 && round >= 1 ? 0 : 1; + expect(resolutions(observed[index])).toHaveLength(expected); + for (const message of resolutions(observed[index])) { + expect(message.payload).toMatchObject({ + subscriptionId: owners[index].subscriptionId, + resolution: { behavior: "deny" }, + }); + } + const outcomes = resolutions(raw[index]).filter( + (message) => !message.payload.subscriptionId, + ); + expect(outcomes).toHaveLength(index === 0 && round < 2 ? 1 : 0); + } + expect(resolutions(raw[4])).toHaveLength(1); + expect(raw[3]).toEqual([]); + } + } finally { + await Promise.all(clients.map((client) => client.close())); + await daemon.close(); + } +}); + +test("mark unread replies remain source-owned while directory observers receive attention", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const peers: SubscriptionPeer[] = []; + try { + await admin.connect(); + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + title: "Finished", + }); + const actor = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(actor); + const sibling = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(sibling); + const quiet = await SubscriptionPeer.connect(daemon.port, "mark-unread-shared"); + peers.push(quiet); + await sibling.request({ type: "fetch_agents_request", requestId: "directory", subscribe: {} }); + const snapshot = sibling.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "fetch_agents_response" + ? [frame.message.payload] + : [], + )[0]; + const workspaceId = snapshot.entries.find((entry) => entry.agent.id === agent.id)!.agent + .workspaceId!; + await actor.request({ + type: "workspace.clear_attention.request", + workspaceId, + requestId: "clear", + }); + actor.frames.length = sibling.frames.length = quiet.frames.length = 0; + await actor.request({ type: "workspace.mark_unread.request", workspaceId, requestId: "mark" }); + expect(actor.frames).toContainEqual( + expect.objectContaining({ + message: { + type: "workspace.mark_unread.response", + payload: { + workspaceId, + requestId: "mark", + markedAgentId: agent.id, + success: true, + error: null, + }, + }, + }), + ); + await expect + .poll(() => + sibling.frames.some( + (frame) => + frame.type === "session" && + frame.message.type === "agent_update" && + frame.message.payload.kind === "upsert" && + frame.message.payload.agent.id === agent.id && + frame.message.payload.agent.requiresAttention && + frame.message.payload.subscriptionId === snapshot.subscriptionId, + ), + ) + .toBe(true); + expect( + sibling.frames.some( + (frame) => + frame.type === "session" && frame.message.type === "workspace.mark_unread.response", + ), + ).toBe(false); + expect(quiet.frames).toEqual([]); + expect(quiet.binaryFrames).toEqual([]); + await actor.request({ + type: "workspace.mark_unread.request", + workspaceId, + requestId: "already-unread", + }); + expect(actor.frames).toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + type: "workspace.mark_unread.response", + payload: expect.objectContaining({ requestId: "already-unread", success: false }), + }), + }), + ); + } finally { + for (const peer of peers) peer.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("legacy event subscribers retain notifications without the new notifications flag", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let legacy: SubscriptionPeer | undefined; + try { + await admin.connect(); + const workspace = ( + await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }) + ).workspace!; + const agent = await admin.createAgent({ + provider: "codex", + cwd: daemon.staticDir, + workspaceId: workspace.id, + }); + legacy = await SubscriptionPeer.connect(daemon.port, "legacy-notifications", { + owned_subscriptions: false, + selective_agent_timeline: true, + explicit_event_subscriptions: true, + }); + await legacy.request({ + type: "fetch_agents_request", + requestId: "directory", + subscribe: { subscriptionId: "directory" }, + }); + await legacy.request({ + type: "session.events.set_subscription.request", + requestId: "events", + events: ["agent_attention_required"], + }); + legacy.send({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + lastActivityAt: new Date().toISOString(), + appVisible: true, + }); + await legacy.request({ type: "ping", requestId: "barrier", clientSentAt: 1 }); + const peer = legacy; + await admin.sendMessage(agent.id, "Finish a legacy notification turn"); + await expect + .poll( + () => + peer.frames.flatMap((frame) => + frame.type === "session" && frame.message.type === "agent_attention_required" + ? [frame.message.payload.shouldNotify] + : [], + ), + { timeout: 10_000 }, + ) + .toEqual([true]); + } finally { + legacy?.close(); + await admin.close(); + await daemon.close(); + } +}); + +test("public project subscriptions request updates and release their producer demand", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const api = createPaseoApi(client); + try { + await client.connect(); + const updates: unknown[] = []; + const unsubscribe = api.projects.subscribe((update) => updates.push(update)); + await client.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } }); + await expect + .poll(() => updates) + .toEqual(expect.arrayContaining([expect.objectContaining({ kind: "upsert" })])); + unsubscribe(); + await expect + .poll(async () => (await client.collectDiagnostics()).diagnostic) + .toContain("Registrations: 0"); + } finally { + await api.dispose(); + await client.close(); + await daemon.close(); + } +}); diff --git a/packages/server/src/server/paseo-home.test.ts b/packages/server/src/server/paseo-home.test.ts index 99a6c30d25..d8623d230b 100644 --- a/packages/server/src/server/paseo-home.test.ts +++ b/packages/server/src/server/paseo-home.test.ts @@ -1,24 +1,16 @@ -import { mkdtempSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { resolvePaseoHome } from "./paseo-home.js"; -import { PRIVATE_DIRECTORY_MODE } from "./private-files.js"; - -const MODE_MASK = 0o777; - -function modeOf(filePath: string): number { - return statSync(filePath).mode & MODE_MASK; -} - -describe.skipIf(process.platform === "win32")("resolvePaseoHome permissions", () => { - test("creates PASEO_HOME with private permissions", () => { +describe("resolvePaseoHome", () => { + test("resolves PASEO_HOME without creating it", () => { const parent = mkdtempSync(path.join(tmpdir(), "paseo-home-parent-")); const paseoHome = path.join(parent, "home"); try { expect(resolvePaseoHome({ PASEO_HOME: paseoHome })).toBe(paseoHome); - expect(modeOf(paseoHome)).toBe(PRIVATE_DIRECTORY_MODE); + expect(existsSync(paseoHome)).toBe(false); } finally { rmSync(parent, { recursive: true, force: true }); } diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts index f933653b8b..90b0410cbd 100644 --- a/packages/server/src/server/paseo-home.ts +++ b/packages/server/src/server/paseo-home.ts @@ -1,6 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { ensurePrivateDirectory } from "./private-files.js"; function expandHomeDir(input: string): string { if (input.startsWith("~/")) { @@ -15,6 +14,5 @@ function expandHomeDir(input: string): string { export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string { const raw = env.PASEO_HOME ?? "~/.paseo"; const resolved = path.resolve(expandHomeDir(raw)); - ensurePrivateDirectory(resolved); return resolved; } diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index c40f710d38..a5120aa5ff 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -71,7 +71,7 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/); expect(result.workspace.projectId).toBe("remote:github.com/acme/repo"); expect(result.workspace.displayName).toBe("feature-one"); - expect(result.workspace.baseBranch).toBe("main"); + expect(result.workspace.baseBranch).toBe("refs/heads/main"); expect(result.workspace.title).toBe("Feature One"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); expect(deps.projects.get(sourceProject.projectId)).toEqual({ @@ -791,13 +791,14 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename createDeps(), ); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", }); - // A checkout-branch worktree has no distinct base, so the workspace records a - // null baseBranch even though worktree.json's baseRefName is the branch itself. - expect(created.workspace.baseBranch).toBe(null); + expect(created.workspace.baseBranch).toBe("main"); await expect( attemptFirstAgentBranchAutoName({ cwd: created.worktree.worktreePath, @@ -1117,6 +1118,7 @@ function createDeps(options?: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createTestLogger(), }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index dea17e1bb9..94e82d5e95 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -20,7 +20,6 @@ import { import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { markPaseoWorktreeFirstAgentBranchAutoNameAttempted, - normalizeBaseRefName, readPaseoWorktreeMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata, } from "../utils/worktree-metadata.js"; @@ -31,8 +30,11 @@ import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import { runWithGitCommandPriority } from "../utils/run-git-command.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { + workspaceId?: string; projectId?: string; title?: string; + background?: boolean; + callerWorkspaceId?: string; } export interface CreatePaseoWorktreeResult { @@ -93,13 +95,16 @@ async function createPaseoWorktreeWithPriority( const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, + workspaceId: input.workspaceId, repoRoot: createdWorktree.repoRoot, cwd: workspaceCwd, worktreeRoot: createdWorktree.worktree.worktreePath, branch: createdWorktree.worktree.branchName || null, - baseBranch: resolveIntentBaseBranch(createdWorktree.intent), + baseBranch: createdWorktree.worktree.comparisonBaseRef, title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext), expectsInitialAgent: Boolean(input.firstAgentContext), + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, ...(createdWorktree.intent.kind === "checkout-change-request" && createdWorktree.intent.headRepository ? { @@ -271,18 +276,3 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: { placeholderBranchName: createdWorktree.worktree.branchName, }); } - -// The base branch is normalized to match worktree.json's baseRefName (origin/ -// stripped). checkout-branch worktrees have no distinct base, so they stay null. -function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null { - switch (intent.kind) { - case "branch-off": - return normalizeBaseRefName(intent.baseBranch); - case "checkout-change-request": - return normalizeBaseRefName(intent.baseRefName); - case "checkout-github-pr": - return normalizeBaseRefName(intent.baseRefName); - case "checkout-branch": - return null; - } -} diff --git a/packages/server/src/server/persisted-config-edit.test.ts b/packages/server/src/server/persisted-config-edit.test.ts new file mode 100644 index 0000000000..4b4bf4c0db --- /dev/null +++ b/packages/server/src/server/persisted-config-edit.test.ts @@ -0,0 +1,45 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { + editPersistedConfig, + getPersistedConfigValue, + readPersistedConfig, +} from "./persisted-config.js"; + +test("configuration edits validate before writing and preserve unrelated settings", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-config-edit-")); + const home = path.join(root, "home"); + try { + expect(readPersistedConfig(home)).toEqual({}); + expect(existsSync(home)).toBe(false); + for (const field of [ + "unknown", + "daemon.missing", + "daemon.__proto__.listen", + "daemon.auth.password", + ]) { + expect(() => editPersistedConfig(home, field, { value: "value" })).toThrow(); + expect(existsSync(home)).toBe(false); + } + editPersistedConfig(home, "daemon.listen", { value: "127.0.0.1:12345" }); + editPersistedConfig(home, "features.webUi.enabled", { value: true }); + const before = await readFile(path.join(home, "config.json"), "utf8"); + expect(() => editPersistedConfig(home, "features.webUi.enabled", { value: "true" })).toThrow(); + expect(() => + editPersistedConfig(home, "daemon", { value: { auth: { password: "plaintext" } } }), + ).toThrow(/set-password/); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe(before); + editPersistedConfig(home, "features.webUi.enabled", { unset: true }); + const edited = readPersistedConfig(home); + expect(getPersistedConfigValue(edited, "features.webUi.enabled")).toBeUndefined(); + expect(edited.daemon?.listen).toBe("127.0.0.1:12345"); + await writeFile(path.join(home, "config.json"), "invalid json"); + expect(() => editPersistedConfig(home, "daemon.listen", { value: "12345" })).toThrow(); + expect(await readFile(path.join(home, "config.json"), "utf8")).toBe("invalid json"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/persisted-config-json-schema.test.ts b/packages/server/src/server/persisted-config-json-schema.test.ts new file mode 100644 index 0000000000..f943f859ec --- /dev/null +++ b/packages/server/src/server/persisted-config-json-schema.test.ts @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import Ajv from "ajv"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { PersistedConfigSchema } from "./persisted-config.js"; + +const publishedSchema = JSON.parse( + readFileSync( + new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url), + "utf8", + ), +); + +test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => { + const config = { + $schema: "https://paseo.sh/schemas/paseo.config.v1.json", + version: 1, + daemon: { + browserTools: { enabled: true }, + terminalProfiles: [], + agentProfiles: [], + }, + pluginsEnabled: true, + plugins: {}, + agents: { + metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] }, + }, + }; + expect(PersistedConfigSchema.parse(config)).toEqual(config); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + const valid = validate(config); + expect(validate.errors).toBeNull(); + expect(valid).toBe(true); +}); + +test("the published schema stays current with the daemon's configuration schema", () => { + const generated = z.toJSONSchema(PersistedConfigSchema, { + target: "draft-07", + unrepresentable: "any", + io: "input", + }); + generated.title = "PaseoConfigV1"; + expect( + publishedSchema, + "Run npm run generate:config-schema --workspace=@getpaseo/server", + ).toEqual(generated); +}); + +test("editors retain the existing proxy configuration help", () => { + const daemon = publishedSchema.properties.daemon.properties; + expect(daemon.trustedProxies.description).toBe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ); + expect(daemon.serviceProxy.properties.enabled.description).toBe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ); + expect(daemon.serviceProxy.properties.listen.description).toBe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ); + expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe( + "Optional public base URL for service host aliases.", + ); +}); + +test.each([ + { daemon: { browserTools: { enabled: "yes" } } }, + { daemon: { terminalProfiles: "default" } }, + { daemon: { agentProfiles: "default" } }, + { pluginsEnabled: "yes" }, + { plugins: [] }, + { agents: { metadataGeneration: { providers: [{ provider: "" }] } } }, + { unknownSetting: true }, +])("editors still reject invalid configuration: %j", (config) => { + expect(PersistedConfigSchema.safeParse(config).success).toBe(false); + const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema); + expect(validate(config)).toBe(false); +}); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 8ea1c5c25d..f443f84865 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -6,9 +6,10 @@ import { describe, expect, test } from "vitest"; import { loadPersistedConfig, PersistedConfigSchema, + readPersistedConfig, savePersistedConfig, } from "./persisted-config.js"; -import { PRIVATE_FILE_MODE } from "./private-files.js"; +import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js"; const MODE_MASK = 0o777; const PERMISSIVE_FILE_MODE = 0o644; @@ -412,20 +413,24 @@ describe("provider overrides (new format)", () => { expect(parsed.agents?.providers?.claude?.order).toBe(1); }); - test("new provider without extends → error", () => { - const result = PersistedConfigSchema.safeParse({ - agents: { - providers: { - zai: { - label: "ZAI", - }, - }, - }, + test("accepts a plugin override before plugin registration", () => { + const override = { enabled: false, command: ["/opt/agent"], env: { LOGIN: "yes" } }; + const parsed = PersistedConfigSchema.parse({ + agents: { providers: { "plugin-agent": override } }, }); - - expect(result.success).toBe(false); + expect(parsed.agents?.providers?.["plugin-agent"]).toEqual(override); }); + test.each(["plugin.agent", "plugin_agent"])( + "keeps %s outside the config provider ID alphabet", + (id) => { + expect( + PersistedConfigSchema.safeParse({ agents: { providers: { [id]: { enabled: false } } } }) + .success, + ).toBe(false); + }, + ); + test("new provider without label → error", () => { const result = PersistedConfigSchema.safeParse({ agents: { @@ -736,6 +741,62 @@ describe("loadPersistedConfig", () => { }); }); +describe("config.json saved with a UTF-8 byte order mark", () => { + // Windows Notepad writes this shape: a BOM, then CRLF line endings. + const notepadConfig = + '{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n'; + + test("loadPersistedConfig reads it", () => { + const home = createTempHome(); + try { + writeFileSync(path.join(home, "config.json"), notepadConfig); + + expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("readPersistedConfig reads it", () => { + const home = createTempHome(); + try { + writeFileSync(path.join(home, "config.json"), notepadConfig); + + expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("readPersistedConfig with an unreadable config.json", () => { + test("names the file when it is not valid JSON", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, '{"version":1,'); + + expect(() => readPersistedConfig(home)).toThrow(`[Config] Invalid JSON in ${configPath}: `); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("names the file and the field when it does not match the schema", () => { + const home = createTempHome(); + const configPath = path.join(home, "config.json"); + try { + writeFileSync(configPath, '{"daemon":{"listen":5}}'); + + expect(() => readPersistedConfig(home)).toThrow( + `[Config] Invalid config in ${configPath}:\n - daemon.listen: `, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + describe.skipIf(process.platform === "win32")("persisted config file permissions", () => { test("initializes config.json with private permissions", () => { const home = createTempHome(); @@ -764,7 +825,8 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }); test("saves config.json with private permissions", () => { - const home = createTempHome(); + const parent = createTempHome(); + const home = path.join(parent, "home"); try { savePersistedConfig(home, { providers: { @@ -774,9 +836,10 @@ describe.skipIf(process.platform === "win32")("persisted config file permissions }, }); + expect(modeOf(home)).toBe(PRIVATE_DIRECTORY_MODE); expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE); } finally { - rmSync(home, { recursive: true, force: true }); + rmSync(parent, { recursive: true, force: true }); } }); }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 1cdaf209d8..4998455713 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -1,3 +1,4 @@ +import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry"; import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; @@ -9,13 +10,9 @@ import { } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js"; -import { - AgentProfileSchema, - AgentSkillSelectionSchema, - PluginIdSchema, - PluginSourceSchema, - TerminalProfileSchema, -} from "@getpaseo/protocol/messages"; +import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile"; +import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config"; +import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile"; import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema"; export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]); @@ -240,7 +237,12 @@ export const PersistedConfigSchema = z listen: z.string().optional(), hostnames: z.union([z.literal(true), z.array(z.string())]).optional(), allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(), - trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(), + trustedProxies: z + .union([z.literal(true), z.array(z.string())]) + .optional() + .describe( + 'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.', + ), mcp: z .object({ enabled: z.boolean().optional(), @@ -287,9 +289,22 @@ export const PersistedConfigSchema = z // COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02. // Parsed only to suppress optional public/listen layers for old configs; // localhost service proxying remains always enabled. - enabled: z.boolean().optional(), - listen: z.string().optional(), - publicBaseUrl: z.url().optional(), + enabled: z + .boolean() + .optional() + .describe( + "Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.", + ), + listen: z + .string() + .optional() + .describe( + "Optional service-only listener address. Presence enables the standalone service proxy listener.", + ), + publicBaseUrl: z + .url() + .optional() + .describe("Optional public base URL for service host aliases."), }) .strict() .optional(), @@ -310,6 +325,7 @@ export const PersistedConfigSchema = z .optional(), providers: ProvidersSchema.optional(), + pluginRegistries: PluginRegistriesSchema.optional(), pluginsEnabled: z.boolean().optional(), plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(), worktrees: WorktreesConfigSchema.optional(), @@ -440,9 +456,32 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } + const config = parseConfigFile(configPath, raw); + log?.info(`Loaded from ${configPath}`); + return config; +} + +/** Observe the file without initializing a home, identity, or default configuration. */ +export function readPersistedConfig( + paseoHome: string, + options: { defaultsIfMissing?: boolean } = {}, +): PersistedConfig { + const configPath = getConfigPath(paseoHome); + let raw: string; + try { + raw = readFileSync(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {}; + throw error; + } + return parseConfigFile(configPath, raw); +} + +function parseConfigFile(configPath: string, raw: string): PersistedConfig { let parsed: unknown; try { - parsed = JSON.parse(raw); + parsed = parseConfigText(raw); } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, { @@ -450,19 +489,83 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per }); } - const migrated = stripRemovedConfigFields(parsed); - const result = PersistedConfigSchema.safeParse(migrated); + const result = PersistedConfigSchema.safeParse(stripRemovedConfigFields(parsed)); if (!result.success) { const issues = result.error.issues .map((i) => ` - ${i.path.join(".")}: ${i.message}`) .join("\n"); throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`); } - - log?.info(`Loaded from ${configPath}`); return result.data as PersistedConfig; } +/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */ +function parseConfigText(raw: string): unknown { + return JSON.parse(raw.replace(/^\uFEFF/, "")); +} + +function configPathParts(field: string): string[] { + const parts = field.split("."); + let schema: z.ZodType = PersistedConfigSchema; + for (const part of parts) { + if (!part || ["__proto__", "prototype", "constructor"].includes(part)) { + throw new Error(`Invalid configuration path: ${field}`); + } + while ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodDefault || + schema instanceof z.ZodPipe + ) + schema = (schema instanceof z.ZodPipe ? schema.in : schema.unwrap()) as z.ZodType; + if (!(schema instanceof z.ZodObject) || !Object.hasOwn(schema.shape, part)) { + throw new Error( + `Unknown configuration path: ${field}. Replace its containing object with JSON for dynamic keys.`, + ); + } + schema = schema.shape[part]; + } + return parts; +} + +export function getPersistedConfigValue(config: PersistedConfig, field: string): unknown { + return configPathParts(field).reduce( + (value, part) => + value && typeof value === "object" ? (value as Record)[part] : undefined, + config, + ); +} + +export function editPersistedConfig( + paseoHome: string, + field: string, + edit: { value: unknown } | { unset: true }, +): PersistedConfig { + const parts = configPathParts(field); + if (field === "daemon.auth" || field.startsWith("daemon.auth.")) { + throw new Error("Use daemon set-password to change the daemon password."); + } + const config = readPersistedConfig(paseoHome, { defaultsIfMissing: true }); + let object = config as Record; + for (const part of parts.slice(0, -1)) { + object[part] ??= {}; + object = object[part] as Record; + } + const key = parts.at(-1)!; + if (field === "daemon") { + const previousAuth = config.daemon?.auth; + const nextAuth = + "value" in edit && edit.value && typeof edit.value === "object" + ? (edit.value as Record).auth + : undefined; + if (JSON.stringify(previousAuth) !== JSON.stringify(nextAuth)) + throw new Error("Use daemon set-password to change the daemon password."); + } + if ("unset" in edit) delete object[key]; + else object[key] = edit.value; + savePersistedConfig(paseoHome, config); + return config; +} + export function savePersistedConfig( paseoHome: string, config: PersistedConfig, diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index da8b5419a5..a0bbb49035 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -27,9 +27,28 @@ function createRecord(overrides?: Partial): StoredAgentRecord } describe("persistence hooks", () => { + test("buildConfigOverrides prefers the last live mode over the creation-time mode", () => { + const record = createRecord({ + lastModeId: "bypass", + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("bypass"); + }); + + test("buildConfigOverrides falls back to the configured mode when no live mode was recorded", () => { + const record = createRecord({ + lastModeId: null, + config: { modeId: "acceptEdits" }, + }); + + expect(buildConfigOverrides(record).modeId).toBe("acceptEdits"); + }); + test("buildConfigOverrides preserves the complete private launch config", () => { const record = createRecord({ title: "Voice agent (current)", + lastModeId: "default", config: { modeId: "default", model: "gpt-5.4-mini", @@ -79,6 +98,7 @@ describe("persistence hooks", () => { const record = createRecord({ provider: "codex", title: "Renamed title", + lastModeId: null, config: { model: "gpt-5.4-mini", systemPrompt: "Confirm and speak first.", diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 16e850990f..e95743d6be 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -6,6 +6,7 @@ import type { AgentSessionConfig, } from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AttentionState } from "./agent/agent-manager.js"; interface LoggerLike { child(bindings: Record): LoggerLike; @@ -66,7 +67,10 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial typeof value === "string" && value.length > 0) + .map((value) => ({ + raw: value, + parsed: Date.parse(value), + })) + .filter((value) => !Number.isNaN(value.parsed)); + + if (timestamps.length === 0) { + return record.updatedAt; + } + + timestamps.sort((a, b) => b.parsed - a.parsed); + return timestamps[0].raw; +} + export function extractTimestamps(record: StoredAgentRecord): { createdAt: Date; updatedAt: Date; @@ -116,7 +144,7 @@ export function extractTimestamps(record: StoredAgentRecord): { } { return { createdAt: new Date(record.createdAt), - updatedAt: new Date(record.lastActivityAt ?? record.updatedAt), + updatedAt: new Date(resolveStoredAgentUpdatedAt(record)), lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, labels: record.labels, workspaceId: record.workspaceId, @@ -124,6 +152,22 @@ export function extractTimestamps(record: StoredAgentRecord): { }; } +/** + * Unread state survives a resume. Attention is set by the agent finishing or failing and + * cleared by the user reading the chat (`workspace.clear_attention`); reloading the runtime + * is neither, so a resumed agent that drops it silently marks the chat read. + */ +export function extractAttention(record: StoredAgentRecord): AttentionState { + if (!record.requiresAttention || !record.attentionReason || !record.attentionTimestamp) { + return { requiresAttention: false }; + } + return { + requiresAttention: true, + attentionReason: record.attentionReason, + attentionTimestamp: new Date(record.attentionTimestamp), + }; +} + export function toAgentPersistenceHandle( registeredProviders: Iterable, handle: StoredAgentRecord["persistence"], diff --git a/packages/server/src/server/pid-lock.test.ts b/packages/server/src/server/pid-lock.test.ts index 1dc6c260bb..70e2eddae3 100644 --- a/packages/server/src/server/pid-lock.test.ts +++ b/packages/server/src/server/pid-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, open, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "vitest"; @@ -15,7 +15,8 @@ import { describe("pid-lock ownership", () => { test("writes and releases lock for explicit owner pid", async () => { - const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const parent = await mkdtemp(join(tmpdir(), "paseo-pid-lock-owner-")); + const paseoHome = join(parent, "home"); const ownerPid = process.pid + 10_000; try { @@ -27,6 +28,9 @@ describe("pid-lock ownership", () => { ) => Promise )(paseoHome, null, { ownerPid }); + if (process.platform !== "win32") { + expect((await stat(paseoHome)).mode & 0o777).toBe(0o700); + } const lock = await getPidLockInfo(paseoHome); expect(lock?.pid).toBe(ownerPid); expect(lock?.listen).toBeNull(); @@ -55,7 +59,7 @@ describe("pid-lock ownership", () => { const lockAfterOwnerRelease = await getPidLockInfo(paseoHome); expect(lockAfterOwnerRelease).toBeNull(); } finally { - await rm(paseoHome, { recursive: true, force: true }); + await rm(parent, { recursive: true, force: true }); } }); @@ -69,7 +73,7 @@ describe("pid-lock ownership", () => { pidPath, JSON.stringify({ pid: process.pid, - startedAt: "2026-01-01T00:00:00.000Z", + startedAt: new Date().toISOString(), hostname: "old-host", uid: process.getuid?.() ?? 0, listen: "127.0.0.1:6767", @@ -92,7 +96,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale desktop heartbeat lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live desktop heartbeat lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-stale-desktop-heartbeat-")); const replacementOwnerPid = process.pid + 10_000; @@ -102,7 +106,7 @@ describe("pid-lock ownership", () => { pidPath, JSON.stringify({ pid: process.pid, - startedAt: "2026-01-01T00:00:00.000Z", + startedAt: new Date().toISOString(), hostname: "old-host", uid: process.getuid?.() ?? 0, listen: "127.0.0.1:6767", @@ -113,14 +117,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.listen).toBeNull(); + expect(lock?.pid).toBe(process.pid); + expect(lock?.listen).toBe("127.0.0.1:6767"); } finally { await rm(paseoHome, { recursive: true, force: true }); } @@ -135,7 +138,7 @@ describe("pid-lock ownership", () => { pidPath, JSON.stringify({ pid: process.pid, - startedAt: "2026-01-01T00:00:00.000Z", + startedAt: new Date().toISOString(), hostname: "old-host", uid: process.getuid?.() ?? 0, listen: "127.0.0.1:6767", @@ -156,7 +159,7 @@ describe("pid-lock ownership", () => { } }); - test("reclaims a stale legacy desktop lock after desktop confirms the daemon is unreachable", async () => { + test("preserves a stale live legacy desktop lock", async () => { const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-legacy-desktop-")); const replacementOwnerPid = process.pid + 10_000; const pidPath = join(paseoHome, "paseo.pid"); @@ -166,7 +169,7 @@ describe("pid-lock ownership", () => { pidPath, JSON.stringify({ pid: process.pid, - startedAt: "2026-01-01T00:00:00.000Z", + startedAt: new Date().toISOString(), hostname: "old-host", uid: process.getuid?.() ?? 0, listen: "127.0.0.1:6767", @@ -176,14 +179,13 @@ describe("pid-lock ownership", () => { const staleTime = new Date(Date.now() - 10 * 60_000); await utimes(pidPath, staleTime, staleTime); - await acquirePidLock(paseoHome, null, { - ownerPid: replacementOwnerPid, - reclaimStaleDesktopLock: true, - }); + await expect( + acquirePidLock(paseoHome, null, { ownerPid: replacementOwnerPid }), + ).rejects.toThrow("Another Paseo daemon is already running"); const lock = await getPidLockInfo(paseoHome); - expect(lock?.pid).toBe(replacementOwnerPid); - expect(lock?.heartbeat).toBe(true); + expect(lock?.pid).toBe(process.pid); + expect(lock?.heartbeat).toBeUndefined(); } finally { await rm(paseoHome, { recursive: true, force: true }); } @@ -254,4 +256,38 @@ describe("pid-lock ownership", () => { await rm(paseoHome, { recursive: true, force: true }); } }); + + test("starts over an empty lock file left by a supervisor killed before writing it", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-")); + const ownerPid = process.pid + 10_000; + + try { + await writeFile(join(paseoHome, "paseo.pid"), ""); + + await expect(getPidLockInfo(paseoHome)).resolves.toBeNull(); + await acquirePidLock(paseoHome, null, { ownerPid }); + + const lock = await getPidLockInfo(paseoHome); + expect(lock?.pid).toBe(ownerPid); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); + + test("keeps a lock file whose contents cannot be read as a lock", async () => { + const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-")); + const pidPath = join(paseoHome, "paseo.pid"); + + try { + await writeFile(pidPath, JSON.stringify({ pid: "unknown" })); + + await expect( + acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }), + ).rejects.toThrow("Cannot read daemon state"); + + await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" })); + } finally { + await rm(paseoHome, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/pid-lock.ts b/packages/server/src/server/pid-lock.ts index 9202f49dfd..314b44ea47 100644 --- a/packages/server/src/server/pid-lock.ts +++ b/packages/server/src/server/pid-lock.ts @@ -1,18 +1,22 @@ -import { open, readFile, stat, unlink, mkdir, utimes } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { open, readFile, stat, unlink, utimes } from "node:fs/promises"; import type { FileHandle } from "node:fs/promises"; -import { existsSync } from "node:fs"; +import { ensurePrivateDirectory } from "./private-files.js"; import { join } from "node:path"; -import { hostname } from "node:os"; +import { uptime } from "node:os"; +import { getHostName } from "./host-name.js"; import { z } from "zod"; export const pidLockInfoSchema = z.object({ - pid: z.number(), + pid: z.number().int().positive(), startedAt: z.string(), hostname: z.string(), uid: z.number(), listen: z.string().nullable(), + serverId: z.string().nullable().optional(), desktopManaged: z.boolean().optional(), heartbeat: z.literal(true).optional(), + bootId: z.string().optional(), }); export interface PidLockInfo extends z.infer {} @@ -36,8 +40,6 @@ export class PidLockError extends Error { } } -// Stale recovery is for abandoned locks, so keep this well above ordinary event-loop stalls. -const PID_LOCK_STALE_MS = 5 * 60_000; const PID_LOCK_HEARTBEAT_INTERVAL_MS = 30_000; const PID_LOCK_READ_RETRY_ATTEMPTS = 10; const PID_LOCK_READ_RETRY_DELAY_MS = 50; @@ -46,36 +48,92 @@ function isPidRunning(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { - return false; + } catch (error) { + return isErrnoException(error) && error.code === "EPERM"; } } +// `uptime()` reports whole seconds on some platforms, so the derived boot instant carries +// about a second of error. This covers that resolution and nothing more: every extra second +// is a window in which a lock written just before a reboot still reads as current. +const BOOT_INSTANT_TOLERANCE_MS = 5_000; + +function precedesThisBoot(startedAt: string): boolean { + const stamped = Date.parse(startedAt); + if (Number.isNaN(stamped)) return false; + return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS; +} + +let cachedBootId: string | null | undefined; + +// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while +// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its +// running supervisor wrote. +function currentBootId(): string | null { + if (cachedBootId === undefined) { + try { + cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null; + } catch { + cachedBootId = null; + } + } + return cachedBootId; +} + +function writtenDuringThisBoot(lock: PidLockInfo): boolean { + const thisBoot = currentBootId(); + if (lock.bootId && thisBoot) return lock.bootId === thisBoot; + return !precedesThisBoot(lock.startedAt); +} + +/** + * Whether the process that wrote this lock is still running. + * + * A PID alone does not identify the supervisor: the operating system hands the number to + * something else once the supervisor is gone, and a reboot reassigns it freely. A process + * cannot predate the boot it runs under, so a lock written during another boot is abandoned + * however alive its PID looks. + */ +export function isPidLockOwnerRunning(lock: PidLockInfo): boolean { + if (!writtenDuringThisBoot(lock)) return false; + return isPidRunning(lock.pid); +} + function getPidFilePath(paseoHome: string): string { return join(paseoHome, "paseo.pid"); } -async function isPidLockFresh(pidPath: string): Promise { - try { - const lockStat = await stat(pidPath); - return lockStat.mtimeMs >= Date.now() - PID_LOCK_STALE_MS; - } catch { - return false; - } -} - async function touchPidLockFile(pidPath: string): Promise { const now = new Date(); await utimes(pidPath, now, now); } async function readPidLock(pidPath: string): Promise { - try { - const content = await readFile(pidPath, "utf-8"); - return parsePidLockInfo(JSON.parse(content)); - } catch { - return null; + let lastError: unknown; + let empty = false; + for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) { + try { + const content = await readFile(pidPath, "utf-8"); + empty = content === ""; + if (!empty) { + const lock = parsePidLockInfo(JSON.parse(content)); + if (lock) return lock; + lastError = new Error("Invalid lock shape"); + } + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + empty = false; + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS)); } + // A supervisor writes its lock right after creating the file, so a file still empty + // after the retries was abandoned in between and names no owner. + if (empty) return null; + throw Object.assign( + new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`), + { code: "DAEMON_STATE_READ_FAILED" }, + ); } function resolveOwnerPid(ownerPid?: number): number { @@ -87,19 +145,9 @@ function resolveOwnerPid(ownerPid?: number): number { interface AcquirePidLockOptions { ownerPid?: number; - reclaimStaleDesktopLock?: boolean; } -function canReclaimLiveLock( - lock: PidLockInfo, - options: AcquirePidLockOptions | undefined, -): boolean { - // COMPAT(pidLockHeartbeat): v0.1.108 desktop startup has already confirmed the old daemon is - // unreachable before it launches the supervisor. Remove after 2027-01-15. - return options?.reclaimStaleDesktopLock === true && lock.desktopManaged === true; -} - -function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { +export function isSamePidLock(left: PidLockInfo, right: PidLockInfo): boolean { return left.pid === right.pid && left.startedAt === right.startedAt; } @@ -114,35 +162,35 @@ async function clearExistingPidLock( pidPath: string, existingLock: PidLockInfo, lockOwnerPid: number, - options: AcquirePidLockOptions | undefined, ): Promise<"already_owned" | "cleared"> { - const lockOwnerRunning = isPidRunning(existingLock.pid); + const lockOwnerRunning = isPidLockOwnerRunning(existingLock); if (existingLock.pid === lockOwnerPid && lockOwnerRunning) { await touchPidLockFile(pidPath); return "already_owned"; } - if (lockOwnerRunning) { - const reclaimable = canReclaimLiveLock(existingLock, options); - if (!reclaimable || (await isPidLockFresh(pidPath))) { - throw createLockHeldError(existingLock); - } - - // Re-read immediately before unlinking so a heartbeat at the stale boundary wins. - const confirmedLock = await readPidLock(pidPath); - if ( - !confirmedLock || - !isSamePidLock(existingLock, confirmedLock) || - (await isPidLockFresh(pidPath)) - ) { - throw new PidLockError("PID lock changed while checking whether it was abandoned"); - } + if (lockOwnerRunning) throw createLockHeldError(existingLock); + const confirmedLock = await readPidLock(pidPath); + if ( + !confirmedLock || + !isSamePidLock(existingLock, confirmedLock) || + isPidLockOwnerRunning(confirmedLock) + ) { + throw new PidLockError("PID lock changed while checking whether it was abandoned"); } await unlink(pidPath).catch(() => {}); return "cleared"; } +async function removeEmptyPidLock(pidPath: string): Promise { + try { + if ((await stat(pidPath)).size === 0) await unlink(pidPath); + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") throw error; + } +} + async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise { let fd; try { @@ -173,10 +221,7 @@ export async function acquirePidLock( ): Promise { const pidPath = getPidFilePath(paseoHome); - // Ensure paseoHome directory exists - if (!existsSync(paseoHome)) { - await mkdir(paseoHome, { recursive: true }); - } + ensurePrivateDirectory(paseoHome); // Try to read existing lock const existingLock = await readPidLock(pidPath); @@ -184,20 +229,24 @@ export async function acquirePidLock( // Check if existing lock is stale const lockOwnerPid = resolveOwnerPid(options?.ownerPid); if (existingLock) { - const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid, options); + const result = await clearExistingPidLock(pidPath, existingLock, lockOwnerPid); if (result === "already_owned") { return; } + } else { + await removeEmptyPidLock(pidPath); } // Create new lock with exclusive flag + const bootId = currentBootId(); const lockInfo: PidLockInfo = { pid: lockOwnerPid, startedAt: new Date().toISOString(), - hostname: hostname(), + hostname: getHostName(), uid: process.getuid?.() ?? 0, listen, heartbeat: true, + ...(bootId ? { bootId } : {}), ...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}), }; @@ -298,7 +347,7 @@ export function startPidLockHeartbeat( export async function updatePidLock( paseoHome: string, - patch: { listen: string }, + patch: { listen: string; serverId: string } | { listen: null; serverId: null }, options?: { ownerPid?: number }, ): Promise { const pidPath = getPidFilePath(paseoHome); @@ -329,7 +378,7 @@ export async function updatePidLock( export async function releasePidLock( paseoHome: string, - options?: { ownerPid?: number }, + options?: { ownerPid?: number; startedAt?: string }, ): Promise { const pidPath = getPidFilePath(paseoHome); const lockOwnerPid = resolveOwnerPid(options?.ownerPid); @@ -337,7 +386,10 @@ export async function releasePidLock( // Only remove if it's our lock const content = await readFile(pidPath, "utf-8"); const lock = parsePidLockInfo(JSON.parse(content)); - if (lock?.pid === lockOwnerPid) { + if ( + lock?.pid === lockOwnerPid && + (options?.startedAt === undefined || lock.startedAt === options.startedAt) + ) { await unlink(pidPath); } } catch { @@ -357,7 +409,7 @@ export async function isLocked( if (!info) { return { locked: false }; } - if (!isPidRunning(info.pid)) { + if (!isPidLockOwnerRunning(info)) { return { locked: false, info }; } return { locked: true, info }; diff --git a/packages/server/src/server/plugins/agent-configuration.e2e.test.ts b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts new file mode 100644 index 0000000000..3228306297 --- /dev/null +++ b/packages/server/src/server/plugins/agent-configuration.e2e.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-")); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) }, + mcpEnabled: false, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/agent-configuration", import.meta.url)), + ); + const agent = await client.createAgent({ + provider: "codex", + cwd: directory, + systemPrompt: "Keep the user's instructions.", + providerOptions: { + sandbox_mode: "read-only", + approval_policy: "never", + web_search: "disabled", + }, + mcpServers: { existing: { type: "http", url: "https://existing.example.com/mcp" } }, + }); + expect(daemon.daemon.agentManager.getAgent(agent.id)?.config).toMatchObject({ + provider: "codex", + cwd: directory, + systemPrompt: "Keep the user's instructions.", + providerOptions: { + sandbox_mode: "workspace-write", + approval_policy: "on-request", + web_search: "disabled", + }, + mcpServers: { + existing: { type: "http", url: "https://existing.example.com/mcp" }, + company: { type: "http", url: "https://tools.example.com/mcp" }, + }, + }); + await client.archiveAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/builtin/index.test.ts b/packages/server/src/server/plugins/builtin/index.test.ts new file mode 100644 index 0000000000..6160c8f855 --- /dev/null +++ b/packages/server/src/server/plugins/builtin/index.test.ts @@ -0,0 +1,134 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, expect, test } from "vitest"; +import { compilePlugin } from "../compiler.js"; +import { readPluginManifest } from "../manifest.js"; +import { DaemonClient } from "../../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, builtinPlugins, resolveBuiltinPluginsRoot } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture(root: string, id: string, client = false): Promise { + const directory = path.join(root, id); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + "export default function contribute() { return () => {}; }", + ); + if (client) { + await writeFile( + path.join(directory, "index.client.tsx"), + "export default function contribute() { const marker = 'builtin-client-marker'; void marker; return () => {}; }", + ); + } + return directory; +} + +test("listed built-ins resolve to matching manifests and compile", async () => { + const root = resolveBuiltinPluginsRoot(); + for (const id of builtinPlugins) { + const directory = path.join(root, id); + expect((await readPluginManifest(directory)).id).toBe(id); + const findEntry = async (names: string[]) => { + for (const name of names) { + const entry = path.join(directory, name); + if ((await stat(entry).catch(() => null))?.isFile()) return entry; + } + return null; + }; + const bundles = await compilePlugin({ + server: await findEntry(["index.server.ts", "index.server.tsx"]), + client: await findEntry(["index.client.ts", "index.client.tsx"]), + }); + expect(bundles.serverBundle).toBeTruthy(); + } +}); + +test("listed client bundle is published while plugins are disabled; unlisted directory is inert", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-fixture-")); + roots.push(root); + await fixture(root, "listed", true); + await fixture(root, "unlisted", true); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["listed"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + expect(await client.listPlugins()).toEqual([]); + const catalog = await client.getPluginCatalog(); + expect(catalog.map(({ id }) => id)).toEqual(["listed"]); + expect(catalog[0]?.clientBundle).toContain("builtin-client-marker"); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); + +test("directory, Git, and npm installs reject a built-in ID", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-")); + roots.push(root); + const directory = await fixture(root, "reserved"); + const gitDirectory = await fixture(root, "git-source"); + await writeFile( + path.join(gitDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "reserved", requirements: { paseo: ">=0.9.2" } }), + ); + execFileSync("git", ["init", "-q", gitDirectory]); + execFileSync("git", [ + "-C", + gitDirectory, + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "add", + ".", + ]); + execFileSync("git", [ + "-C", + gitDirectory, + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-qm", + "fixture", + ]); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["reserved"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + await expect(client.installDirectoryPlugin(directory)).rejects.toThrow( + /reserved for a built-in/, + ); + await expect( + client.installPluginSource({ source: pathToFileURL(gitDirectory).href }), + ).rejects.toThrow(/reserved for a built-in/); + await expect( + client.installPluginSource({ source: "npm:unused-fixture", id: "reserved" }), + ).rejects.toThrow(/reserved for a built-in/); + expect(await client.listPlugins()).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts new file mode 100644 index 0000000000..c14c027853 --- /dev/null +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -0,0 +1,54 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const builtinPlugins = [ + "antigravity-provider", + "claude-usage-source", + "codex-usage-source", + "copilot-usage-source", + "cursor-usage-source", + "grok-usage-source", + "kimi-usage-source", + "minimax-usage-source", + "muse-provider", + "opencode-go-usage-source", + "zai-usage-source", +] as const; + +export function resolveBuiltinPluginsRoot(moduleUrl: string | URL = import.meta.url): string { + const moduleDir = path.dirname(fileURLToPath(moduleUrl)); + const archiveSegment = `${path.sep}app.asar${path.sep}`; + const archiveIndex = moduleDir.indexOf(archiveSegment); + if (archiveIndex !== -1) { + // esbuild runs outside Electron's filesystem shim and cannot read archive entries. + return path.join(moduleDir.slice(0, archiveIndex), "builtin-plugins"); + } + const candidates = [ + path.resolve(moduleDir, "..", "..", "..", "builtin-plugins"), + path.resolve(moduleDir, "..", "..", "..", "..", "..", "..", "plugins"), + ]; + return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]!; +} + +export interface BuiltinPlugin { + id: string; + directory: string; +} + +export class BuiltinPluginLoader { + readonly ids: ReadonlySet; + + constructor( + private readonly root = resolveBuiltinPluginsRoot(), + private readonly list: readonly string[] = builtinPlugins, + ) { + this.ids = new Set(list); + } + + async load(start: (plugin: BuiltinPlugin) => Promise): Promise { + for (const id of this.list) { + await start({ id, directory: path.join(this.root, id) }); + } + } +} diff --git a/packages/server/src/server/plugins/bundle-evaluator.ts b/packages/server/src/server/plugins/bundle-evaluator.ts new file mode 100644 index 0000000000..e80db885ff --- /dev/null +++ b/packages/server/src/server/plugins/bundle-evaluator.ts @@ -0,0 +1,39 @@ +import { createRequire } from "node:module"; +import * as pluginSharedRuntime from "@getpaseo/plugin"; +import * as pluginProviderRuntime from "@getpaseo/plugin/server/provider"; +import * as pluginAcpRuntime from "@getpaseo/plugin/server/acp"; +import * as pluginUsageRuntime from "@getpaseo/plugin/server/usage"; +import * as pluginServerRuntime from "@getpaseo/plugin/server"; +import type { PluginServerContribution } from "@getpaseo/plugin/server"; +import * as zod from "zod"; +import { isPluginClientOnlySdkSpecifier } from "./plugin-sdk-specifiers.js"; + +const nodeRequire = createRequire(import.meta.url); + +function runtimeRequire(name: string): unknown { + if (isPluginClientOnlySdkSpecifier(name)) { + throw new Error(`${name} is available only in plugin client code`); + } + if (name === "@getpaseo/plugin") return pluginSharedRuntime; + if (name === "@getpaseo/plugin/server") return pluginServerRuntime; + if (name === "@getpaseo/plugin/server/provider") return pluginProviderRuntime; + if (name === "@getpaseo/plugin/server/acp") return pluginAcpRuntime; + if (name === "@getpaseo/plugin/server/usage") return pluginUsageRuntime; + if (name === "zod") return zod; + if (name === "@getpaseo/plugin/client/host") + throw new Error(`${name} is private to the app host`); + return nodeRequire(name); +} + +export function evaluateBundle(bundle: string): PluginServerContribution { + const evaluate: (source: string) => unknown = globalThis.eval; + const factory = evaluate(bundle); + if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); + const exports = factory(runtimeRequire); + const setup = + exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; + if (typeof setup !== "function") { + throw new Error("Plugin server bundle must default export a function"); + } + return setup as PluginServerContribution; +} diff --git a/packages/server/src/server/plugins/compiler-imports.ts b/packages/server/src/server/plugins/compiler-imports.ts new file mode 100644 index 0000000000..04d5549d50 --- /dev/null +++ b/packages/server/src/server/plugins/compiler-imports.ts @@ -0,0 +1,126 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import type { ImportKind } from "esbuild"; +import type { Node, NamedImportBindings, NamedExportBindings } from "typescript"; + +const nodeRequire = createRequire(import.meta.url); + +export type PluginImportKind = ImportKind | "type-reference"; + +interface ModuleImport { + specifier: string; + kind: PluginImportKind; + typeOnly: boolean; +} + +// Keep the TypeScript compiler off daemon startup. esbuild erases these edges, so +// ownership validation needs the original syntax and declaration-aware resolution. +export function createPluginImportReader(directory: string) { + const ts = nodeRequire("typescript") as typeof import("typescript"); + const configFile = ts.findConfigFile(directory, ts.sys.fileExists); + const config = configFile + ? ts.getParsedCommandLineOfConfigFile( + configFile, + {}, + { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")); + }, + }, + ) + : undefined; + const options = { + ...config?.options, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + allowJs: true, + }; + const cache = ts.createModuleResolutionCache(directory, (file) => file, options); + return { + resolve(specifier: string, importer: string, kind: PluginImportKind): string | undefined { + const resolved = + kind === "type-reference" + ? ts.resolveTypeReferenceDirective(specifier, importer, options, ts.sys) + .resolvedTypeReferenceDirective?.resolvedFileName + : ts.resolveModuleName( + specifier, + importer, + options, + ts.sys, + cache, + undefined, + kind === "require-call" ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext, + ).resolvedModule?.resolvedFileName; + return resolved ? realpathSync.native(resolved) : undefined; + }, + read(file: string): ModuleImport[] { + const source = ts.createSourceFile( + file, + readFileSync(file, "utf8"), + ts.ScriptTarget.Latest, + true, + ); + const imports: ModuleImport[] = []; + function add( + node: Node | undefined, + typeOnly: boolean, + kind: ImportKind = "import-statement", + ) { + if (node && ts.isStringLiteralLike(node)) { + imports.push({ + specifier: node.text, + kind, + typeOnly: source.isDeclarationFile || typeOnly, + }); + } + } + function onlyTypeBindings( + bindings: NamedImportBindings | NamedExportBindings | undefined, + ): boolean { + return ( + !!bindings && + (ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) && + bindings.elements.length > 0 && + bindings.elements.every((element) => element.isTypeOnly) + ); + } + function visit(node: Node): void { + if (ts.isImportDeclaration(node)) { + const clause = node.importClause; + const typeOnly = + !!clause?.isTypeOnly || (!clause?.name && onlyTypeBindings(clause?.namedBindings)); + add(node.moduleSpecifier, typeOnly); + } else if (ts.isExportDeclaration(node)) { + add(node.moduleSpecifier, node.isTypeOnly || onlyTypeBindings(node.exportClause)); + } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + add(node.argument.literal, true); + } else if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + add(node.moduleReference.expression, node.isTypeOnly, "require-call"); + } else if (ts.isCallExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) + add(node.arguments[0], false, "dynamic-import"); + else if (ts.isIdentifier(node.expression) && node.expression.text === "require") + add(node.arguments[0], false, "require-call"); + } + ts.forEachChild(node, visit); + } + visit(source); + for (const reference of source.referencedFiles) { + imports.push({ + specifier: path.resolve(path.dirname(file), reference.fileName), + kind: "import-statement", + typeOnly: true, + }); + } + for (const reference of source.typeReferenceDirectives) { + imports.push({ specifier: reference.fileName, kind: "type-reference", typeOnly: true }); + } + return imports; + }, + }; +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index a922403aa7..facba48fdb 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -1,3 +1,4 @@ +import { realpathSync } from "node:fs"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -80,6 +81,8 @@ async function createSplitPlugin(): Promise<{ client: string; server: string; }> { + // Keep the platform's original spelling (including Windows short names) in symlink + // targets. Only diagnostic expectations use canonical paths. const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); temporaryDirectories.push(directory); await Promise.all([ @@ -126,7 +129,321 @@ export default function contribute(server) { return { directory, client, server }; } +async function createRootAlias(directory: string): Promise { + const aliases = await mkdtemp(path.join(tmpdir(), "paseo-plugin-root-alias-")); + temporaryDirectories.push(aliases); + const rootAlias = path.join(aliases, "plugin"); + await symlink(directory, rootAlias, process.platform === "win32" ? "junction" : "dir"); + return rootAlias; +} + describe("plugin runtime entries", () => { + it.each([ + "react", + "react/jsx-runtime", + "react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + ])("rejects %s from server code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("client-only module"); + }); + + it.each([ + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ])("rejects %s from client code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + `import * as value from "${specifier}"; export default function contribute() { return value; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it.each([ + "react", + "node:fs", + "fs", + "@getpaseo/plugin/client", + "@getpaseo/plugin/server", + "../client/surface", + "../server/handler", + ])("rejects %s from shared code", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "${specifier}"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { target: "client", dependency: "react" }, + { target: "server", dependency: "node:fs" }, + { target: "server", dependency: "@getpaseo/plugin/server/provider" }, + ] as const)( + "rejects a shared dependency reaching $dependency in the $target bundle", + async ({ target, dependency }) => { + const entries = await createSplitPlugin(); + const dependencyDirectory = path.join(entries.directory, "node_modules/impure"); + await mkdir(dependencyDirectory, { recursive: true }); + await writeFile( + path.join(dependencyDirectory, "package.json"), + JSON.stringify({ name: "impure", main: "index.js" }), + ); + await writeFile(path.join(dependencyDirectory, "index.js"), `export * from "${dependency}";`); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `import * as value from "impure"; export const clientLabel = value; export const serverLabel = value;`, + ); + await expect( + compilePlugin({ + client: target === "client" ? entries.client : null, + server: target === "server" ? entries.server : null, + }), + ).rejects.toThrow("plugin shared"); + }, + ); + + it.each([ + 'import type { PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + 'export type Context = import("@getpaseo/plugin/client").PluginClientContext;', + 'import { type PluginClientContext } from "@getpaseo/plugin/client"; export type Context = PluginClientContext;', + 'import type { ComponentType } from "react"; export type Component = ComponentType;', + ])("rejects runtime-owned types in shared code: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + typeSource + '\nexport const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([{ types: "./index.d.ts" }, { exports: { ".": { types: "./index.d.ts" } } }])( + "accepts declaration-only dependencies: %j", + async (manifest) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", ...manifest }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + "export type JsonValue = string | number | boolean | null;", + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { JsonValue } from "neutral-types"; export const clientLabel: JsonValue = "client"; export const serverLabel: JsonValue = "server";', + ); + const result = await compilePlugin(entries); + expect(result.clientBundle).toContain('"client"'); + expect(result.serverBundle).toContain('"server"'); + expect(result.clientBundle).not.toContain("neutral-types"); + }, + ); + + it("accepts a declaration import used only as a type without import type", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/neutral-types"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "neutral-types", types: "./index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.d.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { Value } from "neutral-types"; export const clientLabel = Value; export const serverLabel = Value;', + ); + await expect(compilePlugin(entries)).rejects.toThrow('Could not resolve "neutral-types"'); + }); + + it("preserves guarded optional requires in server dependencies", async () => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/optional-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "optional-helper", main: "index.js" }), + ); + await writeFile( + path.join(dependency, "index.js"), + 'let value = "fallback"; try { value = require("missing-accelerator"); } catch {} module.exports = value;', + ); + await writeFile(entries.server, 'import value from "optional-helper"; export default value;'); + await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({ + serverBundle: expect.stringContaining("fallback"), + }); + await writeFile( + path.join(dependency, "index.js"), + 'module.exports = require("missing-accelerator");', + ); + await expect(compilePlugin({ client: null, server: entries.server })).rejects.toThrow( + 'Could not resolve "missing-accelerator"', + ); + }); + + it.each(["path", "types"])( + "checks declaration reference %s directives", + async (referenceKind) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + const referencedDirectory = + referenceKind === "path" + ? dependency + : path.join(entries.directory, "node_modules/@types/referenced-helper"); + await mkdir(dependency, { recursive: true }); + await mkdir(referencedDirectory, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", types: "index.d.ts" }), + ); + await writeFile( + path.join(dependency, "index.d.ts"), + `/// +export type Value = string;`, + ); + const referencedFile = path.join( + referencedDirectory, + referenceKind === "path" ? "context.d.ts" : "index.d.ts", + ); + await writeFile( + referencedFile, + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "typed-helper"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + await writeFile(referencedFile, "export type Neutral = string;"); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + }, + ); + + it("checks the physical owner of type-only symlinks", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "server/context.ts"), + "export type Value = string;", + ); + await symlink( + path.join(entries.directory, "server/context.ts"), + path.join(entries.directory, "shared/context.ts"), + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "./context"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("server-only module"); + }); + + it("resolves type-only path aliases from the plugin tsconfig", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { baseUrl: ".", paths: { "shared-types": ["shared/types.ts"] } }, + }), + ); + await writeFile(path.join(entries.directory, "shared/types.ts"), "export type Value = string;"); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import type { Value } from "shared-types"; export const clientLabel: Value = "client"; export const serverLabel: Value = "server";', + ); + await expect(compilePlugin(entries)).resolves.toMatchObject({ + clientBundle: expect.any(String), + serverBundle: expect.any(String), + }); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext as Value } from "@getpaseo/plugin/client";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + { specifier: "@getpaseo/plugin/client", importKind: "import type" }, + { specifier: "@getpaseo/plugin/server", importKind: "import type" }, + { specifier: "@getpaseo/plugin/client", importKind: "import" }, + { specifier: "@getpaseo/plugin/server", importKind: "import" }, + ])( + "rejects transitive declaration dependencies on $specifier through $importKind", + async ({ specifier, importKind }) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/typed-helper"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "typed-helper", main: "index.js", types: "index.d.ts" }), + ); + await writeFile(path.join(dependency, "index.js"), "export {};"); + await writeFile( + path.join(dependency, "index.d.ts"), + 'export type { Context } from "./context";', + ); + const context = specifier.endsWith("client") ? "PluginClientContext" : "PluginServerContext"; + await writeFile( + path.join(dependency, "context.d.ts"), + `export type { ${context} as Context } from "${specifier}";`, + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + `${importKind} { Context } from "typed-helper"; export type Value = Context; export const clientLabel = "client"; export const serverLabel = "server";`, + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }, + ); + + it("checks files reached only through type imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared/types.ts"), + 'export type { PluginClientContext } from "@getpaseo/plugin/client";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'export type { PluginClientContext } from "./types"; export const clientLabel = "client"; export const serverLabel = "server";', + ); + await expect(compilePlugin(entries)).rejects.toThrow("plugin shared"); + }); + + it.each([ + "@paseo/plugin", + "@getpaseo/plugin/react-native", + "@getpaseo/plugin/ui", + "@getpaseo/plugin/provider", + "@getpaseo/plugin/acp", + "@getpaseo/plugin/host", + ])("rejects retired entry %s", async (specifier) => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + `import * as sdk from "${specifier}"; export default function contribute() { return sdk; }`, + ); + await expect(compilePlugin(entries)).rejects.toThrow(specifier); + }); + it("builds each runtime from its own entry and shares neutral modules", async () => { const entries = await createSplitPlugin(); @@ -138,6 +455,33 @@ describe("plugin runtime entries", () => { expect(serverBundle).not.toContain("Client contribution"); }); + it.each(["main", "module"])( + "builds a dependency that declares only %s into both runtimes", + async (field) => { + const entries = await createSplitPlugin(); + const dependency = path.join(entries.directory, "node_modules/legacy-entry"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "legacy-entry", [field]: "lib/entry.js" }), + ); + await mkdir(path.join(dependency, "lib")); + await writeFile( + path.join(dependency, "lib/entry.js"), + 'exports.label = "legacy entry contribution";', + ); + await writeFile( + path.join(entries.directory, "shared/labels.ts"), + 'import { label } from "legacy-entry"; export const clientLabel = label; export const serverLabel = label;', + ); + + const { clientBundle, serverBundle } = await compilePlugin(entries); + + expect(clientBundle).toContain("legacy entry contribution"); + expect(serverBundle).toContain("legacy entry contribution"); + }, + ); + it("uses the automatic JSX runtime without a React import", async () => { const entries = await createSplitPlugin(); const { clientBundle } = await compilePlugin(entries); @@ -205,7 +549,7 @@ export default function contribute() { void Surface; return () => undefined; }`, export default function contribute() { void value; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(entries.directory, "helper")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(entries.directory), "helper")}`, ); }); @@ -225,7 +569,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "secret")}`, ); }); @@ -242,7 +586,7 @@ export default function contribute() { void secret; return () => undefined; }`, ); await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${outside}`, + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(outside)}`, ); }); @@ -263,7 +607,7 @@ export default function contribute() { void secret; return () => undefined; }`, ]); await expect(compilePlugin({ client: null, server })).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${path.join(parent, "node_modules", "secret")}`, + `Plugin modules belong in client/, server/, or shared/: ${path.join(realpathSync.native(parent), "node_modules", "secret")}`, ); }); @@ -363,10 +707,69 @@ export default function contribute() { void handler; return () => undefined; }`, ); }); - it("classifies symlinks by their canonical target", async () => { + it.each(["file", "directory"])( + "preserves authored ownership through root and %s aliases", + async (aliasKind) => { + const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const sharedValue = path.join(entries.directory, "shared/value.ts"); + await writeFile(sharedValue, 'export const value = "shared";'); + if (aliasKind === "file") { + await symlink(sharedValue, path.join(entries.directory, "server/alias.ts")); + } else { + await rm(path.join(entries.directory, "server"), { recursive: true }); + await symlink( + path.join(entries.directory, "shared"), + path.join(entries.directory, "server"), + process.platform === "win32" ? "junction" : "dir", + ); + } + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "server", aliasKind === "file" ? "alias.ts" : "value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "server-only module", + ); + await writeFile( + entries.client, + `export { value } from ${JSON.stringify(path.join(rootAlias, "shared/value.ts"))};`, + ); + await expect(compilePlugin({ client: entries.client, server: null })).resolves.toMatchObject({ + clientBundle: expect.stringContaining("shared"), + }); + }, + ); + + it("rejects generated React imports in dependencies reached through shared symlinks", async () => { const entries = await createSplitPlugin(); + const rootAlias = await createRootAlias(entries.directory); + const dependency = path.join(entries.directory, "node_modules/decoration"); + await mkdir(dependency, { recursive: true }); + await writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "decoration", main: "index.tsx" }), + ); + await writeFile(path.join(dependency, "index.tsx"), "export const decoration = <>;"); + await writeFile( + path.join(entries.directory, "shared/decoration.ts"), + 'export { decoration } from "decoration";', + ); + await symlink( + path.join(rootAlias, "shared/decoration.ts"), + path.join(entries.directory, "client/decoration.ts"), + ); + await writeFile(entries.client, 'export { decoration } from "./client/decoration";'); + await expect(compilePlugin({ client: entries.client, server: null })).rejects.toThrow( + "plugin shared", + ); + }); + + it.each([false, true])("classifies symlink targets through a root alias: %s", async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; const link = path.join(entries.directory, "client", "handler.ts"); - await symlink(path.join(entries.directory, "server", "handler.ts"), link); + await symlink(path.join(targetRoot, "server", "handler.ts"), link); await writeFile( entries.client, `import { handler } from "./client/handler"; @@ -432,33 +835,37 @@ export default function contribute() { void value; return () => undefined; }`, expect(serverBundle).toContain("linked dependency"); }); - it("does not treat the plugin package manifest as a linked dependency root", async () => { - const entries = await createSplitPlugin(); - const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); - const secret = path.join(entries.directory, "secret.ts"); - await mkdir(dependency, { recursive: true }); - await Promise.all([ - writeFile( - path.join(entries.directory, "package.json"), - JSON.stringify({ name: "fixture-dependency" }), - ), - writeFile( - path.join(dependency, "package.json"), - JSON.stringify({ name: "fixture-dependency", main: "index.js" }), - ), - writeFile(secret, `export const secret = "plugin root";`), - symlink(secret, path.join(dependency, "index.js")), - writeFile( - entries.client, - `import { secret } from "fixture-dependency"; + it.each([false, true])( + "rejects plugin manifests as linked roots through an alias: %s", + async (aliased) => { + const entries = await createSplitPlugin(); + const targetRoot = aliased ? await createRootAlias(entries.directory) : entries.directory; + const dependency = path.join(entries.directory, "node_modules", "fixture-dependency"); + const secret = path.join(entries.directory, "secret.ts"); + await mkdir(dependency, { recursive: true }); + await Promise.all([ + writeFile( + path.join(entries.directory, "package.json"), + JSON.stringify({ name: "fixture-dependency" }), + ), + writeFile( + path.join(dependency, "package.json"), + JSON.stringify({ name: "fixture-dependency", main: "index.js" }), + ), + writeFile(secret, `export const secret = "plugin root";`), + symlink(path.join(targetRoot, "secret.ts"), path.join(dependency, "index.js")), + writeFile( + entries.client, + `import { secret } from "fixture-dependency"; export default function contribute() { void secret; return () => undefined; }`, - ), - ]); + ), + ]); - await expect(compilePlugin(entries)).rejects.toThrow( - `Plugin modules belong in client/, server/, or shared/: ${secret}`, - ); - }); + await expect(compilePlugin(entries)).rejects.toThrow( + `Plugin modules belong in client/, server/, or shared/: ${realpathSync.native(secret)}`, + ); + }, + ); it("does not let remembered linked roots hide plugin-local runtime boundaries", async () => { const entries = await createSplitPlugin(); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 981ccc1229..15df395d06 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,13 +1,16 @@ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire, isBuiltin } from "node:module"; import path from "node:path"; -import type { OnResolveResult, Plugin } from "esbuild"; +import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js"; +import type { Metafile, OnResolveResult, Plugin } from "esbuild"; import { - PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, + isPluginClientOnlySdkSpecifier, + isPluginServerOnlySdkSpecifier, PLUGIN_SDK_SPECIFIERS, } from "./plugin-sdk-specifiers.js"; const nodeRequire = createRequire(import.meta.url); +export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"]; const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH"; // esbuild resolves its own platform binary via require.resolve() the first time its @@ -100,12 +103,13 @@ function findDependencyRoot( pluginDirectory: string, ): string | null { const expectedName = dependencyName(specifier); + const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`; let directory = path.dirname(resolvedPath); for (;;) { const manifestPath = path.join(directory, "package.json"); if (existsSync(manifestPath)) { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown }; - if (manifest.name === expectedName) { + if (manifest.name === expectedName || manifest.name === ambientName) { if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) { return null; } @@ -120,7 +124,7 @@ function findDependencyRoot( function moduleBoundaryError( moduleLocation: PluginModuleLocation | null, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", filePath: string, ): OnResolveResult | null { if (moduleLocation === "invalid") { @@ -144,12 +148,22 @@ function lexicalBoundaryError( specifier: string, resolveDirectory: string, pluginDirectory: string, - target: PluginBuildTarget, + target: PluginBuildTarget | "shared", ): OnResolveResult | null { if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null; const lexicalPath = path.resolve(resolveDirectory, specifier); - if (!containsPath(pluginDirectory, lexicalPath)) return null; - return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + if (containsPath(pluginDirectory, lexicalPath)) { + return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath); + } + // Normalize only a containing root alias. Resolving the whole import would erase + // an authored server/ or client/ location when the final file is a symlink. + for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) { + if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) { + const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath)); + return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath); + } + if (path.dirname(ancestor) === ancestor) return null; + } } function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin { @@ -158,20 +172,115 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: return { name: `paseo-plugin-${target}-runtime-boundary`, setup(buildContext) { + const checked = new Set(); + const imports = createPluginImportReader(pluginDirectory); + function resolvedBoundaryError( + resolvedFile: string, + specifier: string, + importer: string, + owner: PluginBuildTarget | "shared", + ) { + const file = realpathSync.native(resolvedFile); + const location = directoryTarget(file, pluginDirectory); + if (location === "invalid") { + if ( + [...linkedDependencyRoots].some( + (root) => containsPath(root, importer) && containsPath(root, file), + ) + ) + return null; + if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) { + const root = findDependencyRoot(file, specifier, pluginDirectory); + if (root) { + linkedDependencyRoots.add(root); + return null; + } + } + } + return moduleBoundaryError(location, owner, file); + } + async function resolveImportFiles( + file: string, + specifier: string, + kind: PluginImportKind, + typeOnly: boolean, + ): Promise> { + const declaration = imports.resolve(specifier, file, kind); + const dependencyFiles = new Set(); + if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration))) + dependencyFiles.add(declaration); + if (typeOnly && !declaration) { + throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`); + } + if (!typeOnly && kind !== "type-reference") { + const resolution = await buildContext.resolve(specifier, { + importer: file, + resolveDir: path.dirname(file), + kind, + }); + // esbuild owns missing-runtime errors: erased imports and guarded optional + // requires are legal. Inspect every dependency that actually resolves. + if (!resolution.errors.length && !resolution.external && resolution.namespace === "file") + dependencyFiles.add(realpathSync.native(resolution.path)); + } + return dependencyFiles; + } + async function checkSourceImports( + sourcePath: string, + inheritedOwner: PluginBuildTarget | "shared", + ): Promise { + const file = realpathSync.native(sourcePath); + const owner = + directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner; + const key = `${owner}:${file}`; + if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null; + checked.add(key); + for (const { specifier, kind, typeOnly } of imports.read(file)) { + const packageSpecifier = + kind === "type-reference" + ? `@types/${specifier.replace(/^@/, "").replace("/", "__")}` + : specifier; + const error = + runtimeSpecifierError(specifier, owner, file) ?? + runtimeSpecifierError(packageSpecifier, owner, file) ?? + lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); + if (error) return error; + // Host modules have separately enforced SDK boundaries and need no local installation. + if ( + (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || + /^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) || + isBuiltin(specifier) || + packageSpecifier === "@types/node" + ) + continue; + const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly); + for (const dependencyFile of dependencyFiles) { + const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner); + if (boundaryError) return boundaryError; + const dependencyError = await checkSourceImports(dependencyFile, owner); + if (dependencyError) return dependencyError; + } + } + return null; + } + buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) => + checkSourceImports(args.path, target), + ); buildContext.onResolve({ filter: /.*/ }, async (args) => { if (args.kind === "entry-point") return null; if (args.pluginData === boundaryResolution) return null; - const lexicalError = lexicalBoundaryError( - args.path, - args.resolveDir, - pluginDirectory, - target, - ); + const importer = + args.namespace === "file" ? realpathSync.native(args.importer) : args.importer; + const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir; + const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target; + const specifierError = runtimeSpecifierError(args.path, owner, importer); + if (specifierError) return specifierError; + const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner); if (lexicalError) return lexicalError; const resolution = await buildContext.resolve(args.path, { - importer: args.importer, + importer, namespace: args.namespace, - resolveDir: args.resolveDir, + resolveDir, kind: args.kind, pluginData: boundaryResolution, with: args.with, @@ -183,26 +292,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: ) { return null; } - const resolvedPath = resolution.path; - const importedTarget = directoryTarget(resolvedPath, pluginDirectory); - if (importedTarget === "invalid") { - if ( - [...linkedDependencyRoots].some( - (root) => containsPath(root, args.importer) && containsPath(root, resolvedPath), - ) - ) { - return null; - } - if (!args.path.startsWith(".") && !path.isAbsolute(args.path)) { - const dependencyRoot = findDependencyRoot(resolvedPath, args.path, pluginDirectory); - if (dependencyRoot) { - linkedDependencyRoots.add(dependencyRoot); - return null; - } - } - return moduleBoundaryError(importedTarget, target, resolvedPath); - } - return moduleBoundaryError(importedTarget, target, resolvedPath); + return resolvedBoundaryError(resolution.path, args.path, importer, owner); }); }, }; @@ -219,62 +309,92 @@ function makeHermesInteropEager(code: string): string { return code.replaceAll("get: () => from[key]", "value: from[key]"); } -function exactSpecifierFilter(specifiers: readonly string[]): RegExp { - const alternatives = specifiers.map((specifier) => - specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - ); - return new RegExp(`^(${alternatives.join("|")})$`); -} - -function createUnusedPlatformModulePlugin(): Plugin { - const filter = exactSpecifierFilter([ - "@tanstack/react-query", - "react", - "react/jsx-runtime", - "react-native", - ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, - ]); - return { - name: "paseo-plugin-server-unused-platform-modules", - setup(buildContext) { - buildContext.onResolve({ filter }, (args) => ({ - path: args.path, - namespace: "paseo-unused-platform-module", - sideEffects: false, - })); - buildContext.onLoad({ filter: /.*/, namespace: "paseo-unused-platform-module" }, () => ({ - contents: "module.exports = {};", - loader: "js", - })); - }, - }; -} - -function createClientNodeImportPlugin(): Plugin { - return { - name: "paseo-plugin-client-node-imports", - setup(buildContext) { - buildContext.onResolve({ filter: /^node:/ }, (args) => ({ +function runtimeSpecifierError( + specifier: string, + target: PluginBuildTarget | "shared", + importer: string, +): OnResolveResult | null { + let kind: string | null = null; + if (specifier === "@getpaseo/plugin/client/host") kind = "host-private"; + else if ( + (specifier === "@getpaseo/plugin" || + specifier.startsWith("@getpaseo/plugin/") || + specifier === "@paseo/plugin" || + specifier.startsWith("@paseo/plugin/")) && + !(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) + ) + kind = "Unknown SDK"; + else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node")) + kind = "Node"; + else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only"; + else if ( + target !== "client" && + (isPluginClientOnlySdkSpecifier(specifier) || + /^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test( + specifier, + )) + ) + kind = "client-only"; + return kind + ? { errors: [ { - text: `Node module cannot be imported into the plugin client bundle: ${args.path} imported by ${args.importer}`, + text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`, }, ], - })); - }, - }; + } + : null; +} + +function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void { + function inputLocation(file: string): PluginModuleLocation | null { + const absolutePath = path.resolve(file); + // Metafile keys must stay unchanged for graph traversal. Only filesystem + // inputs have canonical paths; data URLs and external specifiers do not. + return directoryTarget( + existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath, + pluginDirectory, + ); + } + const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared"); + const visited = new Set(); + while (pending.length) { + const file = pending.pop()!; + if (visited.has(file)) continue; + visited.add(file); + for (const dependency of inputs[file]?.imports ?? []) { + const location = dependency.external ? null : inputLocation(dependency.path); + const error = + runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ?? + (dependency.external + ? null + : moduleBoundaryError( + location === "invalid" ? null : location, + "shared", + dependency.path, + )); + if (error?.errors?.length) throw new Error(error.errors[0].text); + if (!dependency.external) pending.push(dependency.path); + } + } } async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { const { build } = loadEsbuild(); - const pluginDirectory = path.dirname(entryPath); + // Use native canonical paths throughout: TypeScript expands Windows short names + // when resolving type references, while the JS realpath implementation retains them. + const pluginDirectory = realpathSync.native(path.dirname(entryPath)); const result = await build({ - entryPoints: [entryPath], + entryPoints: [realpathSync.native(entryPath)], bundle: true, format: "cjs", - jsx: target === "client" ? "automatic" : undefined, + jsx: "automatic", platform: target === "server" ? "node" : "neutral", target: target === "server" ? "node20" : "es2020", + // The neutral platform reads no package.json entry fields, so packages without + // `exports` would not resolve. Read the same fields as esbuild's browser platform + // minus `browser`, since the bundle also runs in React Native. + mainFields: target === "client" ? ["module", "main"] : undefined, // Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro, // so apply the same compatibility transform before the app evaluates them from source. supported: target === "client" ? { "async-await": false } : undefined, @@ -288,17 +408,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom "react-native", "zod", ] - : [...PLUGIN_SDK_SPECIFIERS, "zod"], - plugins: [ - createRuntimeBoundaryPlugin(target, pluginDirectory), - ...(target === "client" - ? [createClientNodeImportPlugin()] - : [createUnusedPlatformModulePlugin()]), - ], + : SERVER_HOST_MODULES, + plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)], + metafile: true, logLevel: "silent", treeShaking: true, write: false, }); + checkSharedDependencies(result.metafile.inputs, pluginDirectory); const output = result.outputFiles[0]?.text; if (!output) throw new Error(`Plugin ${target} compilation produced no output`); return wrapCommonJsBundle(makeHermesInteropEager(output)); diff --git a/packages/server/src/server/plugins/connection-demand.e2e.test.ts b/packages/server/src/server/plugins/connection-demand.e2e.test.ts new file mode 100644 index 0000000000..7c6cbfca28 --- /dev/null +++ b/packages/server/src/server/plugins/connection-demand.e2e.test.ts @@ -0,0 +1,91 @@ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("an RPC-only plugin receives no agent, project or provider data", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-quiet-plugin-")); + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "quiet" })); + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; +export default function contribute(server) { + const counts = {}; + const observe = message => { + if (message.type !== "paseo_frame" || typeof message.data !== "string") return; + const frame = JSON.parse(message.data); + const type = frame.message?.type; + if (type) counts[type] = (counts[type] ?? 0) + 1; + }; + process.on("message", observe); + server.handle(defineRpc({name:"counts", input:z.object({}), output:z.record(z.string(),z.number())}), () => counts); + return () => process.off("message", observe); +}`, + ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const idle = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const legacy = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + capabilities: { + selective_agent_timeline: false, + explicit_event_subscriptions: false, + provider_snapshot_references: false, + }, + }); + const legacyTypes: string[] = []; + legacy.subscribeRawMessages((message) => legacyTypes.push(message.type)); + const idleTypes: string[] = []; + idle.subscribeRawMessages((message) => idleTypes.push(message.type)); + try { + await client.connect(); + await idle.connect(); + await legacy.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ provider: "claude", cwd: directory }); + const api = createPaseoApi(client); + const received: string[] = []; + const release = api.agents + .ref(agent.id) + .timeline.subscribe((event) => received.push(event.event.type)); + await release.ready; + await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + id: "row", + text: "only the subscriber sees this", + }); + await expect.poll(() => received).toEqual(["timeline"]); + await client.sendMessage(agent.id, "Say hello"); + await client.waitForFinish(agent.id); + await client.refreshProvidersSnapshot({ cwd: directory }); + const counts = await client.invokePluginRpc("quiet", "counts", {}); + expect(counts).toEqual({ status: 1 }); + await legacy.getDaemonConfig(); + expect(legacyTypes).toEqual( + expect.arrayContaining(["project.update", "providers_snapshot_update", "agent_stream"]), + ); + expect(idleTypes.filter((type) => type !== "status")).toEqual([]); + const deliveredBeforeRelease = received.length; + release(); + await client.getDaemonConfig(); + await daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, { + type: "assistant_message", + id: "row-2", + text: "unsubscribed", + }); + await client.getDaemonConfig(); + expect(received).toHaveLength(deliveredBeforeRelease); + } finally { + await legacy.close(); + await idle.close(); + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/index.posix.test.ts b/packages/server/src/server/plugins/index.posix.test.ts index 33d790a59b..71fe2b96e9 100644 --- a/packages/server/src/server/plugins/index.posix.test.ts +++ b/packages/server/src/server/plugins/index.posix.test.ts @@ -1,4 +1,16 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { + cp, + rename, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -6,12 +18,23 @@ import pino from "pino"; import { afterEach, describe, expect, it } from "vitest"; import { DaemonConfigStore } from "../daemon-config-store.js"; import { PluginService } from "./index.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; import { ManagedPluginSources } from "./managed-source.js"; +import { + startNpmRegistry, + npmPluginPackages, +} from "../../../../../scripts/test-support/npm-registry.mjs"; import { runGitCommand } from "../../utils/run-git-command.js"; const roots: string[] = []; type TestPluginRuntime = NonNullable[3]["runtime"]>; +const emptyUsageRuntime = { + getUsageSourceRegistrations: () => [], + fetchUsage: async () => undefined, + discoverUsage: async () => [], +} satisfies Pick; + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-service-")); roots.push(directory); @@ -99,6 +122,7 @@ function createPausedRuntime() { }); const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -131,6 +155,7 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { const starts: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -155,6 +180,140 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) { } describe("PluginService", () => { + it.each([ + { shadow: false, runtime: "subprocess" }, + { shadow: true, runtime: "subprocess" }, + { shadow: false, runtime: "builtin" }, + { shadow: true, runtime: "builtin" }, + ])( + "publishes status and launch metadata through $runtime despite a configured provider (extends: $shadow)", + async ({ shadow, runtime }) => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-status", + `export default function contribute(server) { + server.registerProvider({ id: "plugin-agent", label: "Plugin agent", command: ["agent", "serve"], + async status({ launch }) { return launch.env.LOGIN === "yes" ? { available: true } : { available: false, diagnostic: "Sign in" }; }, + async getCatalogCacheKey({ launch }) { return launch.command + ":" + launch.env.LOGIN; }, + async connect({ launch }) { + if (launch.command !== process.execPath || launch.env.LOGIN !== "no") throw new Error("Unexpected launch"); + return { version: 1, capabilities: [], async send() {}, onEvent() { return () => {}; }, async close() {} }; + }, + }); + return () => {}; + }`, + ); + const store = createStore(home); + await store.patch({ + providers: { + "plugin-agent": shadow + ? { extends: "acp", label: "Own provider", command: ["own-agent"] } + : { command: ["configured-agent"], enabled: false }, + }, + }); + const builtinRoot = path.join(home, "builtins"); + if (runtime === "builtin") { + await mkdir(builtinRoot); + await cp(directory, path.join(builtinRoot, "provider-status"), { recursive: true }); + } + const builtinPlugins = new BuiltinPluginLoader( + builtinRoot, + runtime === "builtin" ? ["provider-status"] : [], + ); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { builtinPlugins }), + ); + await service.start(); + try { + if (runtime === "subprocess") await service.installDirectory({ path: directory }); + const [registration] = service.getProviderRegistrations(); + expect(registration).toMatchObject({ + id: "plugin-agent", + command: ["agent", "serve"], + status: expect.any(Function), + }); + const launch = { command: process.execPath, args: [], env: { LOGIN: "no" } }; + expect(await registration!.getCatalogCacheKey!({ scope: "global", launch })).toBe( + process.execPath + ":no", + ); + expect(await registration!.status!({ launch })).toEqual({ + available: false, + diagnostic: "Sign in", + }); + expect( + await registration!.status!({ launch: { ...launch, env: { LOGIN: "yes" } } }), + ).toEqual({ available: true }); + const connection = await registration!.connect({ versions: [1], capabilities: [], launch }); + await connection.close(); + } finally { + await service.stopAllPlugins(); + } + }, + ); + + it("resolves a provider icon path to sanitized inline SVG", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-icon", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + icon: "icon.svg", + async getCatalogCacheKey(options) { return options.scope === "workspace" ? "runtime:" + options.cwd : undefined; }, + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const iconSvg = ''; + await writeFile(path.join(directory, "icon.svg"), iconSvg); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", icon: iconSvg, getCatalogCacheKey: expect.any(Function) }, + ]); + const provider = service.getProviderRegistrations()[0]!; + expect(await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-a" })).toBe( + "runtime:/project-a", + ); + expect( + await provider.getCatalogCacheKey!({ scope: "workspace", cwd: "/project-b", force: true }), + ).toBe("runtime:/project-b"); + expect(await provider.getCatalogCacheKey!({ scope: "global" })).toBeUndefined(); + }); + + it("publishes provider registrations only while their plugin is running", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = await createPlugin( + "provider-lifecycle", + `export default function contribute(server) { + server.registerProvider({ + id: "plugin-agent", + label: "Plugin agent", + async connect() { throw new Error("not opened by this test"); }, + }); + return () => {}; +}`, + ); + const service = createService(home); + + await service.start(); + await service.installDirectory({ path: directory }); + expect(service.getProviderRegistrations()).toMatchObject([ + { id: "plugin-agent", label: "Plugin agent" }, + ]); + + await service.disablePlugin("provider-lifecycle"); + expect(service.getProviderRegistrations()).toEqual([]); + }); + it("retains logs when disabled and clears them only when removed", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -168,6 +327,7 @@ describe("PluginService", () => { ]; const cleared: string[] = []; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [], invoke: async () => undefined, getLogs: () => entries, @@ -247,7 +407,7 @@ describe("PluginService", () => { await writeFile(path.join(directory, "index.server.ts"), "export default broken syntax !!!"); await expect(service.reloadPlugin("work-plugin")).rejects.toThrow(); expect(service.catalog()).toEqual([]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ expect.objectContaining({ id: "work-plugin", status: "failed", error: expect.any(String) }), ]); @@ -259,7 +419,153 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); - it("prefers an existing directory and installs its selected plugin subdirectory", async () => { + it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const running = await createPlugin("running", "export default () => () => {};"); + const disabled = await createPlugin("disabled", "export default () => () => {};"); + const malformed = await createPlugin("malformed", "export default () => () => {};"); + await writeFile( + path.join(running, "paseo-plugin.json"), + JSON.stringify({ + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }), + ); + await writeFile( + path.join(disabled, "paseo-plugin.json"), + JSON.stringify({ id: "disabled", description: "Waits until enabled" }), + ); + await writeFile(path.join(malformed, "paseo-plugin.json"), "{"); + const service = createService(home, { + running: { source: "directory", path: running }, + disabled: { source: "directory", path: disabled, enabled: false }, + malformed: { source: "directory", path: malformed }, + }); + + await service.start(); + + expect( + (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({ + id, + description, + name, + icon, + media, + })), + ).toEqual([ + { id: "disabled", description: "Waits until enabled" }, + { id: "malformed", description: undefined }, + { + id: "running", + description: "Runs checks", + name: "Checks", + icon: "icon.png", + media: ["screenshot.png", "https://example.com/demo.mp4"], + }, + ]); + await service.stopAllPlugins(); + }); + + it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`); + await mkdir(owner); + roots.push(home, owner); + const source = `${path.basename(owner)}/example`; + const repository = await createPlugin("registry-example", "export default () => () => {};\n"); + await mkdir(path.join(owner, "example")); + await cp(repository, path.join(owner, "example"), { recursive: true }); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand( + [ + "-c", + "user.name=Paseo Tests", + "-c", + "user.email=paseo@example.test", + "commit", + "-m", + "fixture", + ], + { cwd: repository }, + ); + const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + id: source, + name: "Example", + description: "Test", + categories: [], + author: { github: path.basename(owner) }, + repository: { url: "https://github.com/fixture/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit: stdout.trim(), + pluginPath: ".", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + const store = createStore(home); + store.patch({ pluginsEnabled: false }); + const service = bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.4.0", { + managedSources: new ManagedPluginSources(home, { defaultUrl: url }), + }), + ); + try { + await service.start(); + await expect(service.installSource({ source })).resolves.toMatchObject({ + id: "registry-example", + status: "disabled", + installation: { + identity: { kind: "git", registry: { url, id: source } }, + currentRevision: stdout.trim(), + }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + await expect( + service.installSource({ source: `./${source}`, id: "local-example" }), + ).resolves.toMatchObject({ + installation: { identity: { kind: "directory", path: path.join(owner, "example") } }, + }); + expect(requests).toEqual([`/plugins/${source}.json`]); + } finally { + await service.stopAllPlugins(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("names a missing explicit directory instead of trying a managed source", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); + roots.push(home); + const directory = path.join(home, "missing"); + const service = createService(home); + await expect(service.installSource({ source: directory })).rejects.toThrow( + `Plugin directory does not exist: ${directory}`, + ); + }); + + it("installs an explicit directory and its selected plugin subdirectory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-")); @@ -283,6 +589,150 @@ describe("PluginService", () => { await service.stopAllPlugins(); }, 20_000); + it.each([true, false])( + "updates the shipped legacy Git layout with enabled=%s", + async (enabled) => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-legacy-layout-")); + roots.push(home); + const repository = await mkdtemp(path.join(tmpdir(), "paseo-legacy-remote-")); + roots.push(repository); + const id = "legacy-review"; + const pluginPath = "plugins/review"; + await mkdir(path.join(repository, pluginPath), { recursive: true }); + await writeFile( + path.join(repository, pluginPath, "paseo-plugin.json"), + JSON.stringify({ id, requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(repository, pluginPath, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +export default function contribute(server: PluginServerContext) { + server.registerSettings(defineSettings({ id: "preferences", scope: "host", version: 1, schema: z.object({ message: z.string().default("default") }) })); + return () => {}; +}`, + ); + for (const args of [ + ["init", "-b", "main"], + ["add", "."], + ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"], + ["tag", "v1"], + ]) + await runGitCommand(args, { cwd: repository }); + const initial = ( + await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }) + ).stdout.trim(); + const remote = pathToFileURL(repository).href; + const legacyRoot = path.join(home, "plugins", id, `${initial.slice(0, 12)}-${randomUUID()}`); + const checkoutRoot = path.join(legacyRoot, "checkout"); + const directory = path.join(checkoutRoot, pluginPath); + await mkdir(legacyRoot, { recursive: true }); + await runGitCommand(["clone", remote, checkoutRoot], { cwd: home }); + await runGitCommand(["checkout", "--detach", "v1"], { cwd: checkoutRoot }); + await writeFile( + path.join(home, "plugins", "sources.json"), + JSON.stringify({ + [id]: { + remote, + requestedRef: "v1", + trackingBranch: null, + commit: "f".repeat(40), + pluginPath, + checkoutRoot, + }, + }), + ); + const settingsDirectory = path.join(home, "plugin-settings"); + await mkdir(path.join(settingsDirectory, id), { recursive: true }); + const settingsFile = path.join(settingsDirectory, id, "preferences.json"); + const savedSettings = JSON.stringify({ version: 1, values: { message: "keep my settings" } }); + await writeFile(settingsFile, savedSettings); + const store = createStore(home, { [id]: { source: "directory", path: directory, enabled } }); + const open = () => + bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.8.0", { + managedSources: new ManagedPluginSources(home), + settingsDirectory, + }), + ); + let service = open(); + try { + await service.start(); + const expected = { + identity: { kind: "git", remote, pluginPath }, + currentRevision: initial, + }; + await rename(repository, `${repository}-offline`); + try { + expect(await new ManagedPluginSources(home).describe(id, directory)).toEqual(expected); + expect(await service.listPlugins()).toMatchObject([ + { id, enabled, status: enabled ? "running" : "disabled", installation: expected }, + ]); + } finally { + await rename(`${repository}-offline`, repository); + } + await service.stopAllPlugins(); + service = open(); + await service.start(); + await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository }); + await writeFile(path.join(repository, pluginPath, "revision.txt"), "new default"); + for (const args of [ + ["add", "."], + ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"], + ]) + await runGitCommand(args, { cwd: repository }); + const latest = ( + await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }) + ).stdout.trim(); + const [preview] = await service.previewUpdates({ pluginId: id }); + expect(preview).toMatchObject({ + outcome: "update", + current: expected, + target: { kind: "git", commit: latest }, + }); + const [updated] = await service.applyUpdates([preview!.proposal!]); + expect(updated).toMatchObject({ + outcome: "updated", + plugin: { + id, + enabled, + status: enabled ? "running" : "disabled", + installation: { identity: expected.identity, currentRevision: latest }, + }, + }); + await expect(stat(legacyRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(settingsFile, "utf8")).toBe(savedSettings); + await service.disablePlugin(id); + await service.enablePlugin(id); + expect(await service.reloadPlugin(id)).toMatchObject({ status: "running" }); + expect(await service.invokePluginRpc(id, "settings.preferences.read", {})).toMatchObject({ + status: "ready", + values: { message: "keep my settings" }, + }); + await service.removePlugin(id); + await expect(stat(path.join(home, "plugins", id))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await stat(repository)).toBeDefined(); + const sources = new ManagedPluginSources(home); + // A record cannot confer ownership on another plugin's tree or a malformed version name. + sources.commit(id, { kind: "git", remote }); + for (const outside of [ + path.join(home, "plugins", "other", path.basename(legacyRoot), "checkout"), + path.join(home, "plugins", id, "f".repeat(36), "checkout"), + path.join(home, "plugins", id, `${initial.slice(0, 12)}-invalid`, "checkout"), + ]) + await expect(sources.describe(id, outside)).rejects.toThrow( + "outside its managed installation", + ); + } finally { + await service.stopAllPlugins(); + } + }, + 30_000, + ); + it("keeps the running commit when a Git update build command fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -329,12 +779,14 @@ describe("PluginService", () => { await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["commit", "-m", "broken update"], { cwd: repository }); - await expect(service.updateSources("git-update")).rejects.toThrow(); + expect(await applyReviewedUpdate(service, "git-update")).toMatchObject([ + { outcome: "error", error: expect.stringContaining("build exploded") }, + ]); expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); expect(service.catalog()).toEqual([ expect.objectContaining({ id: "git-update", clientBundle: expect.any(String) }), ]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ expect.objectContaining({ id: "git-update", path: installedPath, @@ -374,6 +826,7 @@ describe("PluginService", () => { const events: string[] = []; const running = new Set(); const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -423,8 +876,8 @@ describe("PluginService", () => { await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["commit", "-m", "prepared update"], { cwd: repository }); - await expect(service.updateSources("prepared-git-plugin")).resolves.toEqual([ - expect.objectContaining({ id: "prepared-git-plugin", updated: true }), + await expect(applyReviewedUpdate(service, "prepared-git-plugin")).resolves.toEqual([ + expect.objectContaining({ id: "prepared-git-plugin", outcome: "updated" }), ]); expect(events).toEqual(["validate:prepared", "start", "validate:updated", "start"]); await service.stopAllPlugins(); @@ -457,6 +910,7 @@ describe("PluginService", () => { const starts: string[] = []; let failNextStart = true; const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), invoke: async () => undefined, getLogs: () => [], @@ -483,7 +937,7 @@ describe("PluginService", () => { managedSources, }); await service.start(); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ expect.objectContaining({ id: "failed-update", status: "failed" }), ]); @@ -494,17 +948,88 @@ describe("PluginService", () => { await runGitCommand(["add", "-A"], { cwd: repository }); await runGitCommand(["commit", "-m", "fixed"], { cwd: repository }); - await expect(service.updateSources("failed-update")).resolves.toEqual([ - expect.objectContaining({ id: "failed-update", updated: true }), + await expect(applyReviewedUpdate(service, "failed-update")).resolves.toEqual([ + expect.objectContaining({ id: "failed-update", outcome: "updated" }), ]); expect(starts).toHaveLength(2); expect(starts[1]).not.toBe(initial.directory); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ expect.objectContaining({ id: "failed-update", path: starts[1], status: "running" }), ]); await service.stopAllPlugins(); }, 30_000); + it.each([false, true])( + "cleans a failed update when restoration also fails=%s", + async (failRestore) => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-recovery-")); + roots.push(home); + const repository = await createPlugin("recovery", "export default () => () => {};\n"); + for (const args of [ + ["init", "-b", "main"], + ["add", "."], + ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "initial"], + ]) + await runGitCommand(args, { cwd: repository }); + const running = new Set(); + let starts = 0; + const runtime: TestPluginRuntime = { + ...emptyUsageRuntime, + catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })), + invoke: async () => undefined, + getLogs: () => [], + clearLogs: () => undefined, + validatePlugin: async () => undefined, + startPlugin: async (id) => { + starts++; + if (starts === 2) throw new Error("candidate activation failed"); + if (starts === 3 && failRestore) throw new Error("prior activation failed"); + running.add(id); + }, + stopPluginById: async (id) => running.delete(id), + stopAll: async () => running.clear(), + subscribe: () => () => undefined, + bindPaseoSessionHost: () => undefined, + }; + const service = createService( + home, + {}, + { runtime, managedSources: new ManagedPluginSources(home) }, + ); + await service.start(); + const installed = await service.installSource({ source: pathToFileURL(repository).href }); + await writeFile(path.join(repository, "revision.txt"), "new"); + for (const args of [ + ["add", "."], + ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "update"], + ]) + await runGitCommand(args, { cwd: repository }); + const result = await applyReviewedUpdate(service, "recovery"); + expect(result).toMatchObject([ + { + outcome: "error", + error: expect.stringContaining( + failRestore ? "restoring previous plugin also failed" : "candidate activation failed", + ), + }, + ]); + expect(await service.listPlugins()).toMatchObject([ + { + path: installed.path, + enabled: true, + status: failRestore ? "failed" : "running", + installation: installed.installation, + }, + ]); + expect(await readdir(path.join(home, "plugins", "recovery"))).toEqual([ + path.basename(path.dirname(installed.path)), + ]); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + await service.stopAllPlugins(); + }, + 30_000, + ); + it("disables and removes a plugin without touching its source directory", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); roots.push(home); @@ -530,7 +1055,7 @@ export default function contribute(plugin: unknown) { expect(await readFile(cleanupFile, "utf8")).toBe("cleaned"); await service.removePlugin("cleanup-plugin"); - expect(service.listPlugins()).toEqual([]); + expect(await service.listPlugins()).toMatchObject([]); await expect(stat(directory)).resolves.toMatchObject({}); await service.stopAllPlugins(); }, 20_000); @@ -562,7 +1087,7 @@ export default function contribute(plugin: unknown) { ); store.patch({ pluginsEnabled: true }); await service.reloadPlugin("first"); - expect(service.listPlugins().map(({ id, status }) => ({ id, status }))).toEqual([ + expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([ { id: "first", status: "running" }, { id: "second", status: "running" }, ]); @@ -589,7 +1114,7 @@ export default function contribute(plugin: unknown) { await service.stopAllPlugins(); expect(service.catalog()).toEqual([]); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ { id: "slow", path: "/plugins/slow", enabled: true, status: "disabled" }, ]); }); @@ -684,6 +1209,9 @@ export default function contribute(plugin: unknown) { await writeFile(path.join(invalid, "paseo-plugin.json"), JSON.stringify({})); const missingEntry = await createPlugin("missing-entry", "export default () => () => {};"); await rm(path.join(missingEntry, "index.server.ts")); + const legacy = await createPlugin("legacy-plugin", "export default () => () => {};"); + await rm(path.join(legacy, "index.server.ts")); + await writeFile(path.join(legacy, "index.ts"), "export default () => () => {};"); const startupFailure = await createPlugin( "startup-failure", `export default function contribute(plugin: unknown) { void plugin; throw new Error("startup exploded"); }`, @@ -695,10 +1223,19 @@ export default function contribute(plugin: unknown) { await expect(service.installDirectory({ path: missingEntry })).rejects.toThrow( "Plugin entry points are missing", ); + await expect(service.installDirectory({ path: legacy })).rejects.toThrow( + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", + ); await expect(service.installDirectory({ path: startupFailure })).rejects.toThrow( "startup exploded", ); - expect(service.listPlugins()).toEqual([ + expect(await service.listPlugins()).toMatchObject([ + expect.objectContaining({ + id: "legacy-plugin", + status: "failed", + error: + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", + }), expect.objectContaining({ id: "missing-entry", status: "failed", @@ -741,3 +1278,282 @@ export default function contribute(plugin: unknown) { expect((await readFile(cleanupFile, "utf8")).trim().split("\n")).toHaveLength(4); }); }); + +describe("npm plugin installation", () => { + it("preserves dependencies and metadata, and reloads without npm", async () => { + const packages = npmPluginPackages(); + const fixture = packages[1]; + packages.push({ ...fixture, version: "2.0.0", tags: ["future"] }); + const failures: Array<[string, Record]> = [ + ["bad-manifest", { "paseo-plugin.json": "{}" }], + [ + "bad-build", + { + "paseo-plugin.json": JSON.stringify({ + id: "bad-build", + requirements: { paseo: ">=0.4.0" }, + build: [[process.execPath, "-e", "throw new Error('preparation failed')"]], + }), + }, + ], + [ + "bad-requirements", + { + "paseo-plugin.json": JSON.stringify({ + id: "bad-requirements", + requirements: { paseo: ">=999.0.0" }, + }), + }, + ], + ["bad-code", { "index.server.ts": "invalid syntax !!!" }], + [ + "bad-activation", + { "index.server.ts": "export default () => { throw new Error('activation failed'); };" }, + ], + ]; + const failedPackages = []; + for (const [name, overrides] of failures) { + failedPackages.push({ ...fixture, name, files: { ...fixture.files, ...overrides } }); + } + const registry = await startNpmRegistry([ + ...packages, + { + name: "paseo-prepared-plugin", + version: "1.0.0", + dependencies: { "paseo-fixture-dependency": "1.0.0" }, + scripts: { postinstall: "node -e \"throw new Error('npm lifecycle must not run')\"" }, + files: { + "nested/paseo-plugin.json": JSON.stringify({ + id: "prepared", + requirements: { paseo: ">=0.4.0" }, + build: [ + [ + process.execPath, + "-e", + "require('node:fs').mkdirSync('server'); require('node:fs').writeFileSync('server/generated.js', 'export default 42;');", + ], + ], + }), + "nested/index.server.ts": `import generated from "./server/generated.js"; +import dependency from "paseo-fixture-dependency"; +import { z } from "zod"; +export default function contribute() { + if (generated !== 42 || z.string().parse(dependency) !== "dependency loaded") throw new Error("Preparation failed"); + return () => {}; +}`, + }, + }, + ...failedPackages, + ]); + const home = await mkdtemp(path.join(tmpdir(), "paseo-npm-service-")); + roots.push(home); + const previousConfig = process.env.npm_config_userconfig; + const previousPath = process.env.PATH; + process.env.npm_config_userconfig = registry.userconfig; + const store = createStore(home); + const open = () => + bindTestSessionHost( + new PluginService(pino({ level: "silent" }), store, "0.8.0", { + managedSources: new ManagedPluginSources(home), + }), + ); + let service = open(); + try { + await service.start(); + const installed = await service.installSource({ source: "npm:paseo-fixture-plugin@^1.0.0" }); + expect(installed).toMatchObject({ + id: "npm-review", + status: "running", + installation: { + identity: { kind: "npm", packageName: "paseo-fixture-plugin", pluginPath: "." }, + currentRevision: "1.1.0", + }, + }); + expect(installed.commit).toBeUndefined(); + expect(installed.remote).toBeUndefined(); + expect( + service + .getLogs("npm-review") + .map((entry) => entry.message) + .join("\n"), + ).toContain("dependency loaded"); + const installRoot = path.dirname(path.dirname(installed.path)); + expect( + JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[ + "npm-review" + ], + ).toEqual({ kind: "npm" }); + expect( + JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")).packages[ + "node_modules/paseo-fixture-dependency" + ].version, + ).toBe("1.0.0"); + for (const [source, id, version] of [ + ["npm:paseo-fixture-plugin@1.0.0", "exact", "1.0.0"], + ["paseo-fixture-plugin@stable", "tag", "1.0.0"], + ["npm:@paseo-fixture/review", "scoped", "2.0.0"], + ]) { + expect(await service.installSource({ source, id })).toMatchObject({ + id, + status: "running", + installation: { currentRevision: version }, + }); + } + const [preview] = await service.previewUpdates({ pluginId: "exact" }); + expect(preview).toMatchObject({ + outcome: "update", + current: { currentRevision: "1.0.0" }, + target: { version: "1.1.0" }, + }); + const before = (await service.listPlugins()).find((item) => item.id === "exact")!; + await service.disablePlugin("exact"); + registry.setTag("paseo-fixture-plugin", "latest", "2.0.0"); + const results = await service.applyUpdates([ + { ...preview!.proposal!, id: "missing-plugin" }, + preview!.proposal!, + ]); + expect(results).toMatchObject([ + { outcome: "error" }, + { + outcome: "updated", + plugin: { enabled: false, installation: { currentRevision: "1.1.0" } }, + }, + ]); + expect(results[1]?.plugin?.installation?.identity).toEqual(before.installation?.identity); + registry.setTag("paseo-fixture-plugin", "latest", "1.1.0"); + expect(await service.applyUpdates([preview!.proposal!])).toMatchObject([ + { outcome: "error", error: expect.stringContaining("changed since review") }, + ]); + await service.enablePlugin("exact"); + const [future] = await service.previewUpdates({ + pluginId: "exact", + target: { kind: "npm", version: "future" }, + }); + expect(await service.applyUpdates([future!.proposal!])).toMatchObject([ + { outcome: "updated", plugin: { installation: { currentRevision: "2.0.0" } } }, + ]); + const [newer] = await service.previewUpdates({ pluginId: "exact" }); + expect(newer).toMatchObject({ + outcome: "installed-newer", + current: { currentRevision: "2.0.0" }, + target: { version: "1.1.0" }, + }); + expect(newer?.proposal).toBeUndefined(); + const [downgrade] = await service.previewUpdates({ + pluginId: "exact", + target: { kind: "npm", version: "1.0.0" }, + }); + const wrongArtifact = { + ...downgrade!.proposal!, + target: { ...downgrade!.proposal!.target, integrity: "sha512-invalid" }, + }; + expect(await service.applyUpdates([wrongArtifact])).toMatchObject([{ outcome: "error" }]); + expect(await service.applyUpdates([downgrade!.proposal!])).toMatchObject([ + { outcome: "updated", plugin: { installation: { currentRevision: "1.0.0" } } }, + ]); + expect((await service.previewUpdates({ pluginId: "exact" }))[0]?.outcome).toBe("update"); + await expect(service.updateSources("exact")).rejects.toThrow("Update the client"); + const prepared = await service.installSource({ + source: "npm:paseo-prepared-plugin@1.0.0:nested", + }); + expect(prepared).toMatchObject({ id: "prepared", status: "running" }); + expect(await readFile(path.join(prepared.path, "server/generated.js"), "utf8")).toBe( + "export default 42;", + ); + expect(prepared.installation).toMatchObject({ identity: { pluginPath: "nested" } }); + await service.removePlugin("prepared"); + expect(registry.requests).not.toContain("/@getpaseo/plugin"); + await expect(service.installSource({ source: "npm:missing-plugin" })).rejects.toThrow(); + await expect( + service.installSource({ source: "npm:paseo-fixture-plugin", ref: "main" }), + ).rejects.toThrow("--ref is only valid for Git"); + await expect(service.installSource({ source: "npm:paseo-fixture-plugin" })).rejects.toThrow( + "already configured", + ); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + for (const name of [ + "bad-manifest", + "bad-build", + "bad-requirements", + "bad-code", + "bad-activation", + ]) { + await expect(service.installSource({ source: `npm:${name}`, id: name })).rejects.toThrow(); + expect((await service.listPlugins()).map((plugin) => plugin.id)).toEqual([ + "exact", + "npm-review", + "scoped", + "tag", + ]); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + } + await service.disablePlugin("scoped"); + await service.stopAllPlugins(); + const recordsPath = path.join(home, "plugins", "sources.json"); + const oldRecords = JSON.parse(await readFile(recordsPath, "utf8")); + Object.assign(oldRecords["npm-review"], { + requestedSpec: "0.0.1", + version: "0.0.1", + packageName: "obsolete", + installRoot: "/obsolete", + integrity: "obsolete", + }); + await writeFile(recordsPath, JSON.stringify(oldRecords)); + process.env.PATH = path.join(home, "no-executables"); + service = open(); + await service.start(); + expect(await service.listPlugins()).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + id: "npm-review", + status: "running", + installation: installed.installation, + }), + expect.objectContaining({ id: "scoped", enabled: false, status: "disabled" }), + ]), + ); + expect(await service.reloadPlugin("npm-review")).toMatchObject({ + status: "running", + installation: installed.installation, + }); + await expect( + service.installSource({ source: "npm:paseo-fixture-plugin", id: "missing-npm" }), + ).rejects.toThrow("npm is required on the daemon host"); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + const packagePath = path.join(installed.path, "package.json"); + const packageJson = await readFile(packagePath, "utf8"); + await writeFile( + packagePath, + JSON.stringify({ ...JSON.parse(packageJson), version: "99.0.0" }), + ); + const corrupt = (await service.listPlugins()).find((plugin) => plugin.id === "npm-review")!; + expect(corrupt.status).toBe("running"); + expect(corrupt.installation).toEqual({ identity: installed.installation!.identity }); + expect((await service.previewUpdates({ pluginId: "npm-review" }))[0]).toMatchObject({ + outcome: "error", + error: expect.stringContaining("Installed revision is unavailable"), + }); + expect(await service.reloadPlugin("npm-review")).toMatchObject({ status: "running" }); + await writeFile(packagePath, packageJson); + await service.removePlugin("npm-review"); + await expect(stat(installRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + JSON.parse(await readFile(path.join(home, "plugins", "sources.json"), "utf8"))[ + "npm-review" + ], + ).toBeUndefined(); + } finally { + process.env.PATH = previousPath; + if (previousConfig === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previousConfig; + await service.stopAllPlugins(); + await registry.close(); + } + }, 120_000); +}); + +async function applyReviewedUpdate(service: PluginService, pluginId: string) { + const [preview] = await service.previewUpdates({ pluginId }); + expect(preview?.outcome).toBe("update"); + return service.applyUpdates([preview!.proposal!]); +} diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index df987c903b..6c4af29e62 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -1,6 +1,8 @@ +import type { PluginLifecycle } from "./lifecycle/index.js"; import path from "node:path"; -import { stat } from "node:fs/promises"; +import { stat, rm } from "node:fs/promises"; import type pino from "pino"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { PluginIdSchema, type PluginLogEntry, @@ -8,21 +10,50 @@ import { type PluginSource, type PluginSourceStatusItem, type PluginSourceUpdateItem, + type PluginUpdateSelection, + type PluginUpdateProposal, + type PluginUpdatePreview, + type PluginUpdateResult, } from "@getpaseo/protocol/messages"; import { parsePluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js"; import { readPluginManifest } from "./manifest.js"; +import { expandTilde } from "../../utils/path.js"; import { runPluginBuild } from "./preparation.js"; import { PluginRuntime } from "./runtime.js"; +import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js"; +import type { PluginProviderMetadata } from "./plugin-process-protocol.js"; +import { readPluginProviderIcon } from "./provider-icon.js"; +import { + type AgentUsageLookup, + type ListUsageReportsOptions, + UsageSourceRegistry, +} from "./usage-sources/index.js"; +import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js"; + +const BUILTIN_PROVIDER_ID_SET: ReadonlySet = new Set(BUILTIN_PROVIDER_IDS); interface PluginRuntimePort { - catalog(): Array<{ id: string; clientBundle: string }>; + emit?: PluginLifecycle["emit"]; + drainEvents?: PluginRuntime["drainEvents"]; + before?: PluginLifecycle["before"]; + catalog: PluginRuntime["catalog"]; invoke(pluginId: string, method: string, input: unknown): Promise; getLogs(pluginId: string): PluginLogEntry[]; clearLogs(pluginId: string): void; + getProviderRegistrations?(pluginId: string): readonly PluginProviderMetadata[]; + getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[]; + fetchUsage: PluginRuntime["fetchUsage"]; + discoverUsage: PluginRuntime["discoverUsage"]; + connectProvider: PluginRuntime["connectProvider"]; + getProviderCatalogCacheKey?: PluginRuntime["getProviderCatalogCacheKey"]; + getProviderStatus?: PluginRuntime["getProviderStatus"]; validatePlugin?(path: string): Promise; startPlugin(pluginId: string, path: string, canPublish: () => boolean): Promise; + startBuiltinPlugin?(plugin: BuiltinPlugin): Promise; stopPluginById(pluginId: string): Promise; stopAll(): Promise; subscribe(listener: (pluginId: string, error?: string) => void): () => void; @@ -30,8 +61,11 @@ interface PluginRuntimePort { } interface PluginServiceDependencies { + usageAgents?: AgentUsageLookup; + settingsDirectory?: string; runtime?: PluginRuntimePort; managedSources?: ManagedPluginSources; + builtinPlugins?: BuiltinPluginLoader; } function resolvePluginStatus(input: { @@ -46,28 +80,73 @@ function resolvePluginStatus(input: { export class PluginService { private readonly runtime: PluginRuntimePort; private readonly managedSources: ManagedPluginSources | null; + private readonly builtinPluginIds: ReadonlySet; + private readonly builtinPlugins: BuiltinPluginLoader; private readonly logger: pino.Logger; private readonly errors = new Map(); private readonly listeners = new Set<(pluginId: string) => void>(); + private readonly providers = new Map(); + private readonly usageSources: UsageSourceRegistry; + private readonly usageSourceIdsByPlugin = new Map(); + private readonly providerIdsByPlugin = new Map(); + private readonly providerListeners = new Set<() => void>(); private lifecycle = Promise.resolve(); private globalStartsBlocked = true; private started = false; + private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>(); constructor( logger: pino.Logger, private readonly configStore: DaemonConfigStore, - daemonVersion: string, - dependencies: PluginServiceDependencies = {}, + private readonly daemonVersion: string, + private readonly dependencies: PluginServiceDependencies = {}, ) { this.logger = logger.child({ module: "plugin-service" }); - this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion); + this.usageSources = new UsageSourceRegistry( + Date.now, + 300_000, + this.logger, + dependencies.usageAgents, + ); + this.runtime = + dependencies.runtime ?? + new PluginRuntime(logger, daemonVersion, { + settingsDirectory: dependencies.settingsDirectory, + onSettingsChanged: (pluginId, settingsId) => { + for (const listener of this.settingsListeners) listener(pluginId, settingsId); + }, + }); this.managedSources = dependencies.managedSources ?? null; + this.builtinPlugins = dependencies.builtinPlugins ?? new BuiltinPluginLoader(undefined, []); + this.builtinPluginIds = this.builtinPlugins.ids; this.runtime.subscribe((pluginId, error) => { + this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); if (error) this.errors.set(pluginId, error); this.notify(pluginId); }); } + readonly emit: PluginLifecycle["emit"] = (name, event) => { + this.runtime.emit?.(name, event); + }; + + async drainEvents(): Promise { + await this.runtime.drainEvents?.(); + } + + readonly before: PluginLifecycle["before"] = async (name, request) => { + if (this.runtime.before) { + return this.runtime.before(name, request); + } + return request; + }; + + subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void { + this.settingsListeners.add(listener); + return () => this.settingsListeners.delete(listener); + } + subscribe(listener: (pluginId: string) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); @@ -77,11 +156,37 @@ export class PluginService { this.runtime.bindPaseoSessionHost(sessionHost); } + getProviderRegistrations(): readonly ProviderRegistration[] { + return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)); + } + + listUsageReports(options?: ListUsageReportsOptions) { + return this.usageSources.listReports(options); + } + + listLegacyUsage() { + return this.usageSources.listLegacyUsage(); + } + + subscribeProviderRegistrations(listener: () => void): () => void { + this.providerListeners.add(listener); + return () => this.providerListeners.delete(listener); + } + async start(): Promise { if (this.started) return; this.started = true; const config = this.configStore.get(); this.globalStartsBlocked = config.pluginsEnabled !== true; + await this.builtinPlugins.load(async (plugin) => { + try { + await this.runtime.startBuiltinPlugin?.(plugin); + await this.publishProviderRegistrations(plugin.id, plugin.directory); + this.publishUsageSources(plugin.id); + } catch (error) { + this.logger.error({ err: error, pluginId: plugin.id }, "Failed to start built-in plugin"); + } + }); if (config.pluginsEnabled === true) { for (const [pluginId, source] of Object.entries(config.plugins ?? {})) { if (source.enabled === false) continue; @@ -94,11 +199,11 @@ export class PluginService { }); } - listPlugins(): PluginListItem[] { + async listPlugins(): Promise { const config = this.configStore.get(); const running = new Set(this.runtime.catalog().map((plugin) => plugin.id)); - return Object.entries(config.plugins ?? {}) - .map(([id, source]) => { + const plugins = await Promise.all( + Object.entries(config.plugins ?? {}).map(async ([id, source]) => { const enabled = source.enabled !== false; const item: PluginListItem = { id, @@ -110,27 +215,39 @@ export class PluginService { running: running.has(id), }), }; - const managed = this.managedSources?.get(id); - if (managed) { + const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); + if (manifest) { + item.name = manifest.name; + item.description = manifest.description; + item.icon = manifest.icon; + item.media = manifest.media; + } + item.installation = await this.managedSources + ?.describe(id, source.path) + .catch(() => undefined); + if (!this.managedSources) + item.installation = { identity: { kind: "directory", path: path.resolve(source.path) } }; + if (item.installation?.identity.kind === "git") { item.source = "git"; - const remote = this.managedSources?.displayRemote(id); - if (remote) item.remote = remote; - item.ref = managed.requestedRef ?? managed.trackingBranch ?? managed.commit; - item.commit = managed.commit; + item.remote = item.installation.identity.remote; + item.commit = item.installation.currentRevision; } const error = this.errors.get(id); if (error && item.status === "failed") item.error = error; return item; - }) - .sort((left, right) => left.id.localeCompare(right.id)); + }), + ); + return plugins.sort((left, right) => left.id.localeCompare(right.id)); } getLogs(pluginId: string): PluginLogEntry[] { - this.requireSource(pluginId); + if (!this.builtinPluginIds.has(pluginId)) { + this.requireSource(pluginId); + } return this.runtime.getLogs(pluginId); } - catalog(): Array<{ id: string; clientBundle: string }> { + catalog(): ReturnType { return this.runtime.catalog(); } @@ -138,7 +255,11 @@ export class PluginService { return this.enqueue(async () => { const directory = path.resolve(input.path); const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const pluginId = PluginIdSchema.parse(input.id ?? manifest.id); + if (this.builtinPluginIds.has(pluginId)) { + throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`); + } if (this.configStore.get().plugins?.[pluginId]) { throw new Error( `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, @@ -151,7 +272,7 @@ export class PluginService { this.configStore.patch({ plugins: sources }); if (this.canPublish(pluginId)) await this.startConfigured(pluginId); this.notify(pluginId); - const installed = this.requireItem(pluginId); + const installed = await this.requireItem(pluginId); if (installed.status === "failed") { throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); } @@ -168,14 +289,14 @@ export class PluginService { id?: string; ref?: string; }): Promise { - const directDirectory = path.resolve(input.source); - const directInfo = await stat(directDirectory).catch(() => null); - const reference = directInfo?.isDirectory() - ? { source: input.source, pluginPath: undefined } - : parsePluginSourceReference(input.source); - const directory = path.resolve(reference.source); - const info = directInfo?.isDirectory() ? directInfo : await stat(directory).catch(() => null); - if (info?.isDirectory()) { + if (input.id && this.builtinPluginIds.has(input.id)) { + throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`); + } + const reference = parsePluginSourceReference(input.source); + if (reference.kind === "directory") { + const directory = path.resolve(expandTilde(reference.source)); + const info = await stat(directory).catch(() => null); + if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`); if (input.ref) throw new Error("Plugin --ref is only valid for Git sources"); const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath); return this.installDirectory({ path: pluginDirectory, id: input.id }); @@ -189,32 +310,40 @@ export class PluginService { }); let pluginId: string; try { - await runPluginBuild(candidate.directory, candidate.build, this.logger); + await this.checkRequirements(candidate.directory); pluginId = PluginIdSchema.parse(input.id ?? candidate.defaultId); + if (this.builtinPluginIds.has(pluginId)) { + throw new Error(`Plugin ID "${pluginId}" is reserved for a built-in plugin`); + } if (this.configStore.get().plugins?.[pluginId]) { throw new Error( `Plugin ID "${pluginId}" is already configured; choose another ID with --id`, ); } + await runPluginBuild(candidate.directory, candidate.build, this.logger); candidate = await managedSources.place(pluginId, candidate); await this.validateCandidate(candidate); + await managedSources.verifyCandidate(pluginId, candidate); } catch (error) { await managedSources.discard(candidate); throw error; } - const sources = { - ...this.configStore.get().plugins, - [pluginId]: { source: "directory" as const, path: candidate.directory, enabled: true }, - }; - managedSources.commit(pluginId, candidate.record); - this.configStore.patch({ plugins: sources }); - if (this.canPublish(pluginId)) await this.startConfigured(pluginId); - this.notify(pluginId); - const installed = this.requireItem(pluginId); - if (installed.status === "failed") { - throw new Error(installed.error ?? `Plugin failed to start: ${pluginId}`); + this.patchSource(pluginId, { source: "directory", path: candidate.directory, enabled: true }); + try { + if (this.canPublish(pluginId)) await this.startExplicit(pluginId, candidate.directory); + managedSources.commit(pluginId, candidate.record); + } catch (error) { + await this.stopPlugin(pluginId); + const sources = { ...this.configStore.get().plugins }; + delete sources[pluginId]; + this.configStore.patch({ plugins: sources }); + await managedSources.discard(candidate); + this.errors.delete(pluginId); + this.notify(pluginId); + throw error; } - return installed; + this.notify(pluginId); + return this.requireItem(pluginId); }); } @@ -233,15 +362,54 @@ export class PluginService { }); } - async updateSources(pluginId?: string): Promise { + // COMPAT(plugin-immediate-update): added in v0.8.0; remove after 2027-03-16 when clients use reviewed targets. + async updateSources(_pluginId?: string): Promise { + throw new Error("Update the client to review plugin updates before applying them."); + } + + async previewUpdates(input: { + pluginId?: string; + target?: PluginUpdateSelection; + }): Promise { + if (input.target && !input.pluginId) + throw new Error("An explicit update target requires one plugin ID"); + const sources = this.configStore.get().plugins ?? {}; + const ids = input.pluginId ? [input.pluginId] : Object.keys(sources).sort(); + return Promise.all( + ids.map(async (id) => { + try { + return await this.requireManagedSources().preview( + id, + this.requireSource(id).path, + input.target, + ); + } catch (error) { + return { + id, + outcome: "error" as const, + links: [], + error: error instanceof Error ? error.message : String(error), + }; + } + }), + ); + } + + async applyUpdates(proposals: PluginUpdateProposal[]): Promise { return this.enqueue(async () => { - const sources = this.configStore.get().plugins ?? {}; - const ids = pluginId - ? [pluginId] - : Object.keys(sources).filter((id) => this.managedSources?.get(id)); - const updates: PluginSourceUpdateItem[] = []; - for (const id of ids.sort()) updates.push(await this.updateSource(id)); - return updates; + const results: PluginUpdateResult[] = []; + for (const proposal of proposals) { + try { + results.push(await this.updateSource(proposal)); + } catch (error) { + results.push({ + id: proposal.id, + outcome: "error", + error: error instanceof Error ? error.message : String(error), + }); + } + } + return results; }); } @@ -252,7 +420,7 @@ export class PluginService { throw new Error("Plugins are globally disabled"); } this.errors.delete(pluginId); - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await this.startExplicit(pluginId, source.path); this.notify(pluginId); return this.requireItem(pluginId); @@ -276,7 +444,7 @@ export class PluginService { async disablePlugin(pluginId: string): Promise { const source = this.requireSource(pluginId); this.patchSource(pluginId, { ...source, enabled: false }); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); return this.enqueue(async () => { await stopping; this.errors.delete(pluginId); @@ -287,7 +455,7 @@ export class PluginService { async removePlugin(pluginId: string): Promise { this.requireSource(pluginId); - const stopping = this.runtime.stopPluginById(pluginId); + const stopping = this.stopPlugin(pluginId); const sources = { ...this.configStore.get().plugins }; delete sources[pluginId]; this.configStore.patch({ plugins: sources }); @@ -297,6 +465,11 @@ export class PluginService { this.errors.delete(pluginId); this.notify(pluginId); await this.managedSources?.remove(pluginId); + if (this.dependencies.settingsDirectory) + await rm(path.join(this.dependencies.settingsDirectory, pluginId), { + recursive: true, + force: true, + }); }); } @@ -306,17 +479,17 @@ export class PluginService { async stopAllPlugins(): Promise { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopAll(); await this.enqueue(async () => { await stopping; - await this.runtime.stopAll(); + await this.stopAll(); }); } private handleGlobalSwitch(enabled: boolean): void { if (!enabled) { this.globalStartsBlocked = true; - const stopping = this.runtime.stopAll(); + const stopping = this.stopConfiguredPlugins(); for (const id of Object.keys(this.configStore.get().plugins ?? {})) this.notify(id); void this.enqueue(async () => { await stopping; @@ -337,7 +510,7 @@ export class PluginService { if (!source || source.enabled === false || !this.canPublish(pluginId)) return; this.errors.delete(pluginId); try { - await this.runtime.startPlugin(pluginId, source.path, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, source.path); } catch (error) { if (this.canPublish(pluginId)) this.recordFailure(pluginId, error); } @@ -345,7 +518,7 @@ export class PluginService { private async startExplicit(pluginId: string, sourcePath: string): Promise { try { - await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + await this.startPlugin(pluginId, sourcePath); } catch (error) { if (this.canPublish(pluginId)) { this.recordFailure(pluginId, error); @@ -365,6 +538,142 @@ export class PluginService { ); } + private async startPlugin(pluginId: string, sourcePath: string): Promise { + await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId)); + try { + await this.publishProviderRegistrations(pluginId, sourcePath); + this.publishUsageSources(pluginId); + } catch (error) { + try { + this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); + } finally { + await this.runtime.stopPluginById(pluginId); + } + throw error; + } + } + + private stopPlugin(pluginId: string): Promise { + this.removeProviderRegistrations(pluginId); + this.removeUsageSources(pluginId); + return this.runtime.stopPluginById(pluginId); + } + + private async stopConfiguredPlugins(): Promise { + const stopping = Object.keys(this.configStore.get().plugins ?? {}).map((id) => + this.stopPlugin(id), + ); + await Promise.all(stopping); + } + + private async stopAll(): Promise { + for (const pluginId of this.providerIdsByPlugin.keys()) { + this.removeProviderRegistrations(pluginId); + } + for (const pluginId of this.usageSourceIdsByPlugin.keys()) this.removeUsageSources(pluginId); + await this.runtime.stopAll(); + } + + private publishUsageSources(pluginId: string): void { + const metadata = this.runtime.getUsageSourceRegistrations(pluginId); + const registered: string[] = []; + try { + for (const source of metadata) { + this.usageSources.register({ + id: source.id, + label: source.label, + icon: source.icon, + discover: async (scope) => { + const result = await this.runtime.discoverUsage(pluginId, source.id, scope); + if (!Array.isArray(result)) + throw new Error(`Invalid usage discovery from ${source.id}`); + return result; + }, + fetch: (input) => { + return this.runtime.fetchUsage(pluginId, source.id, input); + }, + }); + registered.push(source.id); + } + } catch (error) { + for (const id of registered) this.usageSources.unregister(id); + throw error; + } + this.usageSourceIdsByPlugin.set(pluginId, registered); + } + + private removeUsageSources(pluginId: string): void { + for (const id of this.usageSourceIdsByPlugin.get(pluginId) ?? []) + this.usageSources.unregister(id); + this.usageSourceIdsByPlugin.delete(pluginId); + } + + private async publishProviderRegistrations( + pluginId: string, + pluginDirectory: string, + ): Promise { + const metadata = this.runtime.getProviderRegistrations?.(pluginId) ?? []; + for (const provider of metadata) { + if (BUILTIN_PROVIDER_ID_SET.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register builtin provider ID "${provider.id}"`); + } + if (this.providers.has(provider.id)) { + throw new Error(`Plugin ${pluginId} cannot register provider ID "${provider.id}" twice`); + } + } + const registrations = await Promise.all( + metadata.map( + async (provider): Promise => ({ + id: provider.id, + label: provider.label, + description: provider.description, + command: provider.command, + status: provider.hasStatus + ? (request) => { + if (!this.runtime.getProviderStatus) + throw new Error("Plugin runtime cannot resolve provider status"); + return this.runtime.getProviderStatus(pluginId, provider.id, request); + } + : undefined, + getCatalogCacheKey: provider.hasCatalogCacheKey + ? (options) => { + if (!this.runtime.getProviderCatalogCacheKey) + throw new Error("Plugin runtime cannot resolve catalogue keys"); + return this.runtime.getProviderCatalogCacheKey(pluginId, provider.id, options); + } + : undefined, + icon: provider.iconPath + ? await readPluginProviderIcon(pluginDirectory, provider.iconPath) + : undefined, + connect: (request) => this.runtime.connectProvider(pluginId, provider.id, request), + }), + ), + ); + const ids = registrations.map((provider) => provider.id); + for (const provider of registrations) { + this.providers.set(provider.id, { + ...provider, + }); + } + if (ids.length > 0) { + this.providerIdsByPlugin.set(pluginId, ids); + this.notifyProviderRegistrations(); + } + } + + private removeProviderRegistrations(pluginId: string): void { + const ids = this.providerIdsByPlugin.get(pluginId); + if (!ids) return; + this.providerIdsByPlugin.delete(pluginId); + for (const id of ids) this.providers.delete(id); + this.notifyProviderRegistrations(); + } + + private notifyProviderRegistrations(): void { + for (const listener of this.providerListeners) listener(); + } + private enqueue(operation: () => Promise): Promise { const result = this.lifecycle.then(operation, operation); this.lifecycle = result.then( @@ -378,87 +687,88 @@ export class PluginService { this.errors.set(pluginId, error instanceof Error ? error.message : String(error)); } - private async updateSource(pluginId: string): Promise { + private async checkRequirements(directory: string): Promise { + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); + } + + private async updateSource(proposal: PluginUpdateProposal): Promise { + const pluginId = proposal.id; const managedSources = this.requireManagedSources(); const source = this.requireSource(pluginId); - const previous = managedSources.get(pluginId); - if (!previous) throw new Error(`Plugin is not managed by Git: ${pluginId}`); - const prepared = await managedSources.prepareUpdate(pluginId, source.path); - if (!prepared.candidate) { - return { - id: pluginId, - previousCommit: previous.commit, - currentCommit: previous.commit, - commits: 0, - updated: false, - }; - } - let candidate = prepared.candidate; + let candidate = await managedSources.prepareUpdate(proposal, source.path); try { + await this.checkRequirements(candidate.directory); await runPluginBuild(candidate.directory, candidate.build, this.logger); candidate = await managedSources.place(pluginId, candidate); await this.validateCandidate(candidate); + await managedSources.verifyCandidate(pluginId, candidate, proposal.target); + await managedSources.assertCurrent(proposal, this.requireSource(pluginId).path); } catch (error) { await managedSources.discard(candidate); throw error; } - const current = this.configStore.get().plugins?.[pluginId]; - if (!current) { - await managedSources.discard(candidate); - throw new Error(`Plugin is no longer configured: ${pluginId}`); - } + const current = this.requireSource(pluginId); const isRunning = this.runtime.catalog().some((plugin) => plugin.id === pluginId); - const isEnabled = current.enabled !== false; - const isGloballyEnabled = this.configStore.get().pluginsEnabled === true; - const shouldActivate = isEnabled && isGloballyEnabled; + const shouldActivate = + current.enabled !== false && this.configStore.get().pluginsEnabled === true; if (shouldActivate) { - if (isRunning) await this.runtime.stopPluginById(pluginId); + if (isRunning) await this.stopPlugin(pluginId); try { await this.startExplicit(pluginId, candidate.directory); } catch (error) { - this.errors.delete(pluginId); - const latest = this.configStore.get().plugins?.[pluginId]; - const isStillConfigured = latest !== undefined; - const isStillEnabled = isStillConfigured && latest.enabled !== false; - const isStillGloballyEnabled = this.configStore.get().pluginsEnabled === true; - const canRestore = isRunning && isStillEnabled && isStillGloballyEnabled; - if (canRestore) await this.startExplicit(pluginId, source.path); - await managedSources.discard(candidate); - this.notify(pluginId); + let recoveryError: unknown; + try { + this.errors.delete(pluginId); + if (isRunning && this.canPublish(pluginId)) + await this.startExplicit(pluginId, source.path); + } catch (restoreError) { + recoveryError = restoreError; + } finally { + await managedSources.discard(candidate); + this.notify(pluginId); + } + if (recoveryError) + throw new Error( + `Update failed: ${error instanceof Error ? error.message : String(error)}; restoring previous plugin also failed: ${recoveryError instanceof Error ? recoveryError.message : String(recoveryError)}`, + { cause: error }, + ); throw error; } } - const activatedSource = this.configStore.get().plugins?.[pluginId]; if (!activatedSource) { - await this.runtime.stopPluginById(pluginId); + await this.stopPlugin(pluginId); await managedSources.discard(candidate); throw new Error(`Plugin is no longer configured: ${pluginId}`); } this.patchSource(pluginId, { ...activatedSource, path: candidate.directory }); - managedSources.commit(pluginId, candidate.record); - await managedSources.removeVersion(previous); this.errors.delete(pluginId); this.notify(pluginId); + let warning: string | undefined; + try { + await managedSources.removeVersion(pluginId, source.path); + } catch (error) { + warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`; + } return { id: pluginId, - previousCommit: previous.commit, - currentCommit: candidate.record.commit, - commits: prepared.commits, - updated: true, + outcome: "updated", + plugin: await this.requireItem(pluginId), + ...(warning ? { warning } : {}), }; } private validateCandidate(candidate: ManagedPluginCandidate): Promise { if (!this.runtime.validatePlugin) { - throw new Error("Plugin runtime cannot validate managed Git sources"); + throw new Error("Plugin runtime cannot validate managed sources"); } return this.runtime.validatePlugin(candidate.directory); } private requireManagedSources(): ManagedPluginSources { - if (!this.managedSources) throw new Error("Git plugin management is unavailable"); + if (!this.managedSources) throw new Error("Plugin source management is unavailable"); return this.managedSources; } @@ -481,8 +791,8 @@ export class PluginService { }); } - private requireItem(pluginId: string): PluginListItem { - const item = this.listPlugins().find((plugin) => plugin.id === pluginId); + private async requireItem(pluginId: string): Promise { + const item = (await this.listPlugins()).find((plugin) => plugin.id === pluginId); if (!item) throw new Error(`Plugin is not configured: ${pluginId}`); return item; } diff --git a/packages/server/src/server/plugins/internal-child.ts b/packages/server/src/server/plugins/internal-child.ts new file mode 100644 index 0000000000..a80a8c5ec5 --- /dev/null +++ b/packages/server/src/server/plugins/internal-child.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { PluginServerContribution } from "@getpaseo/plugin/server"; +import { createPluginWorker, type PluginWorkerChannel } from "./plugin-process.js"; +import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; + +export class InternalPluginChild extends EventEmitter { + connected = true; + killed = false; + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + private readonly worker: ReturnType; + private readonly workerMessages = new Set<(message: PluginProcessRequest) => void>(); + private closing: Promise | null = null; + + constructor(contribute: PluginServerContribution) { + super(); + const channel: PluginWorkerChannel = { + send: (message: PluginProcessMessage, callback?: () => void) => { + const copy = structuredClone(message); + queueMicrotask(() => { + if (this.connected) this.emit("message", copy); + callback?.(); + }); + }, + onMessage: (handler) => { + this.workerMessages.add(handler); + return () => this.workerMessages.delete(handler); + }, + disconnect: () => this.disconnect(), + }; + this.worker = createPluginWorker({ channel, contribute }); + } + + send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean { + if (!this.connected) { + callback?.(new Error("Plugin child is disconnected")); + return false; + } + const copy = structuredClone(message); + queueMicrotask(() => { + for (const handler of this.workerMessages) handler(copy); + callback?.(null); + }); + return true; + } + + kill(): boolean { + if (this.killed) return false; + this.killed = true; + this.disconnect(); + return true; + } + + disconnect(): void { + if (this.closing) return; + this.closing = Promise.resolve() + .then(() => this.worker.shutdown()) + .finally(() => { + this.connected = false; + this.stdout.end(); + this.stderr.end(); + this.emit("close", 0, null); + }); + } +} diff --git a/packages/server/src/server/plugins/internal-seam.e2e.test.ts b/packages/server/src/server/plugins/internal-seam.e2e.test.ts new file mode 100644 index 0000000000..a3771f4ac5 --- /dev/null +++ b/packages/server/src/server/plugins/internal-seam.e2e.test.ts @@ -0,0 +1,82 @@ +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader } from "./builtin/index.js"; + +const directory = fileURLToPath(new URL("./test-fixtures/internal-seam/", import.meta.url)); +const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); + +test("built-in and directory plugins share RPC and lifecycle behavior while built-ins stay hidden and enabled", async () => { + const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-internal-seam-")); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(fixtureRoot, ["internal-seam"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + expect(await client.listPlugins()).toEqual([]); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 }); + expect((await client.getPluginLogs("internal-seam")).map(({ message }) => message)).toContain( + "[paseo] Plugin ready", + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory, "directory-seam"); + expect((await client.listPlugins()).map(({ id }) => id)).toEqual(["directory-seam"]); + expect(await client.invokePluginRpc("directory-seam", "state", {})).toEqual({ workspaces: 0 }); + await client.createWorkspace({ + idempotencyKey: "internal-seam-workspace", + source: { kind: "directory", path: workspaceDirectory }, + }); + await expect + .poll(() => client.invokePluginRpc("internal-seam", "state", {})) + .toEqual({ workspaces: 1 }); + await expect + .poll(() => client.invokePluginRpc("directory-seam", "state", {})) + .toEqual({ workspaces: 1 }); + await client.patchDaemonConfig({ pluginsEnabled: false }); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 1 }); + expect(await client.listPlugins()).toMatchObject([ + { id: "directory-seam", status: "disabled" }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(workspaceDirectory, { recursive: true, force: true }); + } +}, 60_000); + +test("a failing built-in does not stop the daemon or the next built-in", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-failure-")); + const failingDirectory = path.join(root, "failing-builtin"); + await mkdir(failingDirectory); + await writeFile( + path.join(failingDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "failing-builtin", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(failingDirectory, "index.server.ts"), + "export default function contribute() { throw new Error('fixture startup failure'); }", + ); + await cp(directory, path.join(root, "internal-seam"), { recursive: true }); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(root, ["failing-builtin", "internal-seam"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + expect(await client.invokePluginRpc("internal-seam", "state", {})).toEqual({ workspaces: 0 }); + expect(await client.listPlugins()).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts new file mode 100644 index 0000000000..6f3d8661e7 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle-archive.e2e.test.ts @@ -0,0 +1,109 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("closing and archiving agents publish their distinct lifecycle hooks once", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-archive-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin( + fileURLToPath(new URL("../../../../../plugin-examples/lifecycle-logger", import.meta.url)), + ); + const created = await client.createWorkspace({ + source: { kind: "directory", path: directory }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const live = await client.createAgent({ + provider: "claude", + cwd: directory, + workspaceId: workspace.id, + }); + const closed = await client.createAgent({ + provider: "claude", + cwd: directory, + workspaceId: workspace.id, + }); + await daemon.daemon.agentManager.closeAgent(closed.id); + await daemon.daemon.agentManager.closeAgent(closed.id); + expect(daemon.daemon.agentManager.getAgent(closed.id)).toBeNull(); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id); + }) + .toEqual([closed.id]); + await client.archiveWorkspace(workspace.id); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":"agent.archived"'); + }) + .map((entry) => { + return JSON.parse(entry.message).data.agent.id; + }) + .sort(); + }) + .toEqual([live.id, closed.id].sort()); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle-logger"); + return logs + .filter((entry) => entry.message.startsWith('{"hook":"agent.closed"')) + .map((entry) => JSON.parse(entry.message).data.agent.id) + .sort(); + }) + .toEqual([live.id, closed.id].sort()); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("daemon shutdown lets agent close hooks finish before stopping plugins", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-close-hook-shutdown-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + const completedFile = path.join(directory, "closed.txt"); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "close-hook-shutdown", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { writeFile } from "node:fs/promises"; +import { setTimeout } from "node:timers/promises"; +export default function contribute(server) { + server.on("agent.closed", async (event, context) => { + await setTimeout(200, undefined, { signal: context.signal }); + await writeFile(${JSON.stringify(completedFile)}, event.agent.id); + }); + return () => {}; +}`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ provider: "claude", cwd: directory }); + await daemon.close(); + expect(await readFile(completedFile, "utf8")).toBe(agent.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.e2e.test.ts new file mode 100644 index 0000000000..fd408da004 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.e2e.test.ts @@ -0,0 +1,586 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, onTestFinished, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { resolveDaemonVersion } from "../daemon-version.js"; + +test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "lifecycle", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("workspace.create", ({ request }) => { + console.log(JSON.stringify({ hook: "workspace.before" })); + return { ...request, title: "Created through a hook" }; + }); + server.on("workspace.created", (event) => { + console.log(JSON.stringify({ hook: "workspace.created", event })); + }); + server.on("workspace.archived", (event) => { + console.log(JSON.stringify({ hook: "workspace.archived", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const created = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(created.workspace?.name).toBe("Created through a hook"); + const workspaceId = created.workspace!.id; + const replay = await client.createWorkspace({ + idempotencyKey: "plugin-workspace", + source: { kind: "directory", path: directory }, + }); + expect(replay.error).toBeNull(); + expect(replay.workspace?.id).toBe(workspaceId); + await client.archiveWorkspace(workspaceId); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("lifecycle"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { hook: "workspace.before" }, + { hook: "workspace.created", event: { workspace: { id: workspaceId, cwd: directory } } }, + { hook: "workspace.archived", event: { workspace: { id: workspaceId } } }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("plugins observe turns, answer permissions, and observe archive without blocking the agent", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-turn-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "turn-hooks", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.on("agent.turn_started", (event) => { + console.log(JSON.stringify({ hook: "agent.turn_started", event })); + }); + server.on("agent.turn_ended", (event) => { + console.log(JSON.stringify({ hook: "agent.turn_ended", event })); + }); + server.on("agent.permission_requested", async (event, context) => { + console.log(JSON.stringify({ hook: "agent.permission_requested", event })); + await context.paseo.agents.ref(event.agent.id).respondToPermission({ + requestId: event.request.id, + response: { behavior: "deny", message: "Declined by plugin" }, + }); + }); + server.on("agent.permission_resolved", (event) => { + console.log(JSON.stringify({ hook: "agent.permission_resolved", event })); + }); + server.on("agent.archived", (event) => { + console.log(JSON.stringify({ hook: "agent.archived", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ + provider: "claude", + cwd: directory, + title: "Turn hooks", + modeId: "default", + }); + await client.sendMessage(agent.id, "Run rm -f permission.txt"); + await expect + .poll( + async () => { + const logs = await client.getPluginLogs("turn-hooks"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }) + .sort((left, right) => { + return left.hook.localeCompare(right.hook); + }); + }, + { timeout: 10_000 }, + ) + .toMatchObject([ + { + hook: "agent.permission_requested", + event: { request: { input: { command: "rm -f permission.txt" } } }, + }, + { hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } }, + { + hook: "agent.turn_ended", + event: { outcome: { kind: "completed" }, timeline: expect.any(Array) }, + }, + { hook: "agent.turn_started", event: { agent: { id: agent.id } } }, + ]); + await client.archiveAgent(agent.id); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("turn-hooks"); + return logs.filter((entry) => { + return entry.message.startsWith('{"hook":"agent.archived"'); + }).length; + }) + .toBe(1); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("agent creation hooks change the provider and environment before the session opens", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "agent-hooks", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("agent.create", ({ request }) => { + return { ...request, config: { ...request.config, provider: "codex" }, env: { ...request.env, HOOK_CREATE: "created" } }; + }); + server.before("agent.session_open", ({ request }) => { + console.log(JSON.stringify({ hook: "agent.session_open", request })); + return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } }; + }); + server.on("agent.created", (event) => { + console.log(JSON.stringify({ hook: "agent.created", event })); + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const agent = await client.createAgent({ + provider: "claude", + cwd: directory, + title: "Hook test", + }); + expect(agent.provider).toBe("codex"); + await expect + .poll(async () => { + const logs = await client.getPluginLogs("agent-hooks"); + return logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return JSON.parse(entry.message); + }); + }) + .toMatchObject([ + { + hook: "agent.session_open", + request: { agentId: agent.id, env: { HOOK_CREATE: "created" } }, + }, + { hook: "agent.created", event: { agent: { id: agent.id, provider: "codex" } } }, + ]); + await client.archiveAgent(agent.id); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +test("invalid output from an untyped plugin rejects creation before later callbacks run", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-invalid-hook-")); + const daemon = await createTestPaseoDaemon({ daemonVersion: "0.8.0" }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "invalid-hook", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + ` +export default function contribute(server) { + server.before("workspace.create", () => { + return { source: { kind: "worktree", branchName: 42 } }; + }); + server.before("workspace.create", ({ request }) => { + console.log("Unexpected later callback"); + return request; + }); + return () => {}; +} +`, + ); + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(directory); + const result = await client.createWorkspace({ source: { kind: "directory", path: directory } }); + expect(result.workspace).toBeNull(); + expect(result.error).toContain("Plugin invalid-hook before workspace.create failed"); + const logs = await client.getPluginLogs("invalid-hook"); + expect( + logs.some((entry) => { + return entry.message.includes("Unexpected later callback"); + }), + ).toBe(false); + } finally { + await client.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); + +const PROVIDER_ID = "shutdown-provider"; + +async function createProviderPlugin( + root: string, + options: { acknowledgeClose: boolean } = { acknowledgeClose: true }, +): Promise { + const directory = path.join(root, "plugin"); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "shutdown-provider-plugin", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import type { + ProviderEvent, + ProviderInput, + ProviderRegistration, +} from "@getpaseo/plugin/server/provider"; + +const CAPABILITIES = ["prompt.message", "session.persistence"]; + +const provider: ProviderRegistration = { + id: ${JSON.stringify(PROVIDER_ID)}, + label: "Shutdown provider", + async connect() { + let listener: ((event: ProviderEvent) => void) | null = null; + const emit = (event: ProviderEvent) => listener?.(event); + let turn = 0; + return { + version: 1, + capabilities: CAPABILITIES, + async send(input: ProviderInput) { + if (input.type === "catalog") { + emit({ + type: "catalog", + requestId: input.requestId, + catalog: { + models: [{ id: "shutdown-model", label: "Shutdown model" }], + modes: [], + thinkingOptions: [], + defaultModel: "shutdown-model", + }, + }); + return; + } + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: CAPABILITIES, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type === "session.prompt") { + turn += 1; + const turnId = "turn-" + turn; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" }); + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { type: "assistant_message", id: "answer-" + turn, text: "Done" }, + }); + emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" }); + return; + } + if (input.type === "session.close") { + if (!${JSON.stringify(options.acknowledgeClose)}) return; + emit({ type: "session.closed", sessionId: input.sessionId }); + } + if ("requestId" in input) { + emit({ type: "request.completed", requestId: input.requestId }); + } + }, + onEvent(next: (event: ProviderEvent) => void) { + listener = next; + return () => { + if (listener === next) listener = null; + }; + }, + async close() {}, + }; + }, +}; + +export default function contribute(server: { registerProvider(p: ProviderRegistration): void }) { + server.registerProvider(provider); + return () => undefined; +}`, + ); + return directory; +} + +test("a clean daemon shutdown leaves a completed plugin-provider agent without an error", async () => { + const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-shutdown-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const pluginDirectory = await createProviderPlugin(root); + const workspace = path.join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true }, + }, + }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.1" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const agent = await client.createAgent({ + provider: PROVIDER_ID, + model: "shutdown-model", + cwd: workspace, + title: "Shutdown provider agent", + }); + await client.sendMessage(agent.id, "hello"); + const finished = await client.waitForFinish(agent.id, 30_000); + expect(finished.status).toBe("idle"); + + const beforeShutdown = await daemon.daemon.agentStorage.get(agent.id); + expect(beforeShutdown?.lastError ?? null).toBeNull(); + + await client.close(); + await daemon.daemon.stop(); + await daemon.daemon.agentStorage.flush().catch(() => undefined); + + const persisted = await daemon.daemon.agentStorage.get(agent.id); + expect(persisted).not.toBeNull(); + expect(persisted?.lastError ?? null).toBeNull(); + expect(persisted?.attentionReason ?? null).not.toBe("error"); +}, 60_000); + +test("reloading a plugin leaves a completed plugin-provider agent without an error", async () => { + const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-reload-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const pluginDirectory = await createProviderPlugin(root); + const workspace = path.join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + pluginsEnabled: true, + plugins: { + "shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true }, + }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.1" }); + onTestFinished(async () => { + await client.close(); + await daemon.close(); + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const agent = await client.createAgent({ + provider: PROVIDER_ID, + model: "shutdown-model", + cwd: workspace, + title: "Reload provider agent", + }); + await client.sendMessage(agent.id, "hello"); + expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle"); + + const reloaded = await client.reloadPlugin("shutdown-provider-plugin"); + expect(reloaded.status).toBe("running"); + + const afterReload = await daemon.daemon.agentStorage.get(agent.id); + expect(afterReload?.lastError ?? null).toBeNull(); + expect(afterReload?.attentionReason ?? null).not.toBe("error"); + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect(JSON.stringify(timeline.entries)).not.toContain("Provider connection closed"); + + await client.sendMessage(agent.id, "after reload"); + expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle"); +}, 60_000); + +test("a provider that never acknowledges session.close does not hold the daemon open", async () => { + const root = await mkdtemp(path.join(tmpdir(), "plugin-provider-stuck-close-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const pluginDirectory = await createProviderPlugin(root, { acknowledgeClose: false }); + const workspace = path.join(root, "workspace"); + await mkdir(workspace, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "shutdown-provider-plugin": { source: "directory", path: pluginDirectory, enabled: true }, + }, + }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.1" }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const agent = await client.createAgent({ + provider: PROVIDER_ID, + model: "shutdown-model", + cwd: workspace, + title: "Stuck close agent", + }); + await client.sendMessage(agent.id, "hello"); + await client.waitForFinish(agent.id, 30_000); + await client.close(); + + // Without a deadline on each agent close, stop() never returns here and the + // test fails by timing out. + await expect(daemon.daemon.stop()).resolves.toBeUndefined(); +}, 90_000); + +const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); + +// Hold the provider close until the subprocess has entered shutdown cleanup. +async function createSlowClosingProviderPlugin() { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-slow-close-plugin-")); + const closeStarted = path.join(directory, "close-started"); + const closeRelease = path.join(directory, "close-release"); + const cleanupStarted = path.join(directory, "cleanup-started"); + await cp(path.join(repoRoot, "plugin-examples/provider-direct"), directory, { recursive: true }); + const providerPath = path.join(directory, "server", "provider.ts"); + const source = (await readFile(providerPath, "utf8")).replaceAll("\r\n", "\n"); + const patched = `import { readFile, writeFile } from "node:fs/promises";\n${source.replace( + " closed = true;\n sessions.clear();", + ` closed = true; + await writeFile(${JSON.stringify(closeStarted)}, "started"); + while (true) { + try { await readFile(${JSON.stringify(closeRelease)}); break; } + catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + sessions.clear(); + console.log("provider close completed");`, + )}`; + expect(patched).toContain(`await writeFile(${JSON.stringify(closeStarted)}, "started")`); + await writeFile(providerPath, patched); + const entryPath = path.join(directory, "index.server.ts"); + const entry = (await readFile(entryPath, "utf8")).replaceAll("\r\n", "\n"); + const withCleanup = `import { writeFile } from "node:fs/promises";\n${entry.replace( + " return () => {};", + ` return async () => { await writeFile(${JSON.stringify(cleanupStarted)}, "started"); console.log("plugin cleanup completed"); };`, + )}`; + expect(withCleanup).toContain(`await writeFile(${JSON.stringify(cleanupStarted)}, "started")`); + await writeFile(entryPath, withCleanup); + return { directory, closeStarted, closeRelease, cleanupStarted }; +} + +test("reloading a plugin does not send on a closed IPC channel", async () => { + const { + directory: pluginDirectory, + closeStarted, + closeRelease, + cleanupStarted, + } = await createSlowClosingProviderPlugin(); + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-reload-teardown-")); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + const agent = await client.createAgent({ provider: "direct-example", cwd }); + const turn = client.sendMessage(agent.id, "Say hello").catch(() => undefined); + const reload = client.reloadPlugin("provider-direct-example"); + await expect + .poll(() => readFile(closeStarted, "utf8").catch(() => null), { timeout: 20_000 }) + .toBe("started"); + await expect + .poll(() => readFile(cleanupStarted, "utf8").catch(() => null), { timeout: 20_000 }) + .toBe("started"); + await writeFile(closeRelease, "release"); + const reloaded = await reload; + expect(reloaded.status).toBe("running"); + const entries = await client.getPluginLogs("provider-direct-example"); + const messages = entries.map((entry) => entry.message).join("\n"); + expect(messages).not.toContain("ERR_IPC_CHANNEL_CLOSED"); + expect(entries.filter((entry) => entry.message === "plugin cleanup completed")).toHaveLength(1); + expect(entries.filter((entry) => entry.message === "provider close completed")).toHaveLength(1); + await turn; + await client.archiveAgent(agent.id).catch(() => undefined); + } finally { + await writeFile(closeRelease, "release"); + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + await rm(pluginDirectory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts new file mode 100644 index 0000000000..fc9cfbd84d --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle.real.e2e.test.ts @@ -0,0 +1,342 @@ +import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types"; +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readlink, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pino from "pino"; +import { expect, test } from "vitest"; +import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +type RecordedHook = + | { + [Name in keyof PluginLifecycleEvents]: { hook: Name; data: PluginLifecycleEvents[Name] }; + }[keyof PluginLifecycleEvents] + | { + [Name in keyof PluginBeforeRequests]: { + hook: `before ${Name}`; + data: { request: PluginBeforeRequests[Name] }; + }; + }[keyof PluginBeforeRequests]; + +const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url)); +const evidenceDirectory = path.join(repoRoot, ".dev", "lifecycle-proof"); + +async function readHooks(client: DaemonClient): Promise { + const logs = await client.getPluginLogs("lifecycle-logger"); + const events: RecordedHook[] = []; + for (const entry of logs) { + if (entry.message.startsWith('{"hook":')) { + events.push(JSON.parse(entry.message) as RecordedHook); + } + } + return events; +} + +async function endedTurns(client: DaemonClient, agentId: string) { + const events = await readHooks(client); + return events.filter((event): event is Extract => { + return event.hook === "agent.turn_ended" && event.data.agent.id === agentId; + }); +} + +async function waitForTurns(client: DaemonClient, agentId: string, count: number): Promise { + await expect + .poll( + async () => { + return (await endedTurns(client, agentId)).length; + }, + { timeout: 120_000, interval: 250 }, + ) + .toBe(count); +} + +async function waitForPermission(client: DaemonClient, agentId: string, command: string) { + let requestId = ""; + await expect + .poll( + async () => { + const events = await readHooks(client); + for (const event of events) { + if ( + event.hook === "agent.permission_requested" && + event.data.agent.id === agentId && + event.data.request.input?.command === command + ) { + requestId = event.data.request.id; + } + } + return requestId; + }, + { timeout: 120_000, interval: 250 }, + ) + .not.toBe(""); + return requestId; +} + +test.skipIf(process.platform !== "linux")( + "all lifecycle hooks and the example actions work with a real Claude agent on an isolated daemon", + async () => { + await mkdir(evidenceDirectory, { recursive: true }); + const fixture = await mkdtemp(path.join(tmpdir(), "paseo-real-hooks-")); + const project = path.join(fixture, "project"); + await mkdir(path.join(project, ".claude"), { recursive: true }); + await writeFile( + path.join(project, ".claude", "settings.json"), + JSON.stringify({ permissions: { ask: ["Bash"] } }), + ); + await writeFile(path.join(project, "canary.txt"), "preserve me\n"); + execFileSync("git", ["init", "-b", "main"], { cwd: project, stdio: "pipe" }); + execFileSync("git", ["add", "."], { cwd: project }); + execFileSync( + "git", + [ + "-c", + "user.name=Lifecycle Test", + "-c", + "user.email=lifecycle@example.invalid", + "commit", + "-m", + "Fixture", + ], + { cwd: project, stdio: "pipe" }, + ); + const destination = pino.destination({ + dest: path.join(evidenceDirectory, "daemon.log"), + sync: true, + }); + const logger = pino({ level: "info" }, destination); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.8.0", + logger, + agentClients: { + claude: new ClaudeAgentClient({ logger }), + codex: new CodexAppServerAgentClient(logger), + }, + mcpEnabled: false, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.8.0", + }); + const proof: Record = { + port: daemon.port, + paseoHome: daemon.paseoHome, + startedAt: new Date().toISOString(), + }; + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-logger")); + await client.installDirectoryPlugin(path.join(repoRoot, "plugin-examples/lifecycle-actions")); + const created = await client.createWorkspace({ + title: "Isolated lifecycle example", + source: { kind: "directory", path: project }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + expect(workspace.workspaceKind).toBe("worktree"); + expect(workspace.workspaceDirectory).not.toBe(project); + proof.workspace = workspace; + const agent = await client.createAgent({ + provider: "codex", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Real lifecycle example", + }); + expect(agent.provider).toBe("claude"); + proof.agent = { id: agent.id, requestedProvider: "codex", actualProvider: agent.provider }; + + await client.sendMessage( + agent.id, + "This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.", + ); + await waitForTurns(client, agent.id, 2); + const retryTurns = await endedTurns(client, agent.id); + expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK"); + proof.followup = "RETRY_OK"; + + await client.sendMessage( + agent.id, + "Call the Bash tool with exactly git status. Do not combine it with other commands. Then reply STATUS_OK.", + ); + await waitForTurns(client, agent.id, 3); + const approvalId = await waitForPermission(client, agent.id, "git status"); + expect(await readHooks(client)).toContainEqual( + expect.objectContaining({ + hook: "agent.permission_resolved", + data: expect.objectContaining({ + requestId: approvalId, + resolution: expect.objectContaining({ behavior: "allow" }), + }), + }), + ); + proof.approvedRequestId = approvalId; + + await client.sendMessage( + agent.id, + "Call the Bash tool with exactly rm -rf canary.txt. If declined, do not try any other command or tool. Reply DECLINED and stop.", + ); + await waitForTurns(client, agent.id, 4); + const deniedId = await waitForPermission(client, agent.id, "rm -rf canary.txt"); + expect(await readHooks(client)).toContainEqual( + expect.objectContaining({ + hook: "agent.permission_resolved", + data: expect.objectContaining({ + requestId: deniedId, + resolution: expect.objectContaining({ behavior: "deny" }), + }), + }), + ); + expect(await readFile(path.join(workspace.workspaceDirectory, "canary.txt"), "utf8")).toBe( + "preserve me\n", + ); + proof.deniedRequestId = deniedId; + proof.canaryPreserved = true; + + const envCommand = 'printf \'%s|%s\' "$PASEO_HOOK_CREATE_EXAMPLE" "$PASEO_HOOK_OPEN_EXAMPLE"'; + await client.sendMessage( + agent.id, + `Call Bash with exactly this command: ${envCommand}. Reply with its exact output only.`, + ); + const envPermission = await waitForPermission(client, agent.id, envCommand); + await client.respondToPermission(agent.id, envPermission, { behavior: "allow" }); + await waitForTurns(client, agent.id, 5); + const envTurns = await endedTurns(client, agent.id); + expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened"); + proof.environmentOutput = "created|opened"; + + await client.refreshAgent(agent.id); + await daemon.daemon.agentManager.closeAgent(agent.id); + await client.sendMessage(agent.id, "Reply exactly RESUMED. Do not use tools."); + await waitForTurns(client, agent.id, 6); + + await client.sendMessage( + agent.id, + "Write a detailed 2000-word essay about binary trees. Do not use tools.", + ); + await expect + .poll( + async () => { + return (await readHooks(client)).filter((event) => { + return event.hook === "agent.turn_started" && event.data.agent.id === agent.id; + }).length; + }, + { timeout: 30_000 }, + ) + .toBe(7); + await client.cancelAgent(agent.id); + await waitForTurns(client, agent.id, 7); + expect((await endedTurns(client, agent.id))[6].data.outcome.kind).toBe("canceled"); + await client.archiveAgent(agent.id); + + const failed = await client.createAgent({ + provider: "claude", + model: "haiku", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: "Expected runtime crash", + }); + await client.sendMessage(failed.id, "Reply READY. Do not use tools."); + await waitForTurns(client, failed.id, 1); + const rows = execFileSync("ps", ["-eo", "pid=,ppid=,args="], { encoding: "utf8" }).split( + "\n", + ); + const providerPids: number[] = []; + for (const row of rows) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(row); + if (match && Number(match[2]) === process.pid && match[3].includes("claude")) { + const cwd = await readlink(`/proc/${match[1]}/cwd`); + if (cwd === workspace.workspaceDirectory) { + providerPids.push(Number(match[1])); + } + } + } + expect(providerPids).toHaveLength(1); + const providerPid = providerPids[0]; + await client.sendMessage( + failed.id, + "Write a detailed 2000-word essay about binary trees. Do not use tools.", + ); + process.kill(providerPid, "SIGKILL"); + await waitForTurns(client, failed.id, 2); + expect((await endedTurns(client, failed.id))[1].data.outcome.kind).toBe("failed"); + proof.crashedProviderPid = providerPid; + await client.archiveAgent(failed.id); + await client.archiveWorkspace(workspace.id); + await expect + .poll(async () => { + const names = new Set( + (await readHooks(client)).map((event) => { + return event.hook; + }), + ); + return [...names].sort(); + }) + .toEqual([ + "agent.archived", + "agent.closed", + "agent.created", + "agent.permission_requested", + "agent.permission_resolved", + "agent.turn_ended", + "agent.turn_started", + "before agent.create", + "before agent.session_open", + "before workspace.create", + "workspace.archived", + "workspace.created", + ]); + const openReasons = (await readHooks(client)) + .filter((event) => { + return event.hook === "before agent.session_open"; + }) + .map((event) => { + return event.data.request.reason; + }); + expect(openReasons).toContain("create"); + expect(openReasons).toContain("refresh"); + expect(openReasons).toContain("resume"); + proof.passed = true; + } finally { + proof.finishedAt = new Date().toISOString(); + await writeFile(path.join(evidenceDirectory, "proof.json"), JSON.stringify(proof, null, 2)); + const logs = await client.getPluginLogs("lifecycle-logger").catch(() => { + return []; + }); + await writeFile( + path.join(evidenceDirectory, "hooks.jsonl"), + logs + .filter((entry) => { + return entry.message.startsWith('{"hook":'); + }) + .map((entry) => { + return entry.message; + }) + .join("\n") + "\n", + ); + await client.close(); + await daemon.close(); + destination.end(); + await rm(fixture, { recursive: true, force: true }); + } + }, + 600_000, +); + +function assistantText(timeline: readonly AgentTimelineItem[]): string { + let text = ""; + for (const item of timeline) { + if (item.type === "user_message") { + text = ""; + } else if (item.type === "assistant_message") { + text += item.text; + } + } + return text; +} diff --git a/packages/server/src/server/plugins/lifecycle/handlers.test.ts b/packages/server/src/server/plugins/lifecycle/handlers.test.ts new file mode 100644 index 0000000000..5e30535953 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/handlers.test.ts @@ -0,0 +1,100 @@ +import { expect, test } from "vitest"; +import { createPaseoApi } from "@getpaseo/client"; +import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { PluginHookHandlers } from "./index.js"; + +const paseo = createPaseoApi( + new DaemonClient({ url: "ws://127.0.0.1:1/ws", clientId: "lifecycle-unit" }), +); + +test("removing an old registration twice preserves a newer registration for the same hook", async () => { + const hooks = new PluginHookHandlers(() => {}); + const remove = hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "old" }; + }); + remove(); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "new" }; + }); + remove(); + const output = await hooks.invoke( + "operation", + "before", + "workspace.create", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ); + expect(output).toEqual({ source: { kind: "directory", path: "/project" }, title: "new" }); +}); + +test("before hooks compose returned requests and preserve the original input", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: "first" }; + }); + hooks.before("workspace.create", () => { + return; + }); + hooks.before("workspace.create", ({ request }) => { + return { ...request, title: request.title + ":second" }; + }); + const input = { source: { kind: "directory", path: "/project" } }; + expect(await hooks.invoke("operation", "before", "workspace.create", input, paseo)).toEqual({ + source: { kind: "directory", path: "/project" }, + title: "first:second", + }); + expect(input).toEqual({ source: { kind: "directory", path: "/project" } }); +}); + +test("teardown aborts an active callback and removes its registrations", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("workspace.create", async (_input, context) => { + await new Promise((_resolve, reject) => { + context.signal.addEventListener( + "abort", + () => { + reject(new Error("Hook aborted")); + }, + { once: true }, + ); + }); + }); + const invocation = hooks.invoke( + "operation", + "before", + "workspace.create", + { + source: { kind: "directory", path: "/project" }, + }, + paseo, + ); + hooks.close(); + await expect(invocation).rejects.toThrow("Hook aborted"); + expect(hooks.catalog()).toEqual({ events: [], before: [] }); +}); + +test("session-open hooks reject changes to session identity instead of silently ignoring them", async () => { + const hooks = new PluginHookHandlers(() => {}); + hooks.before("agent.session_open", ({ request }) => { + return { ...request, provider: "another-provider" }; + }); + await expect( + hooks.invoke( + "operation", + "before", + "agent.session_open", + { + agentId: "agent", + workspaceId: "workspace", + provider: "claude", + cwd: "/project", + reason: "resume", + purpose: "interactive", + env: {}, + }, + paseo, + ), + ).rejects.toThrow("agent.session_open hooks can only change env"); +}); diff --git a/packages/server/src/server/plugins/lifecycle/index.ts b/packages/server/src/server/plugins/lifecycle/index.ts new file mode 100644 index 0000000000..447c325ad4 --- /dev/null +++ b/packages/server/src/server/plugins/lifecycle/index.ts @@ -0,0 +1,274 @@ +import type { AgentStreamEvent, AgentTimelineItem } from "../../agent/agent-sdk-types.js"; +import { z } from "zod"; +import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages"; +import type { + PluginHookAgent, + PluginHookContext, + PluginLifecycleRegistration, +} from "@getpaseo/plugin/server"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import type { + PluginBeforeRequests, + PluginHookWorkspace, + PluginLifecycleEvents, +} from "@getpaseo/plugin/server"; +import { WorkspaceCreateRequestSchema } from "@getpaseo/protocol/messages"; +import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; + +export const lifecycleEventNames = [ + "agent.created", + "agent.closed", + "agent.turn_started", + "agent.turn_ended", + "agent.permission_requested", + "agent.permission_resolved", + "agent.archived", + "workspace.created", + "workspace.archived", +] as const; +export const beforeHookNames = ["agent.create", "agent.session_open", "workspace.create"] as const; + +const beforeSchemas = { + "agent.create": CreateAgentRequestMessageSchema.pick({ config: true, env: true }).strict(), + "agent.session_open": z + .object({ + agentId: z.string(), + workspaceId: z.string().nullable(), + provider: z.string(), + cwd: z.string(), + reason: z.enum(["create", "resume", "refresh", "import"]), + purpose: z.enum(["interactive", "history"]), + env: z.record(z.string(), z.string()), + }) + .strict(), + "workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(), +}; + +export interface PluginLifecycle { + emit( + name: Name, + event: PluginLifecycleEvents[Name], + ): void; + before( + name: Name, + request: PluginBeforeRequests[Name], + ): Promise; +} + +export function validateBeforeRequest( + name: Name, + value: unknown, +): PluginBeforeRequests[Name] { + return beforeSchemas[name].parse(value) as PluginBeforeRequests[Name]; +} + +export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): PluginHookWorkspace { + return { + id: workspace.workspaceId, + projectId: workspace.projectId, + cwd: workspace.cwd, + name: workspace.title, + archivedAt: workspace.archivedAt, + }; +} + +export function describeHookAgent(agent: { + id: string; + workspaceId?: string; + provider: string; + cwd: string; + title?: string | null; + labels: Record; +}): PluginHookAgent { + return { + id: agent.id, + workspaceId: agent.workspaceId ?? null, + parentAgentId: agent.labels[PARENT_AGENT_ID_LABEL] ?? null, + provider: agent.provider, + cwd: agent.cwd, + title: agent.title ?? null, + }; +} + +export function publishAgentStream( + lifecycle: PluginLifecycle, + agent: PluginHookAgent, + event: AgentStreamEvent, + timeline: readonly AgentTimelineItem[], +): void { + if (event.type === "turn_started") { + lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null }); + } else if (event.type === "turn_completed") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "completed" }, + }); + } else if (event.type === "turn_failed") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "failed", error: { message: event.error, code: event.code } }, + }); + } else if (event.type === "turn_canceled") { + lifecycle.emit("agent.turn_ended", { + agent, + turnId: event.turnId ?? null, + timeline, + outcome: { kind: "canceled", reason: event.reason }, + }); + } else if (event.type === "permission_requested") { + lifecycle.emit("agent.permission_requested", { agent, request: event.request }); + } else if (event.type === "permission_resolved") { + lifecycle.emit("agent.permission_resolved", { + agent, + requestId: event.requestId, + resolution: event.resolution, + }); + } +} + +export function validateBeforeResult( + name: Name, + input: PluginBeforeRequests[Name], + output: unknown, +): PluginBeforeRequests[Name] { + const result = validateBeforeRequest(name, output); + if (name === "agent.session_open") { + const previous = beforeSchemas["agent.session_open"].parse(input); + const next = beforeSchemas["agent.session_open"].parse(result); + if ( + previous.agentId !== next.agentId || + previous.workspaceId !== next.workspaceId || + previous.provider !== next.provider || + previous.cwd !== next.cwd || + previous.reason !== next.reason || + previous.purpose !== next.purpose + ) { + throw new Error("agent.session_open hooks can only change env"); + } + } + if (name === "agent.create") { + const previous = beforeSchemas["agent.create"].parse(input); + const next = beforeSchemas["agent.create"].parse(result); + if (previous.config.cwd !== next.config.cwd) { + throw new Error("agent.create hooks cannot change the workspace directory"); + } + } + return result; +} + +type Handler = (input: unknown, context: PluginHookContext) => unknown; + +export class PluginHookHandlers implements PluginLifecycleRegistration { + private readonly events = new Map>(); + private readonly transforms = new Map>(); + private readonly active = new Map(); + + private readonly changed: () => void; + + constructor(changed: () => void) { + this.changed = changed; + } + + readonly on: PluginLifecycleRegistration["on"] = (name, handler) => { + if (!lifecycleEventNames.includes(name)) { + throw new Error(`Unknown lifecycle event: ${name}`); + } + return this.register(this.events, name, handler as Handler); + }; + + readonly before: PluginLifecycleRegistration["before"] = (name, handler) => { + if (!beforeHookNames.includes(name)) { + throw new Error(`Unknown before hook: ${name}`); + } + return this.register(this.transforms, name, handler as Handler); + }; + + catalog(): { events: string[]; before: string[] } { + return { events: [...this.events.keys()], before: [...this.transforms.keys()] }; + } + + async invoke( + id: string, + kind: "event" | "before", + name: string, + input: unknown, + paseo: PluginHookContext["paseo"], + ): Promise { + const controller = new AbortController(); + this.active.set(id, controller); + try { + if (kind === "before") { + if ( + !beforeHookNames.includes( + name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests, + ) + ) { + throw new Error(`Unknown before hook: ${name}`); + } + const hookName = name as keyof import("@getpaseo/plugin/server").PluginBeforeRequests; + let request = validateBeforeRequest(hookName, input); + for (const handler of this.transforms.get(name) ?? []) { + controller.signal.throwIfAborted(); + const result = await handler( + { request: structuredClone(request) }, + { paseo, signal: controller.signal }, + ); + if (result !== undefined) { + request = validateBeforeResult(hookName, request, result); + } + } + return request; + } + for (const handler of this.events.get(name) ?? []) { + controller.signal.throwIfAborted(); + try { + await handler(structuredClone(input), { paseo, signal: controller.signal }); + } catch (error) { + console.error(`Lifecycle hook ${name} failed`, error); + } + } + return null; + } finally { + this.active.delete(id); + } + } + + cancel(id: string): void { + this.active.get(id)?.abort(); + } + + close(): void { + for (const controller of this.active.values()) { + controller.abort(); + } + this.events.clear(); + this.transforms.clear(); + } + + private register( + registry: Map>, + name: string, + handler: Handler, + ): () => void { + let handlers = registry.get(name); + if (!handlers) { + handlers = new Set(); + registry.set(name, handlers); + } + handlers.add(handler); + this.changed(); + return () => { + if (!handlers.delete(handler)) { + return; + } + if (handlers.size === 0) { + registry.delete(name); + } + this.changed(); + }; + } +} diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index d315a146d3..466780b79a 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -1,4 +1,6 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, rename, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -34,6 +36,25 @@ async function commitAll(repository: string, message: string): Promise { return stdout.trim(); } +/** Points https://github.com/fixture/repository.git at a local repository for the callback. */ +async function withGitHubFixture(repository: string, run: () => Promise): Promise { + const overlay = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${pathToFileURL(repository).href}.insteadOf`, + GIT_CONFIG_VALUE_0: "https://github.com/fixture/repository.git", + }; + const previous = Object.fromEntries(Object.keys(overlay).map((key) => [key, process.env[key]])); + Object.assign(process.env, overlay); + try { + await run(); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + describe("managed Git plugin sources", () => { it("does not expose Git URL credentials when cloning fails", async () => { const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-")); @@ -41,24 +62,56 @@ describe("managed Git plugin sources", () => { const sources = new ManagedPluginSources(home); const failure = sources.prepareInstall({ - source: "https://oauth2:super-secret@127.0.0.1:1/missing.git", + source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git", }); await expect(failure).rejects.not.toThrow(/oauth2|super-secret/); }); - it("tracks branches, prepares updates, persists commits, and keeps tags pinned", async () => { + it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => { + const repository = await createRepository(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-github-home-")); + roots.push(home); + await withGitHubFixture(repository, async () => { + const sources = new ManagedPluginSources(home); + for (const source of ["github:fixture/repository", "git:fixture/repository"]) { + const candidate = await sources.prepareInstall({ source }); + expect(candidate.record).toMatchObject({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + await sources.discard(candidate); + } + }); + }, 30_000); + + it("offers current default HEAD after installing a tag", async () => { const repository = await createRepository(); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-home-")); roots.push(home); const remote = pathToFileURL(repository).href; const sources = new ManagedPluginSources(home); - let candidate = await sources.prepareInstall({ source: remote }); + let candidate = await sources.prepareInstall({ source: `git:${remote}` }); expect(candidate.defaultId).toBe("managed-example"); candidate = await sources.place("managed-example", candidate); sources.commit("managed-example", candidate.record); + const metadataPath = path.join(home, "plugins", "sources.json"); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")); + delete metadata["managed-example"].kind; + Object.assign(metadata["managed-example"], { + commit: "obsolete", + requestedRef: "v0", + trackingBranch: "missing", + checkoutRoot: "/obsolete", + }); + await writeFile(metadataPath, JSON.stringify(metadata)); + expect( + await new ManagedPluginSources(home).describe("managed-example", candidate.directory), + ).toMatchObject({ identity: { kind: "git", remote } }); - const initial = candidate.record.commit; + if (candidate.record.kind !== "git") throw new Error("Expected Git source"); + const initial = (await sources.describe("managed-example", candidate.directory)) + .currentRevision!; await writeFile( path.join(repository, "index.server.ts"), "export default () => () => { new Date(); };\n", @@ -68,29 +121,443 @@ describe("managed Git plugin sources", () => { expect(status).toMatchObject({ currentCommit: initial, latestCommit: latest, - commitsBehind: 1, updateAvailable: true, }); - const prepared = await sources.prepareUpdate("managed-example", candidate.directory); - expect(prepared.commits).toBe(1); - if (!prepared.candidate) throw new Error("Expected an update candidate"); - const updated = await sources.place("managed-example", prepared.candidate); + const preview = await new ManagedPluginSources(home).preview( + "managed-example", + candidate.directory, + ); + expect(preview.outcome).toBe("update"); + const prepared = await sources.prepareUpdate(preview.proposal!, candidate.directory); + const updated = await sources.place("managed-example", prepared); sources.commit("managed-example", updated.record); expect(await readFile(path.join(updated.directory, "index.server.ts"), "utf8")).toContain( "new Date", ); - expect(new ManagedPluginSources(home).get("managed-example")?.commit).toBe(latest); + expect( + await new ManagedPluginSources(home).describe("managed-example", updated.directory), + ).toMatchObject({ currentRevision: latest }); + expect(JSON.parse(await readFile(metadataPath, "utf8"))["managed-example"]).toEqual({ + kind: "git", + remote, + }); await runGitCommand(["tag", "v1", initial], { cwd: repository }); - let pinned = await sources.prepareInstall({ source: remote, ref: "v1" }); - pinned = await sources.place("pinned-example", pinned); - sources.commit("pinned-example", pinned.record); - expect(await sources.status("pinned-example", pinned.directory)).toMatchObject({ + let tagged = await sources.prepareInstall({ source: remote, ref: "v1" }); + tagged = await sources.place("tagged-example", tagged); + sources.commit("tagged-example", tagged.record); + expect(await sources.status("tagged-example", tagged.directory)).toMatchObject({ currentCommit: initial, - latestCommit: initial, - commitsBehind: 0, - updateAvailable: false, + latestCommit: latest, + updateAvailable: true, }); }, 30_000); + it("resolves branch, tag and commit installs against changed default HEAD and preserves the reviewed commit", async () => { + const repository = await createRepository(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-review-")); + roots.push(home); + const remote = pathToFileURL(repository).href; + const initial = (await runGitCommand(["rev-parse", "HEAD"], { cwd: repository })).stdout.trim(); + await runGitCommand(["tag", "v1"], { cwd: repository }); + const sources = new ManagedPluginSources(home); + const installations = []; + for (const [index, ref] of [undefined, "main", "v1", initial].entries()) { + const id = `review-${index}`; + const candidate = await sources.place( + id, + await sources.prepareInstall({ source: remote, ref }), + ); + sources.commit(id, candidate.record); + installations.push({ id, candidate }); + } + await runGitCommand(["checkout", "-b", "new-default"], { cwd: repository }); + await writeFile(path.join(repository, "revision.txt"), "reviewed"); + const reviewed = await commitAll(repository, "new default"); + const previews = await Promise.all( + installations.map(({ id, candidate }) => sources.preview(id, candidate.directory)), + ); + for (const preview of previews) + expect(preview).toMatchObject({ + outcome: "update", + current: { currentRevision: initial }, + target: { commit: reviewed }, + }); + await writeFile(path.join(repository, "revision.txt"), "moved after review"); + const moved = await commitAll(repository, "move again"); + const restarted = new ManagedPluginSources(home); + const { id, candidate } = installations[0]!; + const updated = await restarted.place( + id, + await restarted.prepareUpdate(previews[0]!.proposal!, candidate.directory), + ); + expect(await restarted.describe(id, updated.directory)).toEqual({ + identity: previews[0]!.current!.identity, + currentRevision: reviewed, + }); + expect(await readFile(path.join(updated.directory, "revision.txt"), "utf8")).toBe("reviewed"); + expect(await restarted.preview(id, updated.directory)).toMatchObject({ + target: { commit: moved }, + }); + await expect( + restarted.assertCurrent(previews[0]!.proposal!, updated.directory), + ).rejects.toThrow("changed since review"); + const sameRevision = await restarted.place( + id, + await restarted.prepareInstall({ source: remote, ref: initial }), + ); + await expect( + restarted.assertCurrent(previews[0]!.proposal!, sameRevision.directory), + ).rejects.toThrow("changed since review"); + expect( + await restarted.preview(id, updated.directory, { kind: "git", ref: initial.slice(0, 8) }), + ).toMatchObject({ target: { commit: initial } }); + }, 30_000); + it("uses retained acquisition remote when credential rewriting changes origin, preserving nested identity", async () => { + const repository = await createRepository(); + const nested = path.join(repository, "plugins", "review"); + await mkdir(nested, { recursive: true }); + for (const name of ["paseo-plugin.json", "index.server.ts"]) + await rename(path.join(repository, name), path.join(nested, name)); + await commitAll(repository, "nested plugin"); + await runGitCommand(["update-server-info"], { cwd: repository }); + const server = createServer((request, response) => { + const pathname = new URL(request.url!, "http://localhost").pathname; + const file = path.resolve(repository, ".git", `.${pathname}`); + if (!file.startsWith(path.join(repository, ".git") + path.sep)) { + response.writeHead(404).end(); + return; + } + try { + response.end(readFileSync(file)); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected HTTP port"); + const remote = `http://fixture:fake-secret@127.0.0.1:${address.port}/`; + const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-credential-")); + roots.push(home); + try { + const sources = new ManagedPluginSources(home); + const candidate = await sources.place( + "nested", + await sources.prepareInstall({ source: remote, pluginPath: "plugins/review" }), + ); + sources.commit("nested", candidate.record); + expect( + ( + await runGitCommand(["remote", "get-url", "origin"], { cwd: candidate.directory }) + ).stdout.trim(), + ).toBe("https://paseo.invalid/plugin.git"); + const before = await sources.describe("nested", candidate.directory); + expect(before.identity).toEqual({ + kind: "git", + remote: `http://127.0.0.1:${address.port}/`, + pluginPath: "plugins/review", + }); + await writeFile(path.join(nested, "revision.txt"), "new"); + const latest = await commitAll(repository, "advance"); + await runGitCommand(["update-server-info"], { cwd: repository }); + const restarted = new ManagedPluginSources(home); + const preview = await restarted.preview("nested", candidate.directory); + expect(preview.target).toEqual({ kind: "git", commit: latest }); + const updated = await restarted.place( + "nested", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + expect(await restarted.describe("nested", updated.directory)).toEqual({ + identity: before.identity, + currentRevision: latest, + }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30_000); }); + +describe("registry plugin sources", () => { + it("explains how to choose a revision when --ref is used with a registry id", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-")); + roots.push(home); + const sources = new ManagedPluginSources(home); + await expect( + sources.prepareInstall({ source: "fixture/example", ref: "main" }), + ).rejects.toThrow( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + }); + + it("names the registry URL and recovery steps when the registry fetch fails", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-")); + roots.push(home); + const server = createServer((request) => request.socket.destroy()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const url = `http://127.0.0.1:${address.port}`; + try { + const sources = new ManagedPluginSources(home, { defaultUrl: url }); + await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow( + `Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("explains explicit local and GitHub sources when a registry ID is missing", async () => { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-")); + roots.push(home); + const requests: Array = []; + const server = createServer((request, response) => { + requests.push(request.url); + response.writeHead(404).end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow( + "If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.", + ); + expect(requests).toEqual(["/plugins/fixture/missing.json"]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => { + const repository = await createRepository(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-")); + roots.push(home); + const requests: Array = []; + const server = createServer((req, res) => { + requests.push(req.url); + res.statusCode = 404; + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + try { + await withGitHubFixture(repository, async () => { + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + for (const source of [ + "git:fixture/repository", + "https://github.com/fixture/repository.git", + ]) { + const candidate = await sources.place( + "managed-example", + await sources.prepareInstall({ source }), + ); + expect(candidate.record).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + }); + sources.commit("managed-example", candidate.record); + const restarted = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}/changed`, + }); + expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe( + "current", + ); + expect( + (await restarted.describe("managed-example", candidate.directory)).identity, + ).toEqual({ + kind: "git", + remote: "https://github.com/fixture/repository.git", + pluginPath: ".", + }); + await sources.discard(candidate); + } + }); + expect(requests).toEqual([]); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30_000); + + it("installs a bare id from a private registry and polls its pin without install intent", async () => { + const repository = await createRepository(); + await mkdir(path.join(repository, "packages/example"), { recursive: true }); + await rename( + path.join(repository, "paseo-plugin.json"), + path.join(repository, "packages/example/paseo-plugin.json"), + ); + await rename( + path.join(repository, "index.server.ts"), + path.join(repository, "packages/example/index.server.ts"), + ); + await commitAll(repository, "move plugin into monorepo"); + const revision = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository }); + const commit = revision.stdout.trim(); + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-home-")); + roots.push(home); + const requests: Array<{ + path: string | undefined; + intent: string | string[] | undefined; + authorization: string | undefined; + }> = []; + let missing = false; + const server = createServer((req, res) => { + requests.push({ + path: req.url, + intent: req.headers["x-paseo-install"], + authorization: req.headers.authorization, + }); + if (missing) { + res.writeHead(404).end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + id: "acme/example", + name: "Example", + description: "Test", + categories: [], + author: { github: "acme" }, + repository: { url: "https://github.com/acme/example" }, + artifact: { + kind: "git", + remote: pathToFileURL(repository).href, + commit, + pluginPath: "packages/example", + }, + media: [], + submittedAt: "2026-10-03", + reviewedAt: "2026-10-03", + updatedAt: "2026-10-03", + publishedAt: "2026-10-03", + readme: "# Test", + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const host = `127.0.0.1:${address.port}`; + const url = `http://${host}/internal`; + try { + const sources = new ManagedPluginSources(home, { + defaultUrl: url, + registries: { [host]: { authorization: "Bearer test" } }, + }); + let candidate = await sources.prepareInstall({ source: "acme/example" }); + candidate = await sources.place("managed-example", candidate); + expect( + JSON.parse(await readFile(path.join(candidate.directory, "paseo-plugin.json"), "utf8")).id, + ).toBe("managed-example"); + await sources.verifyCandidate("managed-example", candidate); + sources.commit("managed-example", candidate.record); + const restarted = new ManagedPluginSources(home, { + defaultUrl: "http://127.0.0.1:1/changed", + registries: { [host]: { authorization: "Bearer test" } }, + }); + const preview = await restarted.preview("managed-example", candidate.directory); + expect(preview.outcome).toBe("current"); + expect(preview.current?.identity).toEqual({ + kind: "git", + remote: pathToFileURL(repository).href, + pluginPath: "packages/example", + registry: { url, id: "acme/example" }, + }); + missing = true; + await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow( + "Check that the installed plugin is still published in this registry before updating", + ); + expect(requests).toEqual([ + { path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + { + path: "/internal/plugins/acme/example.json", + intent: undefined, + authorization: "Bearer test", + }, + ]); + } finally { + await new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }), + ); + } + }); +}); + +it("installs and updates only the npm artifacts pinned by the plugin registry", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { resolveNpm } = await import("./managed-source/npm.js"); + const npm = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = npm.userconfig; + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-")); + roots.push(home); + const intents: Array = []; + let pin = await resolveNpm("paseo-fixture-plugin", "1.0.0", home); + const server = createServer((request, response) => { + intents.push(request.headers["x-paseo-install"]); + response.end( + // Install reads only the ID and artifact, so the document omits the directory's display fields. + JSON.stringify({ + id: "acme/example", + artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, + }), + ); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const options = { defaultUrl: `http://127.0.0.1:${address.port}` }; + try { + const sources = new ManagedPluginSources(home, options); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect((await sources.preview("example", candidate.directory)).outcome).toBe("current"); + pin = await resolveNpm("paseo-fixture-plugin", "1.1.0", home); + const restarted = new ManagedPluginSources(home, options); + const preview = await restarted.preview("example", candidate.directory); + expect(preview.target).toEqual({ kind: "npm", ...pin }); + expect(preview.outcome).toBe("update"); + const updated = await restarted.place( + "example", + await restarted.prepareUpdate(preview.proposal!, candidate.directory), + ); + await restarted.verifyCandidate("example", updated); + expect(updated.record.registry).toEqual({ url: options.defaultUrl, id: "acme/example" }); + expect(intents).toEqual(["1", undefined, undefined, undefined]); + pin = { ...pin, integrity: "sha512-YWJj" }; + await expect(sources.prepareInstall({ source: "acme/example" })).rejects.toThrow( + "changed since review", + ); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await npm.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 844289eb22..5373b7453a 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -1,100 +1,198 @@ +import { + PluginRegistryIdentitySchema, + parsePluginRegistryReference, +} from "@getpaseo/protocol/plugin-registry"; +import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js"; import { randomUUID } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; -import { mkdir, rename, rm } from "node:fs/promises"; +import { mkdir, realpath, rename, rm } from "node:fs/promises"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import compareVersions from "semver/functions/compare.js"; import { z } from "zod"; -import { PluginIdSchema, type PluginSourceStatusItem } from "@getpaseo/protocol/messages"; +import { + PluginIdSchema, + type PluginInstallation, + type PluginSourceStatusItem, + type PluginUpdatePreview, + type PluginUpdateProposal, + type PluginUpdateSelection, + type PluginUpdateTarget, +} from "@getpaseo/protocol/messages"; import { runGitCommand } from "../../utils/run-git-command.js"; import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js"; -import { readPluginManifest } from "./manifest.js"; -import type { PluginManifest } from "./manifest.js"; +import { readPluginManifest, type PluginManifest } from "./manifest.js"; +import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-source/npm.js"; const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; - -const ManagedPluginRecordSchema = z - .object({ +// COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata. +const ManagedPluginRecordSchema = z.union([ + z.object({ + kind: z.literal("git").default("git"), remote: z.string().min(1), - requestedRef: z.string().min(1).nullable(), - trackingBranch: z.string().min(1).nullable(), - commit: z.string().regex(/^[0-9a-f]{40,64}$/), - pluginPath: z.string(), - checkoutRoot: z.string().min(1), - }) - .strict(); - -const ManagedPluginRecordsSchema = z.record(PluginIdSchema, ManagedPluginRecordSchema); - -export interface ManagedPluginRecord extends z.infer {} - + registry: PluginRegistryIdentitySchema.optional(), + }), + z.object({ kind: z.literal("npm"), registry: PluginRegistryIdentitySchema.optional() }), +]); +export type ManagedPluginRecord = z.infer; export interface ManagedPluginCandidate { build: PluginManifest["build"]; defaultId: string; directory: string; record: ManagedPluginRecord; versionRoot: string; + target?: PluginUpdateTarget; } - interface InstallInput { source: string; ref?: string; pluginPath?: string; } - -interface RefResolution { - commit: string; - requestedRef: string | null; - trackingBranch: string | null; +interface OwnedLocation { + versionRoot: string; + sourceRoot: string; + identity: PluginInstallation["identity"]; } export class ManagedPluginSources { private readonly root: string; private readonly metadataPath: string; private readonly records: Record; - - constructor(paseoHome: string) { - this.root = path.join(paseoHome, "plugins"); + constructor( + paseoHome: string, + private readonly registryOptions: RegistryOptions = {}, + ) { + this.root = path.resolve(paseoHome, "plugins"); this.metadataPath = path.join(this.root, "sources.json"); - this.records = this.readRecords(); + this.records = existsSync(this.metadataPath) + ? z + .record(PluginIdSchema, ManagedPluginRecordSchema) + .parse(JSON.parse(readFileSync(this.metadataPath, "utf8"))) + : {}; } - get(pluginId: string): ManagedPluginRecord | null { - return this.records[pluginId] ?? null; - } - - displayRemote(pluginId: string): string | null { - const remote = this.records[pluginId]?.remote; - return remote ? redactRemoteCredentials(remote) : null; - } - - async prepareInstall(input: InstallInput): Promise { - const remote = normalizeGitSource(input.source); - const pluginPath = normalizePluginPath(input.pluginPath); - const stagingRoot = await this.createStagingRoot(); - try { - const checkoutRoot = path.join(stagingRoot, "checkout"); - await clone(remote, checkoutRoot); - const resolution = await resolveRequestedRef(checkoutRoot, input.ref); - await checkout(checkoutRoot, resolution.commit); - const directory = path.resolve(checkoutRoot, pluginPath); - assertPluginPath(checkoutRoot, directory); - const { id: defaultId, build } = await readPluginManifest(directory); + private locate( + pluginId: string, + configuredPath: string, + record = this.records[pluginId], + ): OwnedLocation { + if (!record) throw new Error(`Plugin is a local directory: ${pluginId}`); + const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId)); + const parts = path.relative(pluginRoot, path.resolve(configuredPath)).split(path.sep); + // COMPAT(plugin-git-layout): added in v0.8.0; retain while pre-correction installations exist. + // Git originally used <12-hex-commit>-; the prefix is a directory name, never revision truth. + const versionId = + record.kind === "git" ? (parts[0] ?? "").replace(/^[0-9a-f]{12}-/, "") : (parts[0] ?? ""); + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(versionId) || + parts.some((part) => part === "..") + ) + throw new Error(`Plugin directory is outside its managed installation: ${pluginId}`); + const versionRoot = path.join(pluginRoot, parts[0]!); + if (record.kind === "git") { + if (parts[1] !== "checkout") + throw new Error(`Invalid Git installation directory: ${pluginId}`); return { - build, - defaultId, - directory, - record: { - remote, - requestedRef: resolution.requestedRef, - trackingBranch: resolution.trackingBranch, - commit: resolution.commit, - pluginPath, - checkoutRoot, + versionRoot, + sourceRoot: path.join(versionRoot, "checkout"), + identity: { + kind: "git", + ...(record.registry ? { registry: record.registry } : {}), + remote: redactRemoteCredentials(record.remote), + pluginPath: parts.slice(2).join("/") || ".", }, - versionRoot: stagingRoot, }; + } + if (parts[1] !== "node_modules" || !parts[2]) + throw new Error(`Invalid npm installation directory: ${pluginId}`); + const packageParts = parts[2].startsWith("@") ? 2 : 1; + const packageName = parts.slice(2, 2 + packageParts).join("/"); + if (!isNpmSource(packageName)) throw new Error(`Invalid npm package directory: ${pluginId}`); + return { + versionRoot, + sourceRoot: path.join(versionRoot, "node_modules", packageName), + identity: { + kind: "npm", + ...(record.registry ? { registry: record.registry } : {}), + packageName, + pluginPath: parts.slice(2 + packageParts).join("/") || ".", + }, + }; + } + + async describe(pluginId: string, configuredPath: string): Promise { + if (!this.records[pluginId]) + return { identity: { kind: "directory", path: path.resolve(configuredPath) } }; + const location = this.locate(pluginId, configuredPath); + try { + return { + identity: location.identity, + currentRevision: await this.revision(location, configuredPath), + }; + } catch { + return { identity: location.identity }; + } + } + + private async revision(location: OwnedLocation, configuredPath: string): Promise { + await assertRealContainment(this.root, location.versionRoot); + await assertRealContainment(location.versionRoot, configuredPath); + return location.identity.kind === "npm" + ? (await readNpmArtifact(location.versionRoot, location.identity.packageName)).version + : revParse(location.sourceRoot, "HEAD"); + } + + async prepareInstall( + input: InstallInput, + target?: PluginUpdateTarget, + ): Promise { + const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl); + if (registry) { + if (input.ref) + throw new Error( + "Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.", + ); + if (input.pluginPath) + throw new Error("Registry sources use their reviewed artifact path and revision"); + const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true); + const candidate = await this.prepareInstall(resolved.input, resolved.target); + return { ...candidate, record: { ...candidate.record, registry } }; + } + const pluginPath = normalizePluginPath(input.pluginPath); + const versionRoot = await this.createStagingRoot(); + try { + let record: ManagedPluginRecord; + let sourceRoot: string; + if (isNpmSource(input.source)) { + if (input.ref) + throw new Error( + "Plugin --ref is only valid for Git sources; use npm:package@version for npm", + ); + const artifact = await acquireNpm( + input.source, + versionRoot, + target?.kind === "npm" ? target : undefined, + ); + sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName); + record = { kind: "npm" }; + } else { + const remote = normalizeGitSource(input.source); + sourceRoot = path.join(versionRoot, "checkout"); + await clone(remote, sourceRoot); + const commit = await resolveRequestedRef(sourceRoot, input.ref); + if (target?.kind === "git" && commit !== target.commit) + throw new Error("Git target changed since review"); + await checkout(sourceRoot, commit); + record = { kind: "git", remote }; + } + const directory = path.resolve(sourceRoot, pluginPath); + assertPluginPath(sourceRoot, directory); + await assertRealContainment(sourceRoot, directory); + const { id: defaultId, build } = await readPluginManifest(directory); + return { build, defaultId, directory, record, versionRoot, ...(target ? { target } : {}) }; } catch (error) { - await rm(stagingRoot, { recursive: true, force: true }); + await rm(versionRoot, { recursive: true, force: true }); throw error; } } @@ -103,146 +201,257 @@ export class ManagedPluginSources { pluginId: string, candidate: ManagedPluginCandidate, ): Promise { - const pluginRoot = path.join(this.root, pluginId); + const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId)); ensurePrivateDirectory(pluginRoot); - const versionRoot = path.join( - pluginRoot, - `${candidate.record.commit.slice(0, 12)}-${randomUUID()}`, - ); + await assertRealContainment(this.root, pluginRoot); + const versionRoot = path.join(pluginRoot, randomUUID()); + const relativeDirectory = path.relative(candidate.versionRoot, candidate.directory); await rename(candidate.versionRoot, versionRoot); - const checkoutRoot = path.join(versionRoot, "checkout"); - const directory = path.resolve(checkoutRoot, candidate.record.pluginPath); + return { ...candidate, directory: path.join(versionRoot, relativeDirectory), versionRoot }; + } + + async preview( + pluginId: string, + configuredPath: string, + selection?: PluginUpdateSelection, + ): Promise { + const current = await this.describe(pluginId, configuredPath); + if (current.identity.kind === "directory") { + if (selection) throw new Error(`Plugin is a local directory: ${pluginId}`); + return { id: pluginId, outcome: "local", current, links: [] }; + } + if (!current.currentRevision) + throw new Error( + `Installed revision is unavailable for ${pluginId}; restore its acquisition artifacts before updating`, + ); + if (selection && selection.kind !== current.identity.kind) + throw new Error(`Update target does not match ${current.identity.kind} source: ${pluginId}`); + const location = this.locate(pluginId, configuredPath); + let target: PluginUpdateTarget; + let outcome: PluginUpdatePreview["outcome"]; + if (current.identity.registry) { + if (selection) throw new Error("Registry updates use only the reviewed registry pin"); + const resolved = await resolveRegistryPlugin( + current.identity.registry, + this.registryOptions, + false, + ); + assertRegistryArtifactIdentity(current.identity, resolved.artifact); + target = resolved.target; + const revision = target.kind === "npm" ? target.version : target.commit; + outcome = revision === current.currentRevision ? "current" : "update"; + } else if (current.identity.kind === "npm") { + target = { + kind: "npm", + ...(await resolveNpm( + current.identity.packageName, + selection?.kind === "npm" ? selection.version : "latest", + this.root, + )), + }; + outcome = npmUpdateOutcome(target.version, current.currentRevision, selection); + } else { + const record = this.records[pluginId]; + if (record?.kind !== "git") throw new Error("Git source is no longer configured"); + target = { + kind: "git", + commit: await resolveRemoteCommit( + record.remote, + this.root, + selection?.kind === "git" ? selection.ref : undefined, + ), + }; + outcome = target.commit === current.currentRevision ? "current" : "update"; + } + const links = reviewLinks(current, target); return { - ...candidate, - directory, - versionRoot, - record: { ...candidate.record, checkoutRoot }, + id: pluginId, + outcome, + current, + target, + links, + ...(outcome === "update" + ? { + proposal: { + id: pluginId, + expected: { + identity: current.identity, + installationRoot: location.versionRoot, + revision: current.currentRevision, + }, + target, + }, + } + : {}), }; } - async status(pluginId: string, configuredPath: string): Promise { - const record = this.records[pluginId]; - if (!record) return { id: pluginId, source: "directory", path: configuredPath }; - if (!record.trackingBranch) { - return { - id: pluginId, - source: "git", - path: configuredPath, - remote: redactRemoteCredentials(record.remote), - ref: record.requestedRef ?? record.commit, - currentCommit: record.commit, - latestCommit: record.commit, - commitsBehind: 0, - updateAvailable: false, - }; - } - await fetchOrigin(record.checkoutRoot); - const latestCommit = await revParse( - record.checkoutRoot, - `refs/remotes/origin/${record.trackingBranch}^{commit}`, - ); - const commitsBehind = await countCommits(record.checkoutRoot, record.commit, latestCommit); - return { - id: pluginId, - source: "git", - path: configuredPath, - remote: redactRemoteCredentials(record.remote), - ref: record.requestedRef ?? record.trackingBranch, - currentCommit: record.commit, - latestCommit, - commitsBehind, - updateAvailable: latestCommit !== record.commit, - }; + async assertCurrent(proposal: PluginUpdateProposal, configuredPath: string): Promise { + const current = await this.describe(proposal.id, configuredPath); + const location = this.locate(proposal.id, configuredPath); + if ( + !isDeepStrictEqual(current.identity, proposal.expected.identity) || + current.currentRevision !== proposal.expected.revision || + location.versionRoot !== proposal.expected.installationRoot + ) + throw new Error(`Plugin ${proposal.id} changed since review; check again`); + if (proposal.target.kind !== current.identity.kind) + throw new Error("Update target does not match the installed source"); } async prepareUpdate( - pluginId: string, + proposal: PluginUpdateProposal, configuredPath: string, - ): Promise<{ candidate: ManagedPluginCandidate | null; commits: number }> { - const record = this.records[pluginId]; - if (!record) throw new Error(`Plugin is not managed by Git: ${pluginId}`); - const status = await this.status(pluginId, configuredPath); - const latestCommit = status.latestCommit; - if (!latestCommit || latestCommit === record.commit) return { candidate: null, commits: 0 }; - const candidate = await this.prepareCommit(record, latestCommit); - return { candidate, commits: status.commitsBehind ?? 0 }; + ): Promise { + await this.assertCurrent(proposal, configuredPath); + const identity = this.locate(proposal.id, configuredPath).identity; + const record = this.records[proposal.id]; + if (identity.kind !== "directory" && identity.registry) { + const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false); + assertRegistryArtifactIdentity(identity, resolved.artifact); + if (!isDeepStrictEqual(resolved.target, proposal.target)) + throw new Error("Registry pin changed since review; check again"); + const candidate = await this.prepareInstall(resolved.input, proposal.target); + return { ...candidate, record: { ...candidate.record, registry: identity.registry } }; + } + if (identity.kind === "git" && record?.kind === "git" && proposal.target.kind === "git") + return this.prepareInstall( + { + source: `git:${record.remote}`, + ref: proposal.target.commit, + pluginPath: identity.pluginPath, + }, + proposal.target, + ); + if (identity.kind === "npm" && proposal.target.kind === "npm") + return this.prepareInstall( + { + source: `npm:${identity.packageName}@${proposal.target.version}`, + pluginPath: identity.pluginPath, + }, + proposal.target, + ); + throw new Error("Update target does not match the installed source"); + } + + async verifyCandidate( + pluginId: string, + candidate: ManagedPluginCandidate, + target?: PluginUpdateTarget, + ): Promise { + target ??= candidate.target; + const location = this.locate(pluginId, candidate.directory, candidate.record); + const revision = await this.revision(location, candidate.directory); + if (target?.kind === "git" && revision !== target.commit) + throw new Error("Preparation changed the reviewed Git commit"); + if (target?.kind === "npm" && location.identity.kind === "npm") { + const artifact = await readNpmArtifact(location.versionRoot, location.identity.packageName); + if ( + artifact.version !== target.version || + artifact.integrity !== target.integrity || + artifact.resolved !== target.resolved + ) + throw new Error("Preparation changed the reviewed npm artifact"); + } + } + + async status(pluginId: string, configuredPath: string): Promise { + const preview = await this.preview(pluginId, configuredPath); + const installation = preview.current!; + return { + id: pluginId, + source: installation.identity.kind === "git" ? "git" : "directory", + path: configuredPath, + installation, + ...(installation.identity.kind === "git" + ? { + remote: installation.identity.remote, + currentCommit: installation.currentRevision, + latestCommit: preview.target?.kind === "git" ? preview.target.commit : undefined, + updateAvailable: preview.outcome === "update", + } + : {}), + }; } commit(pluginId: string, record: ManagedPluginRecord): void { - this.records[pluginId] = record; + this.records[pluginId] = ManagedPluginRecordSchema.parse(record); this.writeRecords(); } - async discard(candidate: ManagedPluginCandidate): Promise { await rm(candidate.versionRoot, { recursive: true, force: true }); } - - async removeVersion(record: ManagedPluginRecord): Promise { - const versionRoot = path.dirname(record.checkoutRoot); - const pluginRoot = path.dirname(versionRoot); - const expectedRoot = path.resolve(this.root); - if (path.dirname(pluginRoot) !== expectedRoot) { - throw new Error( - `Managed plugin checkout is outside the plugin store: ${record.checkoutRoot}`, - ); - } - await rm(versionRoot, { recursive: true, force: true }); + async removeVersion(pluginId: string, configuredPath: string): Promise { + const location = this.locate(pluginId, configuredPath); + await assertRealContainment(this.root, path.dirname(location.versionRoot)); + await rm(location.versionRoot, { recursive: true, force: true }); } - async remove(pluginId: string): Promise { if (!this.records[pluginId]) return; + const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId)); + if (existsSync(pluginRoot)) { + await assertRealContainment(this.root, pluginRoot); + await rm(pluginRoot, { recursive: true, force: true }); + } delete this.records[pluginId]; this.writeRecords(); - await rm(path.join(this.root, pluginId), { recursive: true, force: true }); } - - private async prepareCommit( - record: ManagedPluginRecord, - commit: string, - ): Promise { - const stagingRoot = await this.createStagingRoot(); - try { - const checkoutRoot = path.join(stagingRoot, "checkout"); - await clone(record.remote, checkoutRoot); - const resolvedCommit = await revParse(checkoutRoot, `${commit}^{commit}`); - if (resolvedCommit !== commit) - throw new Error(`Git source no longer contains commit ${commit}`); - await checkout(checkoutRoot, commit); - const directory = path.resolve(checkoutRoot, record.pluginPath); - assertPluginPath(checkoutRoot, directory); - const { id: defaultId, build } = await readPluginManifest(directory); - return { - build, - defaultId, - directory, - versionRoot: stagingRoot, - record: { ...record, commit, checkoutRoot }, - }; - } catch (error) { - await rm(stagingRoot, { recursive: true, force: true }); - throw error; - } - } - private async createStagingRoot(): Promise { const stagingRoot = path.join(this.root, ".staging", randomUUID()); await mkdir(stagingRoot, { recursive: true, mode: 0o700 }); + await assertRealContainment(this.root, stagingRoot); return stagingRoot; } - - private readRecords(): Record { - if (!existsSync(this.metadataPath)) return {}; - return ManagedPluginRecordsSchema.parse(JSON.parse(readFileSync(this.metadataPath, "utf8"))); - } - private writeRecords(): void { writePrivateFileAtomicSync(this.metadataPath, `${JSON.stringify(this.records, null, 2)}\n`); } } +async function assertRealContainment(root: string, directory: string): Promise { + assertPluginPath(await realpath(root), await realpath(directory)); +} +function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): string[] { + if ( + current.identity.kind === "npm" && + target.kind === "npm" && + new URL(target.resolved).hostname === "registry.npmjs.org" + ) + return [current.currentRevision, target.version].map( + (version) => + `https://www.npmjs.com/package/${current.identity.kind === "npm" ? current.identity.packageName : ""}/v/${encodeURIComponent(version!)}`, + ); + if (current.identity.kind !== "git" || target.kind !== "git") return []; + const remote = current.identity.remote + .replace(/^git@([^:]+):/, "https://$1/") + .replace(/^ssh:\/\/(?:git@)?/, "https://") + .replace(/\.git$/, ""); + try { + const url = new URL(remote); + if (url.hostname === "github.com") + return [`${url.origin}${url.pathname}/compare/${current.currentRevision}...${target.commit}`]; + if (url.hostname === "gitlab.com") + return [ + `${url.origin}${url.pathname}/-/compare/${current.currentRevision}...${target.commit}`, + ]; + } catch { + /* Local remotes have no review website. */ + } + return []; +} +/** Only explicit Git prefixes expand GitHub shorthand. */ function normalizeGitSource(source: string): string { + const expandsShorthand = source.startsWith("github:") || source.startsWith("git:"); + if (source.startsWith("github:")) { + const shorthand = source.slice(7); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) { + throw new Error("Use github:owner/repository for a GitHub plugin source"); + } + source = shorthand; + } else if (source.startsWith("git:") && !source.startsWith("git://")) { + source = source.slice(4); + } const github = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(source); - if (github) { + if (github && expandsShorthand) { const repository = github[2].replace(/\.git$/, ""); return `https://github.com/${github[1]}/${repository}.git`; } @@ -337,29 +546,70 @@ async function checkout(checkoutRoot: string, commit: string): Promise { async function resolveRequestedRef( checkoutRoot: string, requestedRef: string | undefined, -): Promise { - if (!requestedRef) { - const { stdout } = await runGitCommand( - ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - { cwd: checkoutRoot }, - ); - const trackingBranch = stdout.trim().replace(/^origin\//, ""); - const commit = await revParse(checkoutRoot, `refs/remotes/origin/${trackingBranch}^{commit}`); - return { commit, requestedRef: null, trackingBranch }; - } +): Promise { + if (!requestedRef) return revParse(checkoutRoot, "refs/remotes/origin/HEAD^{commit}"); if (requestedRef.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'"); - const branchRef = `refs/remotes/origin/${requestedRef}`; - if (await refExists(checkoutRoot, branchRef)) { - const commit = await revParse(checkoutRoot, `${branchRef}^{commit}`); - return { commit, requestedRef, trackingBranch: requestedRef }; + for (const ref of [ + `refs/remotes/origin/${requestedRef}`, + `refs/tags/${requestedRef}`, + requestedRef, + ]) { + if (await refExists(checkoutRoot, ref)) return revParse(checkoutRoot, `${ref}^{commit}`); } - const tagRef = `refs/tags/${requestedRef}`; - if (await refExists(checkoutRoot, tagRef)) { - const commit = await revParse(checkoutRoot, `${tagRef}^{commit}`); - return { commit, requestedRef, trackingBranch: null }; + return revParse(checkoutRoot, `${requestedRef}^{commit}`); +} +async function resolveRemoteCommit(remote: string, cwd: string, ref?: string): Promise { + if (ref?.startsWith("-")) throw new Error("Plugin Git ref cannot start with '-'"); + if (ref && /^[0-9a-f]{40,64}$/.test(ref)) return ref; + const publicRemote = redactRemoteCredentials(remote); + const cloneRemote = publicRemote === remote ? remote : "https://paseo.invalid/plugin.git"; + const envOverlay = + cloneRemote === remote + ? GIT_ENV + : { + ...GIT_ENV, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: `url.${remote}.insteadOf`, + GIT_CONFIG_VALUE_0: cloneRemote, + }; + try { + const names = ref + ? [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref] + : ["HEAD"]; + const { stdout } = await runGitCommand(["ls-remote", "--symref", "--", cloneRemote, ...names], { + cwd, + envOverlay, + timeout: GIT_TIMEOUT_MS, + }); + const entries = new Map( + stdout + .trim() + .split("\n") + .map((line) => { + const [commit, name] = line.split("\t"); + return [name, commit]; + }), + ); + for (const name of names) { + const commit = entries.get(name); + if (commit && /^[0-9a-f]{40,64}$/.test(commit)) return commit; + } + if (ref) { + // Abbreviated commits and revision expressions are not advertised by ls-remote. + // Resolve them in a temporary object database without preparing or touching the installed plugin. + const staging = path.join(cwd, ".staging", randomUUID()); + await mkdir(staging, { recursive: true, mode: 0o700 }); + try { + await clone(remote, path.join(staging, "checkout")); + return await resolveRequestedRef(path.join(staging, "checkout"), ref); + } finally { + await rm(staging, { recursive: true, force: true }); + } + } + throw new Error("Cannot resolve Git default HEAD"); + } catch (error) { + throw redactRemoteError(error, remote); } - const commit = await revParse(checkoutRoot, `${requestedRef}^{commit}`); - return { commit, requestedRef, trackingBranch: null }; } async function refExists(cwd: string, ref: string): Promise { @@ -375,16 +625,28 @@ async function revParse(cwd: string, ref: string): Promise { return stdout.trim(); } -async function fetchOrigin(cwd: string): Promise { - await runGitCommand(["fetch", "--prune", "--tags", "origin"], { - cwd, - envOverlay: GIT_ENV, - timeout: GIT_TIMEOUT_MS, - }); +function assertRegistryArtifactIdentity( + identity: Exclude, + artifact: Awaited>["artifact"], +): void { + const matches = + identity.kind === "npm" && artifact.kind === "npm" + ? identity.packageName === artifact.package && identity.pluginPath === "." + : identity.kind === "git" && + artifact.kind === "git" && + identity.remote === redactRemoteCredentials(artifact.remote) && + identity.pluginPath === (artifact.pluginPath ?? "."); + if (!matches) + throw new Error("Registry artifact source changed; reinstall to approve the new source"); } -async function countCommits(cwd: string, current: string, latest: string): Promise { - if (current === latest) return 0; - const { stdout } = await runGitCommand(["rev-list", "--count", `${current}..${latest}`], { cwd }); - return Number.parseInt(stdout.trim(), 10); +function npmUpdateOutcome( + version: string, + current: string, + selection: PluginUpdateSelection | undefined, +): PluginUpdatePreview["outcome"] { + const comparison = compareVersions(version, current); + if (comparison === 0) return "current"; + if (comparison < 0 && !selection) return "installed-newer"; + return "update"; } diff --git a/packages/server/src/server/plugins/managed-source/npm.ts b/packages/server/src/server/plugins/managed-source/npm.ts new file mode 100644 index 0000000000..315bbc981e --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/npm.ts @@ -0,0 +1,157 @@ +import compareVersions from "semver/functions/compare.js"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +import { execCommand } from "../../../utils/spawn.js"; +import { findExecutable } from "../../../executable-resolution/executable-resolution.js"; + +const LockfileSchema = z.object({ + lockfileVersion: z.number().min(2), + packages: z.record( + z.string(), + z.object({ + version: z.string().optional(), + resolved: z.string().optional(), + integrity: z.string().optional(), + }), + ), +}); + +const NPM_SOURCE_PATTERN = + /^(?(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)(?:@(?[^:@/\\]+))?$/; + +export function isNpmSource(source: string): boolean { + return source.startsWith("npm:") || NPM_SOURCE_PATTERN.test(source); +} + +function parseNpmSource(source: string): { packageName: string; requestedSpec: string } { + const spec = source.replace(/^npm:/, ""); + const match = NPM_SOURCE_PATTERN.exec(spec); + if (!match?.groups) { + throw new Error( + `Invalid npm plugin source: ${source}. Use npm:package or npm:@scope/package@version, tag, or range.`, + ); + } + const requestedSpec = match.groups.spec ?? "latest"; + if (!requestedSpec.trim() || requestedSpec !== requestedSpec.trim()) { + throw new Error("An npm version, tag, or range must be non-empty without surrounding spaces"); + } + return { packageName: match.groups.name, requestedSpec }; +} + +export async function acquireNpm(source: string, installRoot: string, target?: NpmArtifact) { + const { packageName, requestedSpec } = parseNpmSource(source); + await writeFile( + path.join(installRoot, "package.json"), + JSON.stringify({ + name: "paseo-plugin-installation", + version: "1.0.0", + private: true, + dependencies: { [packageName]: target?.resolved ?? requestedSpec }, + }), + ); + const npm = await requireNpm(); + await execCommand( + npm, + [ + "install", + "--ignore-scripts", + "--legacy-peer-deps", + "--no-audit", + "--no-fund", + "--package-lock=true", + "--lockfile-version=3", + "--include=prod", + "--omit=dev", + "--global=false", + "--workspaces=false", + ], + { cwd: installRoot, timeout: 120_000, maxBuffer: 1024 * 1024 }, + ); + const installed = await readNpmArtifact(installRoot, packageName); + if ( + target && + (installed.version !== target.version || + installed.integrity !== target.integrity || + installed.resolved !== target.resolved) + ) { + throw new Error(`npm artifact changed since review for ${packageName}`); + } + return { packageName, ...installed }; +} + +export interface NpmArtifact { + version: string; + resolved: string; + integrity: string; +} + +export async function readNpmArtifact( + installRoot: string, + packageName: string, +): Promise { + const lock = LockfileSchema.parse( + JSON.parse(await readFile(path.join(installRoot, "package-lock.json"), "utf8")), + ); + const artifact = lock.packages[`node_modules/${packageName}`]; + if (!artifact?.version || !artifact.resolved || !artifact.integrity) + throw new Error(`Missing npm artifact for ${packageName}`); + const installed = z + .object({ name: z.string(), version: z.string() }) + .parse( + JSON.parse( + await readFile(path.join(installRoot, "node_modules", packageName, "package.json"), "utf8"), + ), + ); + const root = z + .object({ dependencies: z.record(z.string(), z.string()) }) + .parse(JSON.parse(await readFile(path.join(installRoot, "package.json"), "utf8"))); + if ( + Object.keys(root.dependencies).length !== 1 || + !(packageName in root.dependencies) || + installed.name !== packageName || + installed.version !== artifact.version + ) { + throw new Error( + `Installed npm package does not match its acquisition artifacts: ${packageName}`, + ); + } + return { version: artifact.version, resolved: artifact.resolved, integrity: artifact.integrity }; +} + +export async function resolveNpm( + packageName: string, + selector: string, + cwd: string, +): Promise { + // Validate selectors using the same grammar as installation; never interpret an update as another source. + parseNpmSource(`npm:${packageName}@${selector}`); + const { stdout } = await execCommand( + await requireNpm(), + ["view", `${packageName}@${selector}`, "version", "dist", "--json"], + { cwd, timeout: 120_000, maxBuffer: 1024 * 1024 }, + ); + const schema = z.object({ + version: z.string(), + dist: z.object({ tarball: z.string().url(), integrity: z.string().min(1) }), + }); + const raw: unknown = JSON.parse(stdout); + const entries = z.array(schema).parse(Array.isArray(raw) ? raw : [raw]); + const latest = entries.sort((a, b) => compareVersions(b.version, a.version))[0]; + if (!latest) throw new Error(`No npm version matches ${packageName}@${selector}`); + return { + version: latest.version, + resolved: latest.dist.tarball, + integrity: latest.dist.integrity, + }; +} + +async function requireNpm(): Promise { + const npm = await findExecutable("npm"); + if (!npm) + throw new Error( + "npm is required on the daemon host to acquire or check npm plugins. Install Node.js with npm and make npm available on the daemon's PATH.", + ); + return npm; +} diff --git a/packages/server/src/server/plugins/managed-source/registry.ts b/packages/server/src/server/plugins/managed-source/registry.ts new file mode 100644 index 0000000000..009cac5a68 --- /dev/null +++ b/packages/server/src/server/plugins/managed-source/registry.ts @@ -0,0 +1,77 @@ +import { + PluginRegistryArtifactSchema, + PluginRegistryIdSchema, + type PluginRegistries, + type PluginRegistryIdentity, +} from "@getpaseo/protocol/plugin-registry"; +import type { PluginUpdateTarget } from "@getpaseo/protocol/messages"; +import { z } from "zod"; + +// Install reads only these fields; the rest of a published plugin is the directory's display data. +const RegistryInstallDocumentSchema = z.object({ + id: PluginRegistryIdSchema, + artifact: PluginRegistryArtifactSchema, +}); + +export interface RegistryOptions { + defaultUrl?: string; + registries?: PluginRegistries; +} +export async function resolveRegistryPlugin( + identity: PluginRegistryIdentity, + options: RegistryOptions, + install: boolean, +) { + const base = new URL(identity.url); + if (!["http:", "https:"].includes(base.protocol) || base.username || base.password) + throw new Error("Registry base must be an HTTP(S) URL without credentials"); + const authorization = options.registries?.[base.host]?.authorization; + if ( + authorization && + base.protocol !== "https:" && + !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname) + ) + throw new Error("Registry credentials require HTTPS"); + const headers: Record = {}; + if (authorization) headers.Authorization = authorization; + if (install) headers["X-Paseo-Install"] = "1"; + const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`; + let response: Response; + try { + response = await fetch(url, { + headers, + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + } catch (cause) { + const guidance = install + ? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.` + : `Could not check updates for ${identity.id}. Retry later.`; + throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause }); + } + if (response.status === 404) { + const guidance = install + ? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.` + : "Check that the installed plugin is still published in this registry before updating."; + throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`); + } + if (!response.ok) + throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`); + const plugin = RegistryInstallDocumentSchema.parse(await response.json()); + if (plugin.id !== identity.id) throw new Error("Registry returned a different plugin ID"); + const artifact = plugin.artifact; + const target: PluginUpdateTarget = + artifact.kind === "npm" + ? { + kind: "npm", + version: artifact.version, + resolved: artifact.resolved, + integrity: artifact.integrity, + } + : { kind: "git", commit: artifact.commit }; + const input = + artifact.kind === "npm" + ? { source: `npm:${artifact.package}@${artifact.version}` } + : { source: `git:${artifact.remote}`, ref: artifact.commit, pluginPath: artifact.pluginPath }; + return { input, target, artifact }; +} diff --git a/packages/server/src/server/plugins/manifest.test.ts b/packages/server/src/server/plugins/manifest.test.ts index 07328c95b9..399e350493 100644 --- a/packages/server/src/server/plugins/manifest.test.ts +++ b/packages/server/src/server/plugins/manifest.test.ts @@ -1,16 +1,133 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { readPluginManifest } from "./manifest.js"; const directories: string[] = []; +const examplesDirectory = fileURLToPath( + new URL("../../../../../plugin-examples/", import.meta.url), +); +const examples = (await readdir(examplesDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); afterEach(async () => { await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); describe("plugin manifest", () => { + it.each(examples)("validates the %s example manifest", async (name) => { + await expect(readPluginManifest(path.join(examplesDirectory, name))).resolves.toMatchObject({ + id: expect.any(String), + }); + }); + + it("reads display metadata and ignores future top-level fields", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = { + id: "review", + name: "Review tools", + icon: "assets/icon.png", + media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"], + }; + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ ...manifest, futureField: { enabled: true } }), + ); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + }); + + it.each([ + { name: " " }, + { name: 42 }, + { icon: "" }, + { icon: 42 }, + { icon: "icon.svg" }, + { icon: "/icon.png" }, + { icon: "../icon.png" }, + { icon: "assets/../../icon.png" }, + { icon: "https://example.com/icon.png" }, + { icon: "C:/icon.png" }, + { icon: "assets\\icon.png" }, + { icon: "icon.png?query" }, + { media: "screenshot.png" }, + { media: [42] }, + { media: [""] }, + { media: ["../demo.mp4"] }, + { media: ["/demo.mp4"] }, + { media: ["http://example.com/demo.mp4"] }, + { media: ["https://"] }, + { media: ["//example.com/demo.mp4"] }, + { media: ["file:///demo.mp4"] }, + { media: ["data:image/png;base64,abc"] }, + { media: ["."] }, + { media: ["assets/"] }, + { id: "INVALID" }, + { description: 42 }, + { requirements: { paseoo: ">=0.11.0" } }, + ])("rejects invalid known fields: %j", async (fields) => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "example", futureField: true, ...fields }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + + it("accepts omitted metadata and empty media", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) { + await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest)); + await expect(readPluginManifest(directory)).resolves.toEqual(manifest); + } + }); + + it("reads and validates requirements before any plugin code runs", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + const manifest = path.join(directory, "paseo-plugin.json"); + await writeFile(manifest, JSON.stringify({ id: "example", requirements: { paseo: "^0.8.0" } })); + await expect(readPluginManifest(directory)).resolves.toEqual({ + id: "example", + requirements: { paseo: "^0.8.0" }, + }); + for (const requirements of [ + { paseo: "latest" }, + { paseo: "" }, + { paseo: 8 }, + { node: ">=20" }, + "0.8.0", + ]) { + await writeFile(manifest, JSON.stringify({ id: "example", requirements })); + await expect(readPluginManifest(directory)).rejects.toThrow(); + } + }); + + it("reads an optional description", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); + directories.push(directory); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "described", description: "Reviews changes before merge" }), + ); + + await expect(readPluginManifest(directory)).resolves.toEqual({ + id: "described", + description: "Reviews changes before merge", + }); + + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "described", description: " " }), + ); + await expect(readPluginManifest(directory)).rejects.toThrow(); + }); + it("accepts only non-empty argv arrays for build commands", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); directories.push(directory); diff --git a/packages/server/src/server/plugins/manifest.ts b/packages/server/src/server/plugins/manifest.ts index b721f395d3..2312a2cb5a 100644 --- a/packages/server/src/server/plugins/manifest.ts +++ b/packages/server/src/server/plugins/manifest.ts @@ -1,18 +1,40 @@ import { readFile, stat } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; -import { PluginIdSchema } from "@getpaseo/protocol/messages"; +import { PluginIdSchema, PluginRequirementsSchema } from "@getpaseo/protocol/messages"; +import { validatePluginRequirements } from "@getpaseo/protocol/plugin-requirements"; const MANIFEST_FILENAME = "paseo-plugin.json"; const PluginBuildCommandSchema = z .array(z.string().refine((argument) => argument.trim().length > 0)) .min(1); -const PluginManifestSchema = z - .object({ - id: PluginIdSchema, - build: z.array(PluginBuildCommandSchema).min(1).optional(), - }) - .strict(); +// Manifest asset paths use forward slashes and stay inside the plugin package. +const PluginAssetPathSchema = z + .string() + .min(1) + .refine( + (value) => + value === value.trim() && + !value.startsWith("/") && + !/[\\:?#\p{Cc}]/u.test(value) && + !value.split("/").includes("..") && + !value.endsWith("/") && + value.split("/").at(-1) !== ".", + "Expected a relative file path inside the plugin package", + ); +const PluginManifestSchema = z.object({ + id: PluginIdSchema, + name: z.string().trim().min(1).optional(), + description: z.string().trim().min(1).optional(), + icon: PluginAssetPathSchema.refine( + (value) => /\.png$/i.test(value), + "Expected a relative path to a PNG", + ).optional(), + media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(), + // A misspelled requirement must not silently disable compatibility checks. + requirements: PluginRequirementsSchema.strict().optional(), + build: z.array(PluginBuildCommandSchema).min(1).optional(), +}); export type PluginManifest = z.infer; @@ -20,5 +42,7 @@ export async function readPluginManifest(directory: string): Promise null); if (!info?.isFile()) throw new Error(`Plugin manifest is missing: ${manifestPath}`); - return PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); + const manifest = PluginManifestSchema.parse(JSON.parse(await readFile(manifestPath, "utf8"))); + validatePluginRequirements(manifest.requirements); + return manifest; } diff --git a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts index ed28f04196..d0ad016cec 100644 --- a/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts +++ b/packages/server/src/server/plugins/plugin-paseo-api.e2e.test.ts @@ -1,7 +1,10 @@ +import { EventEmitter, once } from "node:events"; +import { resolveDaemonVersion } from "../daemon-version.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; +import { z } from "zod"; import { DaemonClient } from "../test-utils/daemon-client.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { createTestAgentClient, createTestAgentClients } from "../test-utils/fake-agent-client.js"; @@ -18,11 +21,15 @@ test("plugin handlers create workspaces and agents through their Paseo API", asy roots.push(pluginDirectory, workspaceDirectory); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "paseo-api" }), + JSON.stringify({ + id: "paseo-api", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), - `import { defineRpc, type PluginServerContext } from "@getpaseo/plugin"; + `import { defineRpc } from "@getpaseo/plugin"; +import { type PluginServerContext } from "@getpaseo/plugin/server"; import { z } from "zod"; const create = defineRpc({ @@ -141,7 +148,10 @@ test("daemon config reload enables and disables configured plugins without resta roots.push(pluginDirectory, paseoHomeRoot); await writeFile( path.join(pluginDirectory, "paseo-plugin.json"), - JSON.stringify({ id: "reloadable-plugin" }), + JSON.stringify({ + id: "reloadable-plugin", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), ); await writeFile( path.join(pluginDirectory, "index.server.ts"), @@ -170,6 +180,7 @@ test("daemon config reload enables and disables configured plugins without resta appVersion: "0.4.0", }); const configPath = path.join(daemon.paseoHome, "config.json"); + const catalogChanges = new EventEmitter(); async function setPluginsEnabled(enabled: boolean): Promise { const config = JSON.parse(await readFile(configPath, "utf8")); @@ -181,10 +192,25 @@ test("daemon config reload enables and disables configured plugins without resta try { await client.connect(); + const catalog = client.observeEvents(["status.plugin_catalog_changed"]); + catalog.subscribe({ + snapshot: () => undefined, + update: (message) => { + if ( + message.type === "status" && + message.payload.status === "plugin_catalog_changed" && + message.payload.pluginId === "reloadable-plugin" + ) { + catalogChanges.emit("changed"); + } + }, + }); + await catalog.ready; await expect(client.listPlugins()).resolves.toEqual([ expect.objectContaining({ id: "reloadable-plugin", status: "disabled" }), ]); + const enabled = once(catalogChanges, "changed"); await setPluginsEnabled(true); await expect(client.reloadDaemonConfig()).resolves.toMatchObject({ requestId: expect.any(String), @@ -192,9 +218,12 @@ test("daemon config reload enables and disables configured plugins without resta restartRequiredPaths: [], overrideControlledPaths: [], }); - await expect - .poll(async () => (await client.listPlugins()).find(({ id }) => id === "reloadable-plugin")) - .toMatchObject({ enabled: true, status: "running" }); + await enabled; + expect((await client.listPlugins()).find(({ id }) => id === "reloadable-plugin")).toMatchObject( + { enabled: true, status: "running" }, + ); + + const disabled = once(catalogChanges, "changed"); await setPluginsEnabled(false); await expect(client.reloadDaemonConfig()).resolves.toEqual({ @@ -203,11 +232,179 @@ test("daemon config reload enables and disables configured plugins without resta restartRequiredPaths: [], overrideControlledPaths: [], }); - await expect - .poll(async () => (await client.listPlugins()).find(({ id }) => id === "reloadable-plugin")) - .toMatchObject({ enabled: true, status: "disabled" }); + await disabled; + expect((await client.listPlugins()).find(({ id }) => id === "reloadable-plugin")).toMatchObject( + { enabled: true, status: "disabled" }, + ); + await catalog.release(); } finally { await client.close().catch(() => undefined); await daemon.close(); } }, 60_000); + +const ReconnectingPluginStateSchema = z.object({ + snapshots: z.array(z.string()), + names: z.array(z.string()), +}); + +async function readReconnectingPluginState(client: DaemonClient) { + return ReconnectingPluginStateSchema.parse( + await client.invokePluginRpc("reconnecting", "state", {}), + ); +} + +test("plugin host APIs and observations recover after repeated daemon-side socket closes", async () => { + const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reconnecting-plugin-")); + const workspaceDirectory = await mkdtemp(path.join(tmpdir(), "paseo-reconnecting-workspace-")); + roots.push(pluginDirectory, workspaceDirectory); + await writeFile( + path.join(pluginDirectory, "paseo-plugin.json"), + JSON.stringify({ id: "reconnecting", requirements: { paseo: ">=0.8.0" } }), + ); + await writeFile( + path.join(pluginDirectory, "index.server.ts"), + ` +import { defineRpc } from "@getpaseo/plugin"; +import { type PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; + +export default function contribute(server: PluginServerContext) { + const snapshots: string[] = []; + const names: string[] = []; + const snapshotWaiters = new Map void>(); + const nameWaiters = new Map void>(); + let reconnected = Promise.resolve(); + let release: (() => Promise) | undefined; + server.handle(defineRpc({ name: "observe", input: z.object({}), output: z.string() }), async (_, { paseo }) => { + const { subscription } = await paseo.workspaces.list({ subscribe: {} }); + subscription.subscribe({ + snapshot: (snapshot) => { + const id = snapshot.subscriptionId!; + snapshots.push(id); + snapshotWaiters.get(snapshots.length)?.(id); + snapshotWaiters.delete(snapshots.length); + }, + update: (message) => { + if (message.type === "workspace_update" && message.payload.kind === "upsert") { + const name = message.payload.workspace.name; + names.push(name); + nameWaiters.get(name)?.(name); + nameWaiters.delete(name); + } + }, + }); + release = () => subscription.release(); + return subscription.subscriptionId!; + }); + server.handle(defineRpc({ name: "wait-snapshot", input: z.object({ count: z.number().int().positive() }), output: z.string() }), ({ count }) => { + const id = snapshots[count - 1]; + return id ?? new Promise((resolve) => snapshotWaiters.set(count, resolve)); + }); + server.handle(defineRpc({ name: "wait-name", input: z.object({ name: z.string() }), output: z.string() }), ({ name }) => { + return names.includes(name) ? name : new Promise((resolve) => nameWaiters.set(name, resolve)); + }); + server.handle(defineRpc({ name: "state", input: z.object({}), output: z.object({ snapshots: z.array(z.string()), names: z.array(z.string()) }) }), () => ({ snapshots, names })); + server.handle(defineRpc({ name: "probe", input: z.object({}), output: z.object({ pid: z.number(), projectIds: z.array(z.string()) }) }), async (_, { paseo }) => { + await reconnected; + return { pid: process.pid, projectIds: (await paseo.projects.list()).projects.map((project) => project.projectId) }; + }); + server.handle(defineRpc({ name: "release", input: z.object({}), output: z.null() }), async () => { + await release?.(); + return null; + }); + server.handle(defineRpc({ name: "disconnect", input: z.object({}), output: z.null() }), () => new Promise((resolve) => { + // Inject a protocol violation through real IPC. The real daemon closes the + // active socket, and the worker's normal transport must observe that close. + function closed(message: { type: string }) { + if (message.type !== "paseo_close") return; + process.off("message", closed); + resolve(null); + } + process.on("message", closed); + reconnected = new Promise((connected) => { + function ready(message: { type: string; data?: unknown }) { + if (message.type !== "paseo_frame" || typeof message.data !== "string") return; + const frame = JSON.parse(message.data); + if (frame.type !== "session" || frame.message.type !== "status" || frame.message.payload.status !== "server_info") return; + process.off("message", ready); + connected(); + } + process.on("message", ready); + }); + process.send!({ type: "paseo_frame", isBinary: false, data: JSON.stringify({ + type: "hello", clientId: "plugin:reconnecting", clientType: "cli", protocolVersion: 1, + }) }); + })); + return async () => { await release?.(); }; +}`, + ); + const daemon = await createTestPaseoDaemon(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + let projectId: string | undefined; + try { + await client.connect(); + const opened = await client.openProject(workspaceDirectory); + if (!opened.workspace) throw new Error(opened.error ?? "Workspace did not open"); + projectId = opened.workspace.projectId; + const workspaceId = opened.workspace.id; + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(pluginDirectory); + const original = await client.invokePluginRpc("reconnecting", "probe", {}); + expect(original).toEqual({ pid: expect.any(Number), projectIds: [projectId] }); + const initialId = await client.invokePluginRpc("reconnecting", "observe", {}); + const app = await client.fetchWorkspaces({ subscribe: {} }); + const appUpdates = new EventEmitter(); + app.subscription.subscribe({ + snapshot: () => undefined, + update: (message) => { + if (message.type === "workspace_update" && message.payload.kind === "upsert") { + appUpdates.emit(message.payload.workspace.name); + } + }, + }); + await client.setWorkspaceTitle(workspaceId, "Before disconnect"); + await expect( + client.invokePluginRpc("reconnecting", "wait-name", { name: "Before disconnect" }), + ).resolves.toBe("Before disconnect"); + const ids = [initialId]; + for (const name of ["First recovery", "Second recovery"]) { + await client.invokePluginRpc("reconnecting", "disconnect", {}); + const id = await client.invokePluginRpc("reconnecting", "wait-snapshot", { + count: ids.length + 1, + }); + ids.push(id); + expect((await readReconnectingPluginState(client)).snapshots).toEqual(ids); + expect(new Set(ids).size).toBe(ids.length); + expect(app.subscription.subscriptionId).toBe(app.subscriptionId); + const appUpdate = once(appUpdates, name); + await client.setWorkspaceTitle(workspaceId, name); + await expect(client.invokePluginRpc("reconnecting", "wait-name", { name })).resolves.toBe( + name, + ); + await appUpdate; + expect(await client.invokePluginRpc("reconnecting", "probe", {})).toEqual(original); + } + await client.invokePluginRpc("reconnecting", "release", {}); + const releasedState = await client.invokePluginRpc("reconnecting", "state", {}); + await client.invokePluginRpc("reconnecting", "disconnect", {}); + expect(await client.invokePluginRpc("reconnecting", "probe", {})).toEqual(original); + const appUpdateAfterRelease = once(appUpdates, "After release"); + await client.setWorkspaceTitle(workspaceId, "After release"); + await appUpdateAfterRelease; + await client.invokePluginRpc("reconnecting", "probe", {}); + expect(await client.invokePluginRpc("reconnecting", "state", {})).toEqual(releasedState); + await app.subscription.release(); + // Removal while reconnect is scheduled must stop the child, not reload it. + await client.invokePluginRpc("reconnecting", "disconnect", {}); + await client.removePlugin("reconnecting"); + expect(await client.listPlugins()).toEqual([]); + await expect(client.invokePluginRpc("reconnecting", "probe", {})).rejects.toThrow( + "Plugin is not available", + ); + } finally { + if (projectId) await client.removeProject(projectId); + await client.close(); + await daemon.close(); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index c9d61336c0..e959780389 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -1,14 +1,309 @@ +import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage"; +import type { + ProviderConnectRequest, + ProviderCatalogOptions, + ProviderEvent, + ProviderInput, + ProviderStatusRequest, +} from "@getpaseo/plugin/server/provider"; +import { + ProviderEventSchema, + ProviderInputSchema, + ProviderLaunchSchema, +} from "@getpaseo/plugin/server/provider"; +import { z } from "zod"; + +export interface PluginProviderMetadata { + hasCatalogCacheKey?: boolean; + hasStatus?: boolean; + command?: readonly [string, ...string[]]; + id: string; + label: string; + description?: string; + iconPath?: string; +} + +export interface PluginUsageSourceMetadata { + id: string; + label: string; + icon?: string; +} + export type PluginProcessRequest = - | { type: "initialize"; pluginId: string; bundle: string; appVersion: string } + | { + type: "provider.status"; + requestId: string; + providerId: string; + request: ProviderStatusRequest; + } + | { + type: "initialize"; + pluginId: string; + bundle: string; + appVersion: string; + pluginDirectory: string; + settingsDirectory?: string; + } + | { + type: "provider.catalog_key"; + requestId: string; + providerId: string; + options: ProviderCatalogOptions; + } + | { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown } + | { type: "hook.cancel"; requestId: string } + | { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown } + | { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope } | { type: "invoke"; requestId: string; method: string; input: unknown } + | { + type: "provider.connect"; + providerId: string; + connectionId: string; + request: ProviderConnectRequest; + } + | { + type: "provider.send"; + connectionId: string; + acceptanceId: string; + input: ProviderInput; + } + | { type: "provider.close"; connectionId: string } | { type: "shutdown" } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; export type PluginProcessMessage = - | { type: "ready"; methods: string[] } + | { type: "settings.changed"; settingsId: string } + | { type: "hooks.changed"; hooks: { events: string[]; before: string[] } } + | { + type: "ready"; + methods: string[]; + providers: PluginProviderMetadata[]; + usageSources?: PluginUsageSourceMetadata[]; + hooks?: { events: string[]; before: string[] }; + } | { type: "result"; requestId: string; output: unknown } | { type: "error"; requestId: string; error: string } | { type: "fatal"; error: string } + | { + type: "provider.connected"; + connectionId: string; + version: number; + capabilities: readonly string[]; + } + | { type: "provider.connect_failed"; connectionId: string; error: string } + | { type: "provider.accepted"; connectionId: string; acceptanceId: string } + | { + type: "provider.rejected"; + connectionId: string; + acceptanceId: string; + error: string; + } + | { type: "provider.event"; connectionId: string; event: ProviderEvent } + | { type: "provider.closed"; connectionId: string; error?: string } | { type: "paseo_frame"; data: string | Uint8Array; isBinary: boolean } | { type: "paseo_close" }; + +const hooksSchema = z.object({ events: z.array(z.string()), before: z.array(z.string()) }).strict(); + +const providerMetadataSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + description: z.string().optional(), + iconPath: z.string().optional(), + hasCatalogCacheKey: z.boolean().optional(), + hasStatus: z.boolean().optional(), + command: z.tuple([z.string().min(1)], z.string()).optional(), + }) + .strict(); +const usageSourceMetadataSchema = z + .object({ + id: z.string().min(1), + label: z.string().min(1), + icon: z.string().optional(), + }) + .strict(); +const providerConnectRequestSchema = z + .object({ + launch: ProviderLaunchSchema.optional(), + versions: z.array(z.number().int().positive()), + capabilities: z.array(z.string()), + }) + .strict(); +const frameFields = { + data: z.union([z.string(), z.instanceof(Uint8Array)]), + isBinary: z.boolean(), +}; + +export const PluginProcessRequestSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z + .object({ + type: z.literal("provider.status"), + requestId: z.string().min(1), + providerId: z.string().min(1), + request: z.object({ launch: ProviderLaunchSchema.optional() }).strict(), + }) + .strict(), + z + .object({ + type: z.literal("initialize"), + pluginId: z.string().min(1), + bundle: z.string(), + appVersion: z.string(), + pluginDirectory: z.string(), + settingsDirectory: z.string().optional(), + }) + .strict(), + z + .object({ + type: z.literal("provider.catalog_key"), + requestId: z.string().min(1), + providerId: z.string().min(1), + options: z.discriminatedUnion("scope", [ + z + .object({ + scope: z.literal("global"), + force: z.boolean().optional(), + launch: ProviderLaunchSchema.optional(), + }) + .strict(), + z + .object({ + scope: z.literal("workspace"), + cwd: z.string(), + force: z.boolean().optional(), + launch: ProviderLaunchSchema.optional(), + }) + .strict(), + ]), + }) + .strict(), + z + .object({ + type: z.literal("hook"), + requestId: z.string(), + kind: z.enum(["event", "before"]), + name: z.string(), + input: z.unknown(), + }) + .strict(), + z.object({ type: z.literal("hook.cancel"), requestId: z.string() }).strict(), + z + .object({ + type: z.literal("usage.fetch"), + requestId: z.string(), + sourceId: z.string(), + input: z.unknown(), + }) + .strict(), + z + .object({ + type: z.literal("usage.discover"), + requestId: z.string(), + sourceId: z.string(), + scope: UsageScopeSchema, + }) + .strict(), + z + .object({ + type: z.literal("invoke"), + requestId: z.string().min(1), + method: z.string().min(1), + input: z.unknown(), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect"), + providerId: z.string().min(1), + connectionId: z.string().min(1), + request: providerConnectRequestSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.send"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + input: ProviderInputSchema, + }) + .strict(), + z.object({ type: z.literal("provider.close"), connectionId: z.string().min(1) }).strict(), + z.object({ type: z.literal("shutdown") }).strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); + +export const PluginProcessMessageSchema: z.ZodType = z.discriminatedUnion( + "type", + [ + z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(), + z.object({ type: z.literal("hooks.changed"), hooks: hooksSchema }).strict(), + z + .object({ + type: z.literal("ready"), + methods: z.array(z.string()), + providers: z.array(providerMetadataSchema), + usageSources: z.array(usageSourceMetadataSchema).optional(), + hooks: hooksSchema.optional(), + }) + .strict(), + z + .object({ type: z.literal("result"), requestId: z.string().min(1), output: z.unknown() }) + .strict(), + z + .object({ type: z.literal("error"), requestId: z.string().min(1), error: z.string() }) + .strict(), + z.object({ type: z.literal("fatal"), error: z.string() }).strict(), + z + .object({ + type: z.literal("provider.connected"), + connectionId: z.string().min(1), + version: z.number().int().positive(), + capabilities: z.array(z.string()), + }) + .strict(), + z + .object({ + type: z.literal("provider.connect_failed"), + connectionId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.accepted"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + }) + .strict(), + z + .object({ + type: z.literal("provider.rejected"), + connectionId: z.string().min(1), + acceptanceId: z.string().min(1), + error: z.string(), + }) + .strict(), + z + .object({ + type: z.literal("provider.event"), + connectionId: z.string().min(1), + event: ProviderEventSchema, + }) + .strict(), + z + .object({ + type: z.literal("provider.closed"), + connectionId: z.string().min(1), + error: z.string().optional(), + }) + .strict(), + z.object({ type: z.literal("paseo_frame"), ...frameFields }).strict(), + z.object({ type: z.literal("paseo_close") }).strict(), + ], +); diff --git a/packages/server/src/server/plugins/plugin-process.test.ts b/packages/server/src/server/plugins/plugin-process.test.ts new file mode 100644 index 0000000000..ddee3cb291 --- /dev/null +++ b/packages/server/src/server/plugins/plugin-process.test.ts @@ -0,0 +1,50 @@ +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; +import { expect, it } from "vitest"; + +it("keeps React out of the plugin host's runtime dependency graph", async () => { + await expect( + build({ + entryPoints: [ + fileURLToPath(new URL("./plugin-process.ts", import.meta.url)), + "@getpaseo/plugin", + "@getpaseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/acp", + ], + outdir: "unused", + conditions: ["source"], + bundle: true, + platform: "node", + format: "esm", + // Node evaluates re-exports even when the host only imports one helper. + treeShaking: false, + write: false, + logLevel: "silent", + plugins: [ + { + name: "no-react", + setup(context) { + context.onResolve( + { filter: /^(react|react-dom|react-native|use-sync-external-store)(\/|$)/ }, + ({ path, importer }) => ({ + errors: [{ text: `React dependency ${path} imported by ${importer}` }], + }), + ); + }, + }, + ], + }), + ).resolves.toMatchObject({ errors: [] }); +}); + +it("exposes CLI probes through the plugin host's server SDK", async () => { + const { evaluateBundle } = await import("./bundle-evaluator.js"); + const setup = evaluateBundle(`(require) => ({ default: async () => { + const { execCommand } = require("@getpaseo/plugin/server"); + const { stdout } = await execCommand(${JSON.stringify(process.execPath)}, ["-e", "process.stdout.write('plugin CLI ready')"]); + if (stdout !== "plugin CLI ready") throw new Error("CLI output was lost"); + } })`); + // This contribution uses no server context APIs; it exercises the host's real SDK binding. + await expect(setup(undefined as never)).resolves.toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 1b4082df4d..3f11926d7d 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -1,174 +1,575 @@ -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; -import { createRequire } from "node:module"; -import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin"; -import type { PluginHandlerContext } from "@getpaseo/plugin/server"; +import { PluginHookHandlers } from "./lifecycle/index.js"; +import { evaluateBundle } from "./bundle-evaluator.js"; +import { + PluginProcessRequestSchema, + type PluginProcessMessage, + type PluginProcessRequest, +} from "./plugin-process-protocol.js"; +import type { UsageSourceRegistration } from "@getpaseo/plugin/server/usage"; +import type { SettingsDefinition, PluginRpcContract } from "@getpaseo/plugin"; +import type { PluginHandlerContext, PluginServerContribution } from "@getpaseo/plugin/server"; +import { fileURLToPath } from "node:url"; +import type { ZodType } from "zod"; +import { + ProviderEventSchema, + type ProviderConnection, + type ProviderRegistration, + ProviderStatusSchema, +} from "@getpaseo/plugin/server/provider"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; -import { - isPluginClientOnlySdkSpecifier, - isPluginSdkSpecifier, - isPluginServerTypesSdkSpecifier, -} from "./plugin-sdk-specifiers.js"; import { createPluginClientId } from "./plugin-session-identity.js"; -type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; - -interface RegisteredRpc { - contract: PluginRpcContract; - handler: RpcHandler; -} - -const handlers = new Map(); -let cleanup: (() => void | Promise) | null = null; -let daemonClient: DaemonClient | null = null; -let paseo: PaseoApi | null = null; -let stopping = false; -const nodeRequire = createRequire(import.meta.url); - -function send(message: PluginProcessMessage): void { - process.send?.(message); -} - -function sendAndWait(message: PluginProcessMessage): Promise { - return new Promise((resolve) => { - if (!process.send) { - resolve(); - return; - } - process.send(message, () => resolve()); - }); -} - +import { PluginSettingsStore } from "./settings/index.js"; +import { readPluginProviderIcon } from "./provider-icon.js"; function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function validateMethod(method: string): string { - const normalized = method.trim(); - if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { - throw new Error(`Invalid plugin RPC method: ${method}`); - } - if (handlers.has(normalized)) { - throw new Error(`Duplicate plugin RPC method: ${normalized}`); - } - return normalized; +export interface PluginWorkerChannel { + send(message: PluginProcessMessage, callback?: () => void): void; + onMessage(handler: (message: PluginProcessRequest) => void): () => void; + disconnect(): void; } -function register(contract: PluginRpcContract, handler: RpcHandler): void { - if (typeof handler !== "function") { - throw new Error(`Plugin RPC ${contract.name} must provide a handler`); +export function createPluginWorker(options: { + channel: PluginWorkerChannel; + contribute: PluginServerContribution; +}): { shutdown(): Promise } { + const { channel, contribute } = options; + let settingsStore: PluginSettingsStore | null = null; + function registerSettings(definition: SettingsDefinition) { + if (!settingsStore) throw new Error("Plugin settings storage is unavailable"); + const handlers = settingsStore.register(definition); + register(handlers.read.contract, handlers.read.handle); + register(handlers.write.contract, (input) => + handlers.write.handle(handlers.write.contract.input.parse(input)), + ); + register(handlers.reset.contract, (input) => + handlers.reset.handle(handlers.reset.contract.input.parse(input)), + ); + return handlers.settings; } - const method = validateMethod(contract.name); - handlers.set(method, { contract: { ...contract, name: method }, handler }); -} -const pluginAuthorRuntime = { - defineAttachmentSource, - defineRpc, - Icon() { - throw new Error("Icon is available only in plugin client code"); - }, -}; + type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise; -function runtimeRequire(name: string): unknown { - if (isPluginClientOnlySdkSpecifier(name)) { - throw new Error(`${name} is available only in plugin client code`); + interface RegisteredRpc { + contract: PluginRpcContract; + handler: RpcHandler; } - if (isPluginServerTypesSdkSpecifier(name)) return {}; - if (isPluginSdkSpecifier(name)) return pluginAuthorRuntime; - return nodeRequire(name); -} -function evaluateBundle(bundle: string): void { - const evaluate: (source: string) => unknown = globalThis.eval; - const factory = evaluate(bundle); - if (typeof factory !== "function") throw new Error("Plugin server bundle is not executable"); - const exports = factory(runtimeRequire); - const setup = - exports !== null && typeof exports === "object" ? Reflect.get(exports, "default") : undefined; - if (typeof setup !== "function") { - throw new Error("Plugin server bundle must default export a function"); - } - const contributedCleanup = setup({ handle: register }); - if (typeof contributedCleanup !== "function") { - throw new Error("Plugin contribution must return a cleanup function"); - } - cleanup = contributedCleanup; -} - -const transportFactory = createPluginDaemonTransportFactory({ - send, - onMessage(handler) { - process.on("message", handler); - return () => process.off("message", handler); - }, -}); - -async function initialize(message: Extract) { - daemonClient = new DaemonClient({ - url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`, - clientId: createPluginClientId(message.pluginId), - clientType: "cli", - appVersion: message.appVersion, - reconnect: { enabled: false }, - transportFactory, + const hooks = new PluginHookHandlers(() => { + send({ type: "hooks.changed", hooks: hooks.catalog() }); }); - paseo = createPaseoApi(daemonClient); - await daemonClient.connect(); - evaluateBundle(message.bundle); - send({ type: "ready", methods: [...handlers.keys()].sort() }); -} - -async function shutdown(): Promise { - if (stopping) return; - stopping = true; - const currentCleanup = cleanup; - cleanup = null; - try { - await currentCleanup?.(); - } catch (error) { - console.error("Plugin cleanup failed", error); + const handlers = new Map(); + const providers = new Map(); + const usageSources = new Map(); + const providerConnections = new Map< + string, + { connection: ProviderConnection; unsubscribe: () => void; closing?: Promise } + >(); + const pendingProviderConnections = new Map(); + let cleanup: (() => void | Promise) | null = null; + let daemonClient: DaemonClient | null = null; + let paseo: PaseoApi | null = null; + let stopping = false; + function send(message: PluginProcessMessage): void { + channel.send(message); } - await daemonClient?.close().catch(() => undefined); - await sendAndWait({ type: "paseo_close" }); - daemonClient = null; - paseo = null; - process.disconnect(); -} -process.on("message", (message: PluginProcessRequest) => { - if (message.type === "initialize") { - void initialize(message).catch(async (error) => { - send({ type: "fatal", error: describeError(error) }); - await daemonClient?.close().catch(() => undefined); + function sendAndWait(message: PluginProcessMessage): Promise { + return new Promise((resolve) => { + channel.send(message, () => resolve()); }); - return; } - if (message.type === "shutdown") { - void shutdown(); - return; + + function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; } - if (message.type === "paseo_frame" || message.type === "paseo_close") return; - if (stopping) return; - const registered = handlers.get(message.method); - if (!registered) { + + function validateMethod(method: string): string { + const normalized = method.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(normalized)) { + throw new Error(`Invalid plugin RPC method: ${method}`); + } + if (handlers.has(normalized)) { + throw new Error(`Duplicate plugin RPC method: ${normalized}`); + } + return normalized; + } + + function register(contract: PluginRpcContract, handler: RpcHandler): void { + if (typeof handler !== "function") { + throw new Error(`Plugin RPC ${contract.name} must provide a handler`); + } + const method = validateMethod(contract.name); + handlers.set(method, { contract: { ...contract, name: method }, handler }); + } + + function registerProvider(provider: ProviderRegistration): void { + const id = provider.id.trim(); + if (!/^[a-z][a-z0-9._-]*$/.test(id)) { + throw new Error(`Invalid plugin provider ID: ${provider.id}`); + } + if (!provider.label.trim()) throw new Error(`Plugin provider ${id} requires a label`); + if (typeof provider.connect !== "function") { + throw new Error(`Plugin provider ${id} must implement connect()`); + } + if ( + provider.getCatalogCacheKey !== undefined && + typeof provider.getCatalogCacheKey !== "function" + ) { + throw new Error(`Invalid catalogue key callback for plugin provider ${id}`); + } + if (provider.status !== undefined && typeof provider.status !== "function") + throw new Error(`Invalid status callback for plugin provider ${id}`); + if ( + provider.command !== undefined && + (provider.command.length === 0 || + provider.command.some((part) => typeof part !== "string" || !part.trim())) + ) + throw new Error(`Invalid command for plugin provider ${id}`); + if (providers.has(id)) throw new Error(`Duplicate plugin provider ID: ${id}`); + providers.set(id, { ...provider, id }); + } + + function registerUsageSource(source: UsageSourceRegistration): void { + const id = source.id.trim(); + if ( + !/^[a-z][a-z0-9._-]*$/.test(id) || + !source.label.trim() || + typeof source.fetch !== "function" || + typeof source.discover !== "function" || + !source.input || + typeof source.input.parseAsync !== "function" + ) { + throw new Error(`Invalid usage source: ${source.id}`); + } + if (usageSources.has(id)) throw new Error(`Duplicate usage source: ${id}`); + usageSources.set(id, { ...source, id }); + } + + function providerMetadata(provider: ProviderRegistration) { + return { + id: provider.id, + label: provider.label, + description: provider.description, + iconPath: provider.icon, + hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, + hasStatus: provider.status !== undefined, + command: provider.command, + }; + } + + async function connectProvider( + message: Extract, + ): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown plugin provider: ${message.providerId}`); + if ( + providerConnections.has(message.connectionId) || + pendingProviderConnections.has(message.connectionId) + ) { + throw new Error(`Duplicate provider connection: ${message.connectionId}`); + } + const pending = { tombstoned: false }; + pendingProviderConnections.set(message.connectionId, pending); + let connection: ProviderConnection; + try { + connection = await provider.connect(message.request); + } catch (error) { + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) return; + throw error; + } + pendingProviderConnections.delete(message.connectionId); + if (pending.tombstoned || stopping) { + await connection.close().catch(() => undefined); + return; + } + let unsubscribe = () => {}; + unsubscribe = connection.onEvent((event) => { + try { + send({ + type: "provider.event", + connectionId: message.connectionId, + event: ProviderEventSchema.parse(jsonTransportValue(event)), + }); + } catch (error) { + providerConnections.delete(message.connectionId); + unsubscribe(); + void connection.close().catch(() => undefined); + send({ + type: "provider.closed", + connectionId: message.connectionId, + error: describeError(error), + }); + } + }); + providerConnections.set(message.connectionId, { connection, unsubscribe }); send({ - type: "error", - requestId: message.requestId, - error: `Unknown RPC method: ${message.method}`, + type: "provider.connected", + connectionId: message.connectionId, + version: connection.version, + capabilities: connection.capabilities, }); - return; } - void registered.contract.input - .parseAsync(message.input) - .then((input) => { - if (!paseo) throw new Error("Plugin Paseo API is unavailable"); - return registered.handler(input, { paseo }); - }) - .then((output) => registered.contract.output.parseAsync(output)) - .then( + + async function sendProviderInput( + message: Extract, + ): Promise { + if (stopping) throw new Error("Plugin is stopping"); + const current = providerConnections.get(message.connectionId); + if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`); + if (current.closing) throw new Error("Provider connection is closing"); + await current.connection.send(message.input); + send({ + type: "provider.accepted", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + }); + } + + // The connection stays registered until its close has reported, so shutdown + // waits for a close already in flight instead of disconnecting underneath it. + async function closeProviderConnection(connectionId: string): Promise { + const current = providerConnections.get(connectionId); + if (!current) return; + if (current.closing) return current.closing; + const closing = (async () => { + current.unsubscribe(); + try { + await current.connection.close(); + send({ type: "provider.closed", connectionId }); + } catch (error) { + send({ type: "provider.closed", connectionId, error: describeError(error) }); + } finally { + providerConnections.delete(connectionId); + } + })(); + current.closing = closing; + return closing; + } + + const transportFactory = createPluginDaemonTransportFactory({ + send, + onMessage(handler) { + return channel.onMessage(handler); + }, + }); + + async function initialize(message: Extract) { + daemonClient = new DaemonClient({ + url: `ipc://plugin/${encodeURIComponent(message.pluginId)}`, + clientId: createPluginClientId(message.pluginId), + clientType: "cli", + appVersion: message.appVersion, + // The runtime re-attaches a session when the daemon drops this socket. + reconnect: { enabled: true }, + transportFactory, + }); + paseo = createPaseoApi(daemonClient); + await daemonClient.connect(); + settingsStore = message.settingsDirectory + ? new PluginSettingsStore(message.settingsDirectory, (settingsId) => + send({ type: "settings.changed", settingsId }), + ) + : null; + const contributedCleanup = contribute({ + handle: (contract, handler) => + register(contract, (input, context) => handler(contract.input.parse(input), context)), + registerProvider, + registerUsageSource, + registerSettings, + on: hooks.on, + before: hooks.before, + }); + if (typeof contributedCleanup !== "function") { + throw new Error("Plugin contribution must return a cleanup function"); + } + cleanup = contributedCleanup; + const usageSourceMetadata = await Promise.all( + [...usageSources.values()] + .sort((a, b) => a.id.localeCompare(b.id)) + .map(async (source) => ({ + id: source.id, + label: source.label, + icon: source.icon + ? await readPluginProviderIcon(message.pluginDirectory, source.icon) + : undefined, + })), + ); + send({ + type: "ready", + methods: [...handlers.keys()].sort(), + hooks: hooks.catalog(), + providers: [...providers.values()] + .sort((left, right) => left.id.localeCompare(right.id)) + .map(providerMetadata), + usageSources: usageSourceMetadata, + }); + } + + async function shutdown(): Promise { + if (stopping) return; + stopping = true; + const releaseApi = paseo + ?.dispose() + .catch((error) => console.error("Plugin API cleanup failed", error)); + hooks.close(); + for (const pending of pendingProviderConnections.values()) pending.tombstoned = true; + const currentCleanup = cleanup; + cleanup = null; + try { + await currentCleanup?.(); + } catch (error) { + console.error("Plugin cleanup failed", error); + } + await Promise.all([...providerConnections.keys()].map(closeProviderConnection)); + await releaseApi; + await daemonClient?.close().catch(() => undefined); + await sendAndWait({ type: "paseo_close" }); + daemonClient = null; + paseo = null; + channel.disconnect(); + } + + function handleUsageRequest( + message: Extract, + ): void { + void (async () => { + const source = usageSources.get(message.sourceId); + if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`); + if (message.type === "usage.discover") + return jsonTransportValue(await source.discover(message.scope)); + const input = await source.input.parseAsync(message.input); + return jsonTransportValue(await source.fetch(input)); + })().then( (output) => send({ type: "result", requestId: message.requestId, output }), (error) => send({ type: "error", requestId: message.requestId, error: describeError(error) }), ); -}); + } + + function rejectWhileStopping(message: PluginProcessRequest): void { + if ( + message.type === "provider.status" || + message.type === "provider.catalog_key" || + message.type === "usage.fetch" || + message.type === "usage.discover" + ) { + send({ type: "error", requestId: message.requestId, error: "Plugin is stopping" }); + } else if (message.type === "provider.connect") { + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.send") { + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: "Plugin is stopping", + }); + } else if (message.type === "provider.close") { + send({ type: "provider.closed", connectionId: message.connectionId }); + } + } + + function handleMessage(rawMessage: unknown): void { + const parsed = PluginProcessRequestSchema.safeParse(rawMessage); + if (!parsed.success) { + const value = rawMessage as { connectionId?: unknown; acceptanceId?: unknown } | null; + if ( + value && + typeof value.connectionId === "string" && + typeof value.acceptanceId === "string" + ) { + send({ + type: "provider.rejected", + connectionId: value.connectionId, + acceptanceId: value.acceptanceId, + error: `Invalid provider input: ${parsed.error.message}`, + }); + void closeProviderConnection(value.connectionId); + return; + } + send({ type: "fatal", error: `Invalid plugin process request: ${parsed.error.message}` }); + void shutdown(); + return; + } + const message = parsed.data; + if (message.type === "initialize") { + void initialize(message).catch(async (error) => { + send({ type: "fatal", error: describeError(error) }); + await paseo + ?.dispose() + .catch((failure) => console.error("Plugin API cleanup failed", failure)); + await daemonClient?.close().catch(() => undefined); + }); + return; + } + if (message.type === "shutdown") { + void shutdown(); + return; + } + if (stopping) { + rejectWhileStopping(message); + return; + } + if (message.type === "provider.status") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider || !provider.status) + throw new Error(`Provider has no status capability: ${message.providerId}`); + const output = ProviderStatusSchema.parse(await provider.status(message.request)); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } + if (message.type === "provider.catalog_key") { + void (async () => { + const provider = providers.get(message.providerId); + if (!provider) throw new Error(`Unknown provider: ${message.providerId}`); + const output = await provider.getCatalogCacheKey?.(message.options); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key"); + send({ type: "result", requestId: message.requestId, output }); + })().catch((error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + return; + } + if (message.type === "usage.fetch" || message.type === "usage.discover") { + handleUsageRequest(message); + return; + } + if (message.type === "provider.connect") { + void connectProvider(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.send") { + void sendProviderInput(message).catch((error) => { + if (stopping) return; + send({ + type: "provider.rejected", + connectionId: message.connectionId, + acceptanceId: message.acceptanceId, + error: describeError(error), + }); + }); + return; + } + if (message.type === "provider.close") { + void closeProviderConnection(message.connectionId); + return; + } + if (message.type === "paseo_frame" || message.type === "paseo_close") return; + if (isHookMessage(message)) { + handleHookMessage(message); + return; + } + const registered = handlers.get(message.method); + if (!registered) { + send({ + type: "error", + requestId: message.requestId, + error: `Unknown RPC method: ${message.method}`, + }); + return; + } + void registered.contract.input + .parseAsync(message.input) + .then((input) => { + if (!paseo) throw new Error("Plugin Paseo API is unavailable"); + return registered.handler(input, { paseo }); + }) + .then((output) => registered.contract.output.parseAsync(output)) + .then( + (output) => send({ type: "result", requestId: message.requestId, output }), + (error) => + send({ type: "error", requestId: message.requestId, error: describeError(error) }), + ); + } + channel.onMessage(handleMessage); + + function handleHookMessage( + message: Extract, + ): void { + if (message.type === "hook.cancel") { + hooks.cancel(message.requestId); + return; + } + if (message.type === "hook") { + if (!paseo) { + send({ + type: "error", + requestId: message.requestId, + error: "Plugin Paseo API is unavailable", + }); + return; + } + void hooks.invoke(message.requestId, message.kind, message.name, message.input, paseo).then( + (output) => { + return send({ type: "result", requestId: message.requestId, output }); + }, + (error) => { + return send({ type: "error", requestId: message.requestId, error: describeError(error) }); + }, + ); + return; + } + } + + function isHookMessage( + message: PluginProcessRequest, + ): message is Extract { + return message.type === "hook" || message.type === "hook.cancel"; + } + return { shutdown }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + let worker: ReturnType | null = null; + const handlers = new Set<(message: PluginProcessRequest) => void>(); + const channel: PluginWorkerChannel = { + send(message, callback) { + if (callback) process.send?.(message, () => callback()); + else process.send?.(message); + }, + onMessage(handler) { + handlers.add(handler); + return () => { + handlers.delete(handler); + }; + }, + disconnect() { + process.disconnect(); + }, + }; + process.on("message", (raw) => { + const parsed = PluginProcessRequestSchema.safeParse(raw); + if (!worker && parsed.success && parsed.data.type === "initialize") { + try { + worker = createPluginWorker({ channel, contribute: evaluateBundle(parsed.data.bundle) }); + } catch (error) { + channel.send({ type: "fatal", error: describeError(error) }); + return; + } + } + if (worker) for (const handler of handlers) handler(raw as PluginProcessRequest); + }); +} diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index f4ae72e55b..079f1c501d 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -1,35 +1,27 @@ -// Specifiers a plugin author can import to reach the SDK. The daemon never resolves these -// from disk: the compiler marks them external and the plugin subprocess hands back its own -// runtime, so a plugin typechecks against generated declarations without installing anything. -// -// COMPAT(plugin-sdk-scope): @paseo/plugin was the SDK name through 0.5.0-beta.1 and was never -// published — that scope is not ours. Plugins scaffolded against that name still import it, so -// both spellings resolve. Remove the @paseo/* entries after 2026-11-19. +// These entries are supplied by the host and remain external in author bundles. export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [ - "@getpaseo/plugin/react-native", - "@paseo/plugin/react-native", + "@getpaseo/plugin/client", + "@getpaseo/plugin/client/ui", + "@getpaseo/plugin/client/react-native", ] as const; -export const PLUGIN_SERVER_TYPES_SDK_SPECIFIERS = [ +const PLUGIN_SERVER_ONLY_SDK_SPECIFIERS = [ "@getpaseo/plugin/server", - "@paseo/plugin/server", + "@getpaseo/plugin/server/provider", + "@getpaseo/plugin/server/usage", + "@getpaseo/plugin/server/acp", ] as const; export const PLUGIN_SDK_SPECIFIERS = [ "@getpaseo/plugin", - "@paseo/plugin", - ...PLUGIN_SERVER_TYPES_SDK_SPECIFIERS, + ...PLUGIN_SERVER_ONLY_SDK_SPECIFIERS, ...PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS, ] as const; -export function isPluginSdkSpecifier(name: string): boolean { - return (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(name); -} - export function isPluginClientOnlySdkSpecifier(name: string): boolean { return (PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } -export function isPluginServerTypesSdkSpecifier(name: string): boolean { - return (PLUGIN_SERVER_TYPES_SDK_SPECIFIERS as readonly string[]).includes(name); +export function isPluginServerOnlySdkSpecifier(name: string): boolean { + return (PLUGIN_SERVER_ONLY_SDK_SPECIFIERS as readonly string[]).includes(name); } diff --git a/packages/server/src/server/plugins/preparation.test.ts b/packages/server/src/server/plugins/preparation.test.ts new file mode 100644 index 0000000000..0fdb0e34fc --- /dev/null +++ b/packages/server/src/server/plugins/preparation.test.ts @@ -0,0 +1,128 @@ +import { chmod, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { runPluginBuild } from "./preparation.js"; + +describe("runPluginBuild", () => { + const directories: string[] = []; + + afterEach(async () => { + await Promise.all( + directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it("executes build commands in the target directory", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + + await runPluginBuild( + directory, + [ + [process.execPath, "-e", 'require("node:fs").writeFileSync("step1.txt", process.cwd());'], + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync("step2.txt", process.argv[1]);', + "step2-arg", + ], + ], + logger, + ); + + const step1 = await readFile(path.join(directory, "step1.txt"), "utf8"); + const step2 = await readFile(path.join(directory, "step2.txt"), "utf8"); + + expect(await realpath(step1)).toBe(await realpath(directory)); + expect(step2).toBe("step2-arg"); + }); + + it.runIf(process.platform === "win32")("executes Windows command scripts (.cmd)", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + const scriptPath = path.join(directory, "build.cmd"); + await writeFile(scriptPath, "@echo off\r\necho %1> result.txt\r\n"); + + await runPluginBuild(directory, [[scriptPath, "windows-build-ok"]], logger); + + const result = await readFile(path.join(directory, "result.txt"), "utf8"); + expect(result.trim()).toBe("windows-build-ok"); + }); + + it.runIf(process.platform === "win32")( + "executes bare Windows command scripts resolved through PATH", + async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + + await runPluginBuild(directory, [["npm", "init", "-y"]], logger); + + const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + expect(manifest.name).toBe(path.basename(directory).toLowerCase()); + }, + ); + + it("passes arguments unchanged to a native executable", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + + await runPluginBuild( + directory, + [ + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync("args.txt", process.argv[1]);', + "a & b (c) !", + ], + ], + logger, + ); + + const args = await readFile(path.join(directory, "args.txt"), "utf8"); + expect(args).toBe("a & b (c) !"); + }); + + it.runIf(process.platform !== "win32")("executes POSIX shell scripts", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + const scriptPath = path.join(directory, "build.sh"); + await writeFile(scriptPath, '#!/bin/sh\necho "$1" > result.txt\n'); + await chmod(scriptPath, 0o755); + + await runPluginBuild(directory, [[scriptPath, "posix-build-ok"]], logger); + + const result = await readFile(path.join(directory, "result.txt"), "utf8"); + expect(result.trim()).toBe("posix-build-ok"); + }); + + it("throws when a build command exits with a non-zero code", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + + await expect( + runPluginBuild( + directory, + [[process.execPath, "-e", "console.error('build failed'); process.exit(2);"]], + logger, + ), + ).rejects.toThrow(/Plugin build command failed \(exit 2\)/); + }); + + it("handles empty or undefined build commands gracefully", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-build-")); + directories.push(directory); + const logger = createTestLogger(); + + await expect(runPluginBuild(directory, undefined, logger)).resolves.toBeUndefined(); + await expect(runPluginBuild(directory, [], logger)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/plugins/preparation.ts b/packages/server/src/server/plugins/preparation.ts index 1e9b4aabef..267f9697e9 100644 --- a/packages/server/src/server/plugins/preparation.ts +++ b/packages/server/src/server/plugins/preparation.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { spawnProcess } from "../../utils/spawn.js"; import type pino from "pino"; import type { PluginManifest } from "./manifest.js"; @@ -33,14 +33,14 @@ function run(command: string[], directory: string): Promise { return new Promise((resolve) => { const executable = command[0]!; const arguments_ = command.slice(1); - const child = spawn(executable, arguments_, { cwd: directory, shell: false }); + const child = spawnProcess(executable, arguments_, { cwd: directory }); let stdout = ""; let stderr = ""; let error: Error | undefined; - child.stdout.on("data", (chunk: Buffer | string) => { + child.stdout?.on("data", (chunk: Buffer | string) => { stdout = appendOutput(stdout, chunk.toString()); }); - child.stderr.on("data", (chunk: Buffer | string) => { + child.stderr?.on("data", (chunk: Buffer | string) => { stderr = appendOutput(stderr, chunk.toString()); }); child.once("error", (spawnError) => { diff --git a/packages/server/src/server/plugins/provider-icon.test.ts b/packages/server/src/server/plugins/provider-icon.test.ts new file mode 100644 index 0000000000..4faf9d289c --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.test.ts @@ -0,0 +1,84 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { readPluginProviderIcon } from "./provider-icon.js"; + +const directories: string[] = []; + +async function writeIcon(contents: string): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-icon-")); + directories.push(directory); + await writeFile(path.join(directory, "icon.svg"), contents); + return directory; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("readPluginProviderIcon", () => { + test("accepts a self-contained SVG", async () => { + const svg = + ''; + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).resolves.toBe(svg); + }); + + test.each([ + ["non-SVG document", "hello", "file is not an SVG document"], + ["script", "", "script elements"], + ["foreign object", "", "foreignObject elements"], + ["style", "", "style elements"], + ["event handler", '', "event-handler attributes"], + ["javascript URL", '', "javascript URLs"], + ["external href", '', "external href"], + ])("rejects %s", async (_name, svg, reason) => { + const directory = await writeIcon(svg); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + `icon.svg": ${reason}`, + ); + }); + + test("rejects a path outside the plugin directory", async () => { + const directory = await writeIcon(""); + + await expect(readPluginProviderIcon(directory, "../icon.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a symlink that escapes the plugin directory", async () => { + const directory = await writeIcon(""); + const externalDirectory = await mkdtemp(path.join(tmpdir(), "paseo-external-icon-")); + directories.push(externalDirectory); + const externalIcon = path.join(externalDirectory, "external.svg"); + await writeFile(externalIcon, ""); + await symlink(externalIcon, path.join(directory, "linked.svg")); + + await expect(readPluginProviderIcon(directory, "linked.svg")).rejects.toThrow( + "path leaves the plugin directory", + ); + }); + + test("rejects a directory instead of a regular file", async () => { + const directory = await writeIcon(""); + await mkdir(path.join(directory, "nested.svg")); + + await expect(readPluginProviderIcon(directory, "nested.svg")).rejects.toThrow( + "file does not exist or is not a regular file", + ); + }); + + test("rejects an icon larger than 64 KiB", async () => { + const directory = await writeIcon(`${" ".repeat(64 * 1024)}`); + + await expect(readPluginProviderIcon(directory, "icon.svg")).rejects.toThrow( + "file exceeds 64 KiB", + ); + }); +}); diff --git a/packages/server/src/server/plugins/provider-icon.ts b/packages/server/src/server/plugins/provider-icon.ts new file mode 100644 index 0000000000..9a1e3f6714 --- /dev/null +++ b/packages/server/src/server/plugins/provider-icon.ts @@ -0,0 +1,64 @@ +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +const MAX_ICON_BYTES = 64 * 1024; + +export async function readPluginProviderIcon( + pluginDirectory: string, + iconPath: string, +): Promise { + const resolvedDirectory = await realpath(path.resolve(pluginDirectory)); + const requestedIcon = path.resolve(resolvedDirectory, iconPath); + assertInsidePluginDirectory(resolvedDirectory, requestedIcon, iconPath); + const resolvedIcon = await realpath(requestedIcon).catch(() => null); + if (!resolvedIcon) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + assertInsidePluginDirectory(resolvedDirectory, resolvedIcon, iconPath); + + const iconStat = await stat(resolvedIcon); + if (!iconStat.isFile()) { + throw iconError(iconPath, "file does not exist or is not a regular file"); + } + if (iconStat.size > MAX_ICON_BYTES) throw iconError(iconPath, "file exceeds 64 KiB"); + + const svg = await readFile(resolvedIcon, "utf8"); + validateSvg(iconPath, svg); + return svg; +} + +function assertInsidePluginDirectory( + resolvedDirectory: string, + resolvedIcon: string, + iconPath: string, +): void { + const relative = path.relative(resolvedDirectory, resolvedIcon); + const escapesDirectory = + relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + if (escapesDirectory) throw iconError(iconPath, "path leaves the plugin directory"); +} + +function validateSvg(iconPath: string, svg: string): void { + if (!/^\s*)/i.test(svg)) throw iconError(iconPath, "file is not an SVG document"); + if (/)/i.test(svg)) throw iconError(iconPath, "script elements are not allowed"); + if (/)/i.test(svg)) { + throw iconError(iconPath, "foreignObject elements are not allowed"); + } + if (/)/i.test(svg)) throw iconError(iconPath, "style elements are not allowed"); + if (/\son[a-z0-9_-]*\s*=/i.test(svg)) { + throw iconError(iconPath, "event-handler attributes are not allowed"); + } + if (/javascript\s*:/i.test(svg)) throw iconError(iconPath, "javascript URLs are not allowed"); + + const hrefPattern = /\s(?:href|xlink:href)\s*=\s*(?:(["'])(.*?)\1|([^\s>]+))/gi; + for (const match of svg.matchAll(hrefPattern)) { + const href = match[2] ?? match[3] ?? ""; + if (!href.startsWith("#")) { + throw iconError(iconPath, "external href references are not allowed"); + } + } +} + +function iconError(iconPath: string, reason: string): Error { + return new Error(`Invalid plugin provider icon "${iconPath}": ${reason}`); +} diff --git a/packages/server/src/server/plugins/requirements.posix.test.ts b/packages/server/src/server/plugins/requirements.posix.test.ts new file mode 100644 index 0000000000..65462336a3 --- /dev/null +++ b/packages/server/src/server/plugins/requirements.posix.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import pino from "pino"; +import { afterEach, expect, it } from "vitest"; +import { DaemonConfigStore } from "../daemon-config-store.js"; +import { runGitCommand } from "../../utils/run-git-command.js"; +import { PluginService } from "./index.js"; +import { ManagedPluginSources } from "./managed-source.js"; + +const roots: string[] = []; +const services: PluginService[] = []; +async function directory() { + const root = await mkdtemp(path.join(tmpdir(), "plugin-requirements-")); + roots.push(root); + return root; +} +async function writePlugin(root: string, paseo?: string, build?: string[][]) { + await writeFile( + path.join(root, "paseo-plugin.json"), + JSON.stringify({ + id: "example", + requirements: paseo === undefined ? undefined : { paseo }, + build, + }), + ); + await writeFile( + path.join(root, "index.client.ts"), + "export default function contribute() { return () => {}; }", + ); +} +async function host(version = "0.8.0", pluginPath?: string) { + const home = await directory(); + const store = new DaemonConfigStore(home, { + mcp: { injectIntoAgents: true }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + pluginsEnabled: true, + plugins: pluginPath + ? { example: { source: "directory", path: pluginPath, enabled: true } } + : {}, + }); + const service = new PluginService(pino({ level: "silent" }), store, version, { + managedSources: new ManagedPluginSources(home), + }); + services.push(service); + await service.start(); + return { home, store, service }; +} +afterEach(async () => { + await Promise.all(services.splice(0).map((service) => service.stopAllPlugins())); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +it("rejects incompatible installs without persisting them, then accepts the corrected manifest", async () => { + const root = await directory(); + await writePlugin(root, ">=0.9.0"); + const { service, store } = await host(); + await expect(service.installDirectory({ path: root })).rejects.toThrow("Your daemon is 0.8.0"); + expect(store.get().plugins).toEqual({}); + expect(service.catalog()).toEqual([]); + await writePlugin(root, "^0.8.0"); + await expect(service.installDirectory({ path: root })).resolves.toMatchObject({ + status: "running", + }); + expect(service.catalog()).toEqual([ + { id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) }, + ]); +}); + +it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => { + const root = await directory(); + await writePlugin(root); + const { service } = await host("0.8.0", root); + expect(await service.listPlugins()).toEqual([ + expect.objectContaining({ + status: "failed", + error: expect.stringContaining("https://paseo.sh/docs/plugins/migration"), + }), + ]); + await writePlugin(root, ">=0.8.0"); + await expect(service.reloadPlugin("example")).resolves.toMatchObject({ status: "running" }); + await writePlugin(root, ">=0.9.0"); + await expect(service.reloadPlugin("example")).rejects.toThrow("requires Paseo >=0.9.0"); + expect(service.catalog()).toEqual([]); +}); + +it("still requires entry migration when an old plugin adds a compatible requirement", async () => { + const root = await directory(); + await writePlugin(root, ">=0.8.0"); + await rm(path.join(root, "index.client.ts")); + await writeFile(path.join(root, "index.ts"), "export default () => () => {};"); + const { service } = await host(); + await expect(service.installDirectory({ path: root })).rejects.toThrow( + "https://paseo.sh/docs/plugins/migration", + ); +}); + +it("rejects Git install and update before build commands, preserving the running revision", async () => { + const repository = await directory(); + await runGitCommand(["init", "-b", "main"], { cwd: repository }); + await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository }); + await runGitCommand(["config", "user.email", "tests@example.test"], { cwd: repository }); + const commit = async () => { + await runGitCommand(["add", "-A"], { cwd: repository }); + await runGitCommand(["commit", "-m", "plugin"], { cwd: repository }); + }; + await writePlugin(repository, ">=0.8.0"); + await commit(); + const { service, home } = await host(); + const source = pathToFileURL(repository).href; + const installed = await service.installSource({ source }); + const marker = path.join(home, "build-executed"); + await writePlugin(repository, ">=0.9.0", [ + [process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], "ran")', marker], + ]); + await commit(); + const [preview] = await service.previewUpdates({ pluginId: "example" }); + expect(preview).toMatchObject({ outcome: "update" }); + await expect(service.applyUpdates([preview!.proposal!])).resolves.toMatchObject([ + { id: "example", outcome: "error", error: expect.stringContaining("requires Paseo >=0.9.0") }, + ]); + expect(await service.listPlugins()).toEqual([installed]); + expect(service.catalog()).toHaveLength(1); + expect(await readdir(path.join(home, "plugins", ".staging"))).toEqual([]); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(service.installSource({ source, id: "second" })).rejects.toThrow( + "requires Paseo >=0.9.0", + ); + await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await service.listPlugins()).toEqual([installed]); +}); diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 6980a6f7e3..8dd80ce499 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -1,15 +1,23 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { fork } from "node:child_process"; import { tmpdir } from "node:os"; import { PassThrough } from "node:stream"; import path from "node:path"; import { fileURLToPath } from "node:url"; import pino from "pino"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentStreamEvent } from "../agent/agent-sdk-types.js"; +import { PluginAgentClientRegistry } from "../agent/plugin-provider.js"; import { PluginRuntime } from "./runtime.js"; import type { PluginSessionSocket } from "./session-socket.js"; const temporaryDirectories: string[] = []; +function hasCompletedAgentTurn(events: readonly AgentStreamEvent[]): boolean { + return events.some((event) => event.type === "turn_completed"); +} + async function createPlugin(id: string, source: string): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); @@ -18,7 +26,40 @@ async function createPlugin(id: string, source: string): Promise { return directory; } -function createReloadChild(name: string, events: string[], methods: string[] = []) { +async function readTextIfPresent(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +function pluginMessages(runtime: PluginRuntime, pluginId: string): string[] { + return runtime.getLogs(pluginId).map((entry) => entry.message); +} + +function spawnObservedPluginProcess(onMessage: (message: unknown) => void) { + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const setup = `import { register } from "node:module"; register(${JSON.stringify(loaderUrl)});`; + const child = fork(new URL("./plugin-process.ts", import.meta.url), [], { + execArgv: [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(setup)}`, + ], + serialization: "advanced", + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + child.on("message", onMessage); + return child; +} + +function createReloadChild( + name: string, + events: string[], + methods: string[] = [], + providers: Array<{ id: string; label: string }> = [], +) { const listeners = new Map void>>(); const emit = (event: string, message: unknown) => { for (const listener of listeners.get(event) ?? []) listener(message as never); @@ -32,7 +73,7 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ callback?.(null); if (message.type === "initialize") { events.push(`start:${name}`); - queueMicrotask(() => emit("message", { type: "ready", methods })); + queueMicrotask(() => emit("message", { type: "ready", methods, providers })); } if (message.type === "shutdown") { events.push(`shutdown:${name}`); @@ -59,14 +100,18 @@ function createReloadChild(name: string, events: string[], methods: string[] = [ listeners.set(event, registered); return this; }, + emitMessage(message: unknown) { + emit("message", message); + }, }; } function createTestRuntime( dependencies: NonNullable[2]> = {}, logger = pino({ level: "silent" }), + version = "0.4.0", ): PluginRuntime { - return new PluginRuntime(logger, "0.4.0", { + return new PluginRuntime(logger, version, { ...dependencies, sessionHost: dependencies.sessionHost ?? { async attachPluginSocket(_pluginId, socket) { @@ -84,7 +129,7 @@ function createTestRuntime( status: "server_info", serverId: "plugin-test", hostname: "plugin-test", - version: "0.4.0", + version, features: {}, }, }, @@ -99,8 +144,28 @@ function createTestRuntime( function createTrackedSessionHost() { const active = new Set(); + // One entry per hello handshake: an attached socket is not a redialled one. + const hellos: object[] = []; + const waiters = new Set<{ count: number; resolve: () => void }>(); + function recordHello(socket: object): void { + hellos.push(socket); + for (const waiter of waiters) { + if (hellos.length < waiter.count) continue; + waiters.delete(waiter); + waiter.resolve(); + } + } return { active, + hellos, + // Resolves on the handshake itself rather than after a delay, so the test + // waits for the event it cares about instead of a guess at how long it takes. + waitForHellos(count: number): Promise { + if (hellos.length >= count) return Promise.resolve(); + return new Promise((resolve) => { + waiters.add({ count, resolve }); + }); + }, host: { async attachPluginSocket(_pluginId: string, socket: PluginSessionSocket) { const closed = new Promise((resolve) => socket.once("close", resolve)); @@ -110,6 +175,7 @@ function createTrackedSessionHost() { if (typeof data !== "string") return; const message = JSON.parse(data); if (message.type !== "hello") return; + recordHello(socket); socket.send( JSON.stringify({ type: "session", @@ -140,6 +206,42 @@ function lifecycleMessages(runtime: PluginRuntime): string[] { .sort(); } +function hasCompletedTurn(events: readonly ProviderEvent[]): boolean { + return events.some((event) => event.type === "session.turn" && event.state === "completed"); +} + +function hasReadyRequest(events: readonly ProviderEvent[], requestId: string): boolean { + return events.some((event) => event.type === "session.ready" && event.requestId === requestId); +} + +function runtimeFailure(events: readonly ProviderEvent[]): ProviderEvent | undefined { + return events.find((event) => event.type === "session.runtime_failed"); +} + +function providerCloseCount( + messages: readonly { type: string; connectionId?: string }[], + connectionId: string | undefined, +): number { + return messages.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ).length; +} + +function hasMessageType(messages: readonly { type: string }[], type: string): boolean { + return messages.some((message) => message.type === type); +} + +function adaptRuntimeProvider( + runtime: PluginRuntime, + pluginId: string, + metadata: { id: string; label: string; description?: string; icon?: string }, +): ProviderRegistration { + return { + ...metadata, + connect: (request) => runtime.connectProvider(pluginId, metadata.id, request), + }; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), @@ -147,6 +249,582 @@ afterEach(async () => { }); describe("PluginRuntime", () => { + it.each([ + { specifier: "@getpaseo/plugin", moduleDirectory: "shared" }, + { specifier: "@getpaseo/plugin", moduleDirectory: "server" }, + ])( + "loads $specifier contracts without React in the subprocess module graph", + async ({ specifier, moduleDirectory }) => { + const directory = await createPlugin( + "react-free", + `import { rpc } from "./${moduleDirectory}/contract"; +export default function contribute(server) { + server.handle(rpc, (input) => input); + return () => {}; +}`, + ); + await mkdir(path.join(directory, moduleDirectory)); + await writeFile( + path.join(directory, moduleDirectory, "contract.ts"), + `import { defineRpc, defineAttachmentSource } from "${specifier}"; +import { z } from "zod"; +export const rpc = defineRpc({ name: "echo", input: z.string(), output: z.string() }); +const source = defineAttachmentSource({ id: "test", search: rpc }); +if (source.search !== rpc) throw new Error("Attachment contract was not preserved");`, + ); + // Reject imports even when the workspace has React installed. This covers the host's + // static graph and SDK imports evaluated through the compiled plugin's runtimeRequire. + const guard = `export function resolve(specifier, context, nextResolve) { + if (/^(react|react-dom|react-native|use-sync-external-store)(\\/|$)/.test(specifier)) { + throw new Error("React module reached plugin subprocess: " + specifier); + } + return nextResolve(specifier, context); +}`; + const guardUrl = `data:text/javascript,${encodeURIComponent(guard)}`; + const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href; + const setup = `import { register } from "node:module"; +register(${JSON.stringify(loaderUrl)}); +register(${JSON.stringify(guardUrl)});`; + const runtime = createTestRuntime({ + spawnChild: () => + fork(new URL("./plugin-process.ts", import.meta.url), [], { + execArgv: [ + "--experimental-strip-types", + "--import", + `data:text/javascript,${encodeURIComponent(setup)}`, + ], + serialization: "advanced", + stdio: ["ignore", "pipe", "pipe", "ipc"], + }), + }); + try { + await runtime.startPlugin("react-free", directory); + await expect(runtime.invoke("react-free", "echo", "hello")).resolves.toBe("hello"); + } catch (error) { + throw new Error(JSON.stringify(runtime.getLogs("react-free")), { cause: error }); + } finally { + await runtime.stopAll(); + } + }, + ); + + it("runs the direct example through the existing AgentClient path", async () => { + const pluginId = "provider-direct-example"; + const directory = fileURLToPath( + new URL("../../../../../plugin-examples/provider-direct/", import.meta.url), + ); + const runtime = createTestRuntime({}, undefined, "0.8.0"); + await runtime.startPlugin(pluginId, directory); + const [metadata] = runtime.getProviderRegistrations(pluginId); + expect(metadata).toBeDefined(); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, pluginId, metadata!)]); + const client = adapters.clients()[metadata!.id]; + + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await expect(session.startTurn("hello", { clientMessageId: "direct-client" })).resolves.toEqual( + { turnId: "turn-1" }, + ); + await expect.poll(() => hasCompletedAgentTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { + type: "assistant_message", + id: undefined, + messageId: "assistant-1", + text: "Echo: hello", + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ type: "timeline" }), + }), + ); + + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("runs a provider connection through the real plugin subprocess boundary", async () => { + const directory = await createPlugin( + "provider-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; + +const provider: ProviderRegistration = { + id: "direct-example", + label: "Direct example", + icon: "icon.svg", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { + for (const listener of listeners) listener(event); + }; + return { + version: request.versions[0] ?? 1, + capabilities: request.capabilities, + async send(input) { + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: request.capabilities, + restoration: "core", + persistence: { version: 1, data: { resume: "native-1" } }, + cwd: input.config.cwd, + futureField: "ignored by this protocol version", + } as ProviderEvent); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "example-1", + models: [{ id: "example-1", label: "Example 1" }], + modes: [], + thinkingOptions: [], + settings: [ + { type: "toggle", id: "concise", label: "Concise", value: true }, + ], + }, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return; + } + if (input.type !== "session.prompt") return; + if (input.prompt.input.type === "command") { + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "completed" }, + }); + return; + } + emit({ + type: "timeline.item", + sessionId: input.sessionId, + item: { + type: "user_message", + id: "user-1", + text: "hello", + clientMessageId: input.prompt.clientMessageId, + }, + }); + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "turn", turnId: "turn-1" }, + }); + emit({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: input.sessionId, + capabilities: [], + restoration: "parent", + cwd: "/repo", + }); + emit({ + type: "timeline.item", + sessionId: "child-1", + item: { + type: "plugin", + id: "review-1", + pluginId: "provider-round-trip", + kind: "review", + version: 1, + data: { verdict: "ship" }, + }, + }); + }, + onEvent(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async close() {}, + }; + }, +}; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(provider); + return () => {}; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-round-trip", directory); + + const [registration] = runtime.getProviderRegistrations("provider-round-trip"); + expect(registration).toMatchObject({ + id: "direct-example", + label: "Direct example", + iconPath: "icon.svg", + }); + const connection = await runtime.connectProvider("provider-round-trip", "direct-example", { + versions: [1], + capabilities: [ + "prompt.message", + "prompt.command", + "session.configure", + "session.persistence", + "session.subsession", + "timeline.plugin", + "future.capability", + ], + }); + expect(connection.capabilities).not.toContain("future.capability"); + const events: ProviderEvent[] = []; + const unsubscribe = connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-1", + sessionId: "root-1", + config: { + cwd: "/repo", + env: {}, + mcpServers: {}, + settings: {}, + persist: true, + }, + history: "replay", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-1", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-1", + prompt: { + clientMessageId: "client-2", + delivery: "auto", + input: { type: "command", name: "compact", arguments: "" }, + }, + }); + + await expect.poll(() => events.length).toBe(8); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-1", + result: { type: "turn", turnId: "turn-1" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-2", + result: { type: "completed" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.opened", + sessionId: "child-1", + parentSessionId: "root-1", + }), + ); + expect(events.find((event) => event.type === "session.opened")).not.toHaveProperty( + "futureField", + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + sessionId: "child-1", + item: expect.objectContaining({ type: "plugin", pluginId: "provider-round-trip" }), + }), + ); + + unsubscribe(); + await connection.close(); + await runtime.stopAll(); + }); + + it("adapts an ACP command and example transformer through the AgentClient path", async () => { + const transformerPath = fileURLToPath( + new URL( + "../../../../../plugin-examples/provider-acp-transformer/server/vendor-edit.ts", + import.meta.url, + ), + ); + const directory = await createPlugin( + "provider-acp-round-trip", + `import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { runAcpProvider } from "@getpaseo/plugin/server/acp"; +import { vendorEditTransformer } from "./server/vendor-edit.js"; + +export default function contribute(server: PluginServerContext) { + server.registerProvider(runAcpProvider({ + id: "acp-example", + label: "ACP example", + command: [process.execPath, __ACP_COMMAND__], + transformers: [vendorEditTransformer], + })); + return () => {}; +}`, + ); + await mkdir(path.join(directory, "server")); + await writeFile( + path.join(directory, "server/vendor-edit.ts"), + await readFile(transformerPath, "utf8"), + "utf8", + ); + const agentPath = path.join(directory, "fake-acp.cjs"); + await writeFile( + agentPath, + `const readline = require("node:readline"); +const lines = readline.createInterface({ input: process.stdin }); +const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n"); +lines.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + send({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: message.params.protocolVersion, agentCapabilities: {} } }); + } else if (message.method === "session/new") { + send({ jsonrpc: "2.0", id: message.id, result: { sessionId: "native-1", modes: null, configOptions: [] } }); + } else if (message.method === "session/prompt") { + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "tool_call", toolCallId: "edit-1", name: "vendor_file_edit", title: "Vendor edit", status: "completed", rawInput: { path: "/tmp/example.ts", before: "old", after: "new" } } } }); + send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "native-1", update: { sessionUpdate: "agent_message_chunk", messageId: "assistant-1", content: { type: "text", text: "ACP says hi" } } } }); + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + } else if (message.method === "session/close" || message.method === "session/cancel") { + send({ jsonrpc: "2.0", id: message.id, result: {} }); + } +});`, + "utf8", + ); + const pluginPath = path.join(directory, "index.server.ts"); + const pluginSource = (await readFile(pluginPath, "utf8")).replace( + "__ACP_COMMAND__", + JSON.stringify(agentPath), + ); + await writeFile(pluginPath, pluginSource, "utf8"); + + const runtime = createTestRuntime(); + await runtime.startPlugin("provider-acp-round-trip", directory); + const connection = await runtime.connectProvider("provider-acp-round-trip", "acp-example", { + versions: [1], + capabilities: ["prompt.message", "session.persistence"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + + await connection.send({ + type: "session.open", + requestId: "open-acp", + sessionId: "paseo-1", + config: { cwd: directory, env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await expect.poll(() => hasReadyRequest(events, "open-acp")).toBe(true); + await connection.send({ + type: "session.prompt", + sessionId: "paseo-1", + prompt: { + clientMessageId: "client-acp", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "hello" }] }, + }, + }); + + await expect.poll(() => hasCompletedTurn(events)).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "session.prompt_result", + clientMessageId: "client-acp", + result: { type: "turn", turnId: "acp:client-acp" }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline.item", + item: expect.objectContaining({ + type: "assistant_message", + id: "assistant-1", + text: "ACP says hi", + }), + }), + ); + + await connection.close(); + const [metadata] = runtime.getProviderRegistrations("provider-acp-round-trip"); + const adapters = new PluginAgentClientRegistry(pino({ level: "silent" })); + adapters.replace([adaptRuntimeProvider(runtime, "provider-acp-round-trip", metadata!)]); + const client = adapters.clients()[metadata!.id]; + const session = await client!.createSession({ provider: metadata!.id, cwd: directory }); + const agentEvents: AgentStreamEvent[] = []; + session.subscribe((event) => agentEvents.push(event)); + await expect( + session.startTurn("hello", { clientMessageId: "agent-client-acp" }), + ).resolves.toEqual({ turnId: "acp:agent-client-acp" }); + await expect.poll(() => hasCompletedAgentTurn(agentEvents)).toBe(true); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "assistant_message", text: "ACP says hi" }), + }), + ); + expect(agentEvents).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + type: "tool_call", + callId: "edit-1", + detail: { + type: "edit", + filePath: "/tmp/example.ts", + oldString: "old", + newString: "new", + unifiedDiff: undefined, + }, + }), + }), + ); + await session.close(); + await adapters.shutdown(); + await runtime.stopAll(); + }); + + it("rejects a malformed provider event from a real plugin subprocess", async () => { + const directory = await createPlugin( + "malicious-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; +let connectionId = ""; +process.on("message", (message: unknown) => { + const value = message as { type?: string; connectionId?: string }; + if (value.type === "provider.connect") connectionId = value.connectionId ?? ""; +}); +const provider: ProviderRegistration = { + id: "malicious", + label: "Malicious", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } else if (input.type === "session.prompt") { + process.send?.({ type: "provider.event", connectionId, event: { type: "invented.event", sessionId: input.sessionId } }); + } + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("malicious-provider", directory); + const connection = await runtime.connectProvider("malicious-provider", "malicious", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-malicious", + sessionId: "root-malicious", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + + await expect( + connection.send({ + type: "session.prompt", + sessionId: "root-malicious", + prompt: { + clientMessageId: "malformed-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "break transport" }] }, + }, + }), + ).rejects.toThrow("invalid message"); + expect(events).toContainEqual( + expect.objectContaining({ type: "session.runtime_failed", sessionId: "root-malicious" }), + ); + expect(runtime.catalog()).toHaveLength(1); + await runtime.stopAll(); + }); + + it("emits runtime failure for live sessions when a real plugin process dies", async () => { + const directory = await createPlugin( + "dying-provider", + `import type { ProviderEvent, ProviderRegistration } from "@getpaseo/plugin/server/provider"; +const provider: ProviderRegistration = { + id: "dying", + label: "Dying", + async connect(request) { + const listeners = new Set<(event: ProviderEvent) => void>(); + const emit = (event: ProviderEvent) => { for (const listener of listeners) listener(event); }; + return { + version: request.versions[0] ?? 1, + capabilities: ["prompt.message"], + async send(input) { + if (input.type === "session.open") { + emit({ type: "session.opened", requestId: input.requestId, sessionId: input.sessionId, capabilities: ["prompt.message"], restoration: "core", cwd: input.config.cwd }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + } + if (input.type === "session.prompt") setTimeout(() => process.exit(17), 10); + }, + onEvent(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + async close() {}, + }; + }, +}; +export default function contribute(server: any) { server.registerProvider(provider); return () => undefined; }`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("dying-provider", directory); + const connection = await runtime.connectProvider("dying-provider", "dying", { + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send({ + type: "session.open", + requestId: "open-dying", + sessionId: "root-dying", + config: { cwd: "/repo", env: {}, mcpServers: {}, settings: {}, persist: true }, + history: "skip", + }); + await connection.send({ + type: "session.prompt", + sessionId: "root-dying", + prompt: { + clientMessageId: "die-now", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "bye" }] }, + }, + }); + + await expect + .poll(() => runtimeFailure(events)) + .toMatchObject({ + type: "session.runtime_failed", + sessionId: "root-dying", + error: { message: "Plugin process exited: dying-provider" }, + }); + await runtime.stopAll(); + }); + it("records host-owned plugin lifecycle events", async () => { const directory = await createPlugin( "lifecycle", @@ -382,38 +1060,367 @@ export default function contribute(plugin: unknown) { await rm(cleanupFile, { force: true }); }); - it("does not kill a healthy child while its graceful cleanup is still running", async () => { + it("rejects provider input while a connection is closing", async () => { + const releaseDirectory = await mkdtemp(path.join(tmpdir(), "paseo-provider-close-")); + const releaseFile = path.join(releaseDirectory, "release"); + const directory = await createPlugin( + "closing-provider", + `import { readFile, writeFile } from "node:fs/promises"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; +const provider: ProviderRegistration = { + id: "delayed", + label: "Delayed", + async connect() { + return { + version: 1, + capabilities: [], + async send() { console.log("provider accepted input"); }, + onEvent() { return () => undefined; }, + async close() { + console.log("provider close started"); + while (true) { + try { await readFile(${JSON.stringify(releaseFile)}); break; } + catch { await new Promise((resolve) => setTimeout(resolve, 10)); } + } + console.log("provider close completed"); + }, + }; + }, +}; +export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) { + server.registerProvider(provider); + return () => console.log("plugin cleanup started"); +}`, + ); + const closedReports: unknown[] = []; + const observeMessage = (message: unknown) => { + if ((message as { type?: string }).type === "provider.closed") closedReports.push(message); + }; + const runtime = createTestRuntime({ + spawnChild: () => spawnObservedPluginProcess(observeMessage), + }); + await runtime.startPlugin("closing-provider", directory); + try { + const connection = await runtime.connectProvider("closing-provider", "delayed", { + versions: [1], + capabilities: [], + }); + const closing = connection.close(); + await expect + .poll(() => pluginMessages(runtime, "closing-provider")) + .toContain("provider close started"); + await expect(connection.send({ type: "catalog", requestId: "during-close" })).rejects.toThrow( + "Provider connection is closing", + ); + const stopping = runtime.stopPluginById("closing-provider"); + await expect + .poll(() => pluginMessages(runtime, "closing-provider")) + .toContain("plugin cleanup started"); + await writeFile(releaseFile, "release"); + await Promise.all([closing, stopping]); + const messages = pluginMessages(runtime, "closing-provider"); + expect(messages).toContain("provider close completed"); + expect(messages).not.toContain("provider accepted input"); + expect(closedReports).toHaveLength(1); + } finally { + await writeFile(releaseFile, "release"); + await runtime.stopAll(); + await rm(releaseDirectory, { recursive: true, force: true }); + } + }); + + it("closes a provider connection that resolves during plugin shutdown", async () => { + const suffix = `${process.pid}-${Date.now()}`; + const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`); + const closedFile = path.join(tmpdir(), `paseo-provider-connect-closed-${suffix}`); + const directory = await createPlugin( + "shutdown-connect", + `import { writeFile } from "node:fs/promises"; +import type { ProviderRegistration } from "@getpaseo/plugin/server/provider"; + +const provider: ProviderRegistration = { + id: "delayed", + label: "Delayed", + async connect() { + await writeFile(${JSON.stringify(startedFile)}, "started"); + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + version: 1, + capabilities: [], + async send() {}, + onEvent() { return () => undefined; }, + async close() { await writeFile(${JSON.stringify(closedFile)}, "closed"); }, + }; + }, +}; + +export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) { + server.registerProvider(provider); + return () => undefined; +}`, + ); + const runtime = createTestRuntime(); + await runtime.startPlugin("shutdown-connect", directory); + const pending = runtime.connectProvider("shutdown-connect", "delayed", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => readTextIfPresent(startedFile)).toBe("started"); + + const stopping = runtime.stopPluginById("shutdown-connect"); + await expect(pending).rejects.toThrow("Plugin stopped: shutdown-connect"); + await stopping; + + await expect(readFile(closedFile, "utf8")).resolves.toBe("closed"); + await Promise.all([rm(startedFile, { force: true }), rm(closedFile, { force: true })]); + }); + + it("closes a provider connection that arrives after negotiation timed out", async () => { + const directory = await createPlugin( + "late-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "late-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("late-provider", directory); + vi.useFakeTimers(); + try { + const rejected = expect( + runtime.connectProvider("late-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await rejected; + const connectionId = sent.find( + (message) => message.type === "provider.connect", + )?.connectionId; + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(1); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await vi.advanceTimersByTimeAsync(0); + + expect( + sent.filter( + (message) => message.type === "provider.close" && message.connectionId === connectionId, + ), + ).toHaveLength(2); + child.emitMessage({ type: "provider.closed", connectionId }); + } finally { + await runtime.stopAll(); + vi.useRealTimers(); + } + }); + + it("closes child-side state after invalid provider negotiation", async () => { + const directory = await createPlugin( + "invalid-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "invalid-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connected", + connectionId: message.connectionId, + version: 2, + capabilities: [], + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("invalid-provider", directory); + + await expect( + runtime.connectProvider("invalid-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("unoffered version"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + await expect.poll(() => sent).toContainEqual({ type: "provider.close", connectionId }); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after the child rejected its negotiation", async () => { + const directory = await createPlugin( + "rejected-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "rejected-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect") { + callback?.(null); + queueMicrotask(() => + child.emitMessage({ + type: "provider.connect_failed", + connectionId: message.connectionId, + error: "provider rejected connection", + }), + ); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("rejected-provider", directory); + + await expect( + runtime.connectProvider("rejected-provider", "provider", { + versions: [1], + capabilities: [], + }), + ).rejects.toThrow("provider rejected connection"); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + child.emitMessage({ type: "provider.closed", connectionId }); + await runtime.stopAll(); + }); + + it("closes a late connection after plugin shutdown cancels negotiation", async () => { + const directory = await createPlugin( + "canceled-provider", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const child = createReloadChild( + "canceled-provider", + [], + [], + [{ id: "provider", label: "Provider" }], + ); + const sent: Array<{ type: string; connectionId?: string }> = []; + const originalSend = child.send.bind(child); + child.send = (message, callback) => { + sent.push(message); + if (message.type === "provider.connect" || message.type === "shutdown") { + callback?.(null); + return true; + } + return originalSend(message, callback); + }; + const runtime = createTestRuntime({ spawnChild: () => child }); + await runtime.startPlugin("canceled-provider", directory); + const pending = runtime.connectProvider("canceled-provider", "provider", { + versions: [1], + capabilities: [], + }); + await expect.poll(() => hasMessageType(sent, "provider.connect")).toBe(true); + const connectionId = sent.find((message) => message.type === "provider.connect")?.connectionId; + const rejection = pending.then( + () => null, + (error: unknown) => error, + ); + const stopping = runtime.stopPluginById("canceled-provider"); + try { + await expect.poll(() => providerCloseCount(sent, connectionId)).toBe(1); + await expect(rejection).resolves.toMatchObject({ + message: "Plugin stopped: canceled-provider", + }); + const closesBeforeLateSuccess = providerCloseCount(sent, connectionId); + child.emitMessage({ + type: "provider.connected", + connectionId, + version: 1, + capabilities: [], + }); + await expect + .poll(() => providerCloseCount(sent, connectionId)) + .toBe(closesBeforeLateSuccess + 1); + } finally { + child.kill(); + await stopping; + } + }); + + it("escalates a plugin that ignores graceful shutdown from TERM to KILL", async () => { vi.useFakeTimers(); try { const directory = await createPlugin( "held-cleanup", `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, ); - const events: string[] = []; - const child = createReloadChild("held-cleanup", events); + const child = createReloadChild("held-cleanup", []); const originalSend = child.send.bind(child); - let releaseCleanup = () => undefined; child.send = (message, callback) => { if (message.type !== "shutdown") return originalSend(message, callback); callback?.(null); - events.push("shutdown:held-cleanup"); - releaseCleanup = () => { - child.connected = false; - child.kill(); - child.killed = false; - }; + return true; + }; + const signals: Array = []; + const originalKill = child.kill.bind(child); + child.kill = (signal?: NodeJS.Signals) => { + signals.push(signal); + if (signal === "SIGKILL") return originalKill(); + child.killed = true; return true; }; const runtime = createTestRuntime({ spawnChild: () => child }); await runtime.startPlugin("held-cleanup", directory); const stopping = runtime.stopPluginById("held-cleanup"); - await vi.advanceTimersByTimeAsync(60_000); - expect(child.killed).toBe(false); - - releaseCleanup(); + await vi.advanceTimersByTimeAsync(2_000); + expect(signals).toEqual(["SIGTERM"]); + await vi.advanceTimersByTimeAsync(2_000); await stopping; - expect(events).toContain("shutdown:held-cleanup"); + expect(signals).toEqual(["SIGTERM", "SIGKILL"]); } finally { vi.useRealTimers(); } @@ -483,7 +1490,7 @@ export default function contribute(plugin: unknown) { path.dirname(fileURLToPath(import.meta.url)), "../../../../../plugin-examples/linear", ); - const runtime = createTestRuntime(); + const runtime = createTestRuntime({}, undefined, "0.8.0"); await runtime.startPlugin("linear", directory); @@ -494,7 +1501,7 @@ export default function contribute(plugin: unknown) { await runtime.stopAll(); }); - it("rejects an index.ts plugin with the migration path", async () => { + it("explains that an index.ts plugin was made for an older Paseo version", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); await Promise.all([ @@ -504,10 +1511,7 @@ export default function contribute(plugin: unknown) { const runtime = createTestRuntime(); await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( - "Plugin entry split is required", - ); - await expect(runtime.startPlugin("legacy", directory)).rejects.toThrow( - "https://paseo.sh/docs/plugins/v0.8/migration", + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", ); }); @@ -615,34 +1619,6 @@ export default function contribute(server: any) { await runtime.stopAll(); }); - // COMPAT(plugin-sdk-scope): plugins scaffolded through 0.5.0-beta.1 import the unpublished - // @paseo/plugin name. Drop with the specifiers in plugin-sdk-specifiers.ts. - it("loads a plugin that imports the pre-rename @paseo/plugin specifier", async () => { - const directory = await createPlugin( - "legacy-sdk", - `import { z } from "zod"; -import { defineRpc } from "@paseo/plugin"; - -const pingRpc = defineRpc({ - name: "ping", - input: z.object({}), - output: z.object({ ok: z.boolean() }), -}); - -export default function contribute(plugin: any) { - plugin.handle(pingRpc, async () => ({ ok: true })); - return () => undefined; -}`, - ); - const runtime = createTestRuntime(); - - await runtime.startPlugin("legacy-sdk", directory); - - await expect(runtime.invoke("legacy-sdk", "ping", {})).resolves.toMatchObject({ ok: true }); - - await runtime.stopAll(); - }); - it("keeps client and server modules in their target runtime", async () => { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-")); temporaryDirectories.push(directory); @@ -659,7 +1635,7 @@ export default function contribute(plugin: any) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { client.addSurface("main", Surface); @@ -669,7 +1645,7 @@ export default function contribute(client: PluginClientContext) { ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspectRpc } from "./shared/inspect"; import { inspectHost } from "./server/inspect"; export default function contribute(server: PluginServerContext) { @@ -742,7 +1718,7 @@ export function inspectHost(_input: z.input) { ), writeFile( path.join(directory, "index.client.tsx"), - `import type { PluginClientContext } from "@getpaseo/plugin"; + `import type { PluginClientContext } from "@getpaseo/plugin/client"; import { Surface } from "./client/surface"; export default function contribute(client: PluginClientContext) { @@ -787,7 +1763,7 @@ export function Surface() { return readSecret(); }`, ), writeFile( path.join(directory, "index.server.ts"), - `import type { PluginServerContext } from "@getpaseo/plugin"; + `import type { PluginServerContext } from "@getpaseo/plugin/server"; import { inspect } from "./server/inspect"; import { inspectRpc } from "./shared/inspect"; @@ -908,4 +1884,174 @@ export default function contribute(plugin: any) { "Plugin is not available", ); }); + + it("re-attaches a plugin session when the daemon closes the socket under a live child", async () => { + const directory = await createPlugin( + "reattaching", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const sessions = createTrackedSessionHost(); + const runtime = createTestRuntime({ sessionHost: sessions.host }); + await runtime.startPlugin("reattaching", directory); + + expect(sessions.active.size).toBe(1); + expect(sessions.hellos).toHaveLength(1); + const first = [...sessions.active][0] as PluginSessionSocket; + + // What an expired lease does: drop the socket, leave the process running. + first.close(1000, "expired application lease"); + + // A second handshake, not just a second attachment: the client really redialled. + await sessions.waitForHellos(2); + + expect(sessions.active.size).toBe(1); + expect([...sessions.active][0]).not.toBe(first); + await runtime.stopAll(); + expect(sessions.active.size).toBe(0); + }); + + it("replaces a closed plugin session only once the child sends a fresh hello", async () => { + const directory = await createPlugin( + "lazy-reattach", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const sessions = createTrackedSessionHost(); + const child = createReloadChild("lazy-reattach", []); + const runtime = createTestRuntime({ spawnChild: () => child, sessionHost: sessions.host }); + await runtime.startPlugin("lazy-reattach", directory); + const first = [...sessions.active][0] as PluginSessionSocket; + // This child is a stub with no real client, so no handshake has happened. + expect(sessions.hellos).toHaveLength(0); + + first.close(1000, "expired application lease"); + expect(sessions.active.size).toBe(0); + + // Frames the child had already queued belong to the session that just died. + // Standing a socket up for one would leave it unspoken to until the host's + // hello timeout closed it, and that close would stand up another. + child.emitMessage({ + type: "paseo_frame", + data: JSON.stringify({ type: "session", message: { type: "ping" } }), + isBinary: false, + }); + expect(sessions.active.size).toBe(0); + + child.emitMessage({ + type: "paseo_frame", + data: JSON.stringify({ + type: "hello", + clientId: "plugin:lazy-reattach", + clientType: "cli", + protocolVersion: 1, + }), + isBinary: false, + }); + await sessions.waitForHellos(1); + + expect(sessions.active.size).toBe(1); + expect([...sessions.active][0]).not.toBe(first); + await runtime.stopAll(); + expect(sessions.active.size).toBe(0); + }); + + it("attaches no replacement session when the plugin is stopped", async () => { + const directory = await createPlugin( + "stopping", + `export default function contribute(plugin: unknown) { void plugin; return () => undefined; }`, + ); + const sessions = createTrackedSessionHost(); + const runtime = createTestRuntime({ sessionHost: sessions.host }); + await runtime.startPlugin("stopping", directory); + expect(sessions.active.size).toBe(1); + + await runtime.stopPluginById("stopping"); + + expect(runtime.getLogs("stopping").map((entry) => entry.message)).not.toContain( + "[paseo] Re-attached plugin session", + ); + expect(sessions.active.size).toBe(0); + }); + it("closes an in-flight replacement when the plugin stops before attachment completes", async () => { + const directory = await createPlugin( + "stopping-redial", + `export default function contribute() { return () => undefined; }`, + ); + const child = createReloadChild("stopping-redial", []); + const sessions = createTrackedSessionHost(); + let finishAttachment!: () => void; + const attachmentHeld = new Promise((resolve) => { + finishAttachment = resolve; + }); + let replacementAttached!: (socket: PluginSessionSocket) => void; + const replacementStarted = new Promise((resolve) => { + replacementAttached = resolve; + }); + let attachments = 0; + const runtime = createTestRuntime({ + spawnChild: () => child, + sessionHost: { + async attachPluginSocket(pluginId, socket) { + const attachment = await sessions.host.attachPluginSocket(pluginId, socket); + attachments += 1; + if (attachments === 2) { + replacementAttached(socket); + await attachmentHeld; + } + return attachment; + }, + }, + }); + try { + await runtime.startPlugin("stopping-redial", directory); + const first = [...sessions.active][0] as PluginSessionSocket; + first.close(); + child.emitMessage({ + type: "paseo_frame", + data: JSON.stringify({ type: "hello" }), + isBinary: false, + }); + const replacement = await replacementStarted; + const replacementClosed = new Promise((resolve) => replacement.once("close", resolve)); + await runtime.stopPluginById("stopping-redial"); + finishAttachment(); + await replacementClosed; + expect(sessions.active.size).toBe(0); + expect(sessions.hellos).toEqual([]); + expect(runtime.catalog()).toEqual([]); + child.emitMessage({ + type: "paseo_frame", + data: JSON.stringify({ type: "hello" }), + isBinary: false, + }); + expect(attachments).toBe(2); + } finally { + finishAttachment(); + await runtime.stopAll(); + } + }); +}); + +it("rejects a usage source without discovery at registration", async () => { + const directory = await createPlugin( + "missing-discovery", + ` +export default function contribute(server) { + server.registerUsageSource({ + id: "missing-discovery", + label: "Missing discovery", + input: { parseAsync: async (value) => value }, + fetch: async () => ({ status: "available", windows: [] }), + }); + return () => {}; +} +`, + ); + const runtime = createTestRuntime(); + try { + await expect(runtime.startPlugin("missing-discovery", directory)).rejects.toThrow( + "Invalid usage source: missing-discovery", + ); + } finally { + await runtime.stopAll(); + } }); diff --git a/packages/server/src/server/plugins/runtime.ts b/packages/server/src/server/plugins/runtime.ts index 11951ecad3..9886a368e7 100644 --- a/packages/server/src/server/plugins/runtime.ts +++ b/packages/server/src/server/plugins/runtime.ts @@ -1,14 +1,41 @@ +import type { UsageScope } from "@getpaseo/plugin/server/usage"; +import { + ProviderStatusSchema, + type ProviderStatus, + type ProviderStatusRequest, +} from "@getpaseo/plugin/server/provider"; +import type { PluginBeforeRequests, PluginLifecycleEvents } from "@getpaseo/plugin/server"; +import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.js"; import { fork } from "node:child_process"; import { stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { randomUUID } from "node:crypto"; import type pino from "pino"; +import { + PROVIDER_CAPABILITIES, + requireProviderCapabilities, + type ProviderConnectRequest, + type ProviderCatalogOptions, + type ProviderConnection, + type ProviderEvent, + type ProviderInput, +} from "@getpaseo/plugin/server/provider"; import type { PluginLogEntry } from "@getpaseo/protocol/messages"; import { compilePlugin } from "./compiler.js"; import { readPluginManifest } from "./manifest.js"; -import type { PluginProcessMessage, PluginProcessRequest } from "./plugin-process-protocol.js"; +import type { PluginRequirements } from "@getpaseo/protocol/messages"; +import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements"; +import type { + PluginProcessMessage, + PluginProcessRequest, + PluginProviderMetadata, + PluginUsageSourceMetadata, +} from "./plugin-process-protocol.js"; +import { PluginProcessMessageSchema } from "./plugin-process-protocol.js"; import { PluginSessionSocket } from "./session-socket.js"; +import { InternalPluginChild } from "./internal-child.js"; +import { evaluateBundle } from "./bundle-evaluator.js"; const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const; const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const; @@ -16,6 +43,7 @@ const REQUEST_TIMEOUT_MS = 30_000; const MAX_LOG_ENTRIES = 500; const MAX_LOG_BYTES = 256 * 1024; const MAX_LOG_LINE_BYTES = 16 * 1024; +const SOFT_SHUTDOWN_TIMEOUT_MS = 2_000; interface PluginOutputStream { on(event: "data", listener: (chunk: Buffer | string) => void): this; @@ -27,9 +55,9 @@ interface PluginChild { stdout?: PluginOutputStream | null; stderr?: PluginOutputStream | null; send(message: PluginProcessRequest, callback?: (error: Error | null) => void): boolean; - kill(): boolean; + kill(signal?: NodeJS.Signals): boolean; disconnect(): void; - on(event: "message", listener: (message: PluginProcessMessage) => void): this; + on(event: "message", listener: (message: unknown) => void): this; on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; } @@ -41,16 +69,61 @@ interface PendingInvocation { interface LoadedPlugin { id: string; + requirements: PluginRequirements | undefined; clientBundle: string; methods: ReadonlySet; + hooks: { events: string[]; before: string[] }; + providers: readonly PluginProviderMetadata[]; + usageSources: readonly PluginUsageSourceMetadata[]; child: PluginChild | null; outputCapture: PluginOutputCapture | null; pending: Map; + providerConnections: Map; + providerConnectionTombstones: Set; sessionSocket: PluginSessionSocket | null; sessionClosed: Promise | null; } +/** The session socket a loaded plugin currently speaks through. */ +interface PluginSessionBinding { + socket: PluginSessionSocket; + reattaching: boolean; + plugin: LoadedPlugin | null; +} + +interface PluginFrameInput { + session: PluginSessionBinding; + pluginId: string; + child: PluginChild; + sessionHost: PluginPaseoSessionHost; + frame: string | Uint8Array; + isBinary: boolean; +} + +interface PendingProviderSend { + input: ProviderInput; + resolve: () => void; + reject: (error: Error) => void; +} + +interface RemoteProviderConnection { + request: ProviderConnectRequest; + capabilities: readonly string[]; + listeners: Set<(event: ProviderEvent) => void>; + pendingSends: Map; + sessions: Map; + connected: boolean; + closed: boolean; + closedPromise: Promise; + resolveConnected(connection: ProviderConnection): void; + rejectConnected(error: Error): void; + resolveClosed(): void; + rejectClosed(error: Error): void; +} + interface PluginRuntimeDependencies { + settingsDirectory?: string; + onSettingsChanged?: (pluginId: string, settingsId: string) => void; spawnChild?: () => PluginChild; sessionHost?: PluginPaseoSessionHost; } @@ -174,6 +247,12 @@ function describeError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function jsonTransportValue(value: Value): Value { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error("Provider value is not JSON-serializable"); + return JSON.parse(encoded) as Value; +} + function send(child: PluginChild, message: PluginProcessRequest): Promise { return new Promise((resolve, reject) => { child.send(message, (error) => { @@ -207,7 +286,7 @@ async function resolveEntryPaths(directory: string): Promise<{ const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]); if (legacyEntry) { throw new Error( - "Plugin entry split is required: migrate index.ts to index.client.tsx and/or index.server.ts. See https://paseo.sh/docs/plugins/v0.8/migration", + "This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration", ); } throw new Error( @@ -217,6 +296,7 @@ async function resolveEntryPaths(directory: string): Promise<{ export class PluginRuntime { private readonly plugins = new Map(); + private readonly pendingEvents = new Set>(); private readonly logTails = new Map(); private readonly logger: pino.Logger; private readonly spawnChild: () => PluginChild; @@ -226,7 +306,7 @@ export class PluginRuntime { constructor( logger: pino.Logger, private readonly daemonVersion: string, - dependencies: PluginRuntimeDependencies = {}, + private readonly dependencies: PluginRuntimeDependencies = {}, ) { this.logger = logger.child({ module: "plugins" }); this.spawnChild = dependencies.spawnChild ?? spawnPluginChild; @@ -263,9 +343,33 @@ export class PluginRuntime { this.appendLog(pluginId, "stdout", "[paseo] Plugin ready"); } + async startBuiltinPlugin(input: { id: string; directory: string }): Promise { + if (this.plugins.has(input.id)) throw new Error(`Plugin is already running: ${input.id}`); + this.appendLog(input.id, "stdout", "[paseo] Loading plugin"); + const directory = path.resolve(input.directory); + const manifest = await readPluginManifest(directory); + if (manifest.id !== input.id) { + throw new Error(`Built-in plugin ${input.id} has manifest ID ${manifest.id}`); + } + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); + const bundles = await compilePlugin(await resolveEntryPaths(directory)); + if (!bundles.serverBundle) throw new Error(`Built-in plugin ${input.id} needs a server entry`); + const loaded = await this.launchPlugin({ + pluginId: input.id, + pluginDirectory: directory, + requirements: manifest.requirements, + child: new InternalPluginChild(evaluateBundle(bundles.serverBundle)), + bundle: "", + clientBundle: bundles.clientBundle ?? "", + }); + this.plugins.set(input.id, loaded); + this.appendLog(input.id, "stdout", "[paseo] Plugin ready"); + } + async validatePlugin(configuredPath: string): Promise { const directory = path.resolve(configuredPath); - await readPluginManifest(directory); + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const entryPaths = await resolveEntryPaths(directory); await compilePlugin(entryPaths); } @@ -279,12 +383,114 @@ export class PluginRuntime { return true; } - catalog(): Array<{ id: string; clientBundle: string }> { + catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> { return [...this.plugins.values()] - .map(({ id, clientBundle }) => ({ id, clientBundle })) + .map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements })) .sort((left, right) => left.id.localeCompare(right.id)); } + getProviderRegistrations(pluginId: string): readonly PluginProviderMetadata[] { + return this.plugins.get(pluginId)?.providers ?? []; + } + + getUsageSourceRegistrations(pluginId: string): readonly PluginUsageSourceMetadata[] { + return this.plugins.get(pluginId)?.usageSources ?? []; + } + + fetchUsage(pluginId: string, sourceId: string, input: unknown): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input }); + } + + discoverUsage(pluginId: string, sourceId: string, scope: UsageScope): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return this.request(loaded, { + type: "usage.discover", + requestId: randomUUID(), + sourceId, + scope, + }); + } + + async getProviderStatus( + pluginId: string, + providerId: string, + request: ProviderStatusRequest, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + return ProviderStatusSchema.parse( + await this.request(loaded, { + type: "provider.status", + requestId: randomUUID(), + providerId, + request, + }), + ); + } + + async connectProvider( + pluginId: string, + providerId: string, + request: ProviderConnectRequest, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded?.child) throw new Error(`Plugin is not available: ${pluginId}`); + if (!loaded.providers.some((provider) => provider.id === providerId)) { + throw new Error(`Plugin ${pluginId} does not contribute provider ${providerId}`); + } + const connectionId = randomUUID(); + let resolveConnected!: (connection: ProviderConnection) => void; + let rejectConnected!: (error: Error) => void; + let resolveClosed!: () => void; + let rejectClosed!: (error: Error) => void; + const connected = new Promise((resolve, reject) => { + resolveConnected = resolve; + rejectConnected = reject; + }); + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve; + rejectClosed = reject; + }); + void closed.catch(() => undefined); + const state: RemoteProviderConnection = { + request, + capabilities: [], + listeners: new Set(), + pendingSends: new Map(), + sessions: new Map(), + connected: false, + closed: false, + closedPromise: closed, + resolveConnected, + rejectConnected, + resolveClosed, + rejectClosed, + }; + loaded.providerConnections.set(connectionId, state); + const timeout = setTimeout(() => { + if (state.connected) return; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider connection timed out: ${pluginId}.${providerId}`), + ); + }, REQUEST_TIMEOUT_MS); + void send(loaded.child, { + type: "provider.connect", + providerId, + connectionId, + request, + }).catch((error) => { + clearTimeout(timeout); + this.abandonProviderConnect(loaded, connectionId, state, error); + }); + return connected.finally(() => clearTimeout(timeout)); + } + getLogs(pluginId: string): PluginLogEntry[] { return ( this.logTails.get(pluginId)?.entries.map((entry) => ({ @@ -305,16 +511,107 @@ export class PluginRuntime { if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); if (!loaded.methods.has(method)) throw new Error(`Plugin ${pluginId} does not contribute RPC ${method}`); + return this.request(loaded, { type: "invoke", requestId: randomUUID(), method, input }); + } + + emit( + name: Name, + event: PluginLifecycleEvents[Name], + ): void { + for (const loaded of this.plugins.values()) { + if (!loaded.hooks.events.includes(name)) { + continue; + } + const request = this.request(loaded, { + type: "hook", + requestId: randomUUID(), + kind: "event", + name, + input: event, + }) + .then(() => undefined) + .catch((error) => { + this.appendLog( + loaded.id, + "stderr", + `Lifecycle hook ${name} failed: ${describeError(error)}`, + ); + }); + this.pendingEvents.add(request); + void request.finally(() => this.pendingEvents.delete(request)); + } + } + + async drainEvents(): Promise { + await Promise.all(this.pendingEvents); + } + + async before( + name: Name, + input: PluginBeforeRequests[Name], + ): Promise { + let request = validateBeforeRequest(name, input); + const plugins = [...this.plugins.values()].sort((left, right) => { + return left.id.localeCompare(right.id); + }); + for (const loaded of plugins) { + if (!loaded.hooks.before.includes(name)) { + continue; + } + try { + const output = await this.request(loaded, { + type: "hook", + requestId: randomUUID(), + kind: "before", + name, + input: request, + }); + request = validateBeforeResult(name, request, output); + } catch (error) { + throw new Error(`Plugin ${loaded.id} before ${name} failed: ${describeError(error)}`, { + cause: error, + }); + } + } + return request; + } + + async getProviderCatalogCacheKey( + pluginId: string, + providerId: string, + options: ProviderCatalogOptions, + ): Promise { + const loaded = this.plugins.get(pluginId); + if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`); + const output = await this.request(loaded, { + type: "provider.catalog_key", + requestId: randomUUID(), + providerId, + options, + }); + if (output !== undefined && typeof output !== "string") + throw new Error("Invalid catalogue key from plugin"); + return output; + } + + private request( + loaded: LoadedPlugin, + message: Extract, + ): Promise { const child = loaded.child; + const pluginId = loaded.id; if (!child) throw new Error(`Plugin ${pluginId} has no server entry`); - const requestId = randomUUID(); + const requestId = message.requestId; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { loaded.pending.delete(requestId); - reject(new Error(`Plugin RPC timed out: ${pluginId}.${method}`)); + if (message.type === "hook") { + void send(child, { type: "hook.cancel", requestId }).catch(() => {}); + } + reject(new Error(`Plugin RPC timed out: ${pluginId}.${message.type}`)); }, REQUEST_TIMEOUT_MS); loaded.pending.set(requestId, { resolve, reject, timeout }); - void send(child, { type: "invoke", requestId, method, input }).catch((error) => { + void send(child, message).catch((error) => { clearTimeout(timeout); loaded.pending.delete(requestId); reject(error); @@ -336,7 +633,8 @@ export class PluginRuntime { configuredPath: string, ): Promise { const directory = path.resolve(configuredPath); - await readPluginManifest(directory); + const manifest = await readPluginManifest(directory); + assertPluginCompatibility({ ...manifest, version: this.daemonVersion, runtime: "daemon" }); const entryPaths = await resolveEntryPaths(directory); const bundles = await compilePlugin(entryPaths); const serverBundle = bundles.serverBundle; @@ -344,95 +642,227 @@ export class PluginRuntime { return { id: pluginId, clientBundle: bundles.clientBundle ?? "", + requirements: manifest.requirements, methods: new Set(), + hooks: { events: [], before: [] }, + providers: [], + usageSources: [], child: null, outputCapture: null, pending: new Map(), + providerConnections: new Map(), + providerConnectionTombstones: new Set(), sessionSocket: null, sessionClosed: null, }; } + return this.launchPlugin({ + pluginId, + pluginDirectory: directory, + requirements: manifest.requirements, + child: this.spawnChild(), + bundle: serverBundle, + clientBundle: bundles.clientBundle ?? "", + }); + } + + private async launchPlugin(input: { + pluginId: string; + pluginDirectory: string; + requirements: PluginRequirements | undefined; + child: PluginChild; + bundle: string; + clientBundle: string; + }): Promise { + const { pluginId, requirements, child, bundle, clientBundle } = input; const sessionHost = this.sessionHost; if (!sessionHost) throw new Error("Plugin Paseo session host is not attached"); - const child = this.spawnChild(); const outputCapture = new PluginOutputCapture(child, (stream, message) => { this.appendLog(pluginId, stream, message); }); - const sessionSocket = new PluginSessionSocket(child); + const session: PluginSessionBinding = { + socket: new PluginSessionSocket(child), + reattaching: false, + plugin: null, + }; const pending = new Map(); const sessionAttachment = await sessionHost - .attachPluginSocket(pluginId, sessionSocket) + .attachPluginSocket(pluginId, session.socket) .catch((error) => { terminatePluginChild(child); throw error; }); let loaded: LoadedPlugin | null = null; - let methods: string[]; + let ready: Extract; try { - methods = await new Promise((resolve, reject) => { - let settled = false; - const timeout = setTimeout( - () => fail(new Error(`Plugin ${pluginId} did not initialize`)), - REQUEST_TIMEOUT_MS, - ); - const fail = (error: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject(error); - }; - child.on("message", (message) => { - if (message.type === "paseo_frame") { - sessionSocket.receive(message.data, message.isBinary); - } else if (message.type === "paseo_close") { - sessionSocket.peerClosed(); - } else if (message.type === "ready") { + ready = await new Promise>( + (resolve, reject) => { + let settled = false; + const timeout = setTimeout( + () => fail(new Error(`Plugin ${pluginId} did not initialize`)), + REQUEST_TIMEOUT_MS, + ); + const fail = (error: Error): void => { if (settled) return; settled = true; clearTimeout(timeout); - resolve(message.methods); - } else if (message.type === "fatal") { - fail(new Error(message.error)); - } else if (loaded) { - this.handleChildMessage(loaded, message); - } - }); - child.on("close", () => { - sessionSocket.peerClosed(); - if (!loaded) { - fail(new Error(`Plugin ${pluginId} exited during initialization`)); - return; - } - void this.handleChildClose(loaded); - }); - void send(child, { - type: "initialize", - pluginId, - appVersion: this.daemonVersion, - bundle: serverBundle, - }).catch(fail); - }); + reject(error); + }; + child.on("message", (rawMessage) => { + const parsed = PluginProcessMessageSchema.safeParse(rawMessage); + if (!parsed.success) { + const error = new Error( + `Plugin ${pluginId} sent an invalid message: ${parsed.error.message}`, + ); + if (loaded) this.handleMalformedChildMessage(loaded, rawMessage, error); + else fail(error); + return; + } + const message = parsed.data; + if (message.type === "paseo_frame") { + this.routePluginFrame({ + session, + pluginId, + child, + sessionHost, + frame: message.data, + isBinary: message.isBinary, + }); + } else if (message.type === "paseo_close") { + session.socket.peerClosed(); + } else if (message.type === "ready") { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(message); + } else if (message.type === "fatal") { + fail(new Error(message.error)); + } else if (loaded) { + this.handleChildMessage(loaded, message); + } + }); + child.on("close", () => { + session.socket.peerClosed(); + if (!loaded) { + fail(new Error(`Plugin ${pluginId} exited during initialization`)); + return; + } + void this.handleChildClose(loaded); + }); + void send(child, { + type: "initialize", + pluginId, + pluginDirectory: input.pluginDirectory, + appVersion: this.daemonVersion, + bundle, + settingsDirectory: this.dependencies.settingsDirectory + ? path.join(this.dependencies.settingsDirectory, pluginId) + : undefined, + }).catch(fail); + }, + ); } catch (error) { - sessionSocket.close(); + session.socket.close(); await sessionAttachment.closed; terminatePluginChild(child); throw error; } loaded = { id: pluginId, - clientBundle: bundles.clientBundle ?? "", - methods: new Set(methods), + clientBundle, + requirements, + methods: new Set(ready.methods), + hooks: ready.hooks ?? { events: [], before: [] }, + providers: ready.providers ?? [], + usageSources: ready.usageSources ?? [], child, outputCapture, pending, - sessionSocket, + providerConnections: new Map(), + providerConnectionTombstones: new Set(), + sessionSocket: session.socket, sessionClosed: sessionAttachment.closed, }; - this.logger.info({ pluginId, methods }, "Loaded plugin"); + session.plugin = loaded; + this.logger.info( + { pluginId, methods: ready.methods, providers: ready.providers }, + "Loaded plugin", + ); return loaded; } + /** + * Frames still in flight belong to the closed session; only a fresh hello + * means the child redialled and wants a new one. Attaching on the close + * instead would leave a socket nobody speaks to until the host's hello + * timeout closed it, and that close would attach another. + */ + private routePluginFrame(input: PluginFrameInput): void { + const { session, frame, isBinary } = input; + if (session.socket.readyState === 1) { + session.socket.receive(frame, isBinary); + return; + } + if (session.reattaching || !isSessionHelloFrame({ frame, isBinary })) return; + if (!this.canReattachPluginSession(input)) return; + session.reattaching = true; + void this.reattachPluginSession(input).finally(() => { + session.reattaching = false; + }); + } + + private async reattachPluginSession(input: PluginFrameInput): Promise { + const { session, pluginId, sessionHost, child, frame, isBinary } = input; + const replacement = new PluginSessionSocket(child); + try { + const attachment = await sessionHost.attachPluginSocket(pluginId, replacement); + if (!this.canReattachPluginSession(input)) { + replacement.close(); + return; + } + session.socket = replacement; + if (session.plugin) { + session.plugin.sessionSocket = replacement; + session.plugin.sessionClosed = attachment.closed; + } + replacement.receive(frame, isBinary); + this.appendLog(pluginId, "stdout", "[paseo] Re-attached plugin session"); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.appendLog(pluginId, "stderr", `[paseo] Failed to re-attach plugin session: ${reason}`); + this.logger.warn({ pluginId, err: error }, "Failed to re-attach a plugin session"); + this.notify(pluginId, `Plugin session could not be re-attached: ${pluginId}`); + } + } + + /** + * A published plugin whose process is still up. Anything else is either still + * loading or being torn down, and must not get a new session. + */ + private canReattachPluginSession(input: { + session: PluginSessionBinding; + pluginId: string; + child: PluginChild; + }): boolean { + const { session, pluginId, child } = input; + return ( + session.plugin !== null && this.plugins.get(pluginId) === session.plugin && child.connected + ); + } + private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (message.type === "hooks.changed") { + loaded.hooks = message.hooks; + return; + } + if (message.type === "settings.changed") { + this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId); + return; + } + if (message.type.startsWith("provider.")) { + this.handleProviderMessage(loaded, message); + return; + } if (message.type !== "result" && message.type !== "error") return; const pending = loaded.pending.get(message.requestId); if (!pending) return; @@ -442,6 +872,295 @@ export class PluginRuntime { else pending.reject(new Error(message.error)); } + private handleProviderMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void { + if (!("connectionId" in message)) return; + const state = loaded.providerConnections.get(message.connectionId); + if (!state) { + if (!loaded.providerConnectionTombstones.has(message.connectionId)) return; + if (message.type === "provider.connected" && loaded.child?.connected) { + void send(loaded.child, { + type: "provider.close", + connectionId: message.connectionId, + }).catch(() => undefined); + } + if (message.type === "provider.closed" || message.type === "provider.connect_failed") { + loaded.providerConnectionTombstones.delete(message.connectionId); + } + return; + } + if (message.type === "provider.connected") { + this.finishProviderConnect(loaded, message.connectionId, state, message); + return; + } + if (message.type === "provider.connect_failed") { + this.abandonProviderConnect(loaded, message.connectionId, state, new Error(message.error)); + return; + } + if (message.type === "provider.accepted" || message.type === "provider.rejected") { + const pending = state.pendingSends.get(message.acceptanceId); + if (!pending) return; + state.pendingSends.delete(message.acceptanceId); + if (message.type === "provider.accepted") { + this.trackAcceptedProviderInput(state, pending.input); + pending.resolve(); + } else pending.reject(new Error(message.error)); + return; + } + if (message.type === "provider.event") { + this.publishProviderEvent(loaded, message.connectionId, state, message.event); + return; + } + if (message.type === "provider.closed") { + loaded.providerConnections.delete(message.connectionId); + if (message.error) { + this.failRemoteProviderConnection(state, new Error(message.error)); + return; + } + state.closed = true; + this.rejectProviderSends(state, "Provider connection closed"); + state.resolveClosed(); + } + } + + private finishProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + message: Extract, + ): void { + if (!Number.isInteger(message.version) || message.version < 1) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected invalid version: ${message.version}`), + ); + return; + } + if (!state.request.versions.includes(message.version)) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered version: ${message.version}`), + ); + return; + } + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = message.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.request.capabilities.includes(capability), + ); + if (invalidCapability) { + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin provider selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const capabilities = PROVIDER_CAPABILITIES.filter((capability) => + message.capabilities.includes(capability), + ); + const connection: ProviderConnection = { + version: message.version, + capabilities, + send: (input) => this.sendProviderInput(loaded, connectionId, state, input), + onEvent(listener) { + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + close: () => this.closeProviderConnection(loaded, connectionId, state), + }; + state.capabilities = capabilities; + state.connected = true; + state.resolveConnected(connection); + } + + private abandonProviderConnect( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + if (state.connected || state.closed) return; + loaded.providerConnections.delete(connectionId); + loaded.providerConnectionTombstones.add(connectionId); + state.closed = true; + state.rejectConnected(error); + state.rejectClosed(error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private sendProviderInput( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + input: ProviderInput, + ): Promise { + if (state.closed) return Promise.reject(new Error("Provider connection is closed")); + try { + if (input.type === "session.open" || !("sessionId" in input)) { + requireProviderCapabilities(state.capabilities, input); + } else { + const capabilities = state.sessions.get(input.sessionId); + if (!capabilities) throw new Error(`Unknown provider session: ${input.sessionId}`); + requireProviderCapabilities(capabilities, input); + } + } catch (error) { + return Promise.reject(error); + } + const child = loaded.child; + if (!child) return Promise.reject(new Error(`Plugin has no server entry: ${loaded.id}`)); + const acceptanceId = randomUUID(); + return new Promise((resolve, reject) => { + state.pendingSends.set(acceptanceId, { input, resolve, reject }); + void send(child, { + type: "provider.send", + connectionId, + acceptanceId, + input: jsonTransportValue(input), + }).catch((error) => { + state.pendingSends.delete(acceptanceId); + reject(error); + }); + }); + } + + private async closeProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + ): Promise { + if (state.closed) return; + const child = loaded.child; + if (!child) throw new Error(`Plugin has no server entry: ${loaded.id}`); + await send(child, { type: "provider.close", connectionId }); + await state.closedPromise; + } + + private publishProviderEvent( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + event: ProviderEvent, + ): void { + if ( + event.type === "timeline.item" && + event.item.type === "plugin" && + event.item.pluginId !== loaded.id + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error( + `Provider plugin ${loaded.id} cannot emit timeline items for ${event.item.pluginId}`, + ), + ); + return; + } + if (event.type === "session.opened") { + const knownCapabilities = new Set(PROVIDER_CAPABILITIES); + const invalidCapability = event.capabilities.find( + (capability) => + knownCapabilities.has(capability) && !state.capabilities.includes(capability), + ); + if (invalidCapability) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider session selected an unoffered capability: ${invalidCapability}`), + ); + return; + } + const selectedCapabilities = event.capabilities; + event = { + ...event, + capabilities: PROVIDER_CAPABILITIES.filter((capability) => + selectedCapabilities.includes(capability), + ), + }; + if ( + event.parentSessionId && + !state.sessions.get(event.parentSessionId)?.includes("session.subsession") + ) { + this.failProviderConnection( + loaded, + connectionId, + state, + new Error(`Provider parent session did not negotiate session.subsession`), + ); + return; + } + } + if (event.type === "session.opened") { + state.sessions.set(event.sessionId, event.capabilities); + } + for (const listener of state.listeners) listener(event); + if (event.type === "session.closed") state.sessions.delete(event.sessionId); + } + + private failProviderConnection( + loaded: LoadedPlugin, + connectionId: string, + state: RemoteProviderConnection, + error: Error, + ): void { + loaded.providerConnections.delete(connectionId); + this.failRemoteProviderConnection(state, error); + if (loaded.child?.connected) { + void send(loaded.child, { type: "provider.close", connectionId }).catch(() => undefined); + } + } + + private trackAcceptedProviderInput(state: RemoteProviderConnection, input: ProviderInput): void { + if (input.type === "session.open" && !state.sessions.has(input.sessionId)) { + state.sessions.set(input.sessionId, []); + } + if (input.type === "session.close") state.sessions.delete(input.sessionId); + } + + private handleMalformedChildMessage( + loaded: LoadedPlugin, + rawMessage: unknown, + error: Error, + ): void { + const connectionId = readConnectionId(rawMessage); + const state = connectionId ? loaded.providerConnections.get(connectionId) : undefined; + if (!connectionId || !state) { + this.appendLog(loaded.id, "stderr", `[paseo] ${error.message}`); + terminatePluginChild(loaded.child!); + return; + } + this.failProviderConnection(loaded, connectionId, state, error); + } + + private failRemoteProviderConnection(state: RemoteProviderConnection, error: Error): void { + if (state.closed) return; + for (const sessionId of state.sessions.keys()) { + const event: ProviderEvent = { + type: "session.runtime_failed", + sessionId, + error: { message: error.message }, + }; + for (const listener of state.listeners) listener(event); + } + state.closed = true; + state.rejectConnected(error); + this.rejectProviderSends(state, error.message); + state.rejectClosed(error); + } + + private rejectProviderSends(state: RemoteProviderConnection, message: string): void { + for (const pending of state.pendingSends.values()) pending.reject(new Error(message)); + state.pendingSends.clear(); + } + private async handleChildClose(loaded: LoadedPlugin): Promise { loaded.sessionSocket?.peerClosed(); const wasPublished = this.plugins.get(loaded.id) === loaded; @@ -449,12 +1168,25 @@ export class PluginRuntime { this.plugins.delete(loaded.id); } this.rejectPending(loaded, `Plugin process exited: ${loaded.id}`); + for (const state of loaded.providerConnections.values()) { + this.failRemoteProviderConnection(state, new Error(`Plugin process exited: ${loaded.id}`)); + } + loaded.providerConnections.clear(); await loaded.sessionClosed; if (wasPublished) this.notify(loaded.id, `Plugin process exited: ${loaded.id}`); } private async stopPlugin(loaded: LoadedPlugin): Promise { this.appendLog(loaded.id, "stdout", "[paseo] Stopping plugin"); + for (const [connectionId, state] of loaded.providerConnections) { + if (state.connected) continue; + this.abandonProviderConnect( + loaded, + connectionId, + state, + new Error(`Plugin stopped: ${loaded.id}`), + ); + } const { child, sessionSocket, sessionClosed } = loaded; if (!child || !sessionSocket || !sessionClosed) { this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -474,7 +1206,15 @@ export class PluginRuntime { if (child.connected) { await send(child, { type: "shutdown" }).catch(() => undefined); } - await closed; + let forceTimer: ReturnType | null = setTimeout(() => { + if (!child.killed) child.kill("SIGTERM"); + forceTimer = setTimeout(() => { + child.kill("SIGKILL"); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + }, SOFT_SHUTDOWN_TIMEOUT_MS); + await closed.finally(() => { + if (forceTimer) clearTimeout(forceTimer); + }); sessionSocket.peerClosed(); await sessionClosed; this.appendLog(loaded.id, "stdout", "[paseo] Plugin stopped"); @@ -515,3 +1255,19 @@ export class PluginRuntime { for (const listener of this.listeners) listener(pluginId, error); } } + +function isSessionHelloFrame(input: { frame: string | Uint8Array; isBinary: boolean }): boolean { + const { frame, isBinary } = input; + if (isBinary || typeof frame !== "string") return false; + try { + return (JSON.parse(frame) as { type?: unknown }).type === "hello"; + } catch { + return false; + } +} + +function readConnectionId(value: unknown): string | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const connectionId = Reflect.get(value, "connectionId"); + return typeof connectionId === "string" ? connectionId : null; +} diff --git a/packages/server/src/server/plugins/settings.e2e.test.ts b/packages/server/src/server/plugins/settings.e2e.test.ts new file mode 100644 index 0000000000..a6ccf3f2cc --- /dev/null +++ b/packages/server/src/server/plugins/settings.e2e.test.ts @@ -0,0 +1,190 @@ +import { resolveDaemonVersion } from "../daemon-version.js"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, onTestFinished, test } from "vitest"; +import { z } from "zod"; +import { defineRpc, settingsRpc } from "@getpaseo/plugin"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; + +test("server reads saved settings after daemon restart before any client connects", async () => { + const root = await mkdtemp(path.join(tmpdir(), "settings-restart-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const directory = path.join(root, "plugin"); + const startupReport = path.join(root, "startup.json"); + await mkdir(path.join(directory, "server"), { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "settings-startup", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(directory, "server", "report.ts"), + `import { writeFile } from "node:fs/promises"; +export async function reportStartup(settings) { + const state = await settings.read(); + await writeFile(${JSON.stringify(startupReport)}, JSON.stringify(state)); +}`, + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +import { reportStartup } from "./server/report"; +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + const startup = reportStartup(settings); + return async () => { await startup; }; +}`, + ); + const options = { + paseoHomeRoot: path.join(root, "daemon"), + staticDir: path.join(root, "static"), + cleanup: false, + pluginsEnabled: true, + plugins: { + "settings-startup": { source: "directory" as const, path: directory, enabled: true }, + }, + }; + const first = await createTestPaseoDaemon(options); + onTestFinished(() => first.close()); + const rpc = settingsRpc("display"); + const readStartup = async () => + rpc.read.output.parse(JSON.parse(await readFile(startupReport, "utf8"))); + await expect + .poll(readStartup) + .toEqual({ status: "ready", revision: "missing", values: { enabled: true } }); + + const client = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws`, appVersion: "0.8.0" }); + onTestFinished(() => client.close()); + await client.connect(); + const saved = rpc.write.output.parse( + await client.invokePluginRpc("settings-startup", rpc.write.name, { + revision: "missing", + values: { enabled: false }, + }), + ); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false } }); + const persisted = rpc.read.output.parse( + await client.invokePluginRpc("settings-startup", rpc.read.name, {}), + ); + await client.close(); + await first.close(); + await rm(startupReport); + + const restarted = await createTestPaseoDaemon(options); + onTestFinished(() => restarted.close()); + // No client is created for this daemon: the report comes from server startup alone. + await expect.poll(readStartup).toEqual(persisted); +}, 60_000); + +test("two clients share settings, observe changes, and preserve values through plugin lifecycle", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "settings-plugin-")); + const daemon = await createTestPaseoDaemon(); + const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" }); + const rpc = settingsRpc("display"); + const serverReadRpc = defineRpc({ + name: "settings-test.server-read", + input: z.object({}), + output: z.json(), + }); + const serverChangesRpc = defineRpc({ + name: "settings-test.server-changes", + input: z.object({}), + output: z.object({ count: z.number().int() }), + }); + const read = async (client: DaemonClient, pluginId = "settings-test") => + rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {})); + const changed: string[] = []; + try { + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ + id: "settings-test", + requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` }, + }), + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { defineRpc, defineSettings } from "@getpaseo/plugin"; +import { z } from "zod"; +const serverRead = defineRpc({ name: "settings-test.server-read", input: z.object({}), output: z.json() }); +const serverChanges = defineRpc({ name: "settings-test.server-changes", input: z.object({}), output: z.object({ count: z.number().int() }) }); +export default function(server) { + const settings = server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); + let changes = 0; + settings.subscribe(() => { changes += 1; }); + server.handle(serverRead, () => settings.read()); + server.handle(serverChanges, () => ({ count: changes })); + return () => {}; +}`, + ); + await first.connect(); + await second.connect(); + await second.observeEvents(["status.plugin_settings_changed"]).ready; + second.on("status", (message) => { + if (message.payload.status === "plugin_settings_changed") + changed.push(z.string().parse(message.payload.settingsId)); + }); + await first.patchDaemonConfig({ pluginsEnabled: true }); + await first.installDirectoryPlugin(directory); + const initial = await read(first); + expect(initial).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: true } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 0 }); + expect( + await second.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: false }, + }), + ).toMatchObject({ status: "saved" }); + await expect.poll(() => changed).toEqual(["display"]); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + expect( + serverReadRpc.output.parse( + await first.invokePluginRpc("settings-test", serverReadRpc.name, {}), + ), + ).toMatchObject({ status: "ready", values: { enabled: false } }); + expect( + serverChangesRpc.output.parse( + await first.invokePluginRpc("settings-test", serverChangesRpc.name, {}), + ), + ).toEqual({ count: 1 }); + expect( + await first.invokePluginRpc("settings-test", rpc.write.name, { + revision: initial.revision, + values: { enabled: true }, + }), + ).toMatchObject({ status: "conflict" }); + await first.reloadPlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.disablePlugin("settings-test"); + await first.enablePlugin("settings-test"); + expect(await read(first)).toMatchObject({ values: { enabled: false } }); + await first.installDirectoryPlugin(directory, "other-installation"); + expect(await read(first, "other-installation")).toMatchObject({ values: { enabled: true } }); + await first.removePlugin("settings-test"); + await first.installDirectoryPlugin(directory); + expect(await read(first)).toMatchObject({ values: { enabled: true } }); + } catch (error) { + console.error(await first.getPluginLogs("settings-test")); + throw error; + } finally { + await first.close(); + await second.close(); + await daemon.close(); + await rm(directory, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/settings/index.test.ts b/packages/server/src/server/plugins/settings/index.test.ts new file mode 100644 index 0000000000..de81919eae --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { defineSettings } from "@getpaseo/plugin"; +import type { PluginSettingsState } from "@getpaseo/plugin/server"; +import { z } from "zod"; +import { PluginSettingsStore } from "./index.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); +const definition = defineSettings({ + id: "display", + scope: "host", + version: 1, + schema: z.object({ + enabled: z.boolean().default(true), + count: z.number().int().min(1).default(5), + }), +}); +type DisplaySettingsState = PluginSettingsState; +function mutateNotification(state: DisplaySettingsState): void { + if (state.status === "ready") state.values.count = 99; +} +async function setup() { + const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-")); + roots.push(directory); + const changes: string[] = []; + const store = new PluginSettingsStore(directory, (id) => changes.push(id)); + return { directory, changes, handlers: store.register(definition) }; +} + +test("defaults, atomic saves, concurrent revisions, and restart persistence", async () => { + const { directory, changes, handlers } = await setup(); + expect(await handlers.read.handle()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const outcomes = await Promise.all([ + handlers.write.handle({ revision: "missing", values: { enabled: false, count: 10 } }), + handlers.write.handle({ revision: "missing", values: { enabled: true, count: 20 } }), + ]); + expect(outcomes.map((result) => result.status)).toEqual(["saved", "conflict"]); + const reopened = new PluginSettingsStore(directory, () => {}).register(definition); + expect(await reopened.read.handle()).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(changes).toEqual(["display"]); +}); + +test("server settings read current values and notify after successful writes", async () => { + const { handlers } = await setup(); + const notifications: unknown[] = []; + const unsubscribe = handlers.settings.subscribe((state) => { + notifications.push(state); + mutateNotification(state); + }); + + expect(await handlers.settings.read()).toEqual({ + status: "ready", + revision: "missing", + values: { enabled: true, count: 5 }, + }); + const saved = await handlers.write.handle({ + revision: "missing", + values: { enabled: false, count: 10 }, + }); + expect(saved).toMatchObject({ status: "saved", values: { enabled: false, count: 10 } }); + const current = await handlers.settings.read(); + expect(current).toMatchObject({ + status: "ready", + values: { enabled: false, count: 10 }, + }); + expect(notifications).toEqual([ + { + status: "ready", + revision: current.revision, + values: { enabled: false, count: 99 }, + }, + ]); + + expect( + await handlers.write.handle({ revision: current.revision, values: { count: -1 } }), + ).toMatchObject({ status: "invalid" }); + expect(notifications).toHaveLength(1); + + unsubscribe(); + await handlers.reset.handle({ revision: current.revision }); + expect(notifications).toHaveLength(1); +}); + +test("validation failure preserves disk and leaves the writer usable", async () => { + const { handlers, changes } = await setup(); + expect(await handlers.write.handle({ revision: "missing", values: { count: -1 } })).toMatchObject( + { status: "invalid" }, + ); + expect(await handlers.read.handle()).toMatchObject({ revision: "missing" }); + expect(changes).toEqual([]); + expect(await handlers.write.handle({ revision: "missing", values: { count: 2 } })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 2 }, + }); +}); + +test("migrates once, persists the validated version, and rejects old clients", async () => { + const { directory, handlers } = await setup(); + const saved = await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + if (saved.status !== "saved") throw new Error("save failed"); + let migrations = 0; + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + schema: z.object({ total: z.number().default(0) }), + migrate(values, version) { + migrations++; + expect(version).toBe(1); + return { total: z.object({ count: z.number() }).parse(values).count }; + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ status: "ready", values: { total: 7 } }); + await upgraded.read.handle(); + expect(migrations).toBe(1); + expect( + await handlers.write.handle({ revision: saved.revision, values: { count: 9 } }), + ).toMatchObject({ status: "conflict" }); + const newer = await handlers.read.handle(); + expect(newer).toMatchObject({ + status: "invalid", + error: expect.stringContaining("newer plugin"), + }); + expect( + await handlers.write.handle({ revision: newer.revision, values: { count: 9 } }), + ).toMatchObject({ status: "invalid" }); + expect(await upgraded.read.handle()).toMatchObject({ values: { total: 7 } }); +}); + +test("failed migrations and corrupt data survive reads until an explicit reset", async () => { + const { directory, handlers } = await setup(); + await handlers.write.handle({ revision: "missing", values: { count: 7 } }); + const file = path.join(directory, "display.json"); + const before = await readFile(file, "utf8"); + const upgraded = new PluginSettingsStore(directory, () => {}).register({ + ...definition, + version: 2, + migrate() { + throw new Error("migration failed"); + }, + }); + expect(await upgraded.read.handle()).toMatchObject({ + status: "invalid", + error: "migration failed", + }); + expect(await readFile(file, "utf8")).toBe(before); + await writeFile(file, "broken JSON"); + const invalid = await handlers.read.handle(); + expect(invalid.status).toBe("invalid"); + expect(await readFile(file, "utf8")).toBe("broken JSON"); + expect(await handlers.reset.handle({ revision: invalid.revision })).toMatchObject({ + status: "saved", + values: { enabled: true, count: 5 }, + }); +}); + +test("installation namespaces and definition IDs remain separate", async () => { + const first = await setup(); + const second = await setup(); + await first.handlers.write.handle({ revision: "missing", values: { enabled: false } }); + expect(await second.handlers.read.handle()).toMatchObject({ values: { enabled: true } }); + const store = new PluginSettingsStore(first.directory, () => {}); + store.register(definition); + expect(() => store.register(definition)).toThrow("Duplicate settings"); + expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID"); +}); diff --git a/packages/server/src/server/plugins/settings/index.ts b/packages/server/src/server/plugins/settings/index.ts new file mode 100644 index 0000000000..775a47f6a4 --- /dev/null +++ b/packages/server/src/server/plugins/settings/index.ts @@ -0,0 +1,185 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z, type ZodType } from "zod"; +import { defineSettings, settingsRpc, type SettingsDefinition } from "@getpaseo/plugin"; +import type { PluginSettings, PluginSettingsState } from "@getpaseo/plugin/server"; + +const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() }); +function message(error: unknown): string { + if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n"); + return error instanceof Error ? error.message : String(error); +} +const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex"); + +type SettingsListener = ( + state: PluginSettingsState, +) => void | Promise; +type SettingsWriteState = + | { status: "saved"; revision: string; values: z.output } + | { status: "conflict"; error: string } + | { status: "invalid"; error: string }; + +function reportListenerError(id: string, error: unknown): void { + console.error(`Plugin settings subscriber failed for ${id}`, error); +} + +function copySettingsState( + state: PluginSettingsState, +): PluginSettingsState { + if (state.status === "invalid") return { ...state }; + return { ...state, values: structuredClone(state.values) }; +} + +/** One instance per installation. The subprocess lifetime gives writes a single owner. */ +export class PluginSettingsStore { + private readonly definitions = new Map(); + private queue: Promise = Promise.resolve(); + + private readonly directory: string; + private readonly changed: (id: string) => void; + constructor(directory: string, changed: (id: string) => void) { + this.directory = directory; + this.changed = changed; + } + + register(definition: SettingsDefinition) { + defineSettings(definition); + if (this.definitions.has(definition.id)) + throw new Error(`Duplicate settings: ${definition.id}`); + this.definitions.set(definition.id, definition); + const listeners = new Set>(); + const notify = (state: PluginSettingsState): void => { + for (const listener of listeners) { + try { + void Promise.resolve(listener(copySettingsState(state))).catch((error) => + reportListenerError(definition.id, error), + ); + } catch (error) { + reportListenerError(definition.id, error); + } + } + }; + const rpc = settingsRpc(definition.id); + const read = () => this.serial(() => this.read(definition, notify)); + const write = (input: z.output) => + this.serial(() => this.write(definition, input.revision, input.values, "save", notify)); + const reset = (input: z.output) => + this.serial(() => this.write(definition, input.revision, {}, "reset", notify)); + const settings: PluginSettings = { + read, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + return { + settings, + read: { contract: rpc.read, handle: read }, + write: { contract: rpc.write, handle: write }, + reset: { contract: rpc.reset, handle: reset }, + }; + } + + private serial(work: () => Promise): Promise { + const pending = this.queue.then(work); + this.queue = pending.catch(() => undefined); + return pending; + } + + private async stored(id: string) { + try { + const raw = await readFile(path.join(this.directory, `${id}.json`), "utf8"); + return { raw, revision: revisionOf(raw) }; + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") + return { raw: null, revision: "missing" }; + throw error; + } + } + + private async read( + definition: SettingsDefinition, + notify: (state: PluginSettingsState) => void, + ): Promise> { + const stored = await this.stored(definition.id); + try { + const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw)); + let values: unknown = envelope?.values ?? {}; + if (envelope && envelope.version !== definition.version) { + if (envelope.version > definition.version) + throw new Error("Settings were saved by a newer plugin version"); + if (!definition.migrate) + throw new Error(`Settings version ${envelope.version} requires a migration`); + values = await definition.migrate(values, envelope.version); + } + const parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); + const migrated = envelope !== null && envelope.version !== definition.version; + const revision = migrated ? await this.persist(definition, parsed) : stored.revision; + const result: PluginSettingsState = { + status: "ready", + values: parsed, + revision, + }; + if (migrated) { + notify(result); + this.changed(definition.id); + } + return result; + } catch (error) { + return { status: "invalid", revision: stored.revision, error: message(error) }; + } + } + + private async write( + definition: SettingsDefinition, + revision: string, + values: unknown, + intent: "save" | "reset", + notify: (state: PluginSettingsState) => void, + ): Promise> { + const stored = await this.stored(definition.id); + if (stored.revision !== revision) + return { + status: "conflict", + error: "Settings changed on another client. Reload before saving again.", + }; + let parsed: z.output; + try { + if (intent === "save" && stored.raw !== null) { + const envelope = envelopeSchema.parse(JSON.parse(stored.raw)); + if (envelope.version !== definition.version) + throw new Error("Reload or reset settings before saving a different schema version"); + } + parsed = await definition.schema.parseAsync(values); + z.json().parse(parsed); + } catch (error) { + return { status: "invalid", error: message(error) }; + } + const nextRevision = await this.persist(definition, parsed); + notify({ status: "ready", values: parsed, revision: nextRevision }); + this.changed(definition.id); + return { status: "saved", values: parsed, revision: nextRevision }; + } + + private async persist( + definition: SettingsDefinition, + values: z.output, + ) { + const jsonValues = z.json().parse(values); + await mkdir(this.directory, { recursive: true }); + const target = path.join(this.directory, `${definition.id}.json`); + const temporary = `${target}.${randomUUID()}.tmp`; + const raw = JSON.stringify({ version: definition.version, values: jsonValues }); + try { + await writeFile(temporary, raw, { mode: 0o600 }); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + return revisionOf(raw); + } +} diff --git a/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts b/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts new file mode 100644 index 0000000000..8f0914a4c8 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/internal-seam/index.server.ts @@ -0,0 +1,18 @@ +import { defineRpc } from "@getpaseo/plugin"; +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; + +const state = defineRpc({ + name: "state", + input: z.object({}), + output: z.object({ workspaces: z.number() }), +}); + +export default function contribute(server: PluginServerContext) { + let workspaces = 0; + server.handle(state, () => ({ workspaces })); + server.on("workspace.created", () => { + workspaces += 1; + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json new file mode 100644 index 0000000000..e84dff55d8 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/internal-seam/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "internal-seam", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg new file mode 100644 index 0000000000..b7d6b25aef --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts new file mode 100644 index 0000000000..9a0f102a29 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/index.server.ts @@ -0,0 +1,47 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; + +let fetches = 0; +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "fixture-directory", + label: "Fixture", + icon: "icon.svg", + input: z.object({ account: z.string() }).strict(), + discover: async () => { + const inputs: Array> = [ + { account: "one" }, + { account: "bad", extra: true }, + { account: "throws" }, + { account: "expired" }, + ]; + return inputs.map((input) => ({ key: String(input.account), input })); + }, + fetch: async (input) => { + const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; + if (account === "throws") throw new Error("fixture failure"); + fetches++; + return { + status: "available", + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], + }; + }, + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json new file mode 100644 index 0000000000..273f46f948 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source-directory/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "usage-source-fixture", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg b/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg new file mode 100644 index 0000000000..b7d6b25aef --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts new file mode 100644 index 0000000000..e42aa64edc --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/index.server.ts @@ -0,0 +1,47 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { z } from "zod"; +import { windowFromReportedDuration } from "@getpaseo/plugin/server/usage"; + +let fetches = 0; +export default function contribute(server: PluginServerContext) { + server.registerUsageSource({ + id: "fixture", + label: "Fixture", + icon: "icon.svg", + input: z.object({ account: z.string() }).strict(), + discover: async () => { + const inputs: Array> = [ + { account: "one" }, + { account: "bad", extra: true }, + { account: "throws" }, + { account: "expired" }, + ]; + return inputs.map((input) => ({ key: String(input.account), input })); + }, + fetch: async (input) => { + const account = (input as { account: string }).account; + if (account === "expired") + return { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: new Date(Date.now() - 3_600_000).toISOString(), + refreshedBy: "claude", + }, + }; + if (account === "throws") throw new Error("fixture failure"); + fetches++; + return { + status: "available", + windows: [ + windowFromReportedDuration({ + durationSeconds: 604800, + unknown: { id: "unknown", label: "Limit", shortLabel: "" }, + utilizationPct: fetches, + }), + ], + }; + }, + }); + return () => {}; +} diff --git a/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json b/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json new file mode 100644 index 0000000000..a509a64163 --- /dev/null +++ b/packages/server/src/server/plugins/test-fixtures/usage-source/paseo-plugin.json @@ -0,0 +1 @@ +{ "id": "usage-source", "requirements": { "paseo": ">=0.8.0" } } diff --git a/packages/server/src/server/plugins/usage-sources.e2e.test.ts b/packages/server/src/server/plugins/usage-sources.e2e.test.ts new file mode 100644 index 0000000000..037da5e0c6 --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources.e2e.test.ts @@ -0,0 +1,337 @@ +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { expect, test } from "vitest"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { BuiltinPluginLoader, resolveBuiltinPluginsRoot } from "./builtin/index.js"; + +const fixtureRoot = fileURLToPath(new URL("./test-fixtures/", import.meta.url)); +const subprocessDirectory = fileURLToPath( + new URL("./test-fixtures/usage-source-directory/", import.meta.url), +); + +test("lists built-in and subprocess usage; validates input and isolates fetch errors", async () => { + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(fixtureRoot, ["usage-source"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + const updates: string[] = []; + const first = await client.listUsageReports({}, (report) => updates.push(report.id)); + expect(updates).toEqual(first.reports.map((report) => report.id)); + await expect(client.listUsageReports({ agentId: "missing-agent" })).rejects.toThrow( + "Unknown agent", + ); + expect(first.reports).toHaveLength(4); + expect(first.reports[0]?.icon).toContain(" entry.id === "fixture:one")?.report.windows[0]?.usedPct, + ).toBe(1); + expect(first.reports.filter((entry) => entry.report.status === "error")).toHaveLength(2); + expect( + (await client.listUsageReports()).reports.find((entry) => entry.id === "fixture:one")?.report + .windows[0]?.usedPct, + ).toBe(1); + expect( + ( + await client.listUsageReports({ reportIds: ["fixture:one"], forceRefresh: true }) + ).reports.find((entry) => entry.id === "fixture:one")?.report.windows[0]?.usedPct, + ).toBe(2); + expect( + (await client.listUsageReports({ reportIds: ["fixture:throws"] })).reports[0]?.report.status, + ).toBe("error"); + const expired = first.reports.find((entry) => entry.id === "fixture:expired"); + expect(expired?.report).toEqual({ + status: "unavailable", + problem: { kind: "expired", expiresAt: expect.any(String), refreshedBy: "claude" }, + }); + const legacy = await client.listProviderUsage(); + expect(legacy.providers.find((provider) => provider.displayName === "Fixture")?.status).toBe( + "available", + ); + expect(legacy.providers.find((provider) => provider.status === "unavailable")?.error).toBe( + "Login expired 1h ago. Run claude to refresh it.", + ); + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(subprocessDirectory, "fixture-directory"); + const both = await client.listUsageReports({ forceRefresh: true }); + expect(both.reports).toHaveLength(8); + for (const id of ["fixture:one", "fixture-directory:one"]) { + expect(both.reports.find((entry) => entry.id === id)?.report.windows[0]).toMatchObject({ + id: "weekly", + label: "Weekly", + shortLabel: "wk", + }); + } + await client.patchDaemonConfig({ pluginsEnabled: false }); + await expect.poll(async () => (await client.listUsageReports()).reports.length).toBe(4); + } finally { + await client.close(); + await daemon.close(); + } +}, 60_000); + +test("a packaged daemon serves usage from external built-in resources", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-asar-")); + const packagePath = path.join("node_modules", "@getpaseo", "server", "dist", "server"); + const resourceRoot = path.join(root, "builtin-plugins"); + // An archive is a file, so the external compiler cannot traverse paths beneath it. + // Actual Electron archive packaging is covered by the packaged-app smoke check. + await writeFile(path.join(root, "app.asar"), "opaque archive fixture"); + const directory = path.join(resourceRoot, "listed"); + await mkdir(path.join(directory, "server"), { recursive: true }); + await writeFile( + path.join(directory, "paseo-plugin.json"), + JSON.stringify({ id: "listed", requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(directory, "server", "contract.d.ts"), + "export interface Account { key: string; label: string; }", + ); + await writeFile( + path.join(directory, "server", "account.ts"), + "import type { Account } from './contract.js'; export const account: Account = { key: 'one', label: 'Test account' };", + ); + await writeFile( + path.join(directory, "index.server.ts"), + `import { z } from 'zod'; +import { account } from './server/account.js'; +export default function contribute(server) { + server.registerUsageSource({ + id: 'fixture', label: 'Fixture', input: z.object({}), + discover: async () => [{...account, input: {}}], + fetch: async () => ({ status: 'available', windows: [{ id: 'session', label: 'Session', usedPct: 37 }] }), + }); + return () => {}; +}`, + ); + const moduleUrl = pathToFileURL( + path.join(root, "app.asar", packagePath, "server", "plugins", "builtin", "index.js"), + ); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(resolveBuiltinPluginsRoot(moduleUrl), ["listed"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await client.connect(); + const { reports } = await client.listUsageReports(); + expect( + reports.map(({ id, sourceId, sourceLabel, account, report }) => ({ + id, + sourceId, + sourceLabel, + account, + report, + })), + ).toEqual([ + { + id: "fixture:one", + sourceId: "fixture", + sourceLabel: "Fixture", + account: { label: "Test account" }, + report: { + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 37 }], + }, + }, + ]); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("built-in sources deduplicate cross-harness accounts and fall back without session wiring", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "usage-cross-harness-")); + const home = path.join(root, "home"); + const plugins = path.join(root, "plugins"); + const codexHome = path.join(home, ".codex"); + const env = { CODEX_HOME: codexHome }; + async function json(file: string, value: unknown) { + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value)); + } + async function codexToken(token: string) { + await json(path.join(codexHome, "auth.json"), { + tokens: { access_token: token, account_id: "chatgpt-one" }, + }); + } + await codexToken("fixture-codex"); + const openCodePath = path.join(home, ".local", "share", "opencode", "auth.json"); + await json(openCodePath, { + openai: { type: "oauth", access: "fixture-opencode", accountId: "chatgpt-one" }, + }); + const piPath = path.join(home, ".pi", "agent", "auth.json"); + await json(piPath, { + "openai-codex": { type: "oauth", access: "fixture-pi-codex", accountId: "chatgpt-one" }, + anthropic: { type: "oauth", access: "fixture-pi-claude" }, + }); + await json(path.join(home, ".claude", ".credentials.json"), { + claudeAiOauth: { accessToken: "fixture-claude" }, + }); + const dbPath = path.join(home, ".omp", "agent", "agent.db"); + await mkdir(path.dirname(dbPath), { recursive: true }); + const { DatabaseSync } = await import("node:sqlite"); + const db = new DatabaseSync(dbPath); + db.exec( + "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)", + ); + const insert = db.prepare("INSERT INTO auth_credentials VALUES (?, ?, 'oauth', ?, NULL)"); + insert.run( + 1, + "openai-codex", + JSON.stringify({ + access: "fixture-omp-codex", + accountId: "chatgpt-one", + expires: Date.now() + 60_000, + }), + ); + insert.run( + 2, + "anthropic", + JSON.stringify({ access: "fixture-omp-claude", expires: Date.now() + 60_000 }), + ); + db.close(); + for (const source of ["codex", "claude"]) { + const directory = path.join(plugins, `${source}-usage-source`); + await cp(path.join(resolveBuiltinPluginsRoot(), `${source}-usage-source`), directory, { + recursive: true, + }); + // Inject home/env/HTTP at the source boundary; compile and register the real readers. + await writeFile( + path.join(directory, "index.server.ts"), + ` +import { inputSchema } from './shared/input.js'; +import { discover, fetchUsage } from './server/usage.js'; +const lookup = { home: ${JSON.stringify(home)}, env: ${JSON.stringify(env)}, platform: 'linux' }; +async function fetchApi(url, init) { + const token = new Headers(init.headers).get('Authorization'); + if (String(url).endsWith('/profile')) { + const second = token === 'Bearer fixture-pi-claude'; + return new Response(JSON.stringify({ account: { uuid: second ? 'claude-two' : 'claude-one' }, organization: { uuid: second ? 'org-two' : 'org-one' } })); + } + if (token === 'Bearer fixture-revoked') return new Response(null, { status: 401 }); + const used = { 'Bearer fixture-codex': 11, 'Bearer fixture-opencode': 22, 'Bearer fixture-pi-codex': 33, 'Bearer fixture-omp-codex': 44, 'Bearer fixture-omp-claude': 66, 'Bearer fixture-pi-claude': 77 }[token] || 55; + return new Response(JSON.stringify({ rate_limit: { primary_window: { used_percent: used } }, five_hour: { utilization: used } })); +} +export default function contribute(server) { + server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema, + discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}), + fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup), + }); + return () => {}; +} +`, + ); + } + const daemon = await createTestPaseoDaemon({ + pluginsEnabled: false, + builtinPlugins: new BuiltinPluginLoader(plugins, ["codex-usage-source", "claude-usage-source"]), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + const first = (await client.listUsageReports()).reports; + expect(first.map((entry) => entry.id).sort()).toEqual([ + "claude:claude-one.org-one", + "claude:claude-two.org-two", + "codex:chatgpt-one", + ]); + expect(first.find((entry) => entry.sourceId === "codex")?.report.windows[0]?.usedPct).toBe(11); + expect( + first.find((entry) => entry.id === "claude:claude-two.org-two")?.report.windows[0]?.usedPct, + ).toBe(77); + expect(JSON.stringify(first)).not.toContain("fixture-"); + await json(path.join(home, ".claude", ".credentials.json"), { + claudeAiOauth: { accessToken: "fixture-revoked" }, + }); + const claudeFallback = ( + await client.listUsageReports({ + reportIds: ["claude:claude-one.org-one"], + forceRefresh: true, + }) + ).reports; + expect(claudeFallback[0]?.report.windows[0]?.usedPct).toBe(66); + await codexToken("fixture-revoked"); + const refresh = async () => + (await client.listUsageReports({ reportIds: ["codex:chatgpt-one"], forceRefresh: true })) + .reports[0]; + expect((await refresh())?.report.windows[0]?.usedPct).toBe(22); + await rm(openCodePath); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(33); + await json(piPath, { + "openai-codex": { + type: "oauth", + access: "fixture-pi-codex", + accountId: "chatgpt-one", + expires: 1, + }, + anthropic: { type: "oauth", access: "fixture-pi-claude" }, + }); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(44); + await codexToken("fixture-codex"); + expect((await refresh())?.report.windows[0]?.usedPct).toBe(11); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); + +test("a killed discovery subprocess logs kimi once and produces no provider card", async () => { + const { readFile } = await import("node:fs/promises"); + const { default: pino } = await import("pino"); + const root = await mkdtemp(path.join(os.tmpdir(), "usage-crash-")); + const log = path.join(root, "daemon.log"); + const destination = pino.destination({ dest: log, sync: true }); + const daemon = await createTestPaseoDaemon({ + daemonVersion: "0.9.2", + pluginsEnabled: true, + logger: pino({ level: "warn" }, destination), + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" }); + try { + await writeFile( + path.join(root, "paseo-plugin.json"), + JSON.stringify({ id: "crashing-usage", requirements: { paseo: ">=0.9.2" } }), + ); + await writeFile( + path.join(root, "index.server.ts"), + ` +import {z} from "zod"; +export default function contribute(server) { + server.registerUsageSource({id: "kimi", label: "Kimi", input: z.object({}), + discover: async () => {process.exit(1);}, + fetch: async () => {throw new Error("discovery must never produce an account");}, + }); + return () => {}; +}`, + ); + await client.connect(); + await client.installDirectoryPlugin(root, "crashing-usage"); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + const warnings = (await readFile(log, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((entry) => entry.msg === "Usage source discovery failed"); + expect(warnings.map(({ level, sourceId }) => ({ level, sourceId }))).toEqual([ + { level: 40, sourceId: "kimi" }, + ]); + expect((await client.listUsageReports({ forceRefresh: true })).reports).toEqual([]); + } finally { + await client.close(); + await daemon.close(); + destination.end(); + await rm(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts new file mode 100644 index 0000000000..eeea242788 --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -0,0 +1,558 @@ +import { expect, test } from "vitest"; +import { UsageSourceRegistry } from "./index.js"; + +function source(input: { + id: string; + discover?: () => Promise>; + fetch?: (value: unknown) => Promise; +}) { + return { + id: input.id, + label: input.id, + discover: input.discover ?? (async () => []), + fetch: input.fetch ?? (async () => ({ status: "available", windows: [] })), + }; +} + +test("discovery preserves account IDs and updates input after token rotation", async () => { + const registry = new UsageSourceRegistry(); + let token = "old"; + registry.register( + source({ + id: "codex", + discover: async () => [{ key: "work", input: { token } }], + fetch: async (input) => ({ + status: "available", + windows: [{ id: "token", label: (input as { token: string }).token }], + }), + }), + ); + const first = await registry.listReports(); + expect(first.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(first[0]?.report.windows[0]?.label).toBe("old"); + token = "new"; + const refreshed = await registry.listReports({ forceRefresh: true }); + expect(refreshed.map((entry) => entry.id)).toEqual(["codex:work"]); + expect(refreshed[0]?.report.windows[0]?.label).toBe("new"); +}); + +test("coalesces per account, caches errors, and refreshes only requested IDs", async () => { + let now = 0; + const counts = new Map(); + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), + fetch: async (input) => { + const account = (input as { account: string }).account; + counts.set(account, (counts.get(account) ?? 0) + 1); + if (account === "b") throw new Error("failed"); + return { status: "available", windows: [] }; + }, + }), + ); + const first = await registry.listReports(); + expect(first.map((entry) => entry.id)).toEqual(["source:a", "source:b"]); + expect(first[1]?.report.status).toBe("error"); + expect(await registry.listReports()).toEqual(first); + expect(counts.get("a")).toBe(1); + expect(counts.get("b")).toBe(1); + now = 1000; + const refreshed = await registry.listReports({ + reportIds: ["source:a", "missing:id"], + forceRefresh: true, + }); + expect(refreshed.map((entry) => entry.id)).toEqual(["source:a"]); + expect(refreshed[0]?.fetchedAt).not.toBe(first[0]?.fetchedAt); + expect(counts.get("a")).toBe(2); + expect(counts.get("b")).toBe(1); + now = 301_001; + await registry.listReports({ reportIds: ["source:a"] }); + expect(counts.get("a")).toBe(3); +}); + +test("legacy listing uses oldest fetchedAt", async () => { + let now = 1000; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => ["a", "b"].map((account) => ({ key: account, input: { account } })), + }), + ); + await registry.listReports(); + now = 2000; + await registry.listReports({ reportIds: ["source:b"], forceRefresh: true }); + expect((await registry.listLegacyUsage()).fetchedAt).toBe(new Date(1000).toISOString()); +}); + +test("concurrent requests for the same ID share one vendor fetch", async () => { + let finish!: (report: unknown) => void; + const response = new Promise((resolve) => { + finish = resolve; + }); + let fetches = 0; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "coalesced", + discover: async () => [{ key: "one", input: {} }], + fetch: async () => { + fetches++; + return response; + }, + }), + ); + const first = registry.listReports(); + const second = registry.listReports(); + finish({ status: "available", windows: [] }); + const [one, two] = await Promise.all([first, second]); + expect(one[0]).toBe(two[0]); + expect(fetches).toBe(1); +}); + +test("rediscovery removes an old account from targeted refreshes", async () => { + let now = 0; + const registry = new UsageSourceRegistry(() => now, 100); + let account = "old"; + registry.register(source({ id: "source", discover: async () => [{ key: account, input: {} }] })); + await registry.listReports(); + await registry.listReports({ reportIds: ["source:old"] }); + now = 101; + account = "new"; + await registry.listReports(); + await registry.listReports({ reportIds: ["source:new"] }); + expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]); +}); + +test("discovery failures log once while other sources still report", async () => { + const warnings: unknown[][] = []; + const registry = new UsageSourceRegistry(Date.now, 300_000, { + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }); + const err = new Error("subprocess stopped"); + registry.register( + source({ + id: "kimi", + discover: async () => { + throw err; + }, + }), + ); + registry.register(source({ id: "healthy", discover: async () => [{ key: "one", input: {} }] })); + const updates: string[] = []; + const reports = await registry.listReports({ onReport: (report) => updates.push(report.id) }); + expect(reports.map((report) => report.id)).toEqual(["healthy:one"]); + expect(updates).toEqual(["healthy:one"]); + expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]); +}); + +test("a failed fetch is logged as well as reported", async () => { + const warnings: unknown[][] = []; + const registry = new UsageSourceRegistry(Date.now, 300_000, { + warn: (...args: unknown[]) => { + warnings.push(args); + }, + }); + const err = new Error("Rate limited by Claude. Try again in 38m."); + registry.register( + source({ + id: "claude", + discover: async () => [{ key: "work", input: {} }], + fetch: async () => { + throw err; + }, + }), + ); + const [report] = await registry.listReports(); + expect(report?.report).toEqual({ status: "error", error: err.message }); + expect(warnings).toEqual([[{ sourceId: "claude", err }, "Usage fetch failed"]]); +}); + +test("legacy listing distinguishes labeled accounts and preserves unlabeled names", async () => { + const registry = new UsageSourceRegistry(() => 1000); + registry.register( + source({ + id: "claude", + discover: async () => + ["work", "personal", "default"].map((key) => ({ + key, + label: key === "default" ? undefined : key, + input: {}, + })), + }), + ); + expect( + (await registry.listLegacyUsage()).providers.map((provider) => provider.displayName), + ).toEqual(["claude (work)", "claude (personal)", "claude"]); +}); + +test("duplicate logins prefer discovery order across unavailable, error and thrown fetches", async () => { + for (const failure of ["unavailable", "error", "throw"]) { + const registry = new UsageSourceRegistry(); + const calls: number[] = []; + registry.register( + source({ + id: "codex", + discover: async () => [0, 1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => { + calls.push(Number(input)); + if (input === 0) { + if (failure === "throw") throw new Error("revoked"); + return failure === "unavailable" + ? { status: failure, problem: { kind: "rejected", status: 401 } } + : { status: failure, error: "revoked" }; + } + return { + status: "available", + windows: [{ id: "session", label: "Session", usedPct: 42 }], + }; + }, + }), + ); + const reports = await registry.listReports(); + expect(reports).toHaveLength(1); + expect(reports[0]?.report.status).toBe("available"); + expect(calls).toEqual([0, 1, 2]); + } +}); + +test("targeted refresh re-reads the login without discovering again", async () => { + let discoveries = 0; + let value = "old"; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => { + discoveries++; + return [{ key: "original", input: {} }]; + }, + fetch: async () => ({ status: "available", windows: [{ id: "session", label: value }] }), + }), + ); + await registry.listReports(); + value = "new"; + const reports = await registry.listReports({ + reportIds: ["source:original"], + forceRefresh: true, + }); + expect(reports[0]?.report).toEqual({ + status: "available", + windows: [{ id: "session", label: "new" }], + }); + expect(discoveries).toBe(1); +}); + +test("a thrown fetch is an error card", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "one", input: {} }], + fetch: async () => { + throw new Error("Store deleted"); + }, + }), + ); + expect((await registry.listReports())[0]?.report).toEqual({ + status: "error", + error: "Store deleted", + }); +}); + +test("an unavailable login falls back to the next available login", async () => { + const calls: unknown[] = []; + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => { + calls.push(input); + return input === 1 + ? { + status: "unavailable", + problem: { kind: "expired", expiresAt: "1970-01-01T00:00:00.000Z" }, + } + : { status: "available", windows: [] }; + }, + }), + ); + expect( + (await registry.listReports()).map((entry) => ({ id: entry.id, report: entry.report })), + ).toEqual([{ id: "source:same", report: { status: "available", windows: [] } }]); + expect(calls).toEqual([1, 2]); +}); + +test("failed fallbacks preserve the final problem", async () => { + const registry = new UsageSourceRegistry(); + registry.register( + source({ + id: "source", + discover: async () => [1, 2].map((input) => ({ key: "same", input })), + fetch: async (input) => ({ + status: "unavailable", + problem: { kind: "rejected", status: input === 1 ? 401 : 403 }, + }), + }), + ); + expect((await registry.listReports())[0]?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 403 }, + }); +}); + +test("session discovery reuses accounts, separates launches, and retains live agent reports globally", async () => { + const sessions = new Map([ + ["one", { provider: "claude", env: { HOME: "/one" }, sessionKey: "launch-1" }], + ["two", { provider: "claude", env: { HOME: "/two" }, sessionKey: "launch-2" }], + ]); + const existing = new Set(["one", "two", "closed"]); + const discoveries: string[] = []; + let fetches = 0; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => existing.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "claude", + label: "Claude", + discover: async (scope) => { + discoveries.push(scope.kind === "global" ? "global" : scope.env.HOME); + if (scope.kind === "global") return [{ key: "default", input: "default" }]; + return [{ key: scope.env.HOME === "/new" ? "new" : "same", input: scope.env.HOME }]; + }, + fetch: async () => { + fetches++; + return { status: "available", windows: [] }; + }, + }); + expect(await registry.listReports({ agentId: "closed" })).toEqual([]); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual([ + "claude:same", + ]); + await registry.listReports({ agentId: "one" }); + await registry.listReports({ agentId: "two" }); + expect(discoveries).toEqual(["/one", "/two"]); + expect(fetches).toBe(2); + expect((await registry.listReports()).map((r) => r.id)).toEqual([ + "claude:default", + "claude:same", + ]); + sessions.set("one", { provider: "claude", env: { HOME: "/new" }, sessionKey: "launch-3" }); + expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual(["claude:new"]); + existing.delete("two"); + sessions.delete("two"); + expect((await registry.listReports()).map((r) => r.id)).toEqual(["claude:default", "claude:new"]); + await expect(registry.listReports({ agentId: "missing" })).rejects.toThrow("Unknown agent"); + await expect(registry.listReports({ agentId: "one", reportIds: [] })).rejects.toThrow("agentId"); +}); + +test("reports arrive independently and a hung fetch settles as an error before completion", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + let finishSlow!: (value: unknown) => void; + const slow = new Promise((resolve) => { + finishSlow = resolve; + }); + const reports: string[] = []; + let firstReport!: () => void; + const first = new Promise((resolve) => { + firstReport = resolve; + }); + for (const id of ["slow", "fast", "also-fast"]) { + registry.register( + source({ + id, + discover: async () => [{ key: "account", input: {} }], + fetch: async () => (id === "slow" ? slow : { status: "available", windows: [] }), + }), + ); + } + let complete = false; + const request = registry + .listReports({ + onReport: (entry) => { + reports.push(entry.id); + firstReport(); + }, + }) + .then((entries) => { + complete = true; + return entries; + }); + await first; + expect(reports).toEqual(["fast:account", "also-fast:account"]); + expect(complete).toBe(false); + const entries = await request; + expect(reports).toEqual(["fast:account", "also-fast:account", "slow:account"]); + expect(entries[0]?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + finishSlow({ status: "available", windows: [] }); + expect((await registry.listReports({ reportIds: ["slow:account"] }))[0]?.report).toEqual( + entries[0]?.report, + ); +}); + +test("#6155: agent reports use only their session login despite host and sibling discoveries", async () => { + const sessions = new Map([ + ["codex-agent", { provider: "codex", env: {}, sessionKey: "codex-launch" }], + ["opencode-agent", { provider: "opencode", env: {}, sessionKey: "opencode-launch" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const codexError = { status: "error", error: "Codex usage API returned 500" }; + const expired = { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["codex", "opencode"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: { harness }, + })), + fetch: async (input) => + (input as { harness: string }).harness === "codex" ? codexError : expired, + }); + await registry.listReports(); + await registry.listReports({ agentId: "opencode-agent" }); + expect((await registry.listReports({ agentId: "codex-agent" }))[0]?.report).toEqual(codexError); +}); + +test("all failed host logins retain each source-supplied harness and remedy", async () => { + const registry = new UsageSourceRegistry(); + const failures = [ + { status: "error", error: "Codex usage API returned 500" }, + { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }, + { status: "unavailable", problem: { kind: "rejected", status: 401, refreshedBy: "pi" } }, + { status: "unavailable", problem: { kind: "rejected", status: 403, refreshedBy: "omp" } }, + ]; + const harnesses = ["Codex", "OpenCode", "Pi", "OMP"]; + registry.register( + source({ + id: "codex", + discover: async () => harnesses.map((harness, input) => ({ key: "same", harness, input })), + fetch: async (input) => failures[Number(input)], + }), + ); + const [entry] = await registry.listReports(); + expect(entry?.loginErrors).toEqual( + harnesses.map((harness, index) => ({ harness, report: failures[index] })), + ); +}); + +test("a working agent login never uses another harness's failure or host fallback cache", async () => { + const sessions = new Map([ + ["working", { provider: "codex", env: {}, sessionKey: "one" }], + ["broken", { provider: "opencode", env: {}, sessionKey: "two" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const available = { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], + }; + const error = { status: "error", error: "OpenCode login rejected. Run opencode to refresh it." }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["opencode", "codex"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: harness, + })), + fetch: async (input) => (input === "codex" ? available : error), + }); + const [host] = await registry.listReports(); + expect(host?.report).toEqual(available); + expect(host?.loginErrors).toBeUndefined(); + expect((await registry.listReports({ agentId: "working" }))[0]?.report).toEqual(available); + expect((await registry.listReports({ agentId: "broken" }))[0]?.report).toEqual(error); + expect( + (await registry.listReports({ agentId: "broken", forceRefresh: true }))[0]?.report, + ).toEqual(error); + expect( + (await registry.listReports({ reportIds: ["codex:same"], forceRefresh: true }))[0]?.report, + ).toEqual(available); +}); + +test("concurrent host and agent requests never share a different login's pending fetch", async () => { + let finish!: (report: unknown) => void; + const slow = new Promise((resolve) => { + finish = resolve; + }); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [{ key: "same", harness: "Codex", input: scope.kind }], + fetch: async (input) => + input === "global" ? slow : { status: "error", error: "Session login failed" }, + }); + const host = registry.listReports(); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ status: "error", error: "Session login failed" }); + finish({ status: "available", windows: [] }); + expect((await host)[0]?.report.status).toBe("available"); +}); + +test("duplicate logins across scopes appear only once in the host's errors", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [ + { + key: "same", + harness: "Codex", + input: + scope.kind === "global" + ? { path: "/auth", kind: "file" } + : { kind: "file", path: "/auth" }, + }, + ], + fetch: async () => ({ status: "error", error: "Login failed" }), + }); + await registry.listReports({ agentId: "agent" }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Login failed" } }, + ]); +}); + +test("a hung login records its own timeout and still tries the next login", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + registry.register({ + id: "codex", + label: "Codex", + discover: async () => [ + { key: "same", harness: "Codex", input: 0 }, + { key: "same", harness: "OpenCode", input: 1 }, + ], + fetch: async (input) => + input === 0 ? new Promise(() => {}) : { status: "error", error: "Login rejected" }, + }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Usage fetch timed out" } }, + { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts new file mode 100644 index 0000000000..c0c9d781e5 --- /dev/null +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -0,0 +1,348 @@ +import { createHash } from "node:crypto"; +import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; +import type { UsageScope } from "@getpaseo/plugin/server/usage"; +import { z } from "zod"; +import type { Logger } from "pino"; +import { + UsageReportSchema, + type ProviderUsage, + type UsageReportEntry, + type UsageProblem, + type UsageReport, +} from "@getpaseo/protocol/messages"; + +export interface UsageSource { + id: string; + label: string; + icon?: string; + discover(scope: UsageScope): Promise; + fetch(input: unknown): Promise; +} + +interface Login { + input: unknown; + harness: string; +} + +interface KnownReport { + source: UsageSource; + logins: [Login, ...Login[]]; + label?: string; +} + +export interface AgentUsageLookup { + hasAgent(id: string): boolean; + usageSession(id: string): AgentUsageSession | null; +} + +export interface ListUsageReportsOptions { + forceRefresh?: boolean; + reportIds?: string[]; + agentId?: string; + onReport?: (report: UsageReportEntry) => void; +} + +interface AgentReports { + provider: string; + model?: string; + sessionKey: string; + reports: Map; +} + +/** Owns account identity, ordered logins for each account, and the five-minute fetch cache. */ +export class UsageSourceRegistry { + private readonly sources = new Map(); + private readonly known = new Map(); + private defaults = new Map(); + private readonly byAgent = new Map(); + private readonly cache = new Map(); + private readonly pending = new Map>(); + + constructor( + private readonly now: () => number = Date.now, + private readonly ttlMs = 300_000, + private readonly logger: Pick = { warn: console.warn }, + private readonly agents: AgentUsageLookup = { hasAgent: () => false, usageSession: () => null }, + private readonly deadlineMs = 20_000, + ) {} + + register(source: UsageSource): void { + if (this.sources.has(source.id)) throw new Error(`Duplicate usage source: ${source.id}`); + this.sources.set(source.id, source); + } + + unregister(id: string): void { + this.sources.delete(id); + for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key); + // Re-discover on the next session query when a source is replaced. + for (const [agentId, mapping] of this.byAgent) { + if ([...mapping.reports.values()].some((report) => report.source.id === id)) + this.byAgent.delete(agentId); + } + for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); + for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); + } + + async listReports(options: ListUsageReportsOptions = {}): Promise { + if (options.agentId !== undefined && options.reportIds !== undefined) + throw new Error("agentId and reportIds cannot be combined"); + this.pruneAgents(); + let ids: string[]; + let reports = this.known; + if (options.agentId !== undefined) { + ids = await this.discoverAgent(options.agentId); + reports = this.byAgent.get(options.agentId)?.reports ?? new Map(); + } else if (options.reportIds !== undefined) { + ids = options.reportIds; + } else { + this.defaults = await this.discover({ kind: "global" }); + this.mergeKnown(); + ids = [...this.known.keys()]; + } + return Promise.all( + [...new Set(ids)].flatMap((id) => { + const known = reports.get(id); + if (!known) return []; + return [ + this.fetchId(id, known, options.forceRefresh).then((entry) => { + options.onReport?.(entry); + return entry; + }), + ]; + }), + ); + } + + private pruneAgents(): void { + for (const id of this.byAgent.keys()) { + if (!this.agents.hasAgent(id)) this.byAgent.delete(id); + } + this.mergeKnown(); + } + + private async discoverAgent(agentId: string): Promise { + if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); + const session = this.agents.usageSession(agentId); + if (!session) return []; + const previous = this.byAgent.get(agentId); + const sameScope = + previous?.sessionKey === session.sessionKey && + previous.provider === session.provider && + previous.model === session.model; + if (sameScope) return [...previous.reports.keys()]; + const reports = await this.discover({ + kind: "session", + provider: session.provider, + model: session.model, + env: session.env, + }); + // A query that finishes after a resume must not publish the previous launch's mapping. + if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return []; + this.byAgent.set(agentId, { + sessionKey: session.sessionKey, + provider: session.provider, + model: session.model, + reports, + }); + this.mergeKnown(); + return [...reports.keys()]; + } + + private mergeKnown(): void { + this.known.clear(); + for (const reports of [ + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ]) { + for (const [id, report] of reports) { + if (this.sources.get(report.source.id) !== report.source) continue; + const known = this.known.get(id); + if (!known) { + this.known.set(id, { ...report, logins: [...report.logins] }); + continue; + } + for (const login of report.logins) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } + } + } + } + + private async discover(scope: UsageScope): Promise> { + const discovered = await Promise.all( + [...this.sources.values()].map(async (source) => { + const reports = new Map(); + try { + const accounts = z + .array( + z.object({ + key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), + label: z.string().optional(), + harness: z.string().min(1).optional(), + input: z.json(), + }), + ) + .parse(await source.discover(scope)); + for (const account of accounts) { + const id = `${source.id}:${account.key}`; + const known = reports.get(id); + // COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0. + const login = { input: account.input, harness: account.harness ?? source.label }; + if (known) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } else reports.set(id, { source, logins: [login], label: account.label }); + } + } catch (error) { + this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); + } + return reports; + }), + ); + const merged = new Map(); + for (const reports of discovered) { + for (const [id, report] of reports) merged.set(id, report); + } + return merged; + } + + // COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. + async listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }> { + const reports = await this.listReports(); + return { + fetchedAt: reports.length + ? reports.reduce( + (oldest, entry) => (entry.fetchedAt < oldest ? entry.fetchedAt : oldest), + reports[0]!.fetchedAt, + ) + : new Date(this.now()).toISOString(), + providers: reports.map((entry) => ({ + providerId: entry.sourceId, + displayName: entry.account.label + ? `${entry.sourceLabel} (${entry.account.label})` + : entry.sourceLabel, + status: entry.report.status, + planLabel: entry.report.status === "available" ? (entry.report.planLabel ?? null) : null, + windows: entry.report.status === "available" ? entry.report.windows : [], + balances: entry.report.status === "available" ? (entry.report.balances ?? []) : [], + details: entry.report.status === "available" ? (entry.report.details ?? []) : [], + error: legacyError(entry.report, this.now()), + })), + }; + } + + private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { + // Account identity groups cards; the ordered login set identifies a fetch result. + const cacheKey = `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; + const cached = this.cache.get(cacheKey); + if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) + return Promise.resolve(cached.entry); + const pending = this.pending.get(cacheKey); + if (pending) return pending; + const request = (async () => { + const entry: UsageReportEntry = { + id, + sourceId: known.source.id, + sourceLabel: known.source.label, + icon: known.source.icon, + account: { label: known.label }, + fetchedAt: new Date(this.now()).toISOString(), + ...(await this.fetchWithFallback(known)), + }; + if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); + return entry; + })(); + this.pending.set(cacheKey, request); + void request.finally(() => { + if (this.pending.get(cacheKey) === request) this.pending.delete(cacheKey); + }); + return request; + } + + private writeCache(id: string, entry: UsageReportEntry): void { + const at = this.now(); + for (const [cachedId, cached] of this.cache) { + if (at - cached.at >= this.ttlMs) this.cache.delete(cachedId); + } + this.cache.set(id, { at, entry }); + } + + private async fetchBeforeDeadline(source: UsageSource, input: unknown): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout( + () => resolve({ status: "error", error: "Usage fetch timed out" }), + this.deadlineMs, + ); + }); + try { + return await Promise.race([this.fetchLogin(source, input), deadline]); + } finally { + clearTimeout(timer); + } + } + + private async fetchWithFallback( + known: KnownReport, + ): Promise> { + // Start each login within the same deadline window, then prefer discovery order. + // Sequential deadlines can exceed the client's RPC timeout before all errors arrive. + const attempts = known.logins.map((login) => + this.fetchBeforeDeadline(known.source, login.input), + ); + const loginErrors: NonNullable = []; + for (const [index, login] of known.logins.entries()) { + const report = await attempts[index]!; + if (report.status === "available") return { report }; + loginErrors.push({ harness: login.harness, report }); + } + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once app floor >= v0.11.0. + // Keep the final single report so older apps still parse and display failed accounts. + return { report: loginErrors[loginErrors.length - 1]!.report, loginErrors }; + } + + private async fetchLogin(source: UsageSource, input: unknown): Promise { + try { + return UsageReportSchema.parse(await source.fetch(input)); + } catch (error) { + this.logger.warn({ sourceId: source.id, err: error }, "Usage fetch failed"); + return { status: "error", error: error instanceof Error ? error.message : String(error) }; + } + } +} + +// The legacy RPC has only an error string; the current app owns its own presentation. +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyProblem(problem: UsageProblem, now: number): string { + if (problem.kind === "no_quota") return problem.detail; + const remedy = problem.refreshedBy + ? `Run ${problem.refreshedBy} to refresh it.` + : "Sign in again."; + if (problem.kind === "rejected") return `Login rejected (HTTP ${problem.status}). ${remedy}`; + const elapsed = Math.max(0, now - Date.parse(problem.expiresAt)); + let ago = "just now"; + if (elapsed >= 86_400_000) ago = `${Math.floor(elapsed / 86_400_000)}d ago`; + else if (elapsed >= 3_600_000) ago = `${Math.floor(elapsed / 3_600_000)}h ago`; + else if (elapsed >= 60_000) ago = `${Math.floor(elapsed / 60_000)}m ago`; + return `Login expired ${ago}. ${remedy}`; +} + +// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26. +function legacyError(report: UsageReport, now: number): string | null { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return legacyProblem(report.problem, now); + return null; +} + +// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +function loginKey(login: Login): string { + return JSON.stringify(login, (_key, value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); +} diff --git a/packages/server/src/server/relay-transport.test.ts b/packages/server/src/server/relay-transport.test.ts index 06775e5c5c..2dcf29b948 100644 --- a/packages/server/src/server/relay-transport.test.ts +++ b/packages/server/src/server/relay-transport.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee"; import { exportPublicKey, generateKeyPair } from "@getpaseo/relay"; import { startRelayTransport } from "./relay-transport"; @@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => { vi.useRealTimers(); }); + test("releases a stalled encrypted data connection without restarting control", async () => { + vi.useFakeTimers(); + let attachments = 0; + controllers.push( + startRelayTransport({ + logger: pino({ level: "silent" }), + attachSocket: async () => { + attachments += 1; + }, + relayEndpoint: "relay.paseo.sh:443", + relayUseTls: true, + serverId: "srv_test", + daemonKeyPair: generateKeyPair(), + createWebSocket: relay.createWebSocket, + }), + ); + const control = relay.sockets[0]; + control.open(); + const connected = JSON.stringify({ type: "connected", connectionId: "stalled" }); + control.message(connected); + const stalled = relay.sockets[1]; + stalled.open(); + await vi.advanceTimersByTimeAsync(15_000); + expect(stalled.terminateCalls).toBe(1); + expect(attachments).toBe(0); + expect(control.terminateCalls).toBe(0); + control.message(connected); + expect(relay.sockets).toHaveLength(3); + expect(relay.sockets[2].url).toBe(stalled.url); + }); + test("logs relay_control_connected only after first valid control message", () => { const logger = createMockLogger(); const controller = startRelayTransport({ diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index ddf1a3c234..f7b73a5367 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -420,6 +420,10 @@ async function attachEncryptedSocket( attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise, metadata?: ExternalSocketMetadata, ): Promise { + const handshakeTimeout = setTimeout(() => { + logger.warn("relay_e2ee_handshake_timeout_terminating"); + socket.terminate(); + }, 15_000); try { const relayTransport = createRelayTransportAdapter(socket, logger); const emitter = new EventEmitter(); @@ -440,6 +444,7 @@ async function attachEncryptedSocket( emitter.emit("error", error); }, }); + clearTimeout(handshakeTimeout); const encryptedSocket = createEncryptedRelaySocket({ channel, emitter, @@ -459,6 +464,8 @@ async function attachEncryptedSocket( } catch { // ignore } + } finally { + clearTimeout(handshakeTimeout); } } diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 1d6ae13677..73403f5908 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -1,7 +1,10 @@ import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js"; import type { WorktreeSource } from "../utils/worktree.js"; -export type WorktreeCreationIntent = WorktreeSource; +export type WorktreeCreationIntent = Exclude< + WorktreeSource, + { kind: "restore" | "restore-from-base" } +>; export interface ResolveWorktreeCreationIntentInput { worktreeSlug?: string; diff --git a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts index 250d8ec583..877ec3aced 100644 --- a/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts +++ b/packages/server/src/server/schedule-run-lifecycle.e2e.test.ts @@ -222,7 +222,7 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active }, runOnCreate: false, }); - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "schedule-local-workspaces" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); const events = collectLifecycleUpdates(); const ran = await runScheduleOnce(schedule.id); @@ -235,6 +235,10 @@ test("archiveOnFinish=false local scheduled run emits upserts and remains active await waitForAgentUpsert(events.agentUpdates, agentId); expect(workspaceWasRemoved(events.workspaceUpdates, workspaceId!)).toBe(false); expect(await activeAgentIds()).toContain(agentId); + expect( + (await ctx.client.fetchWorkspaces()).entries.find((entry) => entry.id === workspaceId) + ?.background, + ).toBe(false); events.stop(); }); @@ -256,7 +260,7 @@ test("archiveOnFinish=true scheduled run emits a workspace remove", async () => runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-archive-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); @@ -293,7 +297,7 @@ test("worktree isolation creates a run worktree and archiveOnFinish removes it", runOnCreate: false, }); await ctx.client.fetchWorkspaces({ - subscribe: { subscriptionId: "schedule-worktree-workspaces" }, + subscribe: {}, }); const events = collectLifecycleUpdates(); diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 322bffa8fb..1a9f0f5fd4 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -21,8 +21,6 @@ import type { AgentStreamEvent, } from "../agent/agent-sdk-types.js"; import { createTestAgentClients } from "../test-utils/fake-agent-client.js"; -import { validateProviderOptions } from "../agent/provider-options.js"; -import { ClaudeProviderOptionsSchema } from "../agent/providers/claude/options.js"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; @@ -46,6 +44,37 @@ interface ScheduleServiceInternals { executeSchedule(schedule: StoredSchedule, runId: string): Promise; } +class OverlappingTickRunner { + private resolveFirstRunStarted: () => void = () => {}; + private resolveFirstRunBlocked: () => void = () => {}; + private laterScheduleRunCount = 0; + + readonly firstRunStarted = new Promise((resolve) => { + this.resolveFirstRunStarted = resolve; + }); + private readonly firstRunBlocked = new Promise((resolve) => { + this.resolveFirstRunBlocked = resolve; + }); + + async run(schedule: StoredSchedule): Promise { + if (schedule.prompt === "block the first tick") { + this.resolveFirstRunStarted(); + await this.firstRunBlocked; + } else { + this.laterScheduleRunCount += 1; + } + return { agentId: null, output: "ok" }; + } + + unblockFirstRun(): void { + this.resolveFirstRunBlocked(); + } + + get laterScheduleRuns(): number { + return this.laterScheduleRunCount; + } +} + const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: true, @@ -66,12 +95,6 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick - validateProviderOptions("claude", ClaudeProviderOptionsSchema, options), - applyOptions: (config: AgentSessionConfig, options: AgentSessionConfig["providerOptions"]) => ({ - ...config, - ...(options ? { providerOptions: options } : {}), - }), }; let workspaceArchiveInProgress = false; @@ -212,6 +235,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, }); return { workspaceRegistry, @@ -352,6 +376,241 @@ describe("ScheduleService", () => { expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); }); + test.each(["new-agent", "agent", "maxRuns: 1"] as const)( + "dispatches a shared cron slot only once for %s after an earlier tick resumes", + async (targetKind) => { + const runner = new OverlappingTickRunner(); + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: (schedule) => runner.run(schedule), + }); + const cadence = { type: "cron" as const, expression: "0 6 * * 1", timezone: "Europe/Prague" }; + await service.create({ + prompt: "block the first tick", + cadence, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + // Give the later schedule a distinct creation time so file ordering cannot affect the race. + now = new Date("2026-01-01T00:00:01.000Z"); + const agentId = "00000000-0000-0000-0000-000000000001"; + await agentStorage.upsert( + buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }), + ); + const later = await service.create({ + prompt: "later due schedule", + cadence, + target: + targetKind === "agent" + ? { type: "agent", agentId } + : { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: targetKind === "maxRuns: 1" ? 1 : undefined, + }); + now = new Date("2026-01-05T05:00:00.000Z"); + const delayedTick = service.tick(); + await runner.firstRunStarted; + try { + now = new Date("2026-01-05T05:00:01.000Z"); + await service.tick(); + } finally { + runner.unblockFirstRun(); + await delayedTick; + } + const result = await service.inspect(later.id); + expect.soft(runner.laterScheduleRuns).toBe(1); + expect.soft(result.runs).toHaveLength(1); + expect.soft(result.runs.map((run) => run.scheduledFor)).toEqual(["2026-01-05T05:00:00.000Z"]); + expect(result.nextRunAt).toBe( + targetKind === "maxRuns: 1" ? null : "2026-01-12T05:00:00.000Z", + ); + expect(result.status).toBe(targetKind === "maxRuns: 1" ? "completed" : "active"); + }, + ); + + test("advances the next scheduled slot past a run that crosses a cadence boundary", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + now = new Date("2026-01-01T00:02:00.000Z"); + return { agentId: null, output: "ok" }; + }, + }); + + const created = await service.create({ + prompt: "long-running task", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + + now = new Date("2026-01-01T00:01:00.000Z"); + await service.tick(); + + expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }); + + test("runs the final allowed cron slot without requiring another occurrence", async () => { + let dispatches = 0; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => { + dispatches += 1; + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Run once on January 5 when it is Monday", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(1); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("succeeded"); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "keeps firing future slots after an outcome could not be saved", + async () => { + const schedulesDir = join(tempDir, "schedules"); + let dispatches = 0; + const failOutcomeWrite = async (): Promise => { + dispatches += 1; + if (dispatches === 1) { + await chmod(schedulesDir, 0o555); + } + return { agentId: null, output: "done" }; + }; + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: failOutcomeWrite, + }); + const created = await service.create({ + prompt: "Check status each minute", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + }); + now = new Date("2026-01-01T00:01:00.000Z"); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + expect((await service.inspect(created.id)).runs[0].status).toBe("running"); + now = new Date("2026-01-01T00:02:00.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(dispatches).toBe(2); + expect(result.runs.map((run) => run.scheduledFor)).toEqual([ + "2026-01-01T00:01:00.000Z", + "2026-01-01T00:02:00.000Z", + ]); + expect(result.nextRunAt).toBe("2026-01-01T00:03:00.000Z"); + }, + ); + + test("completes an interrupted final slot during restart recovery", async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "done" }), + }); + const created = await service.create({ + prompt: "Run once", + cadence: { type: "cron", expression: "0 6 5 1 1" }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-05T06:00:00.000Z"); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); + await store.update(created.id, (schedule) => ({ + ...schedule, + nextRunAt: null, + runs: [ + { + id: "interrupted-final-slot", + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }, + ], + })); + now = new Date("2026-01-05T06:01:00.000Z"); + await service.start(); + try { + const result = await service.inspect(created.id); + expect(result.status).toBe("completed"); + expect(result.nextRunAt).toBeNull(); + expect(result.runs).toHaveLength(1); + expect(result.runs[0].status).toBe("failed"); + } finally { + await service.stop(); + } + }); + + test.each([2, null])( + "keeps a next slot when the final run's limit changes to %s", + async (maxRuns) => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async (schedule) => { + await service.update({ id: schedule.id, maxRuns }); + return { agentId: null, output: "done" }; + }, + }); + const created = await service.create({ + prompt: "Allow more runs during execution", + runOnCreate: false, + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + maxRuns: 1, + }); + now = new Date("2026-01-01T00:01:30.000Z"); + await service.tick(); + const result = await service.inspect(created.id); + expect(result.status).toBe("active"); + expect(result.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + expect(result.runs).toHaveLength(1); + }, + ); + test("pause and resume update persisted schedule state", async () => { const service = createScheduleService({ paseoHome: tempDir, @@ -802,7 +1061,7 @@ describe("ScheduleService", () => { const runStarted = new Promise((resolve) => { releaseRun = resolve; }); - const store = new ScheduleStore(join(tempDir, "schedules")); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); const legacy = await store.create({ name: null, prompt: "finish/update race", @@ -911,7 +1170,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -989,7 +1247,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1059,7 +1316,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1119,7 +1375,6 @@ describe("ScheduleService", () => { liveSnapshot: snapshot as Awaited< ReturnType >["liveSnapshot"], - background: true, initialPromptStarted: false, initialPromptError: null, }; @@ -1901,6 +2156,51 @@ describe("ScheduleService", () => { await service2.stop(); }); + test("starts with the valid schedules when the schedules directory holds files that are not schedules", async () => { + const service1 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + const created = await service1.create({ + prompt: "Still scheduled", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "new-agent", + config: { provider: "claude", cwd: tempDir }, + }, + runOnCreate: false, + }); + await service1.stop(); + + const schedulesDir = join(tempDir, "schedules"); + await writeFile(join(schedulesDir, "notes.json"), JSON.stringify({ hello: "world" })); + const { lastRunAt: _omitted, ...withoutLastRunAt } = created; + await writeFile( + join(schedulesDir, "deadbeef.json"), + JSON.stringify({ ...withoutLastRunAt, id: "deadbeef" }), + ); + await writeFile(join(schedulesDir, "broken.json"), "{ not json"); + + const service2 = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ok" }), + }); + await service2.start(); + + expect((await service2.list()).map((schedule) => schedule.id)).toEqual([created.id]); + await service2.stop(); + }); + test("startup recovery archives an interrupted run workspace with an associated agent", async () => { const service1 = createScheduleService({ paseoHome: tempDir, @@ -1925,7 +2225,7 @@ describe("ScheduleService", () => { const interruptedAt = now.toISOString(); const associatedAgentId = "11111111-1111-4111-8111-111111111111"; const workspaceId = "wks_interrupted_with_agent"; - const store = new ScheduleStore(join(tempDir, "schedules")); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); await store.update(created.id, (schedule) => ({ ...schedule, runs: [ @@ -1993,7 +2293,7 @@ describe("ScheduleService", () => { const interruptedAt = now.toISOString(); const workspaceId = "wks_interrupted_without_agent"; - const store = new ScheduleStore(join(tempDir, "schedules")); + const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger()); await store.update(created.id, (schedule) => ({ ...schedule, runs: [ @@ -2829,6 +3129,44 @@ describe("ScheduleService", () => { expect(inspected.runs[0]?.error).toBe("network blip"); }); + // chmod cannot make a directory unwritable on Windows or for root. + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fires again after a tick whose run could not be recorded", + async () => { + const service = createScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: new AgentManager({ logger: createTestLogger() }), + agentStorage, + providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY, + now: () => now, + runner: async () => ({ agentId: null, output: "ran" }), + }); + + const created = await service.create({ + prompt: "ping target", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "agent", agentId: "88888888-8888-4888-8888-888888888888" }, + }); + + const schedulesDir = join(tempDir, "schedules"); + now = new Date("2026-01-01T00:01:00.000Z"); + await chmod(schedulesDir, 0o555); + try { + await expect(service.tick()).rejects.toThrow(); + } finally { + await chmod(schedulesDir, 0o755); + } + + await service.tick(); + + const inspected = await service.inspect(created.id); + expect(inspected.runs).toHaveLength(1); + expect(inspected.runs[0]?.status).toBe("succeeded"); + expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z"); + }, + ); + test("completes the schedule when a scheduled run targets an archived agent", async () => { const service = createScheduleService({ paseoHome: tempDir, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index e3e9504e46..7b5487c3f8 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1,3 +1,4 @@ +import { formatSystemNotificationPrompt } from "../agent/agent-messages/index.js"; import { randomUUID } from "node:crypto"; import { stat } from "node:fs/promises"; import { join } from "node:path"; @@ -7,11 +8,7 @@ import type { AgentSessionConfig } from "../agent/agent-sdk-types.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { ensureAgentLoaded } from "../agent/agent-loading.js"; -import { - formatSystemNotificationPrompt, - startAgentRun, - type AgentRunController, -} from "../agent/agent-prompt.js"; +import { startAgentRun, type AgentRunController } from "../agent/agent-prompt.js"; import { resolveCreateAgentTitles } from "../agent/create-agent-title.js"; import { type BoundCreateAgentCommand, formatProviderModel } from "../agent/create-agent/create.js"; import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; @@ -41,6 +38,18 @@ export class ScheduleTargetGoneError extends Error { } } +function advanceNextRunAtPast( + cadence: StoredSchedule["cadence"], + nextRunAt: Date, + now: Date, +): Date { + let next = nextRunAt; + while (next.getTime() <= now.getTime()) { + next = computeNextRunAt(cadence, next); + } + return next; +} + function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -202,6 +211,7 @@ function buildRunOutput(params: { type ScheduleAgentManager = Pick< AgentRunController, | "getAgent" + | "reloadAgentSession" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" @@ -263,8 +273,8 @@ export class ScheduleService { private tickTimer: ReturnType | null = null; constructor(options: ScheduleServiceOptions) { - this.store = new ScheduleStore(join(options.paseoHome, "schedules")); this.logger = options.logger.child({ module: "schedule-service" }); + this.store = new ScheduleStore(join(options.paseoHome, "schedules"), this.logger); this.agentManager = options.agentManager; this.agentStorage = options.agentStorage; this.createAgent = options.createAgent; @@ -623,6 +633,9 @@ export class ScheduleService { error: "Daemon restarted before the scheduled run completed", }; updated = { ...updated, runs }; + if (shouldCompleteSchedule(updated, now)) { + updated = completeSchedule(updated, now); + } dirty = true; } @@ -692,48 +705,113 @@ export class ScheduleService { options?: { manual?: boolean }, ): Promise { const manual = options?.manual === true; + // Reserve before store I/O; persisted history cannot prove a run is still alive. this.runningScheduleIds.add(schedule.id); - const runId = randomUUID(); - const runningRun: ScheduleRun = { - id: runId, - scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()), - startedAt: now.toISOString(), - endedAt: null, - status: "running", - agentId: null, - output: null, - error: null, - }; - const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); - try { - const result = await this.runner(scheduleWithRun, runId); - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "succeeded", - agentId: result.agentId, - output: result.output, - error: null, - targetGone: false, - manual, - }); - } catch (error) { - await this.finishRun({ - scheduleId: schedule.id, - runId, - status: "failed", - agentId: null, - output: null, - error: error instanceof Error ? error.message : String(error), - targetGone: error instanceof ScheduleTargetGoneError, - manual, - }); + let runId: string; + let scheduleWithRun: StoredSchedule; + if (manual) { + runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor: now.toISOString(), + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun); + } else { + const claimed = await this.claimDueRun(schedule.id, now); + if (!claimed) { + return; + } + ({ schedule: scheduleWithRun, runId } = claimed); + } + + try { + const result = await this.runner(scheduleWithRun, runId); + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "succeeded", + agentId: result.agentId, + output: result.output, + error: null, + targetGone: false, + manual, + }); + } catch (error) { + await this.finishRun({ + scheduleId: schedule.id, + runId, + status: "failed", + agentId: null, + output: null, + error: error instanceof Error ? error.message : String(error), + targetGone: error instanceof ScheduleTargetGoneError, + manual, + }); + } } finally { this.runningScheduleIds.delete(schedule.id); } } + private async claimDueRun( + scheduleId: string, + now: Date, + ): Promise<{ schedule: StoredSchedule; runId: string } | null> { + let claimed: { schedule: StoredSchedule; runId: string } | null = null; + const updatedSchedule = await this.store.update(scheduleId, (schedule) => { + if ( + schedule.status !== "active" || + !schedule.nextRunAt || + shouldCompleteSchedule(schedule, now) || + new Date(schedule.nextRunAt).getTime() > now.getTime() + ) { + return schedule; + } + + const scheduledFor = schedule.nextRunAt; + const isFinalRun = + schedule.maxRuns !== null && countCompletedRuns(schedule) + 1 >= schedule.maxRuns; + const nextRunAt = isFinalRun + ? null + : advanceNextRunAtPast( + schedule.cadence, + computeNextRunAt(schedule.cadence, new Date(scheduledFor)), + now, + ).toISOString(); + + // Claim the due slot and advance its cursor in the same serialized update. + // A tick can hold a stale list while another tick finishes this schedule. + const runId = randomUUID(); + const runningRun: ScheduleRun = { + id: runId, + scheduledFor, + startedAt: now.toISOString(), + endedAt: null, + status: "running", + agentId: null, + output: null, + error: null, + }; + const updated: StoredSchedule = { + ...schedule, + nextRunAt, + updatedAt: now.toISOString(), + runs: [...schedule.runs, runningRun], + }; + claimed = { schedule: updated, runId }; + return updated; + }); + requireSchedule(updatedSchedule, scheduleId); + return claimed; + } + private async appendRunningRun( scheduleId: string, runningRun: ScheduleRun, @@ -794,14 +872,16 @@ export class ScheduleService { nextRunAt: null, }; } else { - const after = new Date(schedule.nextRunAt ?? now.toISOString()); - let nextRunAt = computeNextRunAt(updated.cadence, after); - while (nextRunAt.getTime() <= now.getTime()) { - nextRunAt = computeNextRunAt(updated.cadence, nextRunAt); - } + // A final slot has no successor until its run limit is raised or cleared. + // Every runner invocation has a recorded run with this id. + const finishedRun = completedRuns.find((run) => run.id === params.runId)!; + const nextRunAt = updated.nextRunAt + ? new Date(updated.nextRunAt) + : computeNextRunAt(updated.cadence, new Date(finishedRun.scheduledFor)); + // Move an overdue cursor past a long run without advancing a future one twice. updated = { ...updated, - nextRunAt: nextRunAt.toISOString(), + nextRunAt: advanceNextRunAtPast(updated.cadence, nextRunAt, now).toISOString(), }; } @@ -909,7 +989,6 @@ export class ScheduleService { features: config.featureValues, unattended: true, promptFailure: "return-error", - background: true, notifyOnFinish: false, }); const agent = created.snapshot; diff --git a/packages/server/src/server/schedule/store.test.ts b/packages/server/src/server/schedule/store.test.ts index 7e19bbfd92..5f69119d4d 100644 --- a/packages/server/src/server/schedule/store.test.ts +++ b/packages/server/src/server/schedule/store.test.ts @@ -1,7 +1,10 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { Writable } from "node:stream"; +import pino from "pino"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { createTestLogger } from "../../test-utils/test-logger.js"; import { ScheduleStore } from "./store.js"; describe("ScheduleStore", () => { @@ -10,7 +13,7 @@ describe("ScheduleStore", () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "schedule-store-test-")); - store = new ScheduleStore(tempDir); + store = new ScheduleStore(tempDir, createTestLogger()); }); afterEach(async () => { @@ -40,13 +43,66 @@ describe("ScheduleStore", () => { runs: [], }); - const reloaded = new ScheduleStore(tempDir); + const reloaded = new ScheduleStore(tempDir, createTestLogger()); const listed = await reloaded.list(); expect(created.id).toHaveLength(8); expect(listed).toEqual([created]); }); + test("reports an invalid schedule file once while it stays invalid, by name, and lists the rest", async () => { + const created = await store.create({ + name: "Morning summary", + prompt: "Summarize new commits", + cadence: { type: "every", everyMs: 60_000 }, + target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } }, + status: "active", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: "2026-01-01T00:01:00.000Z", + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }); + await writeFile(join(tempDir, "notes.json"), JSON.stringify({ hello: "world" })); + const logLines: Array<{ msg: string; filePath?: string }> = []; + const logger = pino( + { level: "error" }, + new Writable({ + write(chunk, _encoding, callback) { + logLines.push(JSON.parse(chunk.toString("utf8"))); + callback(); + }, + }), + ); + const reloaded = new ScheduleStore(tempDir, logger); + + expect(await reloaded.list()).toEqual([created]); + expect(await reloaded.list()).toEqual([created]); + await rm(join(tempDir, "notes.json")); + expect(await reloaded.list()).toEqual([created]); + await writeFile(join(tempDir, "notes.json"), "{ not json"); + expect(await reloaded.list()).toEqual([created]); + + const skipped = { + msg: "Skipping invalid schedule file", + filePath: join(tempDir, "notes.json"), + }; + expect(logLines.map(({ msg, filePath }) => ({ msg, filePath }))).toEqual([skipped, skipped]); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "fails the listing when a schedule file cannot be read", + async () => { + await writeFile(join(tempDir, "unreadable.json"), "{}"); + await chmod(join(tempDir, "unreadable.json"), 0o000); + + await expect(store.list()).rejects.toMatchObject({ code: "EACCES" }); + }, + ); + test("update round-trips an updated schedule to disk", async () => { const created = await store.create({ name: "before", @@ -81,7 +137,7 @@ describe("ScheduleStore", () => { }; await store.update(created.id, () => updated); - const reloaded = await new ScheduleStore(tempDir).get(created.id); + const reloaded = await new ScheduleStore(tempDir, createTestLogger()).get(created.id); expect(reloaded).toEqual(updated); }); @@ -182,7 +238,9 @@ describe("ScheduleStore", () => { prompt: "after", runs: [{ id: "run-1" }], }); - await expect(new ScheduleStore(tempDir).get(created.id)).resolves.toMatchObject({ + await expect( + new ScheduleStore(tempDir, createTestLogger()).get(created.id), + ).resolves.toMatchObject({ prompt: "after", runs: [{ id: "run-1" }], }); @@ -211,7 +269,7 @@ describe("ScheduleStore", () => { } } - const gatedStore = new GatedListScheduleStore(tempDir); + const gatedStore = new GatedListScheduleStore(tempDir, createTestLogger()); const target = { type: "new-agent" as const, config: { provider: "claude" as const, cwd: tempDir }, diff --git a/packages/server/src/server/schedule/store.ts b/packages/server/src/server/schedule/store.ts index f3583d5f70..6feee5bc85 100644 --- a/packages/server/src/server/schedule/store.ts +++ b/packages/server/src/server/schedule/store.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { mkdir, readFile, readdir, rm } from "node:fs/promises"; import { join } from "node:path"; +import type { Logger } from "pino"; import { StoredScheduleSchema, type ScheduleTarget, @@ -87,11 +88,27 @@ function matchesNameAndTarget( ); } +function parseStoredSchedule( + content: string, +): { success: true; data: StoredSchedule } | { success: false; error: unknown } { + let json: unknown; + try { + json = JSON.parse(content); + } catch (error) { + return { success: false, error }; + } + return StoredScheduleSchema.safeParse(json); +} + export class ScheduleStore { private readonly scheduleMutations = new Map>(); private readonly identityMutations = new Map>(); + private reportedInvalidFiles = new Set(); - constructor(private readonly dir: string) {} + constructor( + private readonly dir: string, + private readonly logger: Logger, + ) {} private filePath(id: string): string { return join(this.dir, `${id}.json`); @@ -101,17 +118,32 @@ export class ScheduleStore { await mkdir(this.dir, { recursive: true }); } + // The service lists schedules on every tick, so a file that is not a valid schedule is + // reported when it first appears rather than once per second. async list(): Promise { await this.ensureDir(); const entries = await readdir(this.dir, { withFileTypes: true }); - const schedules = await Promise.all( + const files = await Promise.all( entries .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .map(async (entry) => { - const content = await readFile(join(this.dir, entry.name), "utf-8"); - return StoredScheduleSchema.parse(JSON.parse(content)); + const filePath = join(this.dir, entry.name); + return { filePath, parsed: parseStoredSchedule(await readFile(filePath, "utf-8")) }; }), ); + const schedules: StoredSchedule[] = []; + const invalidFiles = new Set(); + for (const { filePath, parsed } of files) { + if (parsed.success) { + schedules.push(parsed.data); + continue; + } + invalidFiles.add(filePath); + if (!this.reportedInvalidFiles.has(filePath)) { + this.logger.error({ err: parsed.error, filePath }, "Skipping invalid schedule file"); + } + } + this.reportedInvalidFiles = invalidFiles; return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } diff --git a/packages/server/src/server/script-health-monitor.test.ts b/packages/server/src/server/script-health-monitor.test.ts index bbc5acf3c7..96af0133ca 100644 --- a/packages/server/src/server/script-health-monitor.test.ts +++ b/packages/server/src/server/script-health-monitor.test.ts @@ -99,14 +99,14 @@ function createStubTerminalManager( }; } -async function startTcpServer(): Promise { +async function startTcpServer(host = "127.0.0.1"): Promise { const server = net.createServer((socket) => { socket.end(); }); await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(0, "127.0.0.1", () => { + server.listen(0, host, () => { server.off("error", reject); resolve(); }); @@ -153,6 +153,45 @@ describe("ScriptHealthMonitor", () => { servers.clear(); }); + it.each(["127.0.0.1", "::1"])("reports a service bound only to %s as healthy", async (host) => { + const service = await startTcpServer(host); + servers.add(service.server); + const serviceProxy = new ScriptRouteStore(); + serviceProxy.registerWorkspaceService({ + workspaceId: "workspace-a", + projectSlug: "repo", + branchName: "main", + scriptName: "api", + port: service.port, + }); + const updates: ScriptHealthEntry[][] = []; + const monitor = new ScriptHealthMonitor({ + serviceProxy, + onChange: (_workspaceId, scripts) => updates.push(scripts), + pollIntervalMs: 10, + probeTimeoutMs: 100, + graceMs: 0, + failuresBeforeStopped: 1, + }); + monitor.start(); + try { + await vi.waitFor(() => { + expect(updates).toEqual([ + [ + { + scriptName: "api", + hostname: "api--repo.localhost", + port: service.port, + health: "healthy", + }, + ], + ]); + }); + } finally { + monitor.stop(); + } + }); + it("starts new service routes in pending and transitions to healthy after the grace period", async () => { vi.useFakeTimers(); diff --git a/packages/server/src/server/script-health-monitor.ts b/packages/server/src/server/script-health-monitor.ts index 810518f999..7992608f24 100644 --- a/packages/server/src/server/script-health-monitor.ts +++ b/packages/server/src/server/script-health-monitor.ts @@ -1,4 +1,4 @@ -import net from "node:net"; +import { connectToLocalService } from "./local-service-connection.js"; import type { ServiceProxyHealthTarget, ServiceProxySubsystem } from "./service-proxy.js"; export type ScriptHealthState = "pending" | "healthy" | "unhealthy"; @@ -208,7 +208,7 @@ export class ScriptHealthMonitor { private probeRoute(port: number): Promise { return new Promise((resolve) => { - const socket = net.connect({ host: "127.0.0.1", port }); + const socket = connectToLocalService(port); let settled = false; const finish = (healthy: boolean) => { diff --git a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts index c9d57c4b59..f47b30ce85 100644 --- a/packages/server/src/server/selective-timeline-delivery.e2e.test.ts +++ b/packages/server/src/server/selective-timeline-delivery.e2e.test.ts @@ -1,9 +1,18 @@ +import type { AgentStreamEvent, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; import { afterEach, beforeEach, expect, test } from "vitest"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { DaemonClient } from "./test-utils/daemon-client.js"; +import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client"; +import { WebSocket } from "ws"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-daemon.js"; -import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js"; +import { + MockLoadTestAgentClient, + MockLoadTestAgentSession, +} from "./agent/providers/mock-load-test-agent.js"; interface MessageWaiter { predicate(message: SessionOutboundMessage): boolean; @@ -17,7 +26,10 @@ class ConnectedClient { private readonly waiters: MessageWaiter[] = []; private readonly unsubscribe: () => void; - constructor(readonly client: DaemonClient) { + constructor( + readonly client: DaemonClient, + private readonly socket: WebSocket, + ) { this.unsubscribe = client.subscribeRawMessages((message) => { this.messages.push(message); for (let waiterIndex = this.waiters.length - 1; waiterIndex >= 0; waiterIndex -= 1) { @@ -30,6 +42,46 @@ class ConnectedClient { }); } + // These cases exercise the pre-owned-subscriptions selective timeline contract. + async setLegacyTimelineMembership(agentIds: string[]): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "agent.timeline.set_subscription.request", + requestId, + agentIds, + }, + }), + ); + await this.next( + (message) => + message.type === "agent.timeline.set_subscription.response" && + message.payload.requestId === requestId, + "legacy timeline acknowledgement", + ); + } + + async observeLegacyDirectory(subscriptionId: string): Promise { + const requestId = crypto.randomUUID(); + this.socket.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agents_request", + requestId, + subscribe: { subscriptionId }, + }, + }), + ); + await this.next( + (message) => + message.type === "fetch_agents_response" && message.payload.requestId === requestId, + "legacy directory acknowledgement", + ); + } + clear(): void { this.messages.length = 0; } @@ -129,24 +181,201 @@ async function connect(input: { clientId: string; selective: boolean; timelineReplacementInvalidation?: boolean; + timelineNotifications?: boolean; + pluginTimelineItems?: boolean; + workspaceSetupBlocked?: boolean; }): Promise { + let socket!: WebSocket; const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, clientId: input.clientId, + webSocketFactory: (url) => { + socket = new WebSocket(url); + return socket as unknown as WebSocketLike; + }, capabilities: { + [CLIENT_CAPS.ownedSubscriptions]: false, [CLIENT_CAPS.selectiveAgentTimeline]: input.selective, - ...(input.timelineReplacementInvalidation - ? { [CLIENT_CAPS.timelineReplacementInvalidation]: true } - : {}), + [CLIENT_CAPS.pluginTimelineItems]: input.pluginTimelineItems ?? false, + [CLIENT_CAPS.workspaceSetupBlocked]: input.workspaceSetupBlocked ?? false, + ...(input.timelineNotifications === undefined + ? {} + : { [CLIENT_CAPS.timelineNotifications]: input.timelineNotifications }), + [CLIENT_CAPS.timelineReplacementInvalidation]: input.timelineReplacementInvalidation ?? false, }, reconnect: { enabled: false }, }); await client.connect(); - const connected = new ConnectedClient(client); + const connected = new ConnectedClient(client, socket); clients.push(connected); return connected; } +test("notification timeline items are sent only to clients that advertise support", async () => { + await daemon.close(); + daemon = await createTestPaseoDaemon({ + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const capable = await connect({ + clientId: "notification-capable", + selective: false, + timelineNotifications: true, + }); + const legacy = await connect({ + clientId: "notification-legacy", + selective: false, + timelineNotifications: false, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + title: "Notification compatibility", + model: "ten-second-stream", + }); + capable.clear(); + legacy.clear(); + + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "notification", + level: "warning", + message: "Capable clients only", + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "Visible to every client", + }); + await Promise.all([ + capable.next(isAgentStream(agent.id), "capable timeline delivery"), + legacy.next(isAgentStream(agent.id), "legacy timeline delivery"), + ]); + await Promise.all([ + capable.barrier("notification-capable"), + legacy.barrier("notification-legacy"), + ]); + + const capableLiveItems = capable.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + const legacyLiveItems = legacy.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + expect(capableLiveItems.some((item) => item.type === "notification")).toBe(true); + expect(legacyLiveItems.some((item) => item.type === "notification")).toBe(false); + expect(legacyLiveItems).toContainEqual( + expect.objectContaining({ type: "assistant_message", text: "Visible to every client" }), + ); + + const [capableTimeline, legacyTimeline] = await Promise.all([ + capable.client.fetchAgentTimeline(agent.id, { direction: "tail", projection: "canonical" }), + legacy.client.fetchAgentTimeline(agent.id, { direction: "tail", projection: "canonical" }), + ]); + expect(capableTimeline.entries.some((entry) => entry.item.type === "notification")).toBe(true); + expect(legacyTimeline.entries.some((entry) => entry.item.type === "notification")).toBe(false); + expect(legacyTimeline.entries).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: "Visible to every client", + }), + }), + ); + expect(legacyTimeline.window).toEqual(capableTimeline.window); + expect(legacyTimeline.endCursor).toEqual(capableTimeline.endCursor); +}); + +test("plugin timeline items are sent only to clients that advertise support", async () => { + await daemon.close(); + daemon = await createTestPaseoDaemon({ + isDev: true, + agentClients: { mock: new MockLoadTestAgentClient() }, + }); + const capable = await connect({ + clientId: "plugin-shared", + selective: false, + pluginTimelineItems: true, + }); + const legacy = await connect({ + clientId: "plugin-shared", + selective: false, + pluginTimelineItems: false, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + title: "Plugin compatibility", + model: "ten-second-stream", + }); + capable.clear(); + legacy.clear(); + + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "plugin", + id: "compat-row", + pluginId: "timeline-items", + kind: "notice", + version: 1, + data: { text: "Capable clients only" }, + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "plugin", + id: "compat-row", + pluginId: "timeline-items", + kind: "notice", + version: 1, + data: { text: "Updated" }, + }); + await daemon.daemon.agentManager.appendTimelineItem(agent.id, { + type: "assistant_message", + text: "Visible to every client", + }); + await Promise.all([ + capable.next(isAgentStream(agent.id), "capable timeline delivery"), + legacy.next(isAgentStream(agent.id), "legacy timeline delivery"), + ]); + await Promise.all([capable.barrier("plugin-capable"), legacy.barrier("plugin-legacy")]); + + const capableLiveItems = capable.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + const legacyLiveItems = legacy.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + expect(capableLiveItems.some((item) => item.type === "plugin")).toBe(true); + expect(legacyLiveItems.some((item) => item.type === "plugin")).toBe(false); + expect(legacyLiveItems).toContainEqual( + expect.objectContaining({ type: "assistant_message", text: "Visible to every client" }), + ); + + for (const projection of ["canonical", "projected"] as const) { + const [capableTimeline, legacyTimeline] = await Promise.all([ + capable.client.fetchAgentTimeline(agent.id, { direction: "tail", projection }), + legacy.client.fetchAgentTimeline(agent.id, { direction: "tail", projection }), + ]); + expect(capableTimeline.entries.some((entry) => entry.item.type === "plugin")).toBe(true); + expect(legacyTimeline.entries.some((entry) => entry.item.type === "plugin")).toBe(false); + expect(legacyTimeline.entries).toContainEqual( + expect.objectContaining({ + item: expect.objectContaining({ + type: "assistant_message", + text: "Visible to every client", + }), + }), + ); + expect(legacyTimeline.window).toEqual(capableTimeline.window); + expect(legacyTimeline.endCursor).toEqual(capableTimeline.endCursor); + expect(legacyTimeline.entries.flatMap((entry) => entry.collapsed)).not.toContain("identity"); + } +}); + test("rewind routes replacement completion by source capability and subscription", async () => { await daemon.close(); daemon = await createTestPaseoDaemon({ @@ -177,9 +406,9 @@ test("rewind routes replacement completion by source capability and subscription }); await Promise.all([ - initiating.client.setAgentTimelineSubscription([agent.id]), - passive.client.setAgentTimelineSubscription([agent.id]), - unrelated.client.setAgentTimelineSubscription([]), + initiating.setLegacyTimelineMembership([agent.id]), + passive.setLegacyTimelineMembership([agent.id]), + unrelated.setLegacyTimelineMembership([]), ]); await initiating.client.sendMessage(agent.id, "Rewind this synthetic prompt"); await initiating.client.cancelAgent(agent.id); @@ -228,7 +457,7 @@ test("subscription acknowledgements stay on the requesting socket of a retained legacy.clear(); capable.clear(); - await capable.client.setAgentTimelineSubscription(["agent-a"]); + await capable.setLegacyTimelineMembership(["agent-a"]); await capable.barrier("targeted-subscription-ack"); expect( @@ -239,12 +468,14 @@ test("subscription acknowledgements stay on the requesting socket of a retained test("real WebSocket sessions enforce selective delivery, retained resets, downgrade, and dedicated attention", async () => { const legacy = await connect({ clientId: "legacy-client", selective: false }); let capable = await connect({ clientId: "capable-client", selective: true }); + const workspaceId = await createAttentionWorkspace(legacy.client); const agents = await Promise.all( ["A", "B", "C"].map((title) => legacy.client.createAgent({ provider: "codex", - cwd: "/tmp", + cwd: daemon.paseoHome, title: `Selective ${title}`, + workspaceId, modeId: "full-access", }), ), @@ -261,7 +492,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("before-membership"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentA.id, agentB.id]); + await capable.setLegacyTimelineMembership([agentA.id, agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -284,7 +515,7 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("unviewed-c"); expect(capable.hasTimeline(agentC.id)).toBe(false); - await capable.client.setAgentTimelineSubscription([agentB.id]); + await capable.setLegacyTimelineMembership([agentB.id]); legacy.clear(); capable.clear(); await daemon.daemon.agentManager.emitLiveTimelineItem(agentA.id, { @@ -316,6 +547,10 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg await capable.barrier("resumed-membership-reset"); expect(capable.hasTimeline(agentB.id)).toBe(false); + await Promise.all([ + legacy.observeLegacyDirectory("legacy-directory"), + capable.observeLegacyDirectory("capable-directory"), + ]); capable.client.sendHeartbeat({ deviceType: "mobile", focusedAgentId: null, @@ -370,3 +605,190 @@ test("real WebSocket sessions enforce selective delivery, retained resets, downg expect(downgradedDelivery.type).toBe("agent_stream"); }, 30_000); + +test("blocked setup remains readable on mixed-capability sockets sharing a session", async () => { + await daemon.close(); + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-blocked-compat-")); + const projects = path.join(root, ".paseo", "projects"); + await mkdir(projects, { recursive: true }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "blocked-workspace", + projectId: "project", + cwd: root, + kind: "directory", + displayName: "Fork PR", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + untrustedSource: { + kind: "change_request", + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }); + await writeFile(path.join(projects, "workspaces.json"), JSON.stringify([workspace])); + daemon = await createTestPaseoDaemon({ paseoHomeRoot: root }); + const legacy = await connect({ clientId: "setup-shared", selective: false }); + const capable = await connect({ + clientId: "setup-shared", + selective: false, + workspaceSetupBlocked: true, + }); + const oldStatus = await legacy.client.fetchWorkspaceSetupStatus(workspace.workspaceId); + const newStatus = await capable.client.fetchWorkspaceSetupStatus(workspace.workspaceId); + expect(oldStatus.snapshot).toMatchObject({ + status: "failed", + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }); + expect(newStatus.snapshot).toMatchObject({ + status: "blocked", + error: null, + blockedSource: workspace.untrustedSource, + }); + expect(oldStatus.snapshot?.detail).toEqual(newStatus.snapshot?.detail); +}); + +// A provider adapter with controllable events and durable provider history. +class CompatibilityProviderSession extends MockLoadTestAgentSession { + private readonly observers = new Set<(event: AgentStreamEvent) => void>(); + private readonly injectedHistory: AgentStreamEvent[] = []; + override subscribe(callback: (event: AgentStreamEvent) => void): () => void { + const unsubscribe = super.subscribe(callback); + this.observers.add(callback); + return () => { + unsubscribe(); + this.observers.delete(callback); + }; + } + push(event: AgentStreamEvent): void { + this.injectedHistory.push(event); + for (const callback of this.observers) callback(event); + } + override async *streamHistory(): AsyncGenerator { + yield* this.injectedHistory; + yield* super.streamHistory(); + } +} + +class CompatibilityProvider extends MockLoadTestAgentClient { + session!: CompatibilityProviderSession; + override async createSession(config: AgentSessionConfig): Promise { + this.session = new CompatibilityProviderSession({ + config, + sessionId: "compat-provider-session", + }); + return this.session; + } +} + +test("plugin items are gated in provider child streams, child fetches, and rewind replay", async () => { + await daemon.close(); + const provider = new CompatibilityProvider(); + daemon = await createTestPaseoDaemon({ isDev: true, agentClients: { mock: provider } }); + const legacy = await connect({ clientId: "provider-shared", selective: false }); + const capable = await connect({ + clientId: "provider-shared", + selective: false, + pluginTimelineItems: true, + }); + const agent = await capable.client.createAgent({ + provider: "mock", + cwd: "/tmp", + model: "ten-second-stream", + }); + const plugin = { + type: "plugin" as const, + id: "provider-row", + pluginId: "provider", + kind: "notice", + version: 1, + data: { text: "Provider notice" }, + }; + provider.session.push({ type: "timeline", provider: "mock", item: plugin }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { type: "upsert", id: "child", title: "Child", status: "running" }, + }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { type: "timeline", id: "child", item: plugin }, + }); + provider.session.push({ + type: "provider_subagent", + provider: "mock", + event: { + type: "timeline", + id: "child", + item: { type: "assistant_message", text: "Child result" }, + }, + }); + function childResult(message: SessionOutboundMessage): boolean { + return ( + message.type === "agent.provider_subagents.update" && + message.payload.kind === "timeline" && + message.payload.item.type === "assistant_message" + ); + } + await Promise.all([ + capable.next(childResult, "capable child result"), + legacy.next(childResult, "legacy child result"), + ]); + function childItems(client: ConnectedClient) { + return client.messages.flatMap((message) => + message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline" + ? [message.payload.item] + : [], + ); + } + expect(childItems(capable)).toContainEqual(plugin); + expect(childItems(legacy)).toEqual([{ type: "assistant_message", text: "Child result" }]); + const oldChild = await legacy.client.fetchProviderSubagentTimeline(agent.id, "child"); + const newChild = await capable.client.fetchProviderSubagentTimeline(agent.id, "child"); + expect(newChild.rows.map((row) => row.item)).toContainEqual(plugin); + expect(oldChild.rows.map((row) => row.item)).toEqual([ + { type: "assistant_message", text: "Child result" }, + ]); + expect(oldChild.window).toEqual(newChild.window); + + await capable.client.sendMessage(agent.id, "Rewind target"); + await capable.client.cancelAgent(agent.id); + const timeline = await capable.client.fetchAgentTimeline(agent.id, { + direction: "tail", + projection: "canonical", + }); + const targetMessageId = rewindMessageId(timeline); + capable.clear(); + legacy.clear(); + await capable.client.rewindAgent(agent.id, targetMessageId, "conversation"); + await Promise.all([capable.barrier("provider-rewind"), legacy.barrier("provider-rewind")]); + function replayItems(client: ConnectedClient) { + return client.messages.flatMap((message) => + message.type === "agent_stream" && message.payload.event.type === "timeline" + ? [message.payload.event.item] + : [], + ); + } + expect(replayItems(capable)).toContainEqual(plugin); + expect(replayItems(legacy).some((item) => item.type === "plugin")).toBe(false); + expect(replayItems(legacy)).toContainEqual(expect.objectContaining({ type: "user_message" })); +}); + +async function createAttentionWorkspace(client: DaemonClient): Promise { + const result = await client.createWorkspace({ + source: { kind: "directory", path: daemon.paseoHome }, + }); + if (!result.workspace) throw new Error(result.error ?? "Expected workspace"); + return result.workspace.id; +} + +function rewindMessageId( + timeline: Awaited>, +): string { + const target = timeline.entries.find((entry) => entry.item.type === "user_message"); + if (target?.item.type !== "user_message" || !target.item.messageId) + throw new Error("Expected rewind target"); + return target.item.messageId; +} diff --git a/packages/server/src/server/service-proxy.test.ts b/packages/server/src/server/service-proxy.test.ts index 5313b01d2d..5764b19539 100644 --- a/packages/server/src/server/service-proxy.test.ts +++ b/packages/server/src/server/service-proxy.test.ts @@ -32,15 +32,20 @@ function readServerSourceFiles(dir = path.resolve(import.meta.dirname)): string[ } interface HttpGetOptions { + hostname?: string; path?: string; headers?: Record; } function httpGet(port: number, host: string, options: HttpGetOptions = {}) { - const { path: requestPath = "/api/health", headers: extraHeaders = {} } = options; + const { + hostname = "127.0.0.1", + path: requestPath = "/api/health", + headers: extraHeaders = {}, + } = options; return new Promise<{ status: number; body: string }>((resolve, reject) => { const req = http.get( - { hostname: "127.0.0.1", port, path: requestPath, headers: { host, ...extraHeaders } }, + { hostname, port, path: requestPath, headers: { host, ...extraHeaders } }, (res) => { let body = ""; res.on("data", (chunk: Buffer) => { @@ -53,6 +58,31 @@ function httpGet(port: number, host: string, options: HttpGetOptions = {}) { }); } +function httpPost(port: number, host: string, requestPath: string, payload: string) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = http.request( + { + hostname: "127.0.0.1", + port, + method: "POST", + path: requestPath, + headers: { host, "content-type": "application/json" }, + }, + (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, body })); + res.on("error", reject); + }, + ); + request.on("error", reject); + request.write(payload.slice(0, 5)); + request.end(payload.slice(5)); + }); +} + describe("service proxy subsystem shape", () => { it("keeps production imports behind the service-proxy entrypoint", () => { const offenders: string[] = []; @@ -303,6 +333,7 @@ describe("service proxy subsystem shape", () => { }); interface ForwardedFixture { + upstreamPort: number; daemonPort: number; hostname: string; close(): Promise; @@ -313,11 +344,16 @@ interface ForwardedFixture { * echoes the headers it received so tests can assert what actually crossed the * proxy, not what a helper returned. */ -async function startForwardedHeadersFixture(): Promise { - const upstreamPort = await findFreePort(); +async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise { const upstream = http.createServer((req, res) => { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify(req.headers)); + let body = ""; + req.on("data", (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on("end", () => { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body })); + }); }); const upgradeSockets: net.Socket[] = []; upstream.on("upgrade", (req, socket) => { @@ -331,7 +367,11 @@ async function startForwardedHeadersFixture(): Promise { `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`, ); }); - await new Promise((resolve) => upstream.listen(upstreamPort, "127.0.0.1", resolve)); + await new Promise((resolve, reject) => { + upstream.once("error", reject); + upstream.listen(0, upstreamHost, resolve); + }); + const upstreamPort = (upstream.address() as net.AddressInfo).port; const serviceProxy = createServiceProxySubsystem({ logger }); const route = serviceProxy.registerWorkspaceService({ @@ -354,6 +394,7 @@ async function startForwardedHeadersFixture(): Promise { await new Promise((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve)); return { + upstreamPort, daemonPort, hostname: route.hostname, async close() { @@ -416,6 +457,59 @@ function upgradeThroughProxy( }); } +describe("service proxy loopback services", () => { + it.each(["127.0.0.1", "::1"])("forwards HTTP to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const direct = await httpGet(fixture.upstreamPort, fixture.hostname, { hostname: host }); + expect(direct.status).toBe(200); + + const response = await httpGet(fixture.daemonPort, fixture.hostname); + expect(response.status).toBe(200); + expect(JSON.parse(response.body).host).toBe(fixture.hostname); + } finally { + await fixture.close(); + } + }); + + it.each(["127.0.0.1", "::1"])( + "streams POST bodies to a service bound only to %s", + async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const payload = JSON.stringify({ message: "hello" }); + const response = await httpPost( + fixture.daemonPort, + fixture.hostname, + "/submit?q=one%20two", + payload, + ); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ + host: fixture.hostname, + "content-type": "application/json", + method: "POST", + url: "/submit?q=one%20two", + body: payload, + }); + } finally { + await fixture.close(); + } + }, + ); + + it.each(["127.0.0.1", "::1"])("forwards upgrades to a service bound only to %s", async (host) => { + const fixture = await startForwardedHeadersFixture(host); + try { + const headers = await upgradeThroughProxy(fixture.daemonPort, fixture.hostname); + expect(headers.host).toBe(fixture.hostname); + expect(headers.upgrade).toBe("websocket"); + } finally { + await fixture.close(); + } + }); +}); + describe("service proxy forwarded headers", () => { it("forwards the client authority with its port so services build reachable URLs", async () => { const fixture = await startForwardedHeadersFixture(); diff --git a/packages/server/src/server/service-proxy.ts b/packages/server/src/server/service-proxy.ts index 525a95ac5d..ef93a27f9a 100644 --- a/packages/server/src/server/service-proxy.ts +++ b/packages/server/src/server/service-proxy.ts @@ -1,5 +1,6 @@ import http, { createServer as createHTTPServer } from "node:http"; import net from "node:net"; +import { connectToLocalService, requestLocalService } from "./local-service-connection.js"; import { createHash } from "node:crypto"; import { existsSync, unlinkSync } from "node:fs"; import type { IncomingMessage } from "node:http"; @@ -328,9 +329,8 @@ function proxyHttpRequest({ }): void { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: req.protocol }); - const proxyReq = http.request( + const proxyReq = requestLocalService( { - hostname: "127.0.0.1", port: route.port, path: req.originalUrl, method: req.method, @@ -368,7 +368,8 @@ function proxyUpgradeRequest({ route: ServiceProxyRoute; logger: Logger; }): void { - const targetSocket = net.connect({ host: "127.0.0.1", port: route.port }, () => { + const targetSocket = connectToLocalService(route.port); + targetSocket.on("connect", () => { const forwardedHeaders = buildForwardedHeaders({ req, route, protocol: "http" }); forwardedHeaders.connection = "Upgrade"; forwardedHeaders.upgrade = req.headers.upgrade ?? "websocket"; diff --git a/packages/server/src/server/session-admission-auth.ts b/packages/server/src/server/session-admission-auth.ts new file mode 100644 index 0000000000..b4195b8d3f --- /dev/null +++ b/packages/server/src/server/session-admission-auth.ts @@ -0,0 +1,37 @@ +import { compare } from "bcryptjs"; +import type { WSHelloMessage } from "@getpaseo/protocol/messages"; +import { OWNER_PERMISSIONS } from "./authorization/index.js"; +import { matchesLocalCredential } from "./local-credential.js"; +import type { SessionAdmission } from "./websocket-server.js"; + +export type AdmissionFailure = "password_required" | "incorrect_password"; + +type AdmissionResolution = { admission: SessionAdmission } | { rejection: AdmissionFailure }; + +export async function resolveSessionAdmission(input: { + credential: WSHelloMessage["auth"]; + passwordHash: string | undefined; + localCredential: string | null; + transport: "direct" | "relay"; +}): Promise { + const { credential, passwordHash, localCredential, transport } = input; + if (!passwordHash) { + return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; + } + if (!credential) { + // COMPAT(relayPasswordOptional): added in v0.9.1, remove once release N mobile builds are live on App Store and Play. + if (transport === "relay") { + return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; + } + return { rejection: "password_required" }; + } + if (credential.kind === "localCredential") { + if (localCredential && matchesLocalCredential(localCredential, credential.token)) { + return { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } }; + } + return { rejection: "incorrect_password" }; + } + return (await compare(credential.password, passwordHash)) + ? { admission: { principalId: "owner", permissions: OWNER_PERMISSIONS } } + : { rejection: "incorrect_password" }; +} diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 94e45d7452..fb23daa6f0 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -1,11 +1,16 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; +import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; -import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; +import { + createDaemonTestContext, + DaemonClient, + type DaemonTestContext, +} from "./test-utils/index.js"; import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; @@ -96,6 +101,7 @@ async function expectWorktreeListEmpty(repoDir: string): Promise { async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; + initialPrompt?: string; branchName?: string; repoDir?: string; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { @@ -112,7 +118,7 @@ async function createAgentInBranchOffWorktree(options?: { base: "main", }, ...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}), - initialPrompt: "Say done.", + initialPrompt: options?.initialPrompt ?? "Say done.", }); return { repoDir, agentId: created.id, worktreePath: created.cwd }; } @@ -154,12 +160,15 @@ test("create_agent_request creates a worktree and auto-archives both after the f // last-reference worktree directory is gone. await expectAgentAbsentFromActiveList(created.id); await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); - // Archived tabs can continue asking for history. These reads must not recreate - // the removed workspace observation or compromise the next agent lifecycle. + // Archived tabs can continue asking for history, and those reads are served from + // persisted state. They must not recreate the removed worktree or its workspace + // observation, and must not compromise the next agent lifecycle. const staleTimelineReads = await Promise.allSettled( Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })), ); - expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true); + expect(staleTimelineReads.every((result) => result.status === "fulfilled")).toBe(true); + expect(existsSync(created.cwd)).toBe(false); + expect((await ctx.client.fetchWorkspaces()).entries).toHaveLength(0); const subsequent = await ctx.client.createAgent({ config: { ...getFullAccessConfig("codex"), cwd: repoDir }, initialPrompt: "Say done.", @@ -347,24 +356,39 @@ test("archiving a created worktree removes the directory on last reference", asy }); test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => { - const created = await createAgentInBranchOffWorktree({ autoArchive: true }); - - // Create a sibling workspace that shares the same backing directory. - const sibling = await ctx.client.createWorkspace({ - source: { kind: "directory", path: created.worktreePath }, - title: "sibling", + // Hold turn completion while establishing the sibling reference. The original + // ordering raced a fast completed turn against sibling workspace creation. + await ctx.cleanup(); + let finishTurn!: () => void; + const completion = new Promise((resolve) => { + finishTurn = resolve; }); - if (!sibling.workspace) { - throw new Error(sibling.error ?? "Failed to create sibling workspace"); + ctx = await createDaemonTestContext({ + agentClients: createTestAgentClients({ + beforeTurnComplete: async (prompt) => { + if (prompt === "Hold for sibling workspace") await completion; + }, + }), + }); + try { + const created = await createAgentInBranchOffWorktree({ + autoArchive: true, + initialPrompt: "Hold for sibling workspace", + }); + const sibling = await ctx.client.createWorkspace({ + source: { kind: "directory", path: created.worktreePath }, + title: "sibling", + }); + if (!sibling.workspace) throw new Error(sibling.error ?? "Failed to create sibling workspace"); + finishTurn(); + await ctx.client.waitForFinish(created.agentId, 10000); + await expectAgentAbsentFromActiveList(created.agentId); + await expectWorktreePresentInList(created.repoDir, created.worktreePath); + expect(existsSync(created.worktreePath)).toBe(true); + await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); + } finally { + finishTurn(); } - - await ctx.client.waitForFinish(created.agentId, 10000); - - await expectAgentAbsentFromActiveList(created.agentId); - await expectWorktreePresentInList(created.repoDir, created.worktreePath); - expect(existsSync(created.worktreePath)).toBe(true); - - await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath }); }); test("create_agent_request rejects legacy git options before creating a worktree", async () => { @@ -413,3 +437,248 @@ test("create_agent_request fails cleanly when worktree creation cannot resolve t await expectActiveAgentListEmpty(); await expectWorktreeListEmpty(repoDir); }); + +/** Every agent id with a record under `$PASEO_HOME/agents`, whatever its cwd bucket. */ +function storedAgentIds(): string[] { + const root = path.join(ctx.daemon.paseoHome, "agents"); + if (!existsSync(root)) return []; + return readdirSync(root, { recursive: true, encoding: "utf8" }) + .filter((entry) => entry.endsWith(".json")) + .map((entry) => path.basename(entry, ".json")); +} + +test.each(["git", "worktreeName"] as const)( + "legacy %s worktree creation resolves final workspace visibility without intermediate records", + async (placement) => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const backgroundCaller = await ctx.client.createAgent({ config, background: true }); + const visibleCaller = await ctx.client.createAgent({ config }); + const cases = [ + { name: "review", background: true, expected: true }, + { name: "review-child", callerAgentId: backgroundCaller.id, expected: true }, + { + name: "visible-child", + callerAgentId: backgroundCaller.id, + background: false, + expected: false, + }, + { name: "hidden-child", callerAgentId: visibleCaller.id, background: true, expected: true }, + { name: "human-default", expected: false }, + ]; + const orderedCases = + placement === "worktreeName" ? [cases[1]!, cases[0]!, ...cases.slice(2)] : cases; + for (const entry of orderedCases) { + const before = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const child = await ctx.client.createAgent({ + config, + ...(entry.callerAgentId ? { callerAgentId: entry.callerAgentId } : {}), + ...(entry.background !== undefined ? { background: entry.background } : {}), + ...(placement === "git" + ? { + git: { + createWorktree: true, + createNewBranch: true, + newBranchName: entry.name, + baseBranch: "main", + }, + } + : { worktreeName: entry.name }), + }); + const inclusive = await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } }); + const created = inclusive.entries.filter( + (workspace) => !before.entries.some((existing) => existing.id === workspace.id), + ); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + id: child.workspaceId, + workspaceDirectory: child.cwd, + background: entry.expected, + workspaceKind: "worktree", + }); + expect(child.cwd).not.toBe(cwd); + expect(child.labels?.["paseo.parent-agent-id"]).toBe(entry.callerAgentId); + const defaultAgents = await ctx.client.fetchAgents(); + const allAgents = await ctx.client.fetchAgents({ filter: { includeBackground: true } }); + expect(defaultAgents.entries.some((item) => item.agent.id === child.id)).toBe( + !entry.expected, + ); + expect(allAgents.entries.some((item) => item.agent.id === child.id)).toBe(true); + const defaultWorkspaces = await ctx.client.fetchWorkspaces(); + expect( + defaultWorkspaces.entries.some((workspace) => workspace.id === child.workspaceId), + ).toBe(!entry.expected); + } + }, + 30_000, +); + +test("background workspaces keep durable agents addressable through restart and archive", async () => { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(hidden.internal).not.toBe(true); + expect( + (await ctx.client.fetchWorkspaces({ filter: { query: hidden.workspaceId } })).entries.map( + (entry) => entry.id, + ), + ).toContain(hidden.workspaceId); + expect(storedAgentIds()).toContain(hidden.id); + expect((await ctx.client.fetchWorkspaces()).entries).not.toContainEqual( + expect.objectContaining({ id: hidden.workspaceId }), + ); + expect( + (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })).entries, + ).toContainEqual(expect.objectContaining({ id: hidden.workspaceId, background: true })); + expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain( + hidden.id, + ); + expect( + (await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); + const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id }); + expect(child.workspaceId).toBe(hidden.workspaceId); + const inherited = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + }); + expect(inherited.workspace?.background).toBe(true); + const visible = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: hidden.id, + background: false, + }); + expect(visible.workspace?.background).toBe(false); + const inheritedWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + worktree: { mode: "branch-off", newBranch: "background-child", base: "main" }, + }); + const overriddenWorktree = await ctx.client.createAgent({ + config, + callerAgentId: hidden.id, + background: false, + worktree: { mode: "branch-off", newBranch: "visible-child", base: "main" }, + }); + const workspaces = (await ctx.client.fetchWorkspaces({ filter: { includeBackground: true } })) + .entries; + expect(workspaces.find((entry) => entry.id === inheritedWorktree.workspaceId)?.background).toBe( + true, + ); + expect(workspaces.find((entry) => entry.id === overriddenWorktree.workspaceId)?.background).toBe( + false, + ); + + await expect( + ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }), + ).rejects.toThrow("workspace creation intent"); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + expect( + (await ctx.client.fetchAgentTimeline(hidden.id, { limit: 20 })).entries.length, + ).toBeGreaterThan(0); + const homeRoot = path.dirname(ctx.daemon.paseoHome); + tempRoots.push(ctx.daemon.staticDir); + await ctx.client.close(); + await ctx.daemon.daemon.stop(); + await ctx.daemon.daemon.agentManager.flush(); + ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot }); + const replay = await ctx.client.createAgent({ + config, + background: true, + idempotencyKey: "background-durable", + }); + expect(replay.id).toBe(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.workspaceId).toBe(hidden.workspaceId); + await ctx.client.sendAgentMessage(hidden.id, "Say done again."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await ctx.client.archiveAgent(hidden.id); + expect((await ctx.client.fetchAgent(hidden.id))?.agent.archivedAt).toEqual(expect.any(String)); + expect( + (await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id), + ).not.toContain(hidden.id); + expect( + (await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map( + (entry) => entry.agent.id, + ), + ).toContain(hidden.id); +}, 30_000); + +test("default and inclusive observers retain independent snapshots, live updates and catch-up", async () => { + const otherClient = new DaemonClient({ url: `ws://127.0.0.1:${ctx.daemon.port}/ws` }); + await otherClient.connect(); + try { + const cwd = createGitRepo(); + const config = { ...getFullAccessConfig("codex"), cwd }; + const hidden = await ctx.client.createAgent({ config, background: true }); + const visible = await ctx.client.createAgent({ config }); + const inclusive = ctx.client.observeAgents({ + scope: "active", + sync: {}, + includeBackground: true, + }); + const ordinary = otherClient.observeAgents({ scope: "active", sync: {} }); + const inclusiveSnapshot = await inclusive.ready; + const defaultSnapshot = await ordinary.ready; + expect(inclusiveSnapshot.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + expect(defaultSnapshot.entries.map((entry) => entry.agent.id)).toEqual([visible.id]); + const inclusiveWorkspaces = ctx.client.observeWorkspaces({ sync: {}, includeBackground: true }); + const ordinaryWorkspaces = otherClient.observeWorkspaces({ sync: {} }); + const full = await inclusiveWorkspaces.ready; + const partial = await ordinaryWorkspaces.ready; + expect(full.entries.map((entry) => entry.id)).toContain(hidden.workspaceId); + expect(partial.entries.map((entry) => entry.id)).not.toContain(hidden.workspaceId); + const received: string[] = []; + const defaultReceived: string[] = []; + inclusive.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + received.push(message.payload.agent.id); + }, + }); + ordinary.subscribe({ + snapshot: () => {}, + update: (message) => { + if (message.type === "agent_update" && message.payload.kind === "upsert") + defaultReceived.push(message.payload.agent.id); + }, + }); + await ctx.client.sendAgentMessage(hidden.id, "Say done."); + await ctx.client.waitForFinish(hidden.id, 10_000); + await expect.poll(() => received).toContain(hidden.id); + expect(defaultReceived).not.toContain(hidden.id); + const catchup = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: inclusiveSnapshot.sync?.generation, + afterSeq: inclusiveSnapshot.sync?.headSeq, + }, + }); + expect(catchup.sync?.mode).toBe("changes"); + expect(catchup.sync?.removals.map((item) => item.id)).not.toContain(hidden.id); + const scopeChanged = await ctx.client.fetchAgents({ + scope: "active", + includeBackground: true, + sync: { + generation: defaultSnapshot.sync?.generation, + afterSeq: defaultSnapshot.sync?.headSeq, + }, + }); + expect(scopeChanged.sync?.mode).toBe("snapshot"); + expect(scopeChanged.entries.map((entry) => entry.agent.id)).toContain(hidden.id); + await inclusive.release(); + await ordinary.release(); + await inclusiveWorkspaces.release(); + await ordinaryWorkspaces.release(); + } finally { + await otherClient.close(); + } +}, 30_000); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index af9fd2d539..e114d8b26e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execSync } from "child_process"; import { existsSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs"; import { tmpdir } from "os"; @@ -41,6 +45,8 @@ import { asGitHubService, asWorkspaceGitService, asDaemonConfigStore, + findByType, + createProviderSnapshot, createProviderSnapshotManagerStub, } from "./test-utils/session-stubs.js"; import { isPlatform } from "../test-utils/platform.js"; @@ -361,6 +367,8 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { const messages = options.messages ?? []; const sessionOptions: SessionOptions = { + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: options.clientId ?? "test-client", onMessage: (message) => messages.push(message), ...(options.targetedMessages @@ -368,7 +376,10 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { onMessageToSource: (source: object, message: SessionOutboundMessage) => options.targetedMessages?.push({ source, message }), } - : {}), + : { + onMessageToSource: (_source: object, message: SessionOutboundMessage) => + messages.push(message), + }), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), @@ -485,7 +496,11 @@ test("routes plugin requests and releases its owned catalog subscription on clea status: "running" as const, }; const pluginRuntime: NonNullable = { - listPlugins: () => [plugin], + before: async (_name, request) => { + return request; + }, + emit: () => {}, + listPlugins: async () => [plugin], getLogs: () => [ { sequence: 1, @@ -506,9 +521,17 @@ test("routes plugin requests and releases its owned catalog subscription on clea }, catalog: () => [{ id: "example", clientBundle: "bundle" }], invokePluginRpc: async () => ({ ok: true }), + listUsageReports: async () => [], + listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }), }; const session = createSessionForTest({ messages, pluginRuntime }); + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "catalog", + events: ["status.plugin_catalog_changed"], + }); + messages.length = 0; await session.handleMessage({ type: "plugin.list.request", requestId: "list" }); await session.handleMessage({ type: "plugin.logs.get.request", @@ -1300,43 +1323,56 @@ describe("workspace file access (behavior preservation)", () => { const messages: SessionOutboundMessage[] = []; const session = createSessionForTest({ messages, paseoHome }); - await session.handleMessage({ - type: "file.upload.request", - fileName: "notes.txt", - mimeType: "text/plain", - size: 11, - modifiedAt: "2026-05-02T00:00:00.000Z", - requestId: "req-upload", - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileBegin, + const source = {}; + await session.handleMessage( + { + type: "file.upload.request", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - metadata: { - mime: "text/plain", - size: 11, - encoding: "binary", - modifiedAt: "2026-05-02T00:00:00.000Z", - fileName: "notes.txt", - }, - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileChunk, - requestId: "req-upload", - payload: new TextEncoder().encode("hello world"), - }), - }); - await session.handleBinaryFrame({ - kind: "file_transfer", - frame: uploadFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId: "req-upload", - }), - }); + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-upload", + metadata: { + mime: "text/plain", + size: 11, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + fileName: "notes.txt", + }, + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-upload", + payload: new TextEncoder().encode("hello world"), + }), + }, + source, + ); + await session.handleBinaryFrame( + { + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-upload", + }), + }, + source, + ); const response = messages.find((message) => message.type === "file.upload.response"); if (response?.type !== "file.upload.response") { @@ -2145,13 +2181,15 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2176,13 +2214,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2207,13 +2247,15 @@ describe("session provider refresh cwd routing", () => { const messages: unknown[] = []; const { manager: providerSnapshotManager, warmUpSnapshotForCwd } = createProviderSnapshotManagerStub(); - providerSnapshotManager.getSnapshot = vi.fn(() => [ - { - provider: "codex", - status: "loading", - enabled: false, - }, - ]); + providerSnapshotManager.getSnapshot = vi.fn(() => + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: false, + }, + ]), + ); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -2242,23 +2284,27 @@ describe("session provider refresh cwd routing", () => { getSnapshot, warmUpSnapshotForCwd, } = createProviderSnapshotManagerStub(); - getSnapshot.mockReturnValueOnce([ - { - provider: "codex", - status: "loading", - enabled: true, - }, - ]); - getSnapshot.mockReturnValue([ - { - provider: "codex", - status: "ready", - enabled: true, - models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], - modes: [], - fetchedAt: "2026-05-28T00:00:00.000Z", - }, - ]); + getSnapshot.mockReturnValueOnce( + createProviderSnapshot([ + { + provider: "codex", + status: "loading", + enabled: true, + }, + ]), + ); + getSnapshot.mockReturnValue( + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [{ provider: "codex", id: "gpt-5.4", label: "GPT-5.4" }], + modes: [], + fetchedAt: "2026-05-28T00:00:00.000Z", + }, + ]), + ); warmUpSnapshotForCwd.mockReturnValue(warmupDeferred.promise); const session = createSessionForTest({ messages, providerSnapshotManager }); @@ -2413,10 +2459,14 @@ describe("session checkout merge handling", () => { requestId: "request-merge-from-base-success", }); - expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith("/tmp/request-worktree", { - baseRef: "main", - requireCleanTarget: true, - }); + expect(checkoutGitMocks.mergeFromBase).toHaveBeenCalledWith( + "/tmp/request-worktree", + { + baseRef: "main", + requireCleanTarget: true, + }, + { paseoHome: "/tmp/paseo-home", worktreesRoot: undefined }, + ); expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { force: true, reason: "merge-from-base", @@ -2873,6 +2923,7 @@ diff --git a/file.txt b/file.txt base: "main", }, expect.anything(), + { paseoHome: "/tmp/paseo-home", worktreesRoot: undefined }, ); expect(messages).toContainEqual({ type: "checkout_pr_create_response", @@ -2997,6 +3048,7 @@ diff --git a/file.txt b/file.txt base: "main", }, expect.anything(), + { paseoHome: "/tmp/paseo-home", worktreesRoot: undefined }, ); expect(messages).toContainEqual({ type: "checkout_pr_create_response", @@ -5182,6 +5234,7 @@ test("acknowledges a timeline subscription only to its socket source", async () payload: { agentIds: ["agent-a"], requestId: "timeline-subscription-targeted", + subscriptionId: expect.any(String), }, }, }, @@ -5194,6 +5247,7 @@ test("unions viewed timelines across socket sources and removes detached sources const session = createSessionForTest({ messages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; @@ -5301,6 +5355,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha messages, targetedMessages, agentManager: { + getAgent: (id: string) => ({ id, internal: false }), subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { agentEventListeners.push(listener); return () => {}; @@ -5692,3 +5747,70 @@ describe("agent config setters", () => { }); }); }); + +test("provider snapshots preserve versionless visibility while capabilities update independently", async () => { + const messages: SessionOutboundMessage[] = []; + const { manager } = createProviderSnapshotManagerStub(); + manager.getSnapshot = () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + }, + { provider: "plugin-provider", status: "ready", enabled: true }, + ]); + const session = createSessionForTest({ messages, providerSnapshotManager: manager }); + const source = {}; + session.updateClientCapabilities(null, source); + const read = async () => { + messages.length = 0; + await session.handleMessage( + { + type: "get_providers_snapshot_request", + requestId: "visibility", + }, + source, + ); + return findByType(messages, "get_providers_snapshot_response")!.payload; + }; + const versionless = await read(); + expect(versionless.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(versionless.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); + session.updateClientCapabilities( + { + [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + ); + const iconsOnly = await read(); + expect(iconsOnly.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(iconsOnly.entries[0]!.modes![0]!.icon).toBe("Sparkles"); + expect(iconsOnly.snapshotHash).toBeUndefined(); + session.updateClientCapabilities( + { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.providerSnapshotReferences]: true }, + source, + "0.1.45", + ); + expect((await read()).entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + session.updateClientCapabilities( + { + [CLIENT_CAPS.compactProviderSnapshots]: true, + [CLIENT_CAPS.providerSnapshotReferences]: true, + }, + source, + "0.1.45", + ); + const references = await read(); + expect(references.entries).toEqual([]); + expect(references.compactSnapshot!.entries.map((entry) => entry.provider)).toEqual([ + "codex", + "plugin-provider", + ]); + expect(references.compactSnapshot!.entries[0]!.modes![0]!.icon).toBe("ShieldCheck"); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bb07240014..08d3f0eefb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,22 @@ +import { AgentDirectory } from "./agent/directory.js"; +import { searchTimeline } from "./agent/chat-search/index.js"; +import type { BrowserToolsBroker } from "./browser-tools/broker.js"; +import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; +import type { + SessionEventSubscription, + UsageReportEntry, + ProviderUsage, +} from "@getpaseo/protocol/messages"; +import { relative } from "node:path"; +import { isAbsolute } from "node:path"; +import { CreationService, type CreatedAgent } from "./creation/index.js"; +import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; +import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; +import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { basename, resolve, sep } from "path"; +import { basename, join, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference"; @@ -12,6 +27,7 @@ import { type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, + type ScriptStatusUpdateMessage, type GitSetupOptions, type StartWorkspaceScriptRequest, type WorkspaceScriptListRequest, @@ -32,12 +48,13 @@ import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import { CursorError } from "./pagination/cursor.js"; import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js"; -import { describeAgentHistoryMatches, rankAgentHistoryCandidates } from "./agent-history-search.js"; +import { matchesAgentHistoryQuery } from "./agent-history-search.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; import { buildConfigOverrides, isStoredAgentProviderAvailable, + resolveStoredAgentUpdatedAt, toAgentPersistenceHandle, } from "./persistence-hooks.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js"; @@ -103,23 +120,13 @@ import { setAgentModeCommand, updateAgentCommand, } from "./agent/lifecycle-command.js"; -import { - buildStoredAgentPayload, - resolveStoredAgentPayloadUpdatedAt, - toAgentPayload, -} from "./agent/agent-projections.js"; +import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from "./agent/timeline-append.js"; import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js"; import { parsePluginClientId } from "./plugins/plugin-session-identity.js"; -import { - projectTimelineRows, - selectProjectedTimelinePage, - type TimelineProjectionEntry, - type TimelineProjectionMode, -} from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; @@ -162,7 +169,7 @@ import { removeProjectCustomIcon, setProjectCustomIcon, } from "../utils/project-custom-icon.js"; -import { VoiceSession } from "./session/voice/voice-session.js"; +import { VoiceSessions } from "./session/voice/index.js"; import { CheckoutSession } from "./session/checkout/checkout-session.js"; import { createWorkspaceGitObserverService, @@ -174,6 +181,7 @@ import { } from "./session/checkout/git-metadata-generator.js"; import { ScheduleSession } from "./session/schedule/schedule-session.js"; import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; +import { UsageSession } from "./session/usage/usage-session.js"; import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; @@ -196,7 +204,6 @@ import { } from "./session/git-mutation/git-mutation-service.js"; import { createWorkspaceProvisioningService, - WorkspaceProvisioningError, type WorkspaceProvisioningService, } from "./session/workspace-provisioning/workspace-provisioning-service.js"; import { @@ -226,8 +233,8 @@ import { type GitHubService, } from "../services/github-service.js"; import type { ForgeService } from "../services/forge-service.js"; -import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { + resolveWorkspaceRootAgent, summarizeFetchWorkspacesEntries, workspaceIdsOnCheckout, WorkspaceDirectory, @@ -242,6 +249,7 @@ import { import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { buildAgentSessionConfig as buildWorktreeAgentSessionConfig, + normalizeAgentCreationGitOptions, createPaseoWorktreeWorkflow as createWorktreeWorkflow, type CreatePaseoWorktreeSetupContinuationInput, type CreatePaseoWorktreeWorkflowResult, @@ -287,17 +295,6 @@ function errorToFriendlyMessage(error: unknown): string { return "Unknown error"; } -function resolveSubscriptionId( - subscribe: unknown, - requestedSubscriptionId: string | undefined, -): string | null { - if (!subscribe) return null; - if (requestedSubscriptionId && requestedSubscriptionId.length > 0) { - return requestedSubscriptionId; - } - return uuidv4(); -} - function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean { if (!appVersion) return false; // Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45" @@ -410,6 +407,7 @@ type WorkspaceUpdatePayload = Extract< >["payload"]; interface WorkspaceUpdatesSubscriptionState { subscriptionId: string; + owner: OwnedSubscription; syncEnabled?: boolean; filter?: WorkspaceUpdatesFilter; isBootstrapping: boolean; @@ -443,6 +441,7 @@ const nodeSessionFileSystem: SessionFileSystem = { type AgentMcpTransportFactory = () => Promise; export interface SessionOptions { + browserToolsBroker?: BrowserToolsBroker | null; clientId: string; permissions: readonly DaemonPermission[]; appVersion?: string | null; @@ -451,9 +450,10 @@ export interface SessionOptions { onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise; + publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void; logger: pino.Logger; downloadTokenStore: DownloadTokenStore; pushNotifications: PushNotifications; @@ -461,6 +461,8 @@ export interface SessionOptions { worktreesRoot?: string; agentManager: AgentManager; agentStorage: AgentStorage; + messageReceipts: Pick; + creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; directorySync?: DirectorySyncService; @@ -477,7 +479,9 @@ export interface SessionOptions { workspaceAutoName: WorkspaceAutoName; daemonConfigStore: DaemonConfigStore; pluginRuntime?: { - listPlugins(): import("@getpaseo/protocol/messages").PluginListItem[]; + before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"]; + emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"]; + listPlugins(): Promise; getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[]; installDirectory(input: { path: string; @@ -492,6 +496,13 @@ export interface SessionOptions { statusSources( pluginId?: string, ): Promise; + previewUpdates(input: { + pluginId?: string; + target?: import("@getpaseo/protocol/messages").PluginUpdateSelection; + }): Promise; + applyUpdates( + proposals: import("@getpaseo/protocol/messages").PluginUpdateProposal[], + ): Promise; updateSources( pluginId?: string, ): Promise; @@ -500,8 +511,14 @@ export interface SessionOptions { disablePlugin(pluginId: string): Promise; removePlugin(pluginId: string): Promise; subscribe(listener: (pluginId: string) => void): () => void; + subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void; catalog(): Array<{ id: string; clientBundle: string }>; invokePluginRpc(pluginId: string, method: string, input: unknown): Promise; + listUsageReports(options?: { + forceRefresh?: boolean; + reportIds?: string[]; + }): Promise; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills; mcpBaseUrl?: string | null; @@ -510,7 +527,6 @@ export interface SessionOptions { tts: Resolvable; terminalManager: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; @@ -593,15 +609,6 @@ function sessionRequestId(message: SessionInboundMessage): string | null { return null; } -interface AgentTimelineProjectionSelection { - timeline: AgentTimelineFetchResult; - entries: TimelineProjectionEntry[]; - startSeq: number | null; - endSeq: number | null; - hasOlder: boolean; - hasNewer: boolean; -} - type RegistryTransition = "created" | "unarchived" | "existing"; interface ArchivedRecordSnapshot { @@ -609,7 +616,6 @@ interface ArchivedRecordSnapshot { } interface WorkspaceUpdateOptions { - dedupeGitState?: boolean; removedProjectId?: string; optimisticStatus?: WorkspaceDescriptorPayload["status"]; } @@ -636,6 +642,16 @@ function resolveWorkspaceLabelService( return service ?? null; } +function hasInitialCreationPrompt( + request?: Pick, +): boolean { + return Boolean( + request && + buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments) + .length, + ); +} + function workspaceLabelErrorCode(error: unknown): string { if ( error instanceof WorkspaceLabelError || @@ -647,7 +663,41 @@ function workspaceLabelErrorCode(error: unknown): string { return "workspace_label_failed"; } +interface ClientActivity { + deviceType: "web" | "mobile"; + focusedAgentId: string | null; + focusedTerminalId: string | null; + lastActivityAt: Date; + appVisible: boolean; + appVisibilityChangedAt: Date; +} + export class Session { + readonly delivery = new SessionDelivery( + (source, message) => { + if (!this.authorization.allowsOutbound(message)) return; + if (this.onMessageToSource) this.onMessageToSource(source, message); + else this.onMessage(message); + }, + (source, frame) => { + if (this.onBinaryMessageToSource) { + void this.onBinaryMessageToSource(source, frame).catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to emit binary frame"), + ); + } else this.emitBinary(frame); + }, + (source, message) => this.workspaceSetupMessageForClient(message, source), + (request, message) => + this.sessionLogger.warn( + { + requestType: request.type, + messageType: message.type, + status: message.type === "status" ? message.payload.status : undefined, + }, + "Unrecognized reply contract; application delivery denied", + ), + ); + private readonly browserToolsBroker: SessionOptions["browserToolsBroker"]; private readonly clientId: string; private readonly authorization: SessionAuthorization; private appVersion: string | null; @@ -661,7 +711,7 @@ export class Session { private readonly onBinaryMessageToSource: | ((source: object, frame: Uint8Array) => Promise) | null; - private readonly getTransportBufferedAmount: () => number | null; + private readonly getTransportBufferedAmount: (source?: object) => number | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onWorkspaceRecovered: | ((workspace: PersistedWorkspaceRecord) => Promise) @@ -676,6 +726,7 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly agentDirectory: AgentDirectory; private readonly directorySync: DirectorySyncService; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; @@ -696,30 +747,31 @@ export class Session { private registryMutationQueue: Promise = Promise.resolve(); private projectUpdateQueue: Promise = Promise.resolve(); private isCleanedUp = false; - private viewedTimelineAgentIds = new Set(); - private readonly viewedTimelineAgentIdsBySource = new Map>(); - private readonly clientCapabilitiesBySource = new Map>(); - private readonly defaultTimelineSubscriptionSource = {}; + private readonly timelineSubscriptions = new Map< + string, + { owner: OwnedSubscription; agentIds: Set } + >(); + private readonly clientSources = new Map< + object, + { + capabilities: ReadonlySet; + appVersion: string | null; + activity: ClientActivity | null; + pushToken: string | null; + } + >(); private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; - private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private readonly workspaceUpdatesSubscriptions = new Map< + string, + WorkspaceUpdatesSubscriptionState + >(); private readonly workspaceLabelService: WorkspaceLabelService | null; - private workspaceLabelSubscription: { - owner: object; - id: string; - unsubscribe: () => void; - } | null = null; - private projectSyncEnabled = false; + private readonly eventSubscriptions = new Map< + string, + { owner: OwnedSubscription; events: Set; notifications: boolean } + >(); private readonly workspaceUpdateTails = new Map>(); - private clientActivity: { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null = null; - private registeredPushToken: string | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly serviceProxy: ServiceProxySubsystem | null; @@ -735,17 +787,20 @@ export class Session { private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; - private readonly voiceSession: VoiceSession; + private readonly voiceSessions: VoiceSessions; private readonly checkoutSession: CheckoutSession; private readonly scheduleSession: ScheduleSession; private readonly providerCatalogSession: ProviderCatalogSession; + private readonly usageSession: UsageSession; private readonly workspaceFilesSession: WorkspaceFilesSession; private readonly agentConfigSession: AgentConfigSession; private readonly projectConfigSession: ProjectConfigSession; private readonly daemonSession: DaemonSession; private readonly hubExecutionController: HubExecutionController | null; private readonly workspaceScripts: WorkspaceScriptsService; + private readonly messageReceipts: Pick; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; + private readonly creationService: Pick; constructor(options: SessionOptions) { const { @@ -760,6 +815,7 @@ export class Session { getTransportBufferedAmount, onLifecycleIntent, onWorkspaceRecovered, + publishScriptStatusUpdate, logger, downloadTokenStore, pushNotifications, @@ -786,7 +842,6 @@ export class Session { tts, terminalManager, providerSnapshotManager, - providerUsageService, serviceProxy, scriptRuntimeStore, workspaceSetupSnapshots, @@ -804,6 +859,7 @@ export class Session { daemonRuntimeConfig, getWebSocketRuntimeMetrics, } = options; + this.browserToolsBroker = options.browserToolsBroker; this.clientId = clientId; this.authorization = new SessionAuthorization(permissions); this.appVersion = appVersion ?? null; @@ -818,11 +874,12 @@ export class Session { this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.pushNotifications = pushNotifications; this.paseoHome = paseoHome; + this.messageReceipts = options.messageReceipts; + this.creationService = options.creationService; this.projectIcons = new ProjectIconReader(paseoHome); this.worktreesRoot = worktreesRoot; this.pluginRuntime = pluginRuntime; this.orchestrationSkills = orchestrationSkills; - this.unsubscribePluginChanges = this.subscribeToPluginChanges(pluginRuntime); this.sessionLogger = logger.child({ module: "session", clientId: this.clientId, @@ -854,10 +911,12 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, + isDirectory: (path) => this.filesystem.isDirectory(path), logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ @@ -897,11 +956,7 @@ export class Session { }); this.workspaceGitObserver = createWorkspaceGitObserverService({ workspaceGitService: this.workspaceGitService, - describeWorkspaceRecordWithGitData: (workspace) => - this.describeWorkspaceRecordWithGitData(workspace), emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), - emitWorkspaceUpdateForWorkspaceId: (workspaceId) => - this.emitWorkspaceUpdateForWorkspaceId(workspaceId), emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot), onBranchChanged, logger: this.sessionLogger, @@ -917,11 +972,37 @@ export class Session { isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots), + publishSnapshot: (project) => { + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("providers_snapshot_update")) continue; + const message = this.delivery.forSource(subscription.owner.source, project); + if (message) subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit provider delivery after 2027-03-09. + for (const source of this.clientSources.keys()) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !this.wantsEvent("providers_snapshot_update", source) + ) + continue; + const message = this.delivery.forSource(source, project); + if (message) this.onMessageToSource?.(source, message); + } + }, + supportsProviderSnapshotReferences: () => + this.supports(CLIENT_CAPS.providerSnapshotReferences), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), }, providerSnapshotManager, - providerUsageService, + logger: this.sessionLogger, + }); + this.usageSession = new UsageSession({ + emit: (msg) => this.emit(msg), + runtime: pluginRuntime, logger: this.sessionLogger, }); this.agentConfigSession = new AgentConfigSession({ @@ -964,6 +1045,22 @@ export class Session { daemonVersion, daemonRuntimeConfig, getWebSocketRuntimeMetrics, + getObservationMetrics: () => ({ + Registrations: this.delivery.registrationCount, + "Source registrations": this.delivery.sourceRegistrationCount(), + "Producer listeners": [ + this.unsubscribeAgentEvents, + this.unsubscribeProjectMutations, + this.unsubscribeWorkspaceMutations, + this.unsubscribePluginChanges, + this.unsubscribeTerminalWorkspaceContributionEvents, + this.providerCatalogSession.isObserving, + this.hubExecutionController?.isObserving, + ].filter(Boolean).length, + "Git observations": this.workspaceGitObserver.getMetrics().subscriptionCount, + "Terminal directories": this.terminalController.getMetrics().directorySubscriptionCount, + "Terminal output": this.terminalController.getMetrics().streamSubscriptionCount, + }), listProviderAvailability: () => this.agentManager.listProviderAvailability(), listAgents: () => this.agentManager.listAgents(), listProjects: () => this.projectRegistry.list(), @@ -985,16 +1082,30 @@ export class Session { this.terminalController = new TerminalSessionController({ terminalManager, emit: (msg) => this.emit(msg), - emitBinary: (frame) => this.emitBinary(frame), hasBinaryChannel: () => this.onBinaryMessage !== null, isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath), sessionLogger: this.sessionLogger, listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(), - clientSupportsWrapReflow: () => - this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot), - getClientBufferedAmount: () => this.getTransportBufferedAmount(), + clientSupportsWrapReflow: (source) => + this.supportsForSource(CLIENT_CAPS.terminalReflowableSnapshot, source), + getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source), + }); + this.agentDirectory = new AgentDirectory({ + manager: this.agentManager, + storage: this.agentStorage, + workspaces: this.workspaceRegistry, + projectLive: (agent) => this.buildAgentPayload(agent), + projectStored: (record) => this.buildStoredAgentPayload(record), + isProviderVisible: (provider) => this.isProviderVisibleToClient(provider), + isStoredProviderAvailable: (record) => + isStoredAgentProviderAvailable( + record, + new Set(this.providerSnapshotManager.listRegisteredProviderIds()), + ), }); this.agentUpdates = createAgentUpdatesService({ + includesWorkspace: (agent, includeBackground) => + this.agentDirectory.includes(agent, includeBackground), emit: (message) => this.emit(message), enrichAgentPayload: (payload) => this.enrichAgentPayload(payload), buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record), @@ -1003,12 +1114,13 @@ export class Session { this.buildProjectPlacementForWorkspaceId(workspaceId), emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), - sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) => + sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence, includeBackground) => this.directorySync.sequenceAgentUpdate( payload, agent && project ? { agent, project } : null, agentId, includeSequence, + includeBackground, ), logger: this.sessionLogger, }); @@ -1058,60 +1170,64 @@ export class Session { resolveScriptHealth: this.resolveScriptHealth, logger: this.sessionLogger, emit: (message) => this.emit(message), + publishStatusUpdate: (message) => { + if (publishScriptStatusUpdate) publishScriptStatusUpdate(message); + else this.emit(message); + }, spawnWorkspaceScript, assertAutomationAllowed: (workspaceId) => assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId), globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts, }); - this.subscribeToOptionalManagers(); this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, - listAgentPayloads: () => this.listAgentPayloads(), + listAgentPayloads: () => this.listAgentPayloads({ includeBackground: true }), listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), }); - this.voiceSession = new VoiceSession({ - host: { - emit: (msg) => this.emit(msg), - loadAgent: (agentId) => - ensureAgentLoaded(agentId, { - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }), - reloadAgentSession: (agentId, overrides) => - this.agentManager.reloadAgentSession(agentId, overrides), - sendSpokenInput: async (agentId, text) => { - await this.handleSendAgentMessage( - agentId, - text, - undefined, - undefined, - undefined, - undefined, - { spokenInput: true }, - ); + this.voiceSessions = new VoiceSessions( + { + host: { + emit: (msg) => this.emit(msg), + loadAgent: (agentId) => + ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }), + reloadAgentSession: (agentId, overrides) => + this.agentManager.reloadAgentSession(agentId, overrides), + sendSpokenInput: async (agentId, text) => { + await this.handleSendAgentMessage( + agentId, + text, + undefined, + undefined, + undefined, + undefined, + { spokenInput: true }, + ); + }, + interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), + hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), }, - interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId), - hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId), + logger: this.sessionLogger, + sessionId: this.sessionId, + sttLanguage, + tts, + stt, + voice, + voiceBridge, + dictation, }, - logger: this.sessionLogger, - sessionId: this.sessionId, - sttLanguage, - tts, - stt, - voice, - voiceBridge, - dictation, - }); - - this.subscribeToAgentEvents(); - this.subscribeToRegistryMutations(); + this.delivery, + () => this.refreshObservationProducers(), + ); this.sessionLogger.trace({}, "agent.session.lifecycle.created"); } @@ -1122,116 +1238,148 @@ export class Session { } } - updateClientCapabilities(capabilities: Record | null, source?: object): void { + updateClientCapabilities( + capabilities: Record | null, + source?: object, + appVersion = this.appVersion, + ): void { this.clientCapabilities = parseClientCapabilities(capabilities); if (source) { - this.clientCapabilitiesBySource.set(source, this.clientCapabilities); - } - if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) { - this.viewedTimelineAgentIdsBySource.clear(); - this.viewedTimelineAgentIds.clear(); + this.delivery.attach(source, capabilities?.[CLIENT_CAPS.ownedSubscriptions] === true); + this.clientSources.set(source, { + capabilities: this.clientCapabilities, + appVersion, + activity: this.clientSources.get(source)?.activity ?? null, + pushToken: this.clientSources.get(source)?.pushToken ?? null, + }); + this.refreshObservationProducers(); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove capability-driven legacy host registration after 2027-03-09. + if ( + !this.delivery.isModern(source) && + this.authorization.allowsPermission("workspace.write") + ) { + const host = BrowserAutomationHostCapabilitySchema.safeParse(capabilities?.browser_host); + if (host.success && this.browserToolsBroker) { + const request = { + type: "browser.host.register.request" as const, + requestId: uuidv4(), + hostKind: host.data.hostKind, + supportedCommands: host.data.supportedCommands, + }; + void this.delivery + .request(source, request, () => this.registerBrowserHost(request, false)) + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to register legacy browser host"), + ); + } + } } } clearAgentTimelineSubscription(source: object): void { - this.clientCapabilitiesBySource.delete(source); - if (this.viewedTimelineAgentIdsBySource.delete(source)) { - this.rebuildViewedTimelineAgentIds(); - } + void this.delivery + .detach(source) + .catch((err) => this.sessionLogger.error({ err }, "Failed to release source subscriptions")); + this.clientSources.delete(source); + this.refreshObservationProducers(); } - private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void { - const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource; - if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource); - else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds)); - this.rebuildViewedTimelineAgentIds(); + private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription { + const owner = this.delivery.begin("timelines", undefined, (id) => { + this.timelineSubscriptions.delete(id); + this.refreshObservationProducers(); + }); + this.timelineSubscriptions.set(owner.id, { owner, agentIds: new Set(agentIds) }); + this.refreshObservationProducers(); + return owner; } - private rebuildViewedTimelineAgentIds(): void { - const viewedAgentIds = new Set(); - for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) { - for (const agentId of agentIds) viewedAgentIds.add(agentId); - } - this.viewedTimelineAgentIds = viewedAgentIds; - } - - private usesSelectiveTimelineDelivery(): boolean { - if (this.clientCapabilitiesBySource.size === 0) { - return this.supports(CLIENT_CAPS.selectiveAgentTimeline); - } - for (const capabilities of this.clientCapabilitiesBySource.values()) { - if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false; - } - return true; + // COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2. + // Remove after 2027-03-07 once the supported client floor is >= v0.8.0. + private supportsTimelineItem(item: { type: string }, source?: object): boolean { + let capability: ClientCapability; + if (item.type === "notification") capability = CLIENT_CAPS.timelineNotifications; + else if (item.type === "plugin") capability = CLIENT_CAPS.pluginTimelineItems; + else return true; + return source ? this.supportsForSource(capability, source) : this.supports(capability); } private forwardAgentStream( event: Extract, serializedEvent: Extract["payload"]["event"], ): void { - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") { - this.emit({ - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - } else if ( - !this.usesSelectiveTimelineDelivery() || - this.viewedTimelineAgentIds.has(event.agentId) - ) { - this.emit({ - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), - }); - } - return; - } - - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline); - if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") { - this.onMessageToSource(source, { - type: "agent_attention_required", - payload: { - agentId: event.agentId, - reason: serializedEvent.reason, - timestamp: serializedEvent.timestamp, - shouldNotify: serializedEvent.shouldNotify, - ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), - }, - }); - continue; - } - if ( - supportsSelectiveDelivery && - !this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId) - ) { - continue; - } - this.onMessageToSource(source, { - type: "agent_stream", - payload: this.buildAgentStreamPayload(event, serializedEvent), + const attention = serializedEvent.type === "attention_required"; + if (attention) { + this.emit({ + type: "agent_attention_required", + payload: { + agentId: event.agentId, + reason: serializedEvent.reason, + timestamp: serializedEvent.timestamp, + shouldNotify: serializedEvent.shouldNotify, + ...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}), + }, }); } + const message: SessionOutboundMessage = { + type: "agent_stream", + payload: this.buildAgentStreamPayload(event, serializedEvent), + }; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(event.agentId)) continue; + if ( + attention && + (this.delivery.isModern(source) || + this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)) + ) + continue; + if ( + serializedEvent.type === "timeline" && + !this.supportsTimelineItem(serializedEvent.item, source) + ) + continue; + subscription.owner.emit(message); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09. + for (const [source, { capabilities }] of this.clientSources) { + if (this.delivery.isModern(source) || capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) + continue; + if ( + serializedEvent.type === "timeline" && + !this.supportsTimelineItem(serializedEvent.item, source) + ) + continue; + const alreadyDelivered = [...this.timelineSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && subscription.agentIds.has(event.agentId), + ); + if (!alreadyDelivered) this.onMessageToSource?.(source, message); + } + if ( + this.clientSources.size === 0 && + !this.supports(CLIENT_CAPS.selectiveAgentTimeline) && + this.timelineSubscriptions.size === 0 + ) + this.emit(message); } supports(capability: ClientCapability): boolean { - return this.clientCapabilities.has(capability); + const source = this.delivery.currentSource; + return source + ? this.supportsForSource(capability, source) + : this.clientCapabilities.has(capability); } supportsForSource(capability: ClientCapability, source: object): boolean { return ( - this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability) + this.clientSources.get(source)?.capabilities.has(capability) ?? + this.clientCapabilities.has(capability) ); } emitProjectUpdate(update: ProjectUpdate): Promise { + if (!this.wantsEvent("project.update")) return Promise.resolve(); const queued = this.projectUpdateQueue .catch(() => undefined) .then(() => this.publishProjectUpdate(update)); @@ -1240,27 +1388,28 @@ export class Session { } private async publishProjectUpdate(update: ProjectUpdate): Promise { + if (!this.wantsEvent("project.update")) return; const projectedPayload = update.kind === "upsert" ? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) } : update; + if (!this.wantsEvent("project.update")) return; const message: SessionOutboundMessage = { type: "project.update", - payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled), + payload: this.directorySync.sequenceProjectUpdate(projectedPayload, true), }; - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { - if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message); - return; - } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - if (capabilities.has(CLIENT_CAPS.projectUpdates)) { - this.onMessageToSource(source, message); - } - } + this.emit(message); } async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + if (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + return; + const descriptor = await this.describeWorkspaceRecord(workspace); + const demanded = [...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + ); + if (demanded || this.wantsEvent("checkout_status_update")) + this.workspaceGitObserver.syncObservers([descriptor]); } async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise { @@ -1271,7 +1420,7 @@ export class Session { workspace: WorkspaceDescriptorPayload, optimisticStatus?: WorkspaceDescriptorPayload["status"], ): Promise { - if (this.workspaceUpdatesSubscription) { + if (this.workspaceUpdatesSubscriptions.size > 0) { await this.emitWorkspaceUpdatesForWorkspaceIds( [workspace.id], optimisticStatus ? { optimisticStatus } : undefined, @@ -1280,6 +1429,7 @@ export class Session { } // COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12. // Older clients create before subscribing and require the causal update beside the response. + if (workspace.background) return; this.emit({ type: "workspace_update", payload: { @@ -1307,25 +1457,29 @@ export class Session { async syncWorkspaceGitObserversForExternalWorkspaceIds( workspaceIds: Iterable, ): Promise { + if (!this.delivery.hasDemand("workspaces") && !this.wantsEvent("checkout_status_update")) + return; await Promise.all( Array.from(new Set(workspaceIds)).map(async (workspaceId) => { const workspace = await this.workspaceRegistry.get(workspaceId); if (workspace && !workspace.archivedAt) { - await this.workspaceGitObserver.syncObserverForWorkspace(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); } }), ); } async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } async refreshRecoveredWorkspaceForExternalMutation( workspace: PersistedWorkspaceRecord, ): Promise { try { - await this.workspaceGitObserver.warmGitData(workspace); + await this.syncWorkspaceGitObserverForWorkspace(workspace); + await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); } catch (error) { this.sessionLogger.warn( { err: error, workspaceId: workspace.workspaceId }, @@ -1345,15 +1499,25 @@ export class Session { /** * Get the client's current activity state */ - public getClientActivity(): { - deviceType: "web" | "mobile"; - focusedAgentId: string | null; - focusedTerminalId: string | null; - lastActivityAt: Date; - appVisible: boolean; - appVisibilityChangedAt: Date; - } | null { - return this.clientActivity; + private currentClientMetadata() { + const source = this.delivery.currentSource; + if (!source) throw new Error("Client control has no source"); + let metadata = this.clientSources.get(source); + if (!metadata) { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove socketless legacy session adapter after 2027-03-09. + metadata = { + capabilities: this.clientCapabilities, + appVersion: this.appVersion, + activity: null, + pushToken: null, + }; + this.clientSources.set(source, metadata); + } + return metadata; + } + + public getClientActivity(source = this.delivery.currentSource): ClientActivity | null { + return source ? (this.clientSources.get(source)?.activity ?? null) : null; } private getFocusedAgentSelectionForCwd(cwd: string): @@ -1363,7 +1527,7 @@ export class Session { thinkingOptionId?: string | null; } | undefined { - const focusedAgentId = this.clientActivity?.focusedAgentId; + const focusedAgentId = this.getClientActivity()?.focusedAgentId; if (!focusedAgentId) { return undefined; } @@ -1481,33 +1645,75 @@ export class Session { /** * Subscribe to AgentManager events and forward them to the client */ - private subscribeToOptionalManagers(): void { - this.terminalController.start(); - if (this.terminalManager) { - this.unsubscribeTerminalWorkspaceContributionEvents = - this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => { - void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => { - this.sessionLogger.warn( - { err: error, terminalId: event.terminalId }, - "Failed to emit workspace update after terminal contribution changed", - ); - }); - }); + private refreshObservationProducers(): void { + const legacy = this.delivery.hasLegacySources(); + const workspaces = + legacy || this.delivery.hasDemand("workspaces") || this.wantsEvent("checkout_status_update"); + const agents = + workspaces || + this.delivery.hasDemand("agents") || + this.delivery.hasDemand("timelines") || + this.voiceSessions.hasDemand || + this.wantsEvent("agent_attention_required") || + this.wantsEvent("agent_permission_request") || + this.wantsEvent("agent_permission_resolved") || + this.wantsEvent("agent.provider_subagents.update"); + if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); + if (!agents) { + this.unsubscribeAgentEvents?.(); + this.unsubscribeAgentEvents = null; } - this.providerCatalogSession.start(); + this.refreshWorkspaceProducers(workspaces); + this.refreshCatalogProducers(legacy); } - private subscribeToRegistryMutations(): void { - this.unsubscribeProjectMutations?.(); - this.unsubscribeProjectMutations = - this.projectRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), - ) ?? null; - this.unsubscribeWorkspaceMutations?.(); - this.unsubscribeWorkspaceMutations = - this.workspaceRegistry.subscribeToMutations?.((mutation) => - this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), - ) ?? null; + private refreshWorkspaceProducers(observing: boolean): void { + if (observing) { + this.unsubscribeProjectMutations ??= + this.projectRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)), + ) ?? null; + this.unsubscribeWorkspaceMutations ??= + this.workspaceRegistry.subscribeToMutations?.((mutation) => + this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)), + ) ?? null; + this.unsubscribeTerminalWorkspaceContributionEvents ??= + this.terminalManager?.subscribeTerminalWorkspaceContributionChanged((event) => { + void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => + this.sessionLogger.warn( + { err: error, terminalId: event.terminalId }, + "Failed to emit workspace terminal contribution", + ), + ); + }) ?? null; + } else { + this.unsubscribeProjectMutations?.(); + this.unsubscribeProjectMutations = null; + this.unsubscribeWorkspaceMutations?.(); + this.unsubscribeWorkspaceMutations = null; + this.unsubscribeTerminalWorkspaceContributionEvents?.(); + this.unsubscribeTerminalWorkspaceContributionEvents = null; + } + } + + private refreshCatalogProducers(legacy: boolean): void { + this.hubExecutionController?.setObserving( + legacy || + this.wantsEvent("hub.execution.agent.update") || + this.wantsEvent("hub.execution.agent.stream"), + ); + if (this.wantsEvent("providers_snapshot_update")) this.providerCatalogSession.start(); + else this.providerCatalogSession.dispose(); + if ( + legacy || + this.wantsEvent("status.plugin_catalog_changed") || + this.wantsEvent("status.plugin_settings_changed") + ) + this.unsubscribePluginChanges ??= this.subscribeToPluginChanges(this.pluginRuntime); + else { + this.unsubscribePluginChanges?.(); + this.unsubscribePluginChanges = null; + } } private enqueueRegistryMutation(handleMutation: () => Promise): Promise { @@ -1546,10 +1752,11 @@ export class Session { } private async syncWorkspaceMutationObserver(mutation: WorkspaceMutation): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!mutation.workspace || !subscription) { + if ( + !mutation.workspace || + (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update")) + ) return; - } const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ workspaceIds: [mutation.workspaceId], includeGitData: false, @@ -1557,7 +1764,10 @@ export class Session { const descriptor = descriptorsByWorkspaceId.get(mutation.workspaceId); if ( !descriptor || - !this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }) + (!this.wantsEvent("checkout_status_update") && + ![...this.workspaceUpdatesSubscriptions.values()].some((subscription) => + this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }), + )) ) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; @@ -1567,16 +1777,24 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); return; } - await this.workspaceGitObserver.syncObserverForWorkspace(currentWorkspace); + await this.syncWorkspaceGitObserverForWorkspace(currentWorkspace); if (this.isCleanedUp) { this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId); } } private async handleProjectMutation(mutation: ProjectMutation): Promise { + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.handleProjectMutationForSubscription(mutation, subscription); + } + } + + private async handleProjectMutationForSubscription( + mutation: ProjectMutation, + subscription: WorkspaceUpdatesSubscriptionState, + ): Promise { try { - const subscription = this.workspaceUpdatesSubscription; - if (this.isCleanedUp || !subscription) { + if (this.isCleanedUp) { return; } const projectWorkspaceIds = (await this.workspaceRegistry.list()) @@ -1605,7 +1823,7 @@ export class Session { for (const workspaceId of projectWorkspaceIds) { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } - await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, { + await this.enqueueWorkspaceUpdates(new Set(updateIds), subscription, { removedProjectId: mutation.projectId, }); return; @@ -1616,7 +1834,7 @@ export class Session { this.workspaceGitObserver.removeForWorkspaceId(workspaceId); } } - await this.emitWorkspaceUpdatesForWorkspaceIds(projectWorkspaceIds); + await this.enqueueWorkspaceUpdates(new Set(projectWorkspaceIds), subscription, undefined); } catch (error) { this.sessionLogger.warn( { err: error, projectId: mutation.projectId, mutationKind: mutation.kind }, @@ -1625,6 +1843,84 @@ export class Session { } } + private supportsSubagentTimelineItem( + item: AgentTimelineFetchResult["rows"][number]["item"], + source?: object, + ): boolean { + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gate after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); + return supportsProjection && this.supportsTimelineItem(item, source); + } + + private forwardProviderSubagentUpdate( + update: Extract["event"], + ): void { + let message: SessionOutboundMessage; + if (update.type === "upsert") { + message = { + type: "agent.provider_subagents.update", + payload: { kind: "upsert", subagent: update.subagent }, + }; + } else if (update.type === "timeline") { + message = { + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + provider: update.provider, + item: update.row.item, + timestamp: update.row.timestamp, + seq: update.row.seq, + epoch: update.epoch, + }, + }; + } else { + message = { + type: "agent.provider_subagents.update", + payload: { + kind: "remove", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + }, + }; + } + + const delivered = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if (!subscription.events.has("agent.provider_subagents.update")) continue; + if ( + update.type === "timeline" && + !this.supportsSubagentTimelineItem(update.row.item, subscription.owner.source) + ) + continue; + subscription.owner.emit(message); + delivered.add(subscription.owner.source); + } + if (this.clientSources.size === 0 || !this.onMessageToSource) { + if ( + this.supports(CLIENT_CAPS.providerSubagents) && + (update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item)) + ) { + this.emit(message); + } + return; + } + for (const [source, { capabilities }] of this.clientSources) { + if ( + delivered.has(source) || + this.delivery.isModern(source) || + !capabilities.has(CLIENT_CAPS.providerSubagents) + ) + continue; + if (update.type === "timeline" && !this.supportsSubagentTimelineItem(update.row.item, source)) + continue; + this.onMessageToSource(source, message); + } + } + private subscribeToAgentEvents(): void { if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -1653,45 +1949,19 @@ export class Session { } if (event.type === "provider_subagent") { + // The subagents track of an internal parent stays hidden. + const parentAgentId = + event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId; + if (this.agentManager.getAgent(parentAgentId)?.internal) return; this.emitProviderSubagentWorkspaceUpdate(event.event); - if (!this.supports(CLIENT_CAPS.providerSubagents)) { - return; - } - const update = event.event; - if (update.type === "upsert") { - this.emit({ - type: "agent.provider_subagents.update", - payload: { kind: "upsert", subagent: update.subagent }, - }); - } else if (update.type === "timeline") { - this.emit({ - type: "agent.provider_subagents.update", - payload: { - kind: "timeline", - parentAgentId: update.parentAgentId, - subagentId: update.subagentId, - provider: update.provider, - item: update.row.item, - timestamp: update.row.timestamp, - seq: update.row.seq, - epoch: update.epoch, - }, - }); - } else { - this.emit({ - type: "agent.provider_subagents.update", - payload: { - kind: "remove", - parentAgentId: update.parentAgentId, - subagentId: update.subagentId, - }, - }); - } + this.forwardProviderSubagentUpdate(event.event); return; } if ( - this.voiceSession.isActiveForAgent(event.agentId) && + this.voiceSessions.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && isVoicePermissionAllowed(event.event.request) ) { @@ -1730,7 +2000,10 @@ export class Session { this.forwardAgentStream(event, serializedEvent); - if (event.event.type === "permission_requested") { + // An internal agent streams only to clients subscribed to it by id; + // session-wide prompts would surface it everywhere. + const internal = this.agentManager.getAgent(event.agentId)?.internal === true; + if (event.event.type === "permission_requested" && !internal) { this.emit({ type: "agent_permission_request", payload: { @@ -1739,7 +2012,8 @@ export class Session { }, }); } else if (event.event.type === "permission_resolved") { - this.emit({ + // A provider event is not the outcome of whichever request is currently running. + this.emitSubscribedEvent({ type: "agent_permission_resolved", payload: { agentId: event.agentId, @@ -1751,7 +2025,9 @@ export class Session { // Title updates may be applied asynchronously after agent creation. }, - { replayState: false }, + // Internal agents flow through so exact-id subscribers and opted-in + // listings see them; everything else above filters them out. + { replayState: false, includeInternal: true }, ); } @@ -1805,7 +2081,14 @@ export class Session { } private isProviderVisibleToClient(provider: string): boolean { - if (clientSupportsAllProviders(this.appVersion)) { + if ( + this.supports(CLIENT_CAPS.allProviders) || + clientSupportsAllProviders( + this.delivery.currentSource + ? (this.clientSources.get(this.delivery.currentSource)?.appVersion ?? null) + : this.appVersion, + ) + ) { return true; } return LEGACY_PROVIDER_IDS.has(provider); @@ -1851,6 +2134,10 @@ export class Session { * Main entry point for processing session messages */ public async handleMessage(msg: SessionInboundMessage, source?: object): Promise { + return this.delivery.request(source, msg, () => this.handleRequest(msg, source)); + } + + private async handleRequest(msg: SessionInboundMessage, source?: object): Promise { this.inflightRequests++; if (this.inflightRequests > this.peakInflightRequests) { this.peakInflightRequests = this.inflightRequests; @@ -1919,6 +2206,13 @@ export class Session { public setPermissions(permissions: readonly DaemonPermission[]): void { this.authorization.replacePermissions(permissions); + if (!this.authorization.allowsPermission("workspace.write")) { + void this.delivery + .releaseFamily("browser-host") + .catch((error) => + this.sessionLogger.error({ err: error }, "Failed to release revoked browser hosting"), + ); + } } public getPermissions(): DaemonPermission[] { @@ -1933,28 +2227,106 @@ export class Session { return this.authorization.allowsPermission(permission); } - public subscribesToAgent(agent: ManagedAgent): Promise { - return this.agentUpdates.includesLiveAgent(agent); + public subscribesToAgent(agent: ManagedAgent, source: object): Promise { + return this.agentUpdates.includesLiveAgent( + agent, + new Set(this.delivery.subscriptionIds(source, "agents")), + ); } - public subscribesToTerminalDirectory(input: { - cwd: string; - workspaceId?: string; - }): Promise { - return this.terminalController.hasDirectorySubscription(input); + public subscribesToTerminalDirectory( + input: { + cwd: string; + workspaceId?: string; + }, + source: object, + ): Promise { + return this.terminalController.hasDirectorySubscription(input, source); + } + + public publishToSource(source: object, message: SessionOutboundMessage): void { + if (!this.authorization.allowsOutbound(message)) return; + this.emitSubscribedEvent(message, source); + } + + public wantsSourceEvent(source: object, event: SessionEventSubscription): boolean { + return this.wantsEvent(event, source); + } + + public wantsSourceNotification(source: object, event: SessionEventSubscription): boolean { + // COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy notification selection after 2027-03-09. + if (!this.delivery.isModern(source)) return true; + return [...this.eventSubscriptions.values()].some( + (subscription) => + subscription.owner.source === source && + subscription.notifications && + subscription.events.has(event), + ); } public publish(message: SessionOutboundMessage): void { this.emit(message); } + private async registerBrowserHost( + request: Extract, + respond = true, + ): Promise { + if (!this.browserToolsBroker) throw new Error("Browser hosting is unavailable"); + let unregister: (() => void) | undefined; + const owner = this.delivery.begin("browser-host", undefined, () => unregister?.()); + try { + unregister = this.browserToolsBroker.registerClient({ + id: owner.id, + hostKind: request.hostKind, + supportedCommands: request.supportedCommands, + sendBrowserAutomationRequest: (message) => owner.emit(message), + }); + if (respond) + this.emit({ + type: "browser.host.register.response", + payload: { requestId: request.requestId, subscriptionId: owner.responseId }, + }); + } catch (error) { + await owner.release(); + throw error; + } + } + + private dispatchSubscriptionMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg); + if (msg.type === "browser.automation.execute.response") { + if (source) + this.browserToolsBroker?.receiveResponse( + msg, + this.delivery.subscriptionIds(source, "browser-host"), + ); + return Promise.resolve(); + } + if (msg.type === "subscription.release.request") { + return this.delivery.release(msg.subscriptionId).then(() => { + this.emit({ + type: "subscription.release.response", + payload: { requestId: msg.requestId, subscriptionId: msg.subscriptionId }, + }); + return undefined; + }); + } + return undefined; + } + private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise { const promise = + this.dispatchSubscriptionMessage(msg, source) ?? this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg, source) ?? this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentTimelineMessage(msg, source) ?? this.dispatchHubExecutionMessage(msg) ?? + this.dispatchCreationMessage(msg, source) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1972,7 +2344,7 @@ export class Session { private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { return ( - this.dispatchWorkspaceRecoveryMessage(msg) ?? + this.dispatchWorkspaceStateMessage(msg) ?? this.dispatchWorkspaceLabelMessage(msg) ?? this.dispatchWorkspaceSetupMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) @@ -2041,11 +2413,13 @@ export class Session { private dispatchPluginMessage(msg: SessionInboundMessage): Promise | undefined { if (msg.type === "plugin.list.request") { - this.emit({ - type: "plugin.list.response", - payload: { requestId: msg.requestId, plugins: this.pluginRuntime?.listPlugins() ?? [] }, + return (this.pluginRuntime?.listPlugins() ?? Promise.resolve([])).then((plugins) => { + this.emit({ + type: "plugin.list.response", + payload: { requestId: msg.requestId, plugins }, + }); + return undefined; }); - return undefined; } if (msg.type === "plugin.logs.get.request") { if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); @@ -2149,6 +2523,28 @@ export class Session { return undefined; }); } + if (msg.type === "plugin.source.update.preview.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime + .previewUpdates({ pluginId: msg.pluginId, target: msg.target }) + .then((plugins) => { + this.emit({ + type: "plugin.source.update.preview.response", + payload: { requestId: msg.requestId, plugins }, + }); + return undefined; + }); + } + if (msg.type === "plugin.source.update.apply.request") { + if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); + return this.pluginRuntime.applyUpdates(msg.proposals).then((plugins) => { + this.emit({ + type: "plugin.source.update.apply.response", + payload: { requestId: msg.requestId, plugins }, + }); + return undefined; + }); + } if (msg.type === "plugin.source.update.request") { if (!this.pluginRuntime) throw new Error("Plugin service is unavailable"); return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => { @@ -2186,36 +2582,32 @@ export class Session { pluginRuntime: SessionOptions["pluginRuntime"], ): (() => void) | null { if (!pluginRuntime) return null; - return pluginRuntime.subscribe((pluginId) => { + const catalog = pluginRuntime.subscribe((pluginId) => { this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } }); }); + const settings = pluginRuntime.subscribeSettings?.((pluginId, settingsId) => { + this.emit({ + type: "status", + payload: { status: "plugin_settings_changed", pluginId, settingsId }, + }); + }); + return () => { + catalog(); + settings?.(); + }; } private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": - return this.voiceSession.handleAudioChunk(msg); case "abort_request": - return this.voiceSession.handleAbort(); case "audio_played": - this.voiceSession.handleAudioPlayed(msg.id); - return undefined; case "set_voice_mode": - return this.voiceSession.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); case "dictation_stream_start": - return this.voiceSession.handleDictationStreamStart(msg); case "dictation_stream_chunk": - return this.voiceSession.handleDictationChunk({ - dictationId: msg.dictationId, - seq: msg.seq, - audioBase64: msg.audio, - format: msg.format, - }); case "dictation_stream_finish": - return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq); case "dictation_stream_cancel": - this.voiceSession.handleDictationCancel(msg.dictationId); - return undefined; + return this.voiceSessions.handleMessage(msg); case "restart_server_request": return this.handleRestartServerRequest(msg.requestId, msg.reason); case "shutdown_server_request": @@ -2271,27 +2663,50 @@ export class Session { return this.handleFetchAgentTimelineRequest(msg, source); case "agent.timeline.append.request": return this.handleAgentTimelineAppendRequest(msg); + case "agent.timeline.search.request": + return this.handleAgentTimelineSearchRequest(msg, source); case "agent.timeline.list_prompts.request": return this.handleAgentTimelineListPromptsRequest(msg, source); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": - return this.handleProviderSubagentTimelineRequest(msg); + return this.handleProviderSubagentTimelineRequest(msg, source); + case "session.events.set_subscription.request": { + const owner = this.delivery.begin("events", undefined, async (id) => { + this.eventSubscriptions.delete(id); + this.refreshObservationProducers(); + if (msg.events.includes("checkout_status_update")) + await this.reconcileWorkspaceGitObservers(); + }); + this.eventSubscriptions.set(owner.id, { + owner, + events: new Set(msg.events), + notifications: msg.notifications === true, + }); + this.emitForSource( + { + type: "session.events.set_subscription.response", + payload: { requestId: msg.requestId, subscriptionId: owner.responseId }, + }, + source, + ); + this.refreshObservationProducers(); + if (!msg.events.includes("checkout_status_update")) return undefined; + return this.reconcileWorkspaceGitObservers().catch(async (error) => { + await owner.release(); + throw error; + }); + } case "agent.timeline.set_subscription.request": { const agentIds = [...new Set(msg.agentIds)].sort(); - if ( - source - ? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source) - : this.supports(CLIENT_CAPS.selectiveAgentTimeline) - ) { - this.replaceAgentTimelineSubscription(source, agentIds); - } - const response: SessionOutboundMessage = { - type: "agent.timeline.set_subscription.response", - payload: { agentIds, requestId: msg.requestId }, - }; - if (source && this.onMessageToSource) this.onMessageToSource(source, response); - else this.emit(response); + const owner = this.subscribeAgentTimelines(agentIds); + this.emitForSource( + { + type: "agent.timeline.set_subscription.response", + payload: { agentIds, requestId: msg.requestId, subscriptionId: owner.responseId }, + }, + source, + ); return undefined; } case "agent.fork_context.request": @@ -2314,6 +2729,22 @@ export class Session { return undefined; } + private dispatchCreationMessage( + msg: SessionInboundMessage, + source?: object, + ): Promise | undefined { + switch (msg.type) { + case "agent.create.request": + return this.handleAgentCreation(msg, source); + case "creation.subscribe.request": + return this.handleCreationSubscription(msg, source); + case "workspace.create.request": + return this.handleWorkspaceCreation(msg, source); + default: + return undefined; + } + } + private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "fetch_agents_request": @@ -2426,11 +2857,12 @@ export class Session { return this.checkoutSession.handleBranchSuggestionsRequest(msg); case "directory_suggestions_request": return this.handleDirectorySuggestionsRequest(msg); + case "checkout.diff.get.request": + return this.checkoutSession.handleReadDiffRequest(msg); case "subscribe_checkout_diff_request": - return this.checkoutSession.handleSubscribeDiffRequest(msg); + return this.checkoutSession.handleSubscribeDiffRequest(msg, this.delivery); case "unsubscribe_checkout_diff_request": - this.checkoutSession.handleUnsubscribeDiffRequest(msg); - return undefined; + return this.checkoutSession.handleUnsubscribeDiffRequest(msg, this.delivery); case "checkout_switch_branch_request": return this.checkoutSession.handleCheckoutSwitchBranchRequest(msg); case "checkout.rename_branch.request": @@ -2510,10 +2942,6 @@ export class Session { return this.handleArchiveWorkspaceRequest(msg); case "project.remove.request": return this.handleProjectRemoveRequest(msg); - case "workspace.create.request": - return this.handleWorkspaceCreateRequest(msg); - case "workspace.clear_attention.request": - return this.handleWorkspaceClearAttentionRequest(msg); case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": @@ -2558,10 +2986,9 @@ export class Session { case "file_explorer_request": return this.workspaceFilesSession.handleFileExplorerRequest(msg, source); case "fs.file.subscribe.request": - return this.workspaceFilesSession.handleFileSubscribeRequest(msg); + return this.workspaceFilesSession.handleFileSubscribeRequest(msg, this.delivery); case "fs.file.unsubscribe.request": - this.workspaceFilesSession.handleFileUnsubscribeRequest(msg); - return undefined; + return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg, this.delivery); case "fs.file.write.request": return this.workspaceFilesSession.handleFileWriteRequest(msg); case "fs.entry.create.request": @@ -2579,19 +3006,23 @@ export class Session { case "file_download_token_request": return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg); case "file.upload.request": - this.workspaceFilesSession.handleFileUploadRequest(msg); + this.workspaceFilesSession.handleFileUploadRequest(msg, this.delivery); return undefined; default: return undefined; } } - private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise | undefined { + private dispatchWorkspaceStateMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "workspace.recovery.inspect.request": return this.handleWorkspaceRecoveryInspectRequest(msg); case "workspace.recovery.restore.request": return this.handleWorkspaceRecoveryRestoreRequest(msg); + case "workspace.clear_attention.request": + return this.handleWorkspaceClearAttentionRequest(msg); + case "workspace.mark_unread.request": + return this.handleWorkspaceMarkUnreadRequest(msg); default: return undefined; } @@ -2614,7 +3045,9 @@ export class Session { case "provider_diagnostic_request": return this.providerCatalogSession.handleProviderDiagnosticRequest(msg); case "provider.usage.list.request": - return this.providerCatalogSession.handleProviderUsageListRequest(msg); + return this.usageSession.handleLegacyList(msg); + case "usage.list_reports.request": + return this.usageSession.handleListReports(msg); default: return undefined; } @@ -2631,7 +3064,7 @@ export class Session { case "workspace.script.stop.request": return this.handleWorkspaceScriptStopRequest(msg); default: - return this.terminalController.dispatch(msg); + return this.terminalController.dispatch(msg, this.delivery); } } @@ -2670,8 +3103,8 @@ export class Session { return; case "push.unregister.request": this.pushNotifications.revoke(msg.token); - if (this.registeredPushToken?.trim() === msg.token.trim()) { - this.registeredPushToken = null; + if (this.currentClientMetadata().pushToken?.trim() === msg.token.trim()) { + this.currentClientMetadata().pushToken = null; } this.emit({ type: "push.unregister.response", @@ -2689,15 +3122,15 @@ export class Session { return this.sessionId; } - public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { + public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise { if (!this.authorization.allowsPermission("workspace.write")) { return; } if (binaryFrame.kind === "file_transfer") { - await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); + await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame); + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); } private async handleRestartServerRequest(requestId: string, reason?: string): Promise { @@ -2829,7 +3262,7 @@ export class Session { agentId, ); - if (this.agentUpdates.hasSubscription()) { + if (archivedRecord && this.agentUpdates.hasSubscription()) { const payload = await this.agentUpdates.emitStoredRecord(archivedRecord); if (payload.workspaceId) { await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); @@ -2947,8 +3380,8 @@ export class Session { return candidate; } const updatedDelta = - Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) - - Date.parse(resolveStoredAgentPayloadUpdatedAt(latest)); + Date.parse(resolveStoredAgentUpdatedAt(candidate)) - + Date.parse(resolveStoredAgentUpdatedAt(latest)); if (updatedDelta !== 0) { return updatedDelta > 0 ? candidate : latest; } @@ -3495,11 +3928,343 @@ export class Session { /** * Handle create agent request */ + private creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage { + return { + type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update", + payload: snapshot, + }; + } + + private async handleCreationSubscription( + request: Extract, + source?: object, + ): Promise { + let unsubscribe = () => {}; + const owner = + request.subscribe === false + ? undefined + : this.delivery.begin( + "creation", + undefined, + () => unsubscribe(), + JSON.stringify([request.kind, request.idempotencyKey]), + ); + try { + const observation = await this.creationService.subscribe( + request.kind, + request.idempotencyKey, + (snapshot) => owner?.emit(this.creationUpdate(snapshot)), + ); + unsubscribe = observation.unsubscribe; + if (!owner || owner.signal.aborted) unsubscribe(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner?.responseId, + snapshot: observation.snapshot, + error: null, + }, + }, + source, + ); + } catch (error) { + await owner?.release(); + this.emitForSource( + { + type: "creation.subscribe.response", + payload: { + requestId: request.requestId, + snapshot: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + + private creationResourceExists = async ( + kind: "workspace" | "agent", + id: string, + ): Promise => + kind === "workspace" + ? (await this.workspaceRegistry.get(id)) !== null + : this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null; + + private createRequestedAgent( + request: AgentCreateRequest, + observer?: (snapshot: CreationSnapshot) => void, + ): Promise { + const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request; + return this.creationService.create( + { + kind: "agent", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: request.agentId, + hasAgent: true, + hasPrompt: hasInitialCreationPrompt(request), + exists: this.creationResourceExists, + readAgent: async (id) => { + const record = await this.agentStorage.get(id); + return record ? this.buildStoredAgentPayload(record) : null; + }, + createAgent: async (id, _workspace, onReady) => { + try { + return await this.createSessionAgent( + { ...request, type: "create_agent_request" }, + id, + onReady, + ); + } catch (error) { + throw new WorktreeRequestError(toWorktreeWireError(error)); + } + }, + }, + observer, + ); + } + + private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise { + const { requestId, subscribe } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + const creation = await this.createRequestedAgent( + request, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "agent.create.response", + payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.create.response", + payload: { + requestId, + agent: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + + private async handleWorkspaceCreation( + request: Extract, + source?: object, + ): Promise { + const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request; + const progress = subscribe + ? this.delivery.operation( + (message) => + message.type === "workspace.create.update" || message.type === "agent.create.update", + () => {}, + ) + : undefined; + try { + if ( + request.agent && + !this.authorization.allowsPermission("workspace.write") && + !this.authorization.allowsPermission("hub.execute") + ) { + throw new Error("Session is not authorized to create an agent"); + } + const agentInput = request.agent; + const creation = await this.creationService.create( + { + kind: "workspace", + key: idempotencyKey ?? requestId, + request: intent, + workspaceId: request.workspaceId, + agentId: agentInput?.agentId, + hasAgent: Boolean(agentInput), + hasPrompt: hasInitialCreationPrompt(agentInput), + exists: this.creationResourceExists, + provision: async (id) => { + const descriptor = await this.createRequestedWorkspace( + { + ...request, + firstAgentContext: agentInput + ? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments } + : request.firstAgentContext, + }, + id, + ); + const workspace = await this.workspaceRegistry.get(descriptor.id); + return { + workspace: descriptor, + setupSkippedReason: workspace?.untrustedSource + ? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource) + : undefined, + }; + }, + createAgent: agentInput + ? async (id, workspace, onReady) => { + if (!workspace?.workspaceDirectory) + throw new Error("Created workspace has no directory"); + const sourceCwd = + request.source.kind === "directory" + ? request.source.path + : await resolveWorktreeSourceCwd(request.source, this.projectRegistry); + const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd)); + if ( + relativeCwd === ".." || + relativeCwd.startsWith(`..${sep}`) || + isAbsolute(relativeCwd) + ) + throw new Error("Agent directory must be inside the workspace source"); + return this.createSessionAgent( + { + ...agentInput, + type: "create_agent_request", + requestId, + config: { + ...agentInput.config, + cwd: resolve(workspace.workspaceDirectory, relativeCwd), + }, + workspaceId: workspace.id, + }, + id, + onReady, + ); + } + : undefined, + }, + progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined, + ); + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: creation.workspace ?? null, + agent: creation.agent, + creation, + setupSkippedReason: creation.setupSkippedReason, + error: creation.error, + errorCode: creation.errorCode, + setupTerminalId: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "workspace.create.response", + payload: { + requestId, + workspace: null, + error: error instanceof Error ? error.message : String(error), + errorCode: + error instanceof Error && "code" in error && typeof error.code === "string" + ? error.code + : undefined, + setupTerminalId: null, + }, + }, + source, + ); + } finally { + await progress?.release(); + } + } + private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise { + try { + let agent: AgentSnapshotPayload; + if (msg.idempotencyKey !== undefined) { + if (msg.initialPrompt !== undefined) { + throw new Error("Idempotent creation requires sending the initial prompt separately"); + } + const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" }); + if (creation.error || !creation.agent) + throw new SessionRequestError( + creation.errorCode ?? "unknown", + creation.error ?? "Agent creation failed", + ); + const record = await this.agentStorage.get(creation.agent.id); + if (!record) throw new Error("Previously created agent no longer exists"); + agent = this.buildStoredAgentPayload(record); + } else { + ({ agent } = await this.createSessionAgent(msg)); + } + this.emit({ + type: "status", + payload: { + status: "agent_created", + agentId: agent.id, + requestId: msg.requestId, + agent, + }, + }); + } catch (error) { + const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error); + this.sessionLogger.error({ err: error }, "Failed to create agent"); + this.emit({ + type: "status", + payload: { + status: "agent_create_failed", + requestId: msg.requestId, + error: wireError.message, + errorCode: wireError.code, + }, + }); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to create agent: ${wireError.message}`, + }, + }); + } + } + + private validatePublicAgentWorkspaceIntent( + request: CreateAgentRequestMessage, + createsWorktree: boolean, + ): void { + if (request.internal) + throw new Error( + "Public internal agents were removed; create a background workspace instead.", + ); + if ( + request.background !== undefined && + (request.workspaceId || (request.callerAgentId && !createsWorktree)) + ) { + throw new Error( + "background is workspace creation intent; configure the selected workspace at creation.", + ); + } + } + + private async createSessionAgent( + msg: CreateAgentRequestMessage, + agentId?: string, + onAgentReady?: (agent: AgentSnapshotPayload) => Promise, + ): Promise { const { config, worktreeName, - requestId, initialPrompt, clientMessageId, outputSchema, @@ -3526,6 +4291,9 @@ export class Session { if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) { throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`); } + // Resolve all supported worktree input forms before provisioning workspace ownership. + const gitIntent = normalizeAgentCreationGitOptions(git, worktreeName, worktree); + this.validatePublicAgentWorkspaceIntent(msg, gitIntent?.createWorktree === true); const trimmedPrompt = initialPrompt?.trim(); const { provisionalTitle } = resolveCreateAgentTitles({ configTitle: config.title, @@ -3539,9 +4307,11 @@ export class Session { const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext); const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({ cwd: config.cwd, - target: worktree, + target: gitIntent, firstAgentContext, - hasLegacyGitOptions: Boolean(git), + setupContinuation: worktree ? undefined : this.agentWorktreeSetupContinuation(), + background: msg.background, + callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId), }); createdWorktreeForCleanup = createdWorktree; const resolvedIntent = await this.resolveSessionCreateAgentIntent({ @@ -3554,7 +4324,7 @@ export class Session { throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`); } - const { snapshot, liveSnapshot } = await createAgentCommand( + const { snapshot, liveSnapshot, initialPromptStarted } = await createAgentCommand( { agentManager: this.agentManager, agentStorage: this.agentStorage, @@ -3565,10 +4335,18 @@ export class Session { }, { kind: "session", + onAgentReady: async (agent) => { + createdAgentId = agent.id; + await onAgentReady?.(await this.buildAgentPayload(agent)); + }, + agentId, config: resolvedIntent.config, workspaceId: resolvedIntent.intent.workspaceId, worktreeName, initialPrompt, + source: msg.callerAgentId + ? { kind: "agent-message", agentId: msg.callerAgentId } + : undefined, clientMessageId, outputSchema, images, @@ -3578,8 +4356,14 @@ export class Session { env, provisionalTitle, firstAgentContext, - buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) => - this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), + buildSessionConfig: async (sessionConfig, gitOptions, legacyWorktreeName, ctx) => + createdWorktree + ? { + sessionConfig, + setupContinuation: createdWorktree.setupContinuation, + createdWorkspaceId: createdWorktree.workspace.workspaceId, + } + : this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx), }, ); createdAgentId = snapshot.id; @@ -3599,50 +4383,17 @@ export class Session { agentId: snapshot.id, createdWorktree, }); - if (requestId) { - const agentPayload = await this.buildAgentPayload(liveSnapshot); - this.emit({ - type: "status", - payload: { - status: "agent_created", - agentId: liveSnapshot.id, - requestId, - agent: agentPayload, - }, - }); - } - this.sessionLogger.info( { agentId: snapshot.id, provider: snapshot.provider }, - `Created agent ${snapshot.id} (${snapshot.provider})`, + "Created agent", ); + return { agent: await this.buildAgentPayload(liveSnapshot), initialPromptStarted }; } catch (error) { await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({ createdWorktree: createdWorktreeForCleanup, createdAgentId, }); - const wireError = toWorktreeWireError(error); - this.sessionLogger.error({ err: error }, "Failed to create agent"); - if (requestId) { - this.emit({ - type: "status", - payload: { - status: "agent_create_failed", - requestId, - error: wireError.message, - errorCode: wireError.code, - }, - }); - } - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to create agent: ${wireError.message}`, - }, - }); + throw error; } } @@ -3682,6 +4433,8 @@ export class Session { createdWorktree: null, cwd: config.cwd, initialTitle: input.workspacePromptTitle, + background: request.background, + callerWorkspaceId: callerAgent?.workspaceId, }), cwd: config.cwd, }), @@ -4018,29 +4771,36 @@ export class Session { const timeline = this.agentManager.fetchTimeline(agentId, { limit: 0 }); const epoch = timeline.epoch; - if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { + if (this.clientSources.size === 0 || !this.onMessageToSource) { if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) { this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch); } return; } - for (const [source, capabilities] of this.clientCapabilitiesBySource) { - const isInitiator = source === initiatingSource; - const supportsReplacement = capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation); - const isSubscribed = this.viewedTimelineAgentIdsBySource.get(source)?.has(agentId) === true; - if (supportsReplacement) { - if (isSubscribed && !isInitiator) { - this.onMessageToSource(source, { - type: "agent.timeline.replacement", - payload: { agentId, epoch }, - }); - } - continue; + for (const subscription of this.timelineSubscriptions.values()) { + const source = subscription.owner.source; + if (!subscription.agentIds.has(agentId)) continue; + const modern = this.delivery.isModern(source); + if ( + modern || + (source !== initiatingSource && + this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source)) + ) { + subscription.owner.emit({ + type: "agent.timeline.replacement", + payload: { agentId, epoch }, + }); + } + } + // COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21. + for (const [source, { capabilities }] of this.clientSources) { + if ( + !this.delivery.isModern(source) && + !capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation) + ) { + this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } - // COMPAT(timelineReplacementInvalidation): added in v0.5.0, replay reconstructed - // rows to legacy clients until the supported client floor is >= v0.5.0 after 2027-02-21. - this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source); } } @@ -4052,6 +4812,9 @@ export class Session { source?: object, ): void { for (const row of rows) { + if (!this.supportsTimelineItem(row.item, source)) { + continue; + } const event = serializeAgentStreamEvent({ type: "timeline", provider, @@ -4070,6 +4833,26 @@ export class Session { } } + private agentWorktreeSetupContinuation(): CreatePaseoWorktreeSetupContinuationInput { + return { + kind: "agent", + terminalManager: this.terminalManager, + appendTimelineItem: ({ agentId, item }) => + appendTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + emitLiveTimelineItem: ({ agentId, item }) => + emitLiveTimelineItemIfAgentKnown({ + agentManager: this.agentManager, + agentId, + item, + }), + logger: this.sessionLogger, + }; + } + private async buildAgentSessionConfig( config: AgentSessionConfig, gitOptions?: GitSetupOptions, @@ -4089,23 +4872,7 @@ export class Session { createPaseoWorktree: (input, serviceOptions) => this.createPaseoWorktreeWorkflow(input, { ...serviceOptions, - setupContinuation: { - kind: "agent", - terminalManager: this.terminalManager, - appendTimelineItem: ({ agentId, item }) => - appendTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - emitLiveTimelineItem: ({ agentId, item }) => - emitLiveTimelineItemIfAgentKnown({ - agentManager: this.agentManager, - agentId, - item, - }), - logger: this.sessionLogger, - }, + setupContinuation: this.agentWorktreeSetupContinuation(), }), checkoutExistingBranch: (cwd, branch) => this.gitMutation.checkoutExistingBranch(cwd, branch), @@ -4186,7 +4953,8 @@ export class Session { const appVisibilityChangedAt = msg.appVisibilityChangedAt ? new Date(msg.appVisibilityChangedAt) : new Date(msg.lastActivityAt); - this.clientActivity = { + const metadata = this.currentClientMetadata(); + metadata.activity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, focusedTerminalId, @@ -4197,8 +4965,8 @@ export class Session { if (msg.appVisible && focusedTerminalId) { void this.clearFocusedTerminalAttention(focusedTerminalId); } - if (this.registeredPushToken) { - this.pushNotifications.renew(this.registeredPushToken); + if (metadata.pushToken) { + this.pushNotifications.renew(metadata.pushToken); } } @@ -4218,7 +4986,7 @@ export class Session { * Handle push token registration */ private handleRegisterPushToken(token: string): void { - this.registeredPushToken = token; + this.currentClientMetadata().pushToken = token; this.pushNotifications.renew(token); this.sessionLogger.info("Registered push token"); } @@ -4324,6 +5092,15 @@ export class Session { response, logger: this.sessionLogger, }); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09. + // Legacy clients consume the single domain resolution; modern request outcomes + // are independent of whether this socket (or its logical Session) observes it. + if (this.delivery.isModern(this.delivery.currentSource)) { + this.delivery.reply({ + type: "agent_permission_resolved", + payload: { agentId, requestId, resolution: response }, + }); + } } catch (error) { this.sessionLogger.error( { err: error, agentId, requestId }, @@ -4348,8 +5125,13 @@ export class Session { try { const workspaceCwd = cwd?.trim(); const searchesWorkspace = Boolean(workspaceCwd); + const homeRoot = process.env.HOME ?? homedir(); + // readdir on TCC-protected folders under ~/Library blocks forever for a process that cannot + // show a consent prompt, and each blocked call holds a libuv threadpool thread. + const excludedDiscoveryPaths = + !searchesWorkspace && process.platform === "darwin" ? [join(homeRoot, "Library")] : []; const entries = await searchDirectoryEntries({ - root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()), + root: workspaceCwd ? expandTilde(workspaceCwd) : homeRoot, query, pathFormat: searchesWorkspace ? "relative" : "absolute", pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted", @@ -4360,6 +5142,7 @@ export class Session { : [], confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, respectGitIgnore: searchesWorkspace, + excludedDiscoveryPaths, includeFiles, includeDirectories, matchMode, @@ -4458,60 +5241,25 @@ export class Session { /** * Build the current agent list payload (live + persisted), optionally filtered by labels. */ - private async listAgentPayloads(filter?: { - labels?: Record; - includeArchived?: boolean; - includeUnavailablePersisted?: boolean; - }): Promise { - const includeArchived = filter?.includeArchived === true; - const labelEntries = filter?.labels ? Object.entries(filter.labels) : []; - - // Get live agents with session modes - const agentSnapshots = this.agentManager.listAgents(); - const liveAgents = await Promise.all( - agentSnapshots.map((agent) => this.buildAgentPayload(agent)), - ); - - // Add persisted agents that have not been lazily initialized yet - // (excluding internal agents which are for ephemeral system tasks) - const registryRecords = await this.agentStorage.list(); - const liveIds = new Set(agentSnapshots.map((a) => a.id)); - const registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()); - const persistedAgents = registryRecords - .filter((record) => !liveIds.has(record.id) && !record.internal) - // Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents. - .filter((record) => includeArchived || !record.archivedAt) - .filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value)) - .filter( - (record) => - filter?.includeUnavailablePersisted === true || - isStoredAgentProviderAvailable(record, registeredProviderIds), - ) - .map((record) => this.buildStoredAgentPayload(record, registeredProviderIds)); - - let agents = [...liveAgents, ...persistedAgents]; - - agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider)); - if (!includeArchived) { - agents = agents.filter((agent) => !agent.archivedAt); - } - - // Filter by labels if filter provided - if (labelEntries.length > 0) { - agents = agents.filter((agent) => - labelEntries.every(([key, value]) => agent.labels[key] === value), - ); - } - - return agents; + private listAgentPayloads( + filter?: Parameters[0], + ): Promise { + return this.agentDirectory.list(filter); } private async resolveAgentIdentifier( identifier: string, - ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> { + ): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> { const trimmed = identifier.trim(); if (!trimmed) { - return { ok: false, error: "Agent identifier cannot be empty" }; + return { ok: false, notFound: false, error: "Agent identifier cannot be empty" }; + } + + // An internal agent, live or recently archived, is addressable by its exact + // id and nothing else: the caller that created it holds the id, and no + // listing ever includes it. + if (this.agentManager.getAgent(trimmed) || this.agentManager.getRetiredInternalAgent(trimmed)) { + return { ok: true, agentId: trimmed }; } const stored = await this.agentStorage.list(); @@ -4535,6 +5283,7 @@ export class Session { if (prefixMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches .slice(0, 5) .map((id) => id.slice(0, 8)) @@ -4549,6 +5298,7 @@ export class Session { if (titleMatches.length > 1) { return { ok: false, + notFound: false, error: `Agent title "${trimmed}" is ambiguous (${titleMatches .slice(0, 5) .map((r) => r.id.slice(0, 8)) @@ -4556,7 +5306,26 @@ export class Session { }; } - return { ok: false, error: `Agent not found: ${trimmed}` }; + return { ok: false, notFound: true, error: `Agent not found: ${trimmed}` }; + } + + /** + * An agent that is no longer live, in its settled form: the retained snapshot + * of a recently archived internal agent, else its stored record. Null when + * neither exists, and for a stored internal record, which is never served. + */ + private async readSettledAgent( + agentId: string, + ): Promise<{ record: StoredAgentRecord; lastMessage: string | null } | null> { + const retired = this.agentManager.getRetiredInternalAgent(agentId); + if (retired) { + return { record: retired.record, lastMessage: retired.lastMessage }; + } + const record = await this.agentStorage.get(agentId); + if (!record || record.internal) { + return null; + } + return { record, lastMessage: null }; } private async getAgentPayloadById(agentId: string): Promise { @@ -4566,11 +5335,11 @@ export class Session { return this.isProviderVisibleToClient(payload.provider) ? payload : null; } - const record = await this.agentStorage.get(agentId); - if (!record || record.internal) { + const settled = await this.readSettledAgent(agentId); + if (!settled) { return null; } - const payload = this.buildStoredAgentPayload(record); + const payload = this.buildStoredAgentPayload(settled.record); return this.isProviderVisibleToClient(payload.provider) ? payload : null; } @@ -4638,8 +5407,9 @@ export class Session { limit: number; getPlacement: (workspaceId: string | undefined) => Promise; filter: AgentUpdatesFilter | undefined; + search?: string; }): Promise { - const { candidates, limit, getPlacement, filter } = params; + const { candidates, limit, getPlacement, filter, search } = params; const matchedEntries: FetchAgentsResponseEntry[] = []; const batchSize = 25; for ( @@ -4667,6 +5437,7 @@ export class Session { ) { continue; } + if (search && !matchesAgentHistoryQuery(search, entry)) continue; matchedEntries.push(entry); if (matchedEntries.length > limit) { break; @@ -4692,6 +5463,9 @@ export class Session { let agents = await this.listAgentPayloads({ labels: filter?.labels, includeArchived: filter?.includeArchived, + includeBackground: + filter?.includeBackground || + (request.type === "fetch_agents_request" && request.includeBackground), includeUnavailablePersisted: request.type === "fetch_agent_history_request", }); const activePlacementsByWorkspaceId = @@ -4725,17 +5499,6 @@ export class Session { }; const search = agentDirectorySearchQuery(request); - if (search) { - return this.listRankedAgentHistoryEntries({ - search, - agents, - sort, - filter, - getPlacement, - page: request.page, - }); - } - let candidates = [...agents]; candidates.sort((left, right) => this.agentsPager.compare(left, right, sort)); const cursorToken = request.page?.cursor; @@ -4753,6 +5516,7 @@ export class Session { limit, getPlacement, filter, + search, }); const pagedEntries = matchedEntries.slice(0, limit); @@ -4772,64 +5536,6 @@ export class Session { }; } - /** - * The searched history page. Ranking has to see every candidate before it can - * name the best one, so this path resolves placements for the whole set - * instead of stopping at the page limit — that is what makes a query answer - * from all persisted sessions rather than from the first page of them. - */ - private async listRankedAgentHistoryEntries(params: { - search: string; - agents: AgentSnapshotPayload[]; - sort: FetchAgentsRequestSort[]; - filter: AgentUpdatesFilter | undefined; - getPlacement: (workspaceId: string | undefined) => Promise; - page: AgentDirectoryRequestMessage["page"]; - }): Promise<{ - entries: FetchAgentsResponseEntry[]; - pageInfo: FetchAgentsResponsePageInfo; - searchTruncated: boolean; - }> { - const { search, agents, sort, filter, getPlacement, page } = params; - if (page?.cursor) { - // A ranked result set has no pages to walk, so a cursor here is caller - // misuse. Returning the ranked head instead would hide it. - throw new SessionRequestError( - "invalid_cursor", - "A history search returns one ranked page; it cannot be paged with a cursor.", - ); - } - - const allEntries = await this.collectFetchAgentsEntries({ - candidates: agents, - limit: Number.MAX_SAFE_INTEGER, - getPlacement, - filter, - }); - - const ranked = rankAgentHistoryCandidates(search, allEntries, (left, right) => - this.agentsPager.compare(left.agent, right.agent, sort), - ); - - const limit = page?.limit ?? 200; - // Ranges are derived only for the rows that will be rendered; ranking - // itself never needs them. - const entries = ranked.slice(0, limit).map((result) => - Object.assign({}, result.candidate, { - searchScore: result.searchScore, - searchMatches: describeAgentHistoryMatches(search, result.candidate), - }), - ); - - return { - entries, - // No next page exists, so `hasMore` is false and truncation is reported - // on its own field. See the note on rankAgentHistoryCandidates. - pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, - searchTruncated: ranked.length > limit, - }; - } - private readonly agentsPager = new SortablePager< AgentSnapshotPayload, FetchAgentsRequestSort["key"] @@ -4903,6 +5609,7 @@ export class Session { title: workspace.title, pinnedAt: workspace.pinnedAt, ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), + background: workspace.background, archivingAt: null, status: "done", statusEnteredAt: null, @@ -4988,6 +5695,7 @@ export class Session { worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, + background: result.workspace.background, name: resolveWorkspaceName({ title: result.workspace.title, derivedDisplayName: result.worktree.branchName || result.workspace.displayName, @@ -5066,7 +5774,13 @@ export class Session { pageInfo: FetchWorkspacesResponsePageInfo; }> { try { - return await this.workspaceDirectory.listFetchEntries(request); + return await this.workspaceDirectory.listFetchEntries({ + ...request, + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, + }); } catch (error) { if (error instanceof CursorError) { throw new SessionRequestError("invalid_cursor", error.message); @@ -5079,6 +5793,8 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, payload: WorkspaceUpdatePayload, ): void { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; if (payload.kind === "upsert") { subscription.visibleEmptyProjectIds?.delete(payload.workspace.projectId); } else { @@ -5096,20 +5812,22 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } - private flushBootstrappedWorkspaceUpdates(options?: { - snapshotByWorkspaceId?: Map< - string, - { status: string; statusEnteredAt: string | null; activityAtMs: number | null } - >; - }): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription || !subscription.isBootstrapping) { + private flushBootstrappedWorkspaceUpdates( + subscription: WorkspaceUpdatesSubscriptionState, + options?: { + snapshotByWorkspaceId?: Map< + string, + { status: string; statusEnteredAt: string | null; activityAtMs: number | null } + >; + }, + ): void { + if ( + this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription || + !subscription.isBootstrapping + ) { return; } @@ -5143,10 +5861,7 @@ export class Session { } const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id; subscription.lastEmittedByWorkspaceId.set(workspaceId, payload); - this.emit({ - type: "workspace_update", - payload, - }); + subscription.owner.emit({ type: "workspace_update", payload }); } } @@ -5284,17 +5999,11 @@ export class Session { workspaceIds: Iterable, options?: WorkspaceUpdateOptions, ): Promise { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) { - return; + const uniqueWorkspaceIds = new Set(workspaceIds); + if (uniqueWorkspaceIds.size === 0) return; + for (const subscription of this.workspaceUpdatesSubscriptions.values()) { + await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } - - const uniqueWorkspaceIds = new Set(Array.from(workspaceIds)); - if (uniqueWorkspaceIds.size === 0) { - return; - } - - await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); } private enqueueWorkspaceUpdates( @@ -5306,7 +6015,9 @@ export class Session { (this.workspaceUpdateTails.get(workspaceId) ?? Promise.resolve()).catch(() => undefined), ); const next = Promise.all(previous).then(() => - this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + this.delivery.forSource(subscription.owner.source, () => + this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + ), ); for (const workspaceId of workspaceIds) { this.workspaceUpdateTails.set(workspaceId, next); @@ -5328,7 +6039,7 @@ export class Session { subscription: WorkspaceUpdatesSubscriptionState, options: WorkspaceUpdateOptions | undefined, ): Promise { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } @@ -5338,7 +6049,7 @@ export class Session { }); for (const workspaceId of workspaceIds) { - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } const workspace = descriptorsByWorkspaceId.get(workspaceId); @@ -5351,18 +6062,12 @@ export class Session { options?.optimisticStatus, ); const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId); - if ( - options?.dedupeGitState && - this.workspaceGitObserver.shouldSkipUpdate(workspaceId, nextWorkspace) - ) { - continue; - } this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); if (!nextWorkspace) { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; } - if (this.workspaceUpdatesSubscription !== subscription) { + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) { return; } subscription.lastEmittedByWorkspaceId.delete(workspaceId); @@ -5377,6 +6082,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ), ); continue; @@ -5387,6 +6093,7 @@ export class Session { workspace ?? null, workspaceId, subscription.syncEnabled === true, + subscription.filter?.includeBackground, ); if ( @@ -5467,12 +6174,8 @@ export class Session { // and emits its own per-id descriptor. This is a deliberate same-folder fan, // not a cwd → id ownership lookup: git never resolves which workspace owns a // path. See `workspaceIdsOnCheckout`. - private async emitWorkspaceUpdateForCwd( - cwd: string, - options?: { - dedupeGitState?: boolean; - }, - ): Promise { + private async emitWorkspaceUpdateForCwd(cwd: string, options?: {}): Promise { + if (this.workspaceUpdatesSubscriptions.size === 0) return; const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd); if (workspaceIds.length === 0) { return; @@ -5483,16 +6186,29 @@ export class Session { private async handleFetchAgents( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("agents", request.subscribe.subscriptionId, (id) => { + this.agentUpdates.clearSubscription(id); + this.refreshObservationProducers(); + }) + : null; + const subscriptionId = owner?.responseId; try { - if (subscriptionId) { + if (owner) { this.agentUpdates.beginSubscription({ - subscriptionId, - filter: request.filter, + subscriptionId: owner.id, + isProviderVisible: (provider) => + this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)), + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, syncEnabled: Boolean(request.sync), + emit: (message) => { + if (message.type === "agent_update") owner.emit(message); + }, }); + this.refreshObservationProducers(); } const payload = request.sync @@ -5516,11 +6232,11 @@ export class Session { }); if (subscriptionId) { - this.agentUpdates.flushBootstrapped(subscriptionId, { snapshotUpdatedAtByAgentId }); + this.agentUpdates.flushBootstrapped(owner!.id, { snapshotUpdatedAtByAgentId }); } } catch (error) { if (subscriptionId) { - this.agentUpdates.clearSubscription(subscriptionId); + await owner?.release(); } const code = error instanceof SessionRequestError ? error.code : "fetch_agents_failed"; const message = error instanceof Error ? error.message : "Failed to fetch agents"; @@ -5612,9 +6328,33 @@ export class Session { private async handleFetchWorkspacesRequest( request: Extract, ): Promise { - const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim(); - const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId); - + const owner = request.subscribe + ? this.delivery.begin("workspaces", request.subscribe.subscriptionId, async (id) => { + this.workspaceUpdatesSubscriptions.delete(id); + this.refreshObservationProducers(); + await this.reconcileWorkspaceGitObservers(); + }) + : null; + const subscriptionId = owner?.responseId; + const subscription: WorkspaceUpdatesSubscriptionState | null = owner + ? { + subscriptionId: owner.id, + owner, + syncEnabled: Boolean(request.sync), + filter: { + ...request.filter, + ...(request.includeBackground ? { includeBackground: true } : {}), + }, + isBootstrapping: true, + pendingUpdatesByWorkspaceId: new Map(), + lastEmittedByWorkspaceId: new Map(), + visibleEmptyProjectIds: new Set(), + } + : null; + if (subscription) { + this.workspaceUpdatesSubscriptions.set(subscription.subscriptionId, subscription); + this.refreshObservationProducers(); + } try { this.sessionLogger.debug( { @@ -5626,22 +6366,10 @@ export class Session { }, "fetch_workspaces_request_received", ); - if (subscriptionId) { - this.workspaceUpdatesSubscription = { - subscriptionId, - syncEnabled: Boolean(request.sync), - filter: request.filter, - isBootstrapping: true, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - visibleEmptyProjectIds: new Set(), - }; - } - const payload = request.sync ? await this.readWorkspaceDirectorySync(request) : await this.listFetchWorkspacesEntries(request); - this.workspaceGitObserver.syncObservers(payload.entries); + this.sessionLogger.debug( { requestId: request.requestId, @@ -5652,12 +6380,13 @@ export class Session { "fetch_workspaces_response_ready", ); const snapshot = this.buildBootstrapSnapshot(payload.entries); - this.seedWorkspaceSubscriptionSnapshot( - subscriptionId, - request.filter, - payload.entries, - payload.emptyProjects, - ); + if (subscription) { + this.seedWorkspaceSubscriptionSnapshot( + subscription, + payload.entries, + payload.emptyProjects, + ); + } this.emit({ type: "fetch_workspaces_response", @@ -5668,13 +6397,12 @@ export class Session { }, }); - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.flushBootstrappedWorkspaceUpdates(snapshot); + if (subscription) { + await this.reconcileWorkspaceGitObservers(); + this.flushBootstrappedWorkspaceUpdates(subscription, snapshot); } } catch (error) { - if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) { - this.workspaceUpdatesSubscription = null; - } + await owner?.release(); const code = error instanceof SessionRequestError ? error.code : "fetch_workspaces_failed"; const message = error instanceof Error ? error.message : "Failed to fetch workspaces"; this.sessionLogger.error({ err: error }, "Failed to handle fetch_workspaces_request"); @@ -5702,7 +6430,6 @@ export class Session { const synchronized = request.sync ? this.directorySync.synchronizeProjects(projects, request.sync) : null; - if (synchronized) this.projectSyncEnabled = true; this.emit({ type: "project.list.response", payload: { @@ -5749,70 +6476,57 @@ export class Session { private async handleWorkspaceLabelList( request: Extract, ): Promise { - const owner = {}; + const service = this.requireWorkspaceLabels(); + if (!request.subscribe) { + this.emit({ + type: "workspace.label.list.response", + payload: { requestId: request.requestId, ...(await service.list(request.sync)) }, + }); + return; + } + let bootstrap: ReturnType | undefined; + const owner = this.delivery.begin("labels", request.subscribe.subscriptionId, async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }); try { - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: () => undefined, - }; - const service = this.requireWorkspaceLabels(); type LiveChange = Parameters[0]["onChange"]>[0]; - const pending: LiveChange[] = []; + const pending = new Map(); let ready = false; const emitChange = (change: LiveChange): void => { - this.emit({ + owner.emit({ type: "workspace.label.update", - payload: - change.kind === "upsert" - ? { - kind: "upsert", - label: change.label, - ...(change.previousName ? { previousName: change.previousName } : {}), - generation: change.generation, - seq: change.seq, - } - : { - kind: "remove", - name: change.name, - generation: change.generation, - seq: change.seq, - }, + payload: change, }); }; - const subscription = await service.subscribe({ + bootstrap = service.subscribe({ cursor: request.sync, onChange: (change) => { - if (this.workspaceLabelSubscription?.owner !== owner) return; - if (!ready) pending.push(change); + if (owner.signal.aborted) return; + if (!ready) + pending.set(change.kind === "upsert" ? change.label.name : change.name, change); else emitChange(change); }, }); - const ownsSubscription = this.workspaceLabelSubscription?.owner === owner; - if (ownsSubscription) { - this.workspaceLabelSubscription = { - owner, - id: request.subscribe.subscriptionId, - unsubscribe: subscription.unsubscribe, - }; - } else { - subscription.unsubscribe(); - } + const subscription = await bootstrap; + if (owner.signal.aborted) return; this.emit({ type: "workspace.label.list.response", - payload: { requestId: request.requestId, ...subscription.snapshot }, + payload: { + requestId: request.requestId, + subscriptionId: owner.responseId, + ...subscription.snapshot, + }, }); ready = true; - if (ownsSubscription) { - for (const change of pending) { - if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); - } + for (const change of pending.values()) { + if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change); } + pending.clear(); } catch (error) { - if (this.workspaceLabelSubscription?.owner === owner) { - this.workspaceLabelSubscription = null; - } + await owner.release(); this.emitWorkspaceLabelError(request, error); } } @@ -5886,24 +6600,41 @@ export class Session { } const snapshot = await this.listFetchAgentsEntries({ ...request, + includeBackground: true, page: { limit: Number.MAX_SAFE_INTEGER }, }); - return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {}); + const result = this.directorySync.synchronizeAgents( + snapshot.entries, + request.sync ?? {}, + request.includeBackground, + ); + const entries = await Promise.all( + result.entries.map(async (entry) => + (await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null, + ), + ); + return { ...result, entries: entries.filter((entry) => entry !== null) }; } private async readWorkspaceDirectorySync( request: Extract, ) { - if (request.filter) { + if (request.filter) throw new SessionRequestError( "invalid_request", "Sequenced workspace directory reads do not support filters.", ); - } - return this.directorySync.synchronizeWorkspaces( + const result = this.directorySync.synchronizeWorkspaces( await this.workspaceDirectory.listDescriptors(), request.sync ?? {}, + request.includeBackground, ); + return { + ...result, + entries: result.entries.filter( + (workspace) => request.includeBackground || !workspace.background, + ), + }; } // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` @@ -5933,16 +6664,27 @@ export class Session { return { snapshotByWorkspaceId }; } + private async reconcileWorkspaceGitObservers(): Promise { + const all = this.wantsEvent("checkout_status_update") + ? await this.workspaceDirectory.listObservationTargets() + : []; + const entries = [...this.workspaceUpdatesSubscriptions.values()].flatMap((subscription) => + [...subscription.lastEmittedByWorkspaceId.values()].flatMap((update) => + update.kind === "upsert" ? [update.workspace] : [], + ), + ); + this.workspaceGitObserver.reconcileObservers( + this.wantsEvent("checkout_status_update") ? [...entries, ...all] : entries, + ); + } + private seedWorkspaceSubscriptionSnapshot( - subscriptionId: string | null, - filter: FetchWorkspacesRequestFilter | undefined, + subscription: WorkspaceUpdatesSubscriptionState, entries: FetchWorkspacesResponseEntry[], emptyProjects: WorkspaceProjectDescriptorPayload[], ): void { - const subscription = this.workspaceUpdatesSubscription; - if (!subscription) return; - if (subscriptionId && subscription.subscriptionId !== subscriptionId) return; - if (!subscriptionId && !equal(subscription.filter, filter)) return; + if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) + return; for (const entry of entries) { subscription.lastEmittedByWorkspaceId.set(entry.id, { kind: "upsert", @@ -5969,56 +6711,48 @@ export class Session { } } - private async handleWorkspaceCreateRequest( + private getCallerWorkspaceId(callerAgentId?: string): string | undefined { + if (!callerAgentId) return undefined; + const caller = this.agentManager.getAgent(callerAgentId); + if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`); + return caller.workspaceId; + } + + private async createRequestedWorkspace( request: Extract, - ): Promise { - try { - if (request.source.kind === "directory") { - await this.handleWorkspaceCreateLocal(request); - return; - } - await this.handleWorkspaceCreateWorktree(request); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create workspace"; - this.sessionLogger.error( - { err: error, sourceKind: request.source.kind, requestId: request.requestId }, - "Failed to create workspace", + workspaceId?: string, + ): Promise { + if (request.internal) + throw new Error( + "Public internal workspaces were removed; use background workspace creation.", ); - const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined; - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: message, - errorCode, - }, - }); + if (request.agent?.background !== undefined) + throw new Error("Configure background on the workspace, not its initial agent."); + let creationRequest = request; + // Hooks belong to the operation: retries fingerprint the caller's input + // and must not rerun hooks or compare their potentially changing output. + if (this.pluginRuntime) { + const { type, requestId, ...input } = request; + const transformed = await this.pluginRuntime.before("workspace.create", input); + creationRequest = { ...transformed, type, requestId }; } + return creationRequest.source.kind === "directory" + ? this.handleWorkspaceCreateLocal(creationRequest, workspaceId) + : this.handleWorkspaceCreateWorktree(creationRequest, workspaceId); } private async handleWorkspaceCreateLocal( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "directory") { - return; + throw new Error("Unexpected workspace source"); } const cwd = expandTilde(request.source.path); const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false); if (!directoryExists) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: `Directory not found: ${cwd}`, - errorCode: "directory_not_found", - }, - }); - return; + throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`); } const explicitTitle = request.title?.trim() || null; @@ -6027,19 +6761,15 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { + expectsInitialAgent: Boolean(request.firstAgentContext), + workspaceId, + background: request.background, + callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), + }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, @@ -6063,29 +6793,24 @@ export class Session { { currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) }, ); } + return descriptor; } private async handleWorkspaceCreateWorktree( request: Extract, - ): Promise { + workspaceId?: string, + ): Promise { if (request.source.kind !== "worktree") { - return; + throw new Error("Unexpected workspace source"); } const source = request.source; if (!source.cwd && !source.projectId) { - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: null, - setupTerminalId: null, - error: "cwd or projectId is required for a worktree-backed workspace", - errorCode: "source_required", - }, - }); - return; + throw new SessionRequestError( + "source_required", + "cwd or projectId is required for a worktree-backed workspace", + ); } const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry); @@ -6093,6 +6818,7 @@ export class Session { const result = await this.createPaseoWorktreeWorkflow( { cwd: sourceCwd, + workspaceId, projectId: source.projectId, worktreeSlug: source.worktreeSlug, action: source.action, @@ -6102,6 +6828,8 @@ export class Session { githubPrNumber: source.githubPrNumber, firstAgentContext: request.firstAgentContext, title: request.title, + background: request.background, + callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId), }, source.baseBranch ? { resolveDefaultBranch: async () => source.baseBranch as string } @@ -6109,26 +6837,11 @@ export class Session { ); const descriptor = await this.describeCreatedWorktreeWorkspace(result); - this.emit({ - type: "workspace.create.response", - payload: { - requestId: request.requestId, - workspace: descriptor, - setupTerminalId: null, - ...(result.workspace.untrustedSource - ? { - setupSkippedReason: formatWorkspaceAutomationBlockedMessage( - result.workspace.untrustedSource, - ), - } - : {}), - error: null, - }, - }); await this.emitCreatedWorkspaceUpdate( descriptor, request.firstAgentContext ? "running" : undefined, ); + return descriptor; } private async handleOpenProjectRequest( @@ -6657,7 +7370,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, @@ -6704,7 +7416,6 @@ export class Session { emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, - archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), serviceProxy: this.serviceProxy, scriptRuntimeStore: this.scriptRuntimeStore, getDaemonTcpPort: this.getDaemonTcpPort, @@ -6792,7 +7503,7 @@ export class Session { const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId]; let agents: AgentSnapshotPayload[]; try { - agents = await this.listAgentPayloads(); + agents = await this.listAgentPayloads({ includeBackground: true }); } catch (error) { const message = getErrorMessage(error); const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({ @@ -6921,12 +7632,76 @@ export class Session { }); } + private async handleWorkspaceMarkUnreadRequest( + request: Extract, + ): Promise { + const { requestId, workspaceId } = request; + let markedAgentId: string | null = null; + try { + const workspace = await this.workspaceRegistry.get(workspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace not found: ${workspaceId}`); + } + + const agents = (await this.listAgentPayloads({ includeBackground: true })).filter((agent) => + this.isProviderVisibleToClient(agent.provider), + ); + const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const)); + const candidates = agents + .filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId) + .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) + .filter((agent) => agent.status === "idle" || agent.status === "closed") + .filter((agent) => agent.requiresAttention !== true) + .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + const candidate = candidates[0]; + if (!candidate) { + throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`); + } + + await this.agentManager.markAgentUnread(candidate.id); + markedAgentId = candidate.id; + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: true, + error: null, + }, + }); + } catch (error) { + const message = getErrorMessage(error); + this.sessionLogger.error({ err: error, workspaceId }, "Failed to mark workspace unread"); + this.emit({ + type: "workspace.mark_unread.response", + payload: { + requestId, + workspaceId, + markedAgentId, + success: false, + error: message, + }, + }); + } + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise { + // An unknown agent is a null agent, not an error. Errors are for empty or ambiguous identifiers. const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ type: "fetch_agent_response", - payload: { requestId, agent: null, project: null, error: resolved.error }, + payload: { + requestId, + agent: null, + project: null, + error: resolved.notFound ? null : resolved.error, + }, }); return; } @@ -6935,12 +7710,7 @@ export class Session { if (!agent) { this.emit({ type: "fetch_agent_response", - payload: { - requestId, - agent: null, - project: null, - error: `Agent not found: ${resolved.agentId}`, - }, + payload: { requestId, agent: null, project: null, error: null }, }); return; } @@ -6954,105 +7724,12 @@ export class Session { }); } - private shouldUseFullTimelineForProjectedPage(input: { - timeline: AgentTimelineFetchResult; - pageLimit: number; - }): boolean { - const { timeline } = input; - if (timeline.rows.length === 0) return false; - - if (timeline.rows.some((row) => row.item.type === "tool_call")) return true; - - const firstRow = timeline.rows[0]; - if ( - timeline.hasOlder && - (firstRow?.item.type === "assistant_message" || firstRow?.item.type === "reasoning") - ) { - return true; - } - - const lastRow = timeline.rows.at(-1); - if ( - timeline.hasNewer && - (lastRow?.item.type === "assistant_message" || lastRow?.item.type === "reasoning") - ) { - return true; - } - - if (!timeline.hasNewer || input.pageLimit === 0) return false; - return projectTimelineRows({ rows: timeline.rows, mode: "projected" }).length < input.pageLimit; - } - - private selectCanonicalTimelineProjection(input: { - timeline: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const entries = projectTimelineRows({ rows: input.timeline.rows, mode: "canonical" }); - return { - timeline: input.timeline, - entries, - startSeq: entries[0]?.seqStart ?? null, - endSeq: entries[entries.length - 1]?.seqEnd ?? null, - hasOlder: input.timeline.hasOlder, - hasNewer: input.timeline.hasNewer, - }; - } - - private selectProjectedTimelineProjection(input: { - agentId: string; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({ - timeline: input.controlTimeline, - pageLimit: input.pageLimit, - }) - ? (input.fullTimeline ?? - this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })) - : input.controlTimeline; - const page = selectProjectedTimelinePage({ - rows: selectedTimeline.rows, - bounds: selectedTimeline.window, - direction: input.controlTimeline.reset ? "tail" : input.direction, - ...(input.cursor ? { cursorSeq: input.cursor.seq } : {}), - limit: input.pageLimit, - }); - - return { - timeline: selectedTimeline, - entries: page.entries, - startSeq: page.startSeq, - endSeq: page.endSeq, - hasOlder: - page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq), - hasNewer: page.hasNewer, - }; - } - - private selectTimelineProjection(input: { - agentId: string; - projection: TimelineProjectionMode; - controlTimeline: AgentTimelineFetchResult; - direction: AgentTimelineFetchDirection; - cursor?: AgentTimelineCursor; - pageLimit: number; - fullTimeline?: AgentTimelineFetchResult; - }): AgentTimelineProjectionSelection { - if (input.projection === "canonical") { - return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline }); - } - - return this.selectProjectedTimelineProjection(input); - } - private async handleFetchAgentTimelineRequest( msg: Extract, source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); - const projection: TimelineProjectionMode = msg.projection ?? "projected"; + const projection = "projected" as const; const requestedLimit = msg.limit; const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200); const cursor: AgentTimelineCursor | undefined = msg.cursor @@ -7075,14 +7752,14 @@ export class Session { cursor, limit: pageLimit, }); - const selectedTimeline = this.selectTimelineProjection({ - agentId: msg.agentId, - projection, - controlTimeline: fetchedControlTimeline, - direction, - ...(cursor ? { cursor } : {}), - pageLimit, - }); + const selectedTimeline = { + timeline: fetchedControlTimeline, + entries: fetchedControlTimeline.rows, + startSeq: fetchedControlTimeline.startSeq, + endSeq: fetchedControlTimeline.endSeq, + hasOlder: fetchedControlTimeline.hasOlder, + hasNewer: fetchedControlTimeline.hasNewer, + }; const startCursor = selectedTimeline.startSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq } @@ -7091,6 +7768,9 @@ export class Session { selectedTimeline.endSeq !== null ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } : null; + const entries = selectedTimeline.entries.filter((entry) => + this.supportsTimelineItem(entry.item, source), + ); this.emitForSource( { @@ -7111,7 +7791,7 @@ export class Session { hasOlder: selectedTimeline.hasOlder, hasNewer: selectedTimeline.hasNewer, ...(msg.mergeWindow === true ? { mergeWindow: true } : {}), - entries: selectedTimeline.entries.map((entry) => { + entries: entries.map((entry) => { const payloadEntry = { provider: snapshot.provider, item: entry.item, @@ -7185,6 +7865,59 @@ export class Session { }); } + private async handleAgentTimelineSearchRequest( + msg: Extract, + source?: object, + ): Promise { + try { + await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const rows = await this.agentManager.getTimelineRows(msg.agentId); + const { epoch } = this.agentManager.fetchTimeline(msg.agentId, { + direction: "tail", + limit: 1, + }); + const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor }); + if ( + this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }).epoch !== + epoch + ) { + throw new Error("History changed; search again"); + } + this.emitForSource( + { + type: "agent.timeline.search.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + epoch, + ...result, + error: null, + }, + }, + source, + ); + } catch (error) { + this.emitForSource( + { + type: "agent.timeline.search.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + epoch: "", + locations: [], + nextCursor: null, + error: error instanceof Error ? error.message : String(error), + }, + }, + source, + ); + } + } + private async handleAgentTimelineListPromptsRequest( msg: Extract, source?: object, @@ -7267,6 +8000,7 @@ export class Session { private async handleProviderSubagentTimelineRequest( msg: Extract, + source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); try { @@ -7279,6 +8013,10 @@ export class Session { if (!descriptor) { throw new Error("Provider subagent not found"); } + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14. + const supportsProjection = source + ? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source) + : this.supports(CLIENT_CAPS.projectedSubagentTimeline); const timeline = this.agentManager.fetchProviderSubagentTimeline( msg.parentAgentId, msg.subagentId, @@ -7288,49 +8026,75 @@ export class Session { limit: msg.limit ?? (direction === "after" ? 0 : 200), }, ); - this.emit({ - type: "agent.provider_subagents.timeline.get.response", - payload: { - requestId: msg.requestId, - parentAgentId: msg.parentAgentId, - subagentId: msg.subagentId, - provider: descriptor.provider, - direction, - epoch: timeline.epoch, - reset: timeline.reset, - staleCursor: timeline.staleCursor, - gap: timeline.gap, - window: timeline.window, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - rows: timeline.rows.map((row) => ({ - item: row.item, - timestamp: row.timestamp, - seq: row.seq, - })), - error: null, + const rows = timeline.rows.filter((row) => this.supportsTimelineItem(row.item, source)); + this.emitForSource( + { + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: descriptor.provider, + direction, + epoch: timeline.epoch, + projection: "projected", + startCursor: + timeline.startSeq === null ? null : { epoch: timeline.epoch, seq: timeline.startSeq }, + endCursor: + timeline.endSeq === null ? null : { epoch: timeline.epoch, seq: timeline.endSeq }, + reset: timeline.reset, + staleCursor: timeline.staleCursor, + gap: timeline.gap, + window: timeline.window, + hasOlder: supportsProjection && timeline.hasOlder, + hasNewer: supportsProjection && timeline.hasNewer, + rows: supportsProjection + ? rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seqEnd, + seqStart: row.seqStart, + seqEnd: row.seqEnd, + sourceSeqRanges: row.sourceSeqRanges, + })) + : [ + { + seq: timeline.window.maxSeq, + timestamp: new Date().toISOString(), + item: { + type: "assistant_message", + text: "Please upgrade the Paseo app to view this subagent conversation.", + }, + }, + ], + error: null, + }, }, - }); + source, + ); } catch (error) { - this.emit({ - type: "agent.provider_subagents.timeline.get.response", - payload: { - requestId: msg.requestId, - parentAgentId: msg.parentAgentId, - subagentId: msg.subagentId, - provider: null, - direction, - epoch: "", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, - hasOlder: false, - hasNewer: false, - rows: [], - error: error instanceof Error ? error.message : String(error), + this.emitForSource( + { + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: null, + direction, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: error instanceof Error ? error.message : String(error), + }, }, - }); + source, + ); } } @@ -7390,6 +8154,19 @@ export class Session { } } + private async prepareAgentMessage(agentId: string, text: string): Promise { + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); + const stored = await this.agentStorage.get(agentId); + if (stored && !stored.title && !stored.lastUserMessageAt) { + const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text }); + if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle); + } + } + private async handleSendAgentMessageRequest( msg: Extract, ): Promise { @@ -7420,59 +8197,44 @@ export class Session { }, "agent.session.send_agent_message", ); - let dispatchResult: { disposition: "out_of_band" | "steered" | "turn_started" }; - try { - dispatchResult = await sendPromptToAgent({ + const send = async () => { + const result = await sendPromptToAgent({ agentManager: this.agentManager, agentStorage: this.agentStorage, agentId, prompt, + source: msg.sourceAgentId + ? { kind: "agent-message", agentId: msg.sourceAgentId } + : undefined, messageId: msg.messageId, activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", clearPendingPermissions: true, logger: this.sessionLogger, }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.handleAgentRunError(agentId, error, "Failed to send agent message"); - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, + if (result.disposition === "turn_started") { + await waitForAgentRunStartWithTimeout( + this.agentManager, agentId, - accepted: false, - error: message, + this.delivery.requestSignal, + ); + } + }; + if (msg.messageId) { + await this.messageReceipts.send({ + agentId, + messageId: msg.messageId, + request: { + prompt, + sourceAgentId: msg.sourceAgentId, + activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt", }, - }); - return; - } - - if (dispatchResult.disposition !== "turn_started") { - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId, - accepted: true, - error: null, + prepare: async () => { + if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text); }, + send, }); - return; - } - - try { - await waitForAgentRunStartWithTimeout(this.agentManager, agentId); - } catch (error) { - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId, - accepted: false, - error: errorToFriendlyMessage(error), - }, - }); - return; + } else { + await send(); } this.emit({ @@ -7485,6 +8247,8 @@ export class Session { }, }); } catch (error) { + if (this.delivery.requestSignal.aborted) return; + this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message"); this.emit({ type: "send_agent_message_response", payload: { @@ -7502,6 +8266,8 @@ export class Session { requestId: string, timeoutMs?: number, ): Promise { + const sourceSignal = this.delivery.requestSignal; + sourceSignal.throwIfAborted(); const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier); if (!resolved.ok) { this.emit({ @@ -7520,8 +8286,8 @@ export class Session { const agentId = resolved.agentId; const live = this.agentManager.getAgent(agentId); if (!live) { - const record = await this.agentStorage.get(agentId); - if (!record || record.internal) { + const settled = await this.readSettledAgent(agentId); + if (!settled) { this.emit({ type: "wait_for_finish_response", payload: { @@ -7534,6 +8300,7 @@ export class Session { }); return; } + const { record, lastMessage } = settled; const final = this.buildStoredAgentPayload(record); let status: "permission" | "error" | "idle"; if (record.attentionReason === "permission") { @@ -7546,7 +8313,7 @@ export class Session { const error = resolveWaitForFinishError({ status, final }); this.emit({ type: "wait_for_finish_response", - payload: { requestId, status, final, error, lastMessage: null }, + payload: { requestId, status, final, error, lastMessage }, }); return; } @@ -7561,7 +8328,7 @@ export class Session { try { let result = await this.agentManager.waitForAgentEvent(agentId, { - signal: abortController.signal, + signal: AbortSignal.any([abortController.signal, sourceSignal]), waitForActive: true, }); let final = await this.getAgentPayloadById(agentId); @@ -7584,6 +8351,7 @@ export class Session { payload: { requestId, status, final, error, lastMessage: result.lastMessage }, }); } catch (error) { + if (sourceSignal.aborted) return; const isAbort = error instanceof Error && (error.name === "AbortError" || error.message.toLowerCase().includes("aborted")); @@ -7622,10 +8390,82 @@ export class Session { /** * Emit a message to the client */ - private emit(msg: SessionOutboundMessage): void { - if (!this.authorization.allowsOutbound(msg)) { + // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. + private wantsEvent(event: SessionEventSubscription, source?: object): boolean { + for (const subscription of this.eventSubscriptions.values()) { + if ((!source || subscription.owner.source === source) && subscription.events.has(event)) + return true; + } + if (!source && this.clientSources.size > 0) { + return [...this.clientSources.keys()].some((candidate) => this.wantsEvent(event, candidate)); + } + if (source && this.delivery.isModern(source)) return false; + if (!source && !this.delivery.hasLegacySources()) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. + const capabilities = source + ? (this.clientSources.get(source)?.capabilities ?? this.clientCapabilities) + : this.clientCapabilities; + return legacyWantsEvent(event, capabilities); + } + + private emitEventToOwner( + subscription: { owner: OwnedSubscription; notifications: boolean }, + message: SessionOutboundMessage, + notified: Set, + ): void { + if ( + message.type !== "agent_attention_required" && + message.type !== "terminal_attention_required" + ) { + subscription.owner.emit(message); return; } + const shouldNotify = + message.payload.shouldNotify && + subscription.notifications && + !notified.has(subscription.owner.source); + subscription.owner.emit( + message.type === "agent_attention_required" + ? { ...message, payload: { ...message.payload, shouldNotify } } + : { ...message, payload: { ...message.payload, shouldNotify } }, + ); + if (shouldNotify) notified.add(subscription.owner.source); + } + + private emitSubscribedEvent(message: SessionOutboundMessage, onlySource?: object): boolean { + const event = sessionEventCategory(message); + if (!event) return false; + if (!this.authorization.allowsOutbound(message)) return true; + const delivered = new Set(); + const notified = new Set(); + for (const subscription of this.eventSubscriptions.values()) { + if ( + !subscription.events.has(event) || + (onlySource && subscription.owner.source !== onlySource) + ) + continue; + this.emitEventToOwner(subscription, message, notified); + delivered.add(subscription.owner.source); + } + // COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. + if (this.onMessageToSource && this.clientSources.size > 0) { + for (const source of this.clientSources.keys()) { + if ( + (!onlySource || source === onlySource) && + !delivered.has(source) && + !this.delivery.isModern(source) && + this.wantsEvent(event, source) + ) + this.onMessageToSource(source, this.workspaceSetupMessageForClient(message, source)); + } + } else if (delivered.size === 0 && this.wantsEvent(event)) this.onMessage(message); + return true; + } + + private emit(msg: SessionOutboundMessage): void { + if (!this.authorization.allowsOutbound(msg)) return; + if (this.delivery.reply(msg)) return; + if (this.emitSubscribedEvent(msg)) return; // JSON.stringify(msg) is only computed when trace is enabled — it runs for // every outbound message otherwise, and trace is disabled by default. // Optional-chained because test logger stubs don't implement isLevelEnabled. @@ -7638,9 +8478,45 @@ export class Session { "agent.session.outbound", ); } + if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { + if (this.clientSources.size > 0 && this.onMessageToSource) { + for (const [source] of this.clientSources) { + this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); + } + return; + } + msg = this.workspaceSetupMessageForClient(msg); + } this.onMessage(msg); } + // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. + private workspaceSetupMessageForClient( + message: SessionOutboundMessage, + source?: object, + ): SessionOutboundMessage { + if ( + message.type !== "workspace_setup_progress" && + message.type !== "workspace_setup_status_response" + ) + return message; + const supportsBlocked = source + ? this.supportsForSource(CLIENT_CAPS.workspaceSetupBlocked, source) + : this.supports(CLIENT_CAPS.workspaceSetupBlocked); + const snapshot = + message.type === "workspace_setup_progress" ? message.payload : message.payload.snapshot; + if (supportsBlocked || snapshot?.status !== "blocked") return message; + const legacySnapshot = { + ...snapshot, + status: "failed" as const, + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }; + return message.type === "workspace_setup_progress" + ? { ...message, payload: { ...message.payload, ...legacySnapshot } } + : { ...message, payload: { ...message.payload, snapshot: legacySnapshot } }; + } + private emitBinary(frame: Uint8Array): void { if (!this.onBinaryMessage) { return; @@ -7654,6 +8530,7 @@ export class Session { private async emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise { if (source && this.onBinaryMessageToSource) { + this.delivery.authorizeFileReply(frame, source); await this.onBinaryMessageToSource(source, frame); return; } @@ -7662,6 +8539,7 @@ export class Session { private emitForSource(msg: SessionOutboundMessage, source?: object): void { if (source && this.onMessageToSource) { + msg = this.delivery.authorizeReply(msg, source); this.onMessageToSource(source, msg); return; } @@ -7674,6 +8552,7 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); this.isCleanedUp = true; + await this.delivery.close(); if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -7685,8 +8564,6 @@ export class Session { this.unsubscribePluginChanges = null; this.unsubscribeWorkspaceMutations?.(); this.unsubscribeWorkspaceMutations = null; - this.workspaceLabelSubscription?.unsubscribe(); - this.workspaceLabelSubscription = null; this.agentUpdates.dispose(); await this.hubExecutionController?.cleanup(); if (this.unsubscribeTerminalWorkspaceContributionEvents) { @@ -7695,14 +8572,11 @@ export class Session { } this.providerCatalogSession.dispose(); - await this.voiceSession.cleanup(); - this.terminalController.dispose(); this.checkoutSession.cleanup(); this.workspaceGitObserver.dispose(); - this.workspaceFilesSession.dispose(); } } @@ -7756,3 +8630,57 @@ function normalizeCloneRepository(input: { function isValidGitHubRepoSegment(value: string): boolean { return /^[A-Za-z0-9._-]+$/u.test(value); } + +function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubscription | null { + switch (message.type) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + case "checkout_status_update": + case "script_status_update": + case "workspace_setup_progress": + case "agent.provider_subagents.update": + case "terminal_attention_required": + case "activity_log": + case "hub.execution.agent.update": + case "hub.execution.agent.stream": + return message.type; + case "status": + switch (message.payload.status) { + case "server_info": + return "status.server_info"; + case "daemon_config_changed": + return "status.daemon_config_changed"; + case "plugin_catalog_changed": + return "status.plugin_catalog_changed"; + case "plugin_settings_changed": + return "status.plugin_settings_changed"; + default: + return null; + } + default: + return null; + } +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09. +function legacyWantsEvent( + event: SessionEventSubscription, + capabilities: ReadonlySet, +): boolean { + if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false; + switch (event) { + case "project.update": + case "providers_snapshot_update": + case "agent_attention_required": + case "agent_permission_request": + case "agent_permission_resolved": + return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions); + case "agent.provider_subagents.update": + return capabilities.has(CLIENT_CAPS.providerSubagents); + default: + return true; + } +} diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 61dce39c5f..836703fd30 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; @@ -23,13 +27,6 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic import type { WorkspaceGitObserverService } from "./session/workspace-git-observer/workspace-git-observer-service.js"; interface SessionInternals { - workspaceUpdatesSubscription: { - subscriptionId: string; - filter: undefined; - isBootstrapping: boolean; - pendingUpdatesByWorkspaceId: Map; - lastEmittedByWorkspaceId: Map; - }; buildWorkspaceDescriptorMap: () => Promise>; workspaceGitObserver: WorkspaceGitObserverService; listAgentPayloads: () => Promise; @@ -191,6 +188,8 @@ function createSessionForWorkspaceGitWatchTests(options?: { }; const session = new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message as { type: string; payload: unknown }), @@ -201,6 +200,7 @@ function createSessionForWorkspaceGitWatchTests(options?: { agentManager: createStub({ subscribe: () => () => {}, listAgents: () => [], + listProviderSubagentActivity: () => [], getAgent: () => null, }), agentStorage: createStub({ @@ -329,13 +329,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); let descriptor = { id: "ws-10", @@ -392,7 +390,6 @@ describe("workspace git watch targets", () => { test("emits checkout_status_update to a client subscribed to the workspace git target", async () => { const { session, emitted, projects, workspaces, workspaceGitService, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -401,13 +398,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; @@ -491,6 +486,11 @@ describe("workspace git watch targets", () => { name: "old-branch", }); + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "branch-demand", + subscribe: {}, + }); await session.syncWorkspaceGitObserversForExternalWorkspaceIds(["ws-10"]); subscriptions[0]?.listener( @@ -573,7 +573,6 @@ describe("workspace git watch targets", () => { test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); - const sessionAny = asInternals(session); seedGitWorkspace({ projects, workspaces, @@ -582,13 +581,11 @@ describe("workspace git watch targets", () => { cwd: REPO_CWD, name: "main", }); - sessionAny.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "workspace-demand", + subscribe: {}, + }); syncGitObserver(session, REPO_CWD, "ws-10"); emitted.length = 0; diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts index b25912beb6..e848e46f01 100644 --- a/packages/server/src/server/session.workspace-resolution-invariants.test.ts +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; // Invariant tests for cwd → workspace resolution on the open_project_request path. // Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`. // Run to see which invariants the current code already satisfies (green) and which @@ -88,6 +92,8 @@ function createHarness(input: { }; const session = new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test", permissions: OWNER_PERMISSIONS, appVersion: null, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 97166cdf76..b9c04e8244 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import { execFileSync } from "node:child_process"; import { existsSync, @@ -145,7 +149,6 @@ interface SessionTestAccess { upsert(record: unknown): Promise; }; agentUpdates: AgentUpdatesService; - workspaceUpdatesSubscription: unknown; interruptAgentIfRunning(agentId: string): unknown; reconcileWorkspaceRecord(workspaceId: string): Promise<{ changed: boolean; @@ -154,7 +157,7 @@ interface SessionTestAccess { [key: string]: unknown; }>; handleArchiveAgentRequest(agentId: string, requestId: string): Promise; - handleMessage(message: unknown): Promise; + handleMessage(message: unknown, source?: object): Promise; handleCreatePaseoWorktreeRequest(params: unknown): Promise; listAgentPayloads(...args: unknown[]): Promise; listFetchWorkspacesEntries(params: unknown): Promise; @@ -171,7 +174,7 @@ interface SessionTestAccess { emitWorkspaceUpdateForCwd(...args: unknown[]): Promise; emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; - updateClientCapabilities(capabilities: Record | null): void; + updateClientCapabilities(capabilities: Record | null, source?: object): void; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -208,16 +211,17 @@ type AgentUpdatesSubscriptionFilter = Parameters< AgentUpdatesService["beginSubscription"] >[0]["filter"]; -// Drives the agent-updates module to a live (non-bootstrapping) subscription — -// the post-extraction equivalent of assigning a subscription with -// `isBootstrapping: false`. begin → flush leaves an empty buffer and emits nothing. -function activateAgentUpdatesSubscription( +async function activateAgentUpdatesSubscription( session: TestSession, subscriptionId: string, filter?: AgentUpdatesSubscriptionFilter, -): void { - session.agentUpdates.beginSubscription({ subscriptionId, filter }); - session.agentUpdates.flushBootstrapped(subscriptionId); +): Promise { + await session.handleMessage({ + type: "fetch_agents_request", + requestId: subscriptionId, + filter, + subscribe: { subscriptionId }, + }); } const AgentIdEntrySchema = z.object({ agent: z.object({ id: z.string() }) }); @@ -232,6 +236,7 @@ function makeAgent(input: { requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; attentionTimestamp?: string | null; + labels?: Record; }): AgentSnapshotPayload { const pendingPermissionCount = input.pendingPermissions ?? 0; return { @@ -268,7 +273,7 @@ function makeAgent(input: { sessionId: null, }, title: null, - labels: {}, + labels: input.labels ?? {}, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, attentionTimestamp: input.attentionTimestamp ?? null, @@ -282,17 +287,20 @@ function makeStoredAgent(input: { updatedAt: string; requiresAttention?: boolean; attentionReason?: StoredAgentRecord["attentionReason"]; + workspaceId?: string; + labels?: Record; }): StoredAgentRecord { return { id: input.id, provider: "codex", cwd: input.cwd, + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), createdAt: input.updatedAt, updatedAt: input.updatedAt, lastActivityAt: input.updatedAt, lastUserMessageAt: null, title: null, - labels: {}, + labels: input.labels ?? {}, lastStatus: "closed", lastModeId: null, config: { provider: "codex", cwd: input.cwd }, @@ -582,6 +590,7 @@ function createSessionForWorkspaceTests( archiveSnapshot: async () => ({}), unarchiveSnapshot: async () => true, clearAgentAttention: async () => {}, + markAgentUnread: async () => {}, notifyAgentState: () => {}, ...options.agentManager, }); @@ -636,6 +645,8 @@ function createSessionForWorkspaceTests( const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: options.appVersion ?? null, @@ -858,7 +869,7 @@ test("agent updates preserve queued live transitions across stored metadata read createdAt: "2026-07-31T10:00:00.000Z", updatedAt: "2026-07-31T10:00:00.000Z", }); - activateAgentUpdatesSubscription(session, "sub-coherent"); + await activateAgentUpdatesSubscription(session, "sub-coherent"); if (!forwardAgentEvent) throw new Error("Agent event listener was not installed"); forwardAgentEvent({ type: "agent_state", agent: running }); @@ -908,17 +919,21 @@ test("client heartbeat clears attention for the focused terminal", async () => { }), }); - await session.handleMessage({ - type: "client_heartbeat", - deviceType: "web", - focusedAgentId: null, - focusedTerminalId: "terminal-1", - lastActivityAt: "2026-06-13T12:00:00.000Z", - appVisible: true, - }); + const source = {}; + await session.handleMessage( + { + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + focusedTerminalId: "terminal-1", + lastActivityAt: "2026-06-13T12:00:00.000Z", + appVisible: true, + }, + source, + ); expect(clearedTerminalIds).toEqual(["terminal-1"]); - expect(session.getClientActivity()).toMatchObject({ + expect(session.getClientActivity(source)).toMatchObject({ focusedAgentId: null, focusedTerminalId: "terminal-1", appVisible: true, @@ -992,6 +1007,8 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const emitted: SessionOutboundMessage[] = []; const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", serverId: "test-server", permissions: OWNER_PERMISSIONS, @@ -1145,6 +1162,8 @@ test("create_agent_request launches from an exact subdirectory in a created work const emitted: SessionOutboundMessage[] = []; const session = new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1282,6 +1301,8 @@ test("create_agent_request does not title an existing workspace from the agent p let generateCalls = 0; const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, appVersion: null, @@ -1452,7 +1473,7 @@ test("agent_update placement does not refresh git snapshots", async () => { session.workspaceRegistry.list = async () => [workspace]; session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1494,7 +1515,7 @@ test("agent_update emits remove when the agent has no workspaceId", async () => }), ); - activateAgentUpdatesSubscription(session, "sub-agents", {}); + await activateAgentUpdatesSubscription(session, "sub-agents", {}); await session.agentUpdates.forwardLiveAgent( makeManagedAgent({ @@ -1551,6 +1572,8 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -1653,7 +1676,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients" }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleArchiveAgentRequest("agent-1", "req-archive"); @@ -1765,6 +1788,123 @@ test("workspace clear attention responds with an error instead of timing out", a }); }); +test("workspace mark unread selects the newest finished workspace root", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: REPO_CWD, + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const storedRecords = new Map([ + [ + "root-agent", + makeStoredAgent({ + id: "root-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T16:00:00.000Z", + }), + ], + [ + "newer-child", + makeStoredAgent({ + id: "newer-child", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + updatedAt: "2026-03-30T17:00:00.000Z", + labels: { "paseo.parent-agent-id": "root-agent" }, + }), + ], + ]); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + + session.workspaceRegistry.list = async () => [workspace]; + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.projectRegistry.list = async () => [project]; + session.projectRegistry.get = async (id: string) => (id === project.projectId ? project : null); + session.listAgentPayloads = async () => + Array.from(storedRecords.values()).map((record) => + makeAgent({ + id: record.id, + cwd: record.cwd, + workspaceId: record.workspaceId, + status: record.lastStatus, + updatedAt: record.updatedAt, + requiresAttention: record.requiresAttention, + attentionReason: record.attentionReason, + attentionTimestamp: record.attentionTimestamp, + labels: record.labels, + }), + ); + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["root-agent"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: "root-agent", + success: true, + error: null, + }); +}); + +test("workspace mark unread rejects workspaces without a finished root agent", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.listAgentPayloads = async () => []; + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(findByType(emitted, "workspace.mark_unread.response").payload).toEqual({ + requestId: "req-mark-unread", + workspaceId: workspace.workspaceId, + markedAgentId: null, + success: false, + error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`, + }); +}); + test("workspace clear attention can clear multiple workspaces in one request", async () => { const emitted: SessionOutboundMessage[] = []; const workspaces = [ @@ -1916,6 +2056,8 @@ test("close_items_request archives agents and kills terminals in one batch", asy const cancelAgentRun = vi.fn(async () => ({ status: "settled" as const })); const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2016,7 +2158,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2084,6 +2226,8 @@ test("close_items_request archives stored agents that are not currently loaded", const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2198,7 +2342,7 @@ test("close_items_request archives stored agents that are not currently loaded", }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2243,6 +2387,8 @@ test("close_items_request continues after an archive failure", async () => { const killTerminalBestEffort = vi.fn(); const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -2348,7 +2494,7 @@ test("close_items_request continues after an archive failure", async () => { }), ); - activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); + await activateAgentUpdatesSubscription(session, "sub-agents", { includeArchived: true }); await session.handleMessage({ type: "close_items_request", @@ -2821,7 +2967,7 @@ test("fetch_agent_history_request pages archived historical rows separately", as expect(session.agentUpdates.hasSubscription()).toBe(false); }); -test("fetch_agent_history_request ranks a search across the whole history, not one page", async () => { +test("fetch_agent_history_request filters across history and paginates chronologically", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const historyCwd = path.resolve("/tmp/history-search"); @@ -2850,8 +2996,7 @@ test("fetch_agent_history_request ranks a search across the whole history, not o session.workspaceRegistry.list = async () => [workspace]; session.workspaceRegistry.get = async () => workspace; session.listAgentPayloads = async () => [ - // The strong match is the oldest row, so a chronological answer would rank - // it last and a first-page-only search would not see it at all. + // Search keeps chronological order, skipping unrelated rows between pages. { ...makeAgent({ id: "weak", @@ -2891,38 +3036,24 @@ test("fetch_agent_history_request ranks a search across the whole history, not o page: { limit: 1 }, }); - const truncated = emitted[0]; - if (truncated?.type !== "fetch_agent_history_response") { - throw new Error(`Expected a history response, got ${truncated?.type}`); - } - expect(truncated.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]); - expect(truncated.payload.entries[0].searchScore).toBeTypeOf("number"); - // More matched than fit. `hasMore` stays false because no page is fetchable; - // truncation is its own fact, so a rank offset can never go stale. - expect(truncated.payload.searchTruncated).toBe(true); - expect(truncated.payload.pageInfo).toEqual({ - nextCursor: null, - prevCursor: null, - hasMore: false, - }); - + expect(emitted).toHaveLength(1); + const first = filterByType(emitted, "fetch_agent_history_response")[0]; + expect(first.payload.entries.map((entry) => entry.agent.id)).toEqual(["weak"]); + expect(first.payload.pageInfo.hasMore).toBe(true); + expect(first.payload.entries[0].searchScore).toBeUndefined(); await session.handleMessage({ type: "fetch_agent_history_request", - requestId: "req-search-whole", + requestId: "req-search-next", search: "bill", - page: { limit: 25 }, + page: { limit: 1, cursor: first.payload.pageInfo.nextCursor! }, }); - - const whole = emitted[1]; - if (whole?.type !== "fetch_agent_history_response") { - throw new Error(`Expected a history response, got ${whole?.type}`); - } - expect(whole.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong", "weak"]); - expect(whole.payload.searchTruncated).toBe(false); - expect(whole.payload.pageInfo.hasMore).toBe(false); + expect(emitted).toHaveLength(2); + const second = filterByType(emitted, "fetch_agent_history_response")[1]; + expect(second.payload.entries.map((entry) => entry.agent.id)).toEqual(["strong"]); + expect(second.payload.pageInfo.hasMore).toBe(false); }); -test("fetch_agent_history_request rejects a cursor on a searched request", async () => { +test("fetch_agent_history_request rejects a malformed search cursor", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); const historyCwd = path.resolve("/tmp/history-cursor"); @@ -2963,8 +3094,7 @@ test("fetch_agent_history_request rejects a cursor on a searched request", async }, ]; - // A ranked result set has no pages to walk. Answering with the ranked head - // would let a caller believe it had paged, so this fails loudly instead. + // Search uses the same validated chronological cursor as unfiltered history. await session.handleMessage({ type: "fetch_agent_history_request", requestId: "req-cursor", @@ -3336,7 +3466,7 @@ test("fetch_agent_request still resolves archived historical agents", async () = session.resolveAgentIdentifier = async (identifier: string) => identifier === "Archived History Agent" ? { ok: true, agentId: agent.id } - : { ok: false, error: `Agent not found: ${identifier}` }; + : { ok: false, notFound: true, error: `Agent not found: ${identifier}` }; session.getAgentPayloadById = async (agentId: string) => (agentId === agent.id ? agent : null); session.buildProjectPlacementForWorkspaceId = async () => ({ projectKey: "proj-history-detail", @@ -3514,6 +3644,8 @@ test("workspace update stream keeps persisted workspace visible after agents sto const session = asTestSession( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage: (message) => emitted.push(message), @@ -3599,13 +3731,13 @@ test("workspace update stream keeps persisted workspace visible after agents sto }), ); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-1" }, + }); + expect(filterByType(emitted, "rpc_error")).toEqual([]); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -3693,33 +3825,13 @@ test("archiving the last workspace emits a remove carrying the now-empty project session.workspaceRegistry.get = async (workspaceId: string) => workspaceId === archivedWorkspace.workspaceId ? archivedWorkspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-1", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - archivedWorkspace.workspaceId, - { - kind: "upsert", - workspace: { - id: archivedWorkspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: archivedWorkspace.cwd, - projectKind: project.kind, - workspaceKind: archivedWorkspace.kind, - name: archivedWorkspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - }, - }, - ], - ]), - }; + archivedWorkspace.archivedAt = null; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-1", + subscribe: { subscriptionId: "sub-1" }, + }); + archivedWorkspace.archivedAt = "2026-03-02T12:00:00.000Z"; // The archived workspace no longer resolves to an active descriptor. session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -3783,13 +3895,12 @@ test("project.remove.request archives active workspaces and removes the project if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-project-remove", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-project-remove", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async (options: { workspaceIds?: Iterable }) => { const workspaceIds = Array.from(options.workspaceIds ?? workspaces.keys()); @@ -3880,15 +3991,11 @@ test("project.remove.request removes an already-empty project", async () => { if (!existing) return; workspaces.set(workspaceId, { ...existing, updatedAt: archivedAt, archivedAt }); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-empty-project-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [archivedWorkspace.workspaceId, { kind: "remove", id: archivedWorkspace.workspaceId }], - ]), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-empty-project-remove", + subscribe: { subscriptionId: "sub-empty-project-remove" }, + }); session.listAgentPayloads = async () => []; session.buildWorkspaceDescriptorMap = async () => new Map(); @@ -4097,13 +4204,12 @@ test("workspace updates stay scoped to the matching cwd", async () => { session.workspaceRegistry.archive = async (workspaceId) => { archivedWorkspaceIds.push(workspaceId); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-dedup", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-dedup", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-dedup" }, + }); session.buildWorkspaceDescriptorMap = async () => new Map([ [ @@ -4260,13 +4366,12 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () session.agentStorage.get = async () => null; session.agentUpdates.forwardLiveAgent = async () => undefined; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-import", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-import", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-import" }, + }); session.buildWorkspaceDescriptorMap = async () => { const workspace = Array.from(workspaces.values()).find( (candidate) => candidate.cwd === importedCwd, @@ -4463,7 +4568,7 @@ test("open_project_response returns immediately even when the GitHub fetch is sl test("open_project_request emits a workspace_update with githubRuntime once the snapshot resolves", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const projects = new Map>(); const workspaces = new Map>(); const cwd = path.resolve("/tmp/github-runtime-repo"); @@ -4472,9 +4577,6 @@ test("open_project_request emits a workspace_update with githubRuntime once the let listener: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null; const peeked = { value: null as WorkspaceGitRuntimeSnapshot | null }; - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => { const project = createPersistedProjectRecord({ @@ -4524,13 +4626,12 @@ test("open_project_request emits a workspace_update with githubRuntime once the listener?.(snapshot); return snapshot; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-open-project", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-open-project", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-open-project" }, + }); await session.handleMessage({ type: "open_project_request", cwd, @@ -5171,13 +5272,12 @@ test("workspace recovery stays accepted when git observer warming fails", async session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => { workspace = record; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-recovery-warm-failure", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-recovery-warm-failure", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-recovery-warm-failure" }, + }); session.listAgentPayloads = async () => []; await session.handleMessage({ @@ -5973,13 +6073,12 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem session.emit = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-reconcile", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-reconcile", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-reconcile" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; session.projectRegistry.list = async () => Array.from(projects.values()); @@ -6713,7 +6812,7 @@ test("buildWorkspaceDescriptorMap stamps workspace archiving state", async () => test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes unchanged emits", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); const archivingAt = "2026-04-30T20:45:00.000Z"; const project = createPersistedProjectRecord({ projectId: "proj-archiving-emit", @@ -6733,20 +6832,17 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-archiving", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-archiving", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-archiving" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; + emitted.length = 0; session.markWorkspaceArchiving([workspace.workspaceId], archivingAt); await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); @@ -6805,13 +6901,16 @@ test("external workspace updates emit one deduplicated batch", async () => { return [main, feature]; }; session.listAgentPayloads = async () => []; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-observer-batch", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-observer-batch", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-observer-batch" }, + }); + main.displayName = "Updated main"; + feature.displayName = "Updated feature"; + snapshotReads.projects = 0; + snapshotReads.workspaces = 0; session.onMessage = (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }; @@ -6980,6 +7079,10 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => { + if (message.type === "fetch_workspaces_response") events.push("response"); + emitted.push(message); + }, }), ); const project = createPersistedProjectRecord({ @@ -7000,13 +7103,6 @@ test("fetch_workspaces_response emits before cold registration-triggered git wor updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message: unknown) => { - if (!isSessionOutboundMessage(message)) return; - if (message.type === "fetch_workspaces_response") { - events.push("response"); - } - emitted.push(message); - }; session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7046,6 +7142,7 @@ test("workspace_update includes updated runtime fields", async () => { const session = asTestSession( createSessionForWorkspaceTests({ workspaceGitService, + onMessage: (message) => emitted.push(message), }), ); const project = createPersistedProjectRecord({ @@ -7066,16 +7163,12 @@ test("workspace_update includes updated runtime fields", async () => { updatedAt: "2026-03-01T12:00:00.000Z", }); - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-runtime", + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-runtime", filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map(), - }; + subscribe: { subscriptionId: "sub-runtime" }, + }); session.listAgentPayloads = async () => []; session.projectRegistry.list = async () => [project]; session.workspaceRegistry.list = async () => [workspace]; @@ -7248,19 +7341,6 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const descriptor = { - id: workspace.workspaceId, - projectId: project.projectId, - projectDisplayName: project.displayName, - projectRootPath: project.rootPath, - workspaceDirectory: workspace.cwd, - projectKind: project.kind, - workspaceKind: workspace.kind, - name: workspace.displayName, - status: "done", - activityAt: null, - diffStat: null, - } as WorkspaceDescriptorPayload; let listedWorkspaces: PersistedWorkspaceRecord[] = []; const workspaceRegistry: SessionOptions["workspaceRegistry"] = { initialize: async () => {}, @@ -7312,12 +7392,12 @@ test("workspace mutation handling does not let a delayed upsert recreate an arch let resumeDescribe!: () => void; const describeStarted = new Promise((resolveStarted) => { - session.describeWorkspaceRecordWithGitData = async () => { + workspaceRegistry.list = async () => { resolveStarted(); await new Promise((resolveResume) => { resumeDescribe = resolveResume; }); - return descriptor; + return listedWorkspaces; }; }); @@ -7427,12 +7507,17 @@ test("workspace mutations outside a filtered subscription neither watch nor emit }); session.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - await session.handleMessage({ - type: "fetch_workspaces_request", - requestId: "req-filtered-mutation", - filter: { projectId: "some-other-project" }, - subscribe: {}, - }); + const source = {}; + session.updateClientCapabilities({ [CLIENT_CAPS.ownedSubscriptions]: true }, source); + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "req-filtered-mutation", + filter: { projectId: "some-other-project" }, + subscribe: {}, + }, + source, + ); emitted.length = 0; await mutationListener?.({ @@ -7506,28 +7591,19 @@ test("project removal mutation broadcasts the final delta to another subscribed remove: async () => {}, }, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-global-remove", - filter: undefined, - isBootstrapping: false, - pendingUpdatesByWorkspaceId: new Map(), - lastEmittedByWorkspaceId: new Map([ - [ - workspace.workspaceId, - { - kind: "remove", - id: workspace.workspaceId, - emptyProject: { - projectId: project.projectId, - projectDisplayName: project.displayName, - projectCustomName: null, - projectRootPath: project.rootPath, - projectKind: project.kind, - }, - }, - ], - ]), - }; + workspace.archivedAt = null; + projectRegistry.list = async () => [project]; + projectRegistry.get = async () => project; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-global-remove", + subscribe: { subscriptionId: "sub-global-remove" }, + }); + workspace.archivedAt = "2026-03-02T12:00:00.000Z"; + await session.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]); + emitted.length = 0; + projectRegistry.list = async () => []; + projectRegistry.get = async () => null; await projectRegistry.remove(project.projectId); @@ -7940,13 +8016,11 @@ test("project.rename.request stores customName and emits an updated workspace de session.workspaceRegistry.get = async (id: string) => id === workspace.workspaceId ? workspace : null; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "project.rename.request", @@ -8117,13 +8191,11 @@ test("workspace.title.set.request stores the title and emits an updated descript return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.title.set.request", @@ -8187,13 +8259,11 @@ test("workspace.pin.set.request stores the pin timestamp and emits an updated de workspaces.set(id, updated); return updated; }; - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: {}, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); await session.handleMessage({ type: "workspace.pin.set.request", @@ -8290,11 +8360,11 @@ test("workspace.title.set.request returns accepted=false when workspace is not f expect(response?.payload.error).toBeTruthy(); }); -function createSessionWithTerminalManager(options: { +async function createSessionWithTerminalManager(options: { workspaces: PersistedWorkspaceRecord[]; projects: PersistedProjectRecord[]; onMessage?: (message: SessionOutboundMessage) => void; -}): { session: TestSession; terminalManager: TerminalManager } { +}): Promise<{ session: TestSession; terminalManager: TerminalManager }> { const terminalManager = createTerminalManager(); terminalManagers.push(terminalManager); @@ -8327,13 +8397,11 @@ function createSessionWithTerminalManager(options: { workspaceRegistry, }); - session.workspaceUpdatesSubscription = { - subscriptionId: "sub-workspaces", - filter: undefined, - isBootstrapping: false, - lastEmittedByWorkspaceId: new Map(), - pendingUpdatesByWorkspaceId: new Map(), - }; + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "sub-workspaces", + subscribe: { subscriptionId: "sub-workspaces" }, + }); return { session, terminalManager }; } @@ -8463,7 +8531,7 @@ test("title-only terminal change does not build workspace descriptors or emit wo createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8507,7 +8575,7 @@ test("terminal activity contribution change updates the correct workspace", asyn createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8565,7 +8633,7 @@ test("same-cwd terminal activity updates only the workspace that owns the termin createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceA, workspaceB], projects: [project], onMessage: (message) => emitted.push(message), @@ -8629,7 +8697,7 @@ test("a worktree terminal updates only the workspace that owns it", async () => createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspaceRoot, workspaceWorktree], projects: [project], onMessage: (message) => emitted.push(message), @@ -8682,7 +8750,7 @@ test("removing an idle terminal does not update workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { session, terminalManager } = createSessionWithTerminalManager({ + const { session, terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -8721,7 +8789,7 @@ test("removing a contributing terminal clears workspace status", async () => { createdAt: "2026-03-01T12:00:00.000Z", updatedAt: "2026-03-01T12:00:00.000Z", }); - const { terminalManager } = createSessionWithTerminalManager({ + const { terminalManager } = await createSessionWithTerminalManager({ workspaces: [workspace], projects: [project], onMessage: (message) => emitted.push(message), @@ -9446,6 +9514,50 @@ test("workspace.create.request attaches a directory workspace to its explicit ac }); }); +test("workspace.create.request with background persists a hidden workspace that only opted-in listings show", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspaces = new Map(); + const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); + session.workspaceRegistry.upsert = async (record: unknown) => { + const workspace = record as PersistedWorkspaceRecord; + workspaces.set(workspace.workspaceId, workspace); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-background-workspace", + background: true, + source: { kind: "directory", path: REPO_CWD }, + }); + + const response = findByType(emitted, "workspace.create.response"); + expect(response?.payload).toMatchObject({ + requestId: "req-background-workspace", + error: null, + workspace: { background: true }, + }); + const workspaceId = response?.payload.workspace?.id as string; + expect(workspaces.get(workspaceId)?.background).toBe(true); + // No subscription exists, and the legacy causal update stays quiet for a hidden workspace. + expect(filterByType(emitted, "workspace_update")).toEqual([]); + + const hidden = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-list-hidden", + }); + expect(hidden.entries).toEqual([]); + + const shown = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-list-shown", + filter: { includeBackground: true }, + }); + expect(shown.entries.map((entry) => [entry.id, entry.background])).toEqual([[workspaceId, true]]); +}); + test("workspace.create.request reports an unknown explicit project", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index c8b00332fe..78343c95ac 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -36,6 +36,7 @@ function makeAgentPayload(input: { requiresAttention?: boolean; effectiveThinkingOptionId?: string | null; thinkingOptionId?: string | null; + internal?: boolean; }): AgentSnapshotPayload { const updatedAt = input.updatedAt ?? "2026-03-01T12:00:00.000Z"; const provider = input.provider ?? "codex"; @@ -44,6 +45,7 @@ function makeAgentPayload(input: { provider, cwd: "/tmp/repo", ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.internal ? { internal: true } : {}), model: null, thinkingOptionId: input.thinkingOptionId ?? null, effectiveThinkingOptionId: input.effectiveThinkingOptionId ?? null, @@ -235,6 +237,15 @@ describe("matchesAgentUpdatesFilter", () => { ); }); + test("internal agents are excluded unless includeInternal", () => { + const agent = makeAgentPayload({ id: "a", internal: true }); + expect(matchesAgentUpdatesFilter({ agent, project })).toBe(false); + expect(matchesAgentUpdatesFilter({ agent, project, filter: {} })).toBe(false); + expect(matchesAgentUpdatesFilter({ agent, project, filter: { includeInternal: true } })).toBe( + true, + ); + }); + test("thinking-option filter compares the resolved option", () => { const high = makeAgentPayload({ id: "a", effectiveThinkingOptionId: "high" }); expect( @@ -681,3 +692,13 @@ describe("subscription lifecycle", () => { expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]); }); }); + +test("an idle session skips agent hydration while preserving workspace updates", async () => { + const h = buildHarness(); + const agent = { ...h.managed("unobserved"), workspaceId: "workspace" }; + // No payload is registered: attempting to hydrate this agent would fail. + await h.service.forwardLiveAgent(agent); + expect(h.loggedErrors).toEqual([]); + expect(h.agentUpdates()).toEqual([]); + expect(h.workspaceUpdates).toEqual([agent.workspaceId]); +}); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 61919378c5..f17e2a2067 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -16,30 +16,20 @@ type AgentUpdatesFilter = NonNullable< interface AgentUpdatesSubscriptionState { subscriptionId: string; - syncEnabled?: boolean; + emit: (message: SessionOutboundMessage) => void; + syncEnabled: boolean; filter?: AgentUpdatesFilter; + isProviderVisible: (provider: string) => boolean; isBootstrapping: boolean; pendingUpdatesByAgentId: Map; } -/** - * Owns the single per-client `agent_update` subscription: when a client subscribes - * via `fetch_agents_request`, every later agent lifecycle change (live forward, - * stored-record archive/detach, delete) is filtered against the subscription's - * filter and either emitted or — while the initial snapshot is still being built — - * buffered and replayed on flush. Keeping the mutable subscription state, the - * bootstrap buffer, the provider-visibility gate, and the filter predicate behind - * one interface stops the rest of session.ts from poking the subscription shape or - * hand-rolling `agent_update` payloads, and the (previously untested) filter/buffer/ - * flush branches become exercisable through injected fakes. - * - * The snapshot listing path applies the SAME filter via the pure - * `matchesAgentUpdatesFilter` so a subscription's initial page and its live updates - * stay consistent. - */ +/** Owns filtered agent snapshots and live updates for independent observers. */ export interface AgentUpdatesService { beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void; @@ -49,7 +39,7 @@ export interface AgentUpdatesService { ): void; clearSubscription(subscriptionId: string): void; hasSubscription(): boolean; - includesLiveAgent(agent: ManagedAgent): Promise; + includesLiveAgent(agent: ManagedAgent, subscriptionIds?: ReadonlySet): Promise; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; removeAgent(agentId: string): Promise; @@ -57,6 +47,7 @@ export interface AgentUpdatesService { } export interface AgentUpdatesServiceDeps { + includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise; emit(message: SessionOutboundMessage): void; enrichAgentPayload(payload: AgentSnapshotPayload): Promise; buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload; @@ -69,6 +60,7 @@ export interface AgentUpdatesServiceDeps { project: ProjectPlacementPayload | null, agentId: string, includeSequence: boolean, + includeBackground?: boolean, ): T; logger: pino.Logger; } @@ -146,6 +138,10 @@ export function matchesAgentUpdatesFilter(input: { return false; } + if (agent.internal && filter?.includeInternal !== true) { + return false; + } + if (filter && !agentThinkingOptionMatchesFilter(agent, filter)) { return false; } @@ -162,7 +158,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { } export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { - let subscription: AgentUpdatesSubscriptionState | null = null; + const subscriptions = new Map(); const liveAgentUpdateTails = new Map>(); const sequence = ( sub: AgentUpdatesSubscriptionState, @@ -170,10 +166,19 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU agent: AgentSnapshotPayload | null, project: ProjectPlacementPayload | null, agentId: string, - ) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true); + ) => + deps.sequenceAgentUpdate( + payload, + agent, + project, + agentId, + sub.syncEnabled === true, + sub.filter?.includeBackground, + ); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { - if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { + if (subscriptions.get(sub.subscriptionId) !== sub) return; + if (payload.kind === "upsert" && !sub.isProviderVisible(payload.agent.provider)) { return; } if (sub.isBootstrapping) { @@ -181,194 +186,120 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return; } - deps.emit({ - type: "agent_update", - payload, - }); + sub.emit({ type: "agent_update", payload }); } function beginSubscription(input: { subscriptionId: string; + isProviderVisible?: (provider: string) => boolean; + emit?: (message: SessionOutboundMessage) => void; filter?: AgentUpdatesFilter; syncEnabled?: boolean; }): void { - subscription = { - subscriptionId: input.subscriptionId, - syncEnabled: input.syncEnabled, - filter: input.filter, + subscriptions.set(input.subscriptionId, { + ...input, + emit: input.emit ?? deps.emit, + syncEnabled: input.syncEnabled ?? false, + isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient, isBootstrapping: true, pendingUpdatesByAgentId: new Map(), - }; + }); } function flushBootstrapped( subscriptionId: string, options?: { snapshotUpdatedAtByAgentId?: Map }, ): void { - if (!subscription || subscription.subscriptionId !== subscriptionId) { - return; - } - if (!subscription.isBootstrapping) { - return; - } - - subscription.isBootstrapping = false; - const pending = Array.from(subscription.pendingUpdatesByAgentId.values()); - subscription.pendingUpdatesByAgentId.clear(); - + const sub = subscriptions.get(subscriptionId); + if (!sub?.isBootstrapping) return; + sub.isBootstrapping = false; + const pending = Array.from(sub.pendingUpdatesByAgentId.values()); + sub.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; - } - } + if ( + snapshotUpdatedAt !== undefined && + Date.parse(payload.agent.updatedAt) < snapshotUpdatedAt + ) + continue; } - - deps.emit({ - type: "agent_update", - payload, - }); + bufferOrEmit(sub, payload); } } function clearSubscription(subscriptionId: string): void { - if (subscription && subscription.subscriptionId === subscriptionId) { - subscription = null; - } + subscriptions.delete(subscriptionId); } function hasSubscription(): boolean { - return subscription !== null; + return subscriptions.size > 0; } - async function includesLiveAgent(agent: ManagedAgent): Promise { - const activeSubscription = subscription; - if (!activeSubscription) return false; - + async function includesLiveAgent( + agent: ManagedAgent, + subscriptionIds?: ReadonlySet, + ): Promise { + const observers = [...subscriptions.values()].filter( + (sub) => !subscriptionIds || subscriptionIds.has(sub.subscriptionId), + ); + if (observers.length === 0) return false; const payload = await deps.enrichAgentPayload(toAgentPayload(agent)); - if (subscription !== activeSubscription || !deps.isProviderVisibleToClient(payload.provider)) { - return false; - } const project = payload.workspaceId ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) : null; - return ( - subscription === activeSubscription && - project !== null && - matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: activeSubscription.filter, - }) + const backgroundVisible = await Promise.all( + observers.map( + (sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true, + ), ); + return ( + project !== null && + observers.some( + (sub, index) => + backgroundVisible[index] && + sub.isProviderVisible(payload.provider) && + matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }), + ) + ); + } + + async function publishPayload(payload: AgentSnapshotPayload): Promise { + const observers = [...subscriptions.values()]; + if (observers.length === 0) return; + const project = payload.workspaceId + ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; + for (const sub of observers) { + const matches = + project && + ((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) && + matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter }); + bufferOrEmit( + sub, + sequence( + sub, + matches + ? { kind: "upsert", agent: payload, project } + : { kind: "remove", agentId: payload.id }, + payload, + project, + payload.id, + ), + ); + } } async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); - const sub = subscription; - if (!sub) { - return payload; - } - - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - return payload; - } - - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - bufferOrEmit( - sub, - sequence( - sub, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); + await publishPayload(payload); return payload; } async function emitLiveAgentUpdate(payload: AgentSnapshotPayload): Promise { try { - const sub = subscription; - payload = await deps.enrichAgentPayload(payload); - if (sub) { - const project = payload.workspaceId - ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (!project) { - bufferOrEmit( - sub, - sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id), - ); - } else { - const matches = matchesAgentUpdatesFilter({ - agent: payload, - project, - filter: sub.filter, - }); - - if (matches) { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "upsert", - agent: payload, - project, - }, - payload, - project, - payload.id, - ), - ); - } else { - bufferOrEmit( - sub, - sequence( - sub, - { - kind: "remove", - agentId: payload.id, - }, - payload, - project, - payload.id, - ), - ); - } - } - } - - // A lifecycle change updates exactly the agent's owning workspace, never - // every workspace sharing its cwd. Ownership is the agent's workspaceId. - if (payload.workspaceId) { - await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); - } + if (hasSubscription()) await publishPayload(await deps.enrichAgentPayload(payload)); + if (payload.workspaceId) await deps.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); } catch (error) { deps.logger.error({ err: error }, "Failed to emit agent update"); } @@ -390,23 +321,32 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } function forwardLiveAgent(agent: ManagedAgent): Promise { + if (!hasSubscription()) { + const workspaceId = agent.workspaceId; + return workspaceId + ? enqueueAgentUpdate(agent.id, async () => { + try { + await deps.emitWorkspaceUpdateForWorkspaceId(workspaceId); + } catch (error) { + deps.logger.error({ err: error }, "Failed to emit workspace update"); + } + }) + : Promise.resolve(); + } const payload = toAgentPayload(agent); return enqueueAgentUpdate(payload.id, () => emitLiveAgentUpdate(payload)); } function removeAgent(agentId: string): Promise { return enqueueAgentUpdate(agentId, () => { - if (subscription) { - bufferOrEmit( - subscription, - sequence(subscription, { kind: "remove", agentId }, null, null, agentId), - ); + for (const sub of subscriptions.values()) { + bufferOrEmit(sub, sequence(sub, { kind: "remove", agentId }, null, null, agentId)); } }); } function dispose(): void { - subscription = null; + subscriptions.clear(); } return { diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index d29418a9a5..bc65b01d87 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { execFileSync } from "node:child_process"; import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -26,6 +27,7 @@ import { createNoGitWorkspaceRuntimeSnapshot, createNoopWorkspaceGitService, } from "../../test-utils/workspace-git-service-stub.js"; +import { createWorktree, deletePaseoWorktree } from "../../../utils/worktree.js"; import { expandTilde } from "../../../utils/path.js"; import type { GitMetadataGenerator } from "./git-metadata-generator.js"; @@ -48,6 +50,7 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { const subscriptions: FakeDiffSubscription[] = []; const refreshedCwds: string[] = []; const subscriber: CheckoutDiffSubscriber = { + read: async (params) => ({ ...initial, cwd: params.cwd }), subscribe: async (params, listener) => { let isSubscribed = true; const subscription: FakeDiffSubscription = { @@ -104,6 +107,7 @@ interface RecordedGeneratorCalls { } function makeCheckoutSession(options?: { + paseoHome?: string; git?: Partial; diff?: CheckoutDiffSubscriber; github?: Partial; @@ -169,11 +173,27 @@ function makeCheckoutSession(options?: { checkoutDiffManager: options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber, gitMetadataGenerator, - paseoHome: "/tmp/paseo-home", + paseoHome: options?.paseoHome ?? "/tmp/paseo-home", worktreesRoot: undefined, logger: pino({ level: "silent" }), }); - return { checkout, emitted, hostCalls, gitMutationCalls, generatorCalls }; + const delivery = new SessionDelivery((_source, message) => emitted.push(message)); + return { + checkout, + emitted, + hostCalls, + gitMutationCalls, + generatorCalls, + subscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleSubscribeDiffRequest(request, delivery), + ), + unsubscribe: (request: Parameters[0]) => + delivery.request(undefined, request, () => + checkout.handleUnsubscribeDiffRequest(request, delivery), + ), + close: () => delivery.close(), + }; } function createGitSnapshot( @@ -532,9 +552,9 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout, emitted } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, unsubscribe, emitted } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -566,7 +586,7 @@ describe("CheckoutSession", () => { }, }); - checkout.handleUnsubscribeDiffRequest({ + await unsubscribe({ type: "unsubscribe_checkout_diff_request", subscriptionId: "s1", }); @@ -574,22 +594,22 @@ describe("CheckoutSession", () => { expect(subscriptions[0].unsubscribeCalls).toBe(1); }); - it("replaces an existing subscription when the same id subscribes again", async () => { + it("preserves legacy replacement when an existing id when the same id subscribes again", async () => { const { subscriber, subscriptions } = createFakeDiffSubscriber({ cwd: "/repo", files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "first", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", @@ -608,16 +628,16 @@ describe("CheckoutSession", () => { files: [], error: null, }); - const { checkout } = makeCheckoutSession({ diff: subscriber }); + const { subscribe, close } = makeCheckoutSession({ diff: subscriber }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s1", cwd: "/repo", compare: { mode: "uncommitted" }, requestId: "r", }); - await checkout.handleSubscribeDiffRequest({ + await subscribe({ type: "subscribe_checkout_diff_request", subscriptionId: "s2", cwd: "/repo", @@ -625,7 +645,7 @@ describe("CheckoutSession", () => { requestId: "r", }); - checkout.cleanup(); + await close(); expect(subscriptions[0].unsubscribeCalls).toBe(1); expect(subscriptions[1].unsubscribeCalls).toBe(1); @@ -1704,3 +1724,91 @@ describe("CheckoutSession", () => { }); }); }); + +it("creates a PR from a restored exact base using the host metadata and a forge branch name", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-restored-pr-"))); + const repo = join(root, "repo"); + const remote = join(root, "remote.git"); + const paseoHome = join(root, "home"); + const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); + try { + git(root, "init", "-b", "main", repo); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "commit", "--allow-empty", "-m", "base"); + git(root, "init", "--bare", remote); + git(repo, "remote", "add", "origin", remote); + git(repo, "push", "-u", "origin", "main"); + const baseRef = "refs/remotes/origin/main"; + const created = await createWorktree({ + cwd: repo, + paseoHome, + worktreeSlug: "restored-pr", + source: { kind: "branch-off", baseBranch: baseRef, branchName: "restored-pr" }, + runSetup: false, + }); + writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); + git(created.worktreePath, "add", "."); + git(created.worktreePath, "commit", "-m", "feature"); + await deletePaseoWorktree({ cwd: repo, paseoHome, worktreePath: created.worktreePath }); + const restored = await createWorktree({ + cwd: repo, + paseoHome, + worktreeSlug: "restored-pr", + source: { kind: "restore", branchName: created.branchName, baseRef }, + runSetup: false, + }); + const requests: Array[0]> = []; + const service: ForgeService = { + ...createGitHubService(), + createPullRequest: async (input) => { + requests.push(input); + return { url: "https://example.com/pull/1", number: 1 }; + }, + }; + const { checkout, emitted } = makeCheckoutSession({ + paseoHome, + git: { resolveForge: async () => ({ forge: "github", host: "github.com", service }) }, + }); + await checkout.handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: restored.worktreePath, + baseRef: "refs/heads/main", + title: "Feature", + body: "Description", + requestId: "wrong-base", + }); + expect(emitted.at(-1)).toMatchObject({ + type: "checkout_pr_create_response", + payload: { error: { message: expect.stringContaining("Base ref mismatch") } }, + }); + expect(requests).toHaveLength(0); + await checkout.handleCheckoutPrCreateRequest({ + type: "checkout_pr_create_request", + cwd: restored.worktreePath, + baseRef: "main", + title: "Feature", + body: "Description", + requestId: "right-base", + }); + expect(emitted.at(-1)).toMatchObject({ + type: "checkout_pr_create_response", + payload: { error: null, number: 1 }, + }); + expect(requests).toEqual([ + { + cwd: restored.worktreePath, + base: "main", + head: created.branchName, + title: "Feature", + body: "Description", + }, + ]); + expect(git(remote, "rev-parse", `refs/heads/${created.branchName}`)).toBe( + git(restored.worktreePath, "rev-parse", "HEAD"), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 789f1f0449..419d418050 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1,4 +1,5 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; import { isAbsolute } from "node:path"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest"; @@ -108,6 +109,9 @@ function toLegacyGithubSearchItems(items: ForgeSearchResultItem[]): LegacyGithub * real CheckoutDiffManager satisfies this structurally; tests supply a fake. */ export interface CheckoutDiffSubscriber { + read( + params: Omit, + ): Promise; subscribe( params: CheckoutDiffSubscriptionRequest, listener: (snapshot: CheckoutDiffSnapshotPayload) => void, @@ -152,7 +156,6 @@ export class CheckoutSession { private readonly paseoHome: string; private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; - private readonly diffSubscriptions = new Map void>(); private readonly statusUpdateFingerprints = new Map(); constructor(options: CheckoutSessionOptions) { @@ -271,7 +274,10 @@ export class CheckoutSession { const { cwd, requestId } = msg; try { - const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) }); + const { baseRef, commits } = await listCheckoutCommits({ + cwd: expandTilde(cwd), + context: { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, + }); this.host.emit({ type: "checkout.commits.list.response", payload: { cwd, baseRef, commits, error: null, requestId }, @@ -400,48 +406,77 @@ export class CheckoutSession { } } - async handleSubscribeDiffRequest(msg: SubscribeCheckoutDiffRequest): Promise { - const cwd = expandTilde(msg.cwd); - this.diffSubscriptions.get(msg.subscriptionId)?.(); - const abort = new AbortController(); - const unsubscribe = () => abort.abort(); - this.diffSubscriptions.set(msg.subscriptionId, unsubscribe); + async handleReadDiffRequest( + msg: Extract, + ): Promise { + const snapshot = await this.checkoutDiffManager.read({ + cwd: expandTilde(msg.cwd), + compare: msg.compare, + }); + this.host.emit({ + type: "checkout.diff.get.response", + payload: { ...snapshot, requestId: msg.requestId }, + }); + } + async handleSubscribeDiffRequest( + msg: SubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: Promise | undefined; + const owner = ownership.begin( + "diffs", + msg.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `diff:${msg.subscriptionId}`, + ); + let ready = false; + let pending: CheckoutDiffSnapshotPayload | null = null; + const emitSnapshot = (snapshot: CheckoutDiffSnapshotPayload) => + owner.emit({ + type: "checkout_diff_update", + payload: { ...snapshot, subscriptionId: owner.responseId }, + }); try { - const subscription = await this.checkoutDiffManager.subscribe( - { cwd, compare: msg.compare, signal: abort.signal }, + bootstrap = this.checkoutDiffManager.subscribe( + { cwd: expandTilde(msg.cwd), compare: msg.compare, signal: owner.signal }, (snapshot) => { - this.host.emit({ - type: "checkout_diff_update", - payload: { - subscriptionId: msg.subscriptionId, - ...snapshot, - }, - }); + if (owner.signal.aborted) return; + if (ready) emitSnapshot(snapshot); + else pending = snapshot; }, ); - + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "subscribe_checkout_diff_response", payload: { - subscriptionId: msg.subscriptionId, ...subscription.initial, + subscriptionId: owner.responseId, requestId: msg.requestId, }, }); + ready = true; + if (pending) emitSnapshot(pending); } catch (error) { - if (this.diffSubscriptions.get(msg.subscriptionId) === unsubscribe) { - this.diffSubscriptions.delete(msg.subscriptionId); - } - unsubscribe(); + await owner.release(); throw error; } } - handleUnsubscribeDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void { - const unsubscribe = this.diffSubscriptions.get(msg.subscriptionId); - this.diffSubscriptions.delete(msg.subscriptionId); - unsubscribe?.(); + async handleUnsubscribeDiffRequest( + msg: UnsubscribeCheckoutDiffRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(msg.subscriptionId); } async handleRefreshRequest(msg: CheckoutRefreshRequest): Promise { @@ -818,10 +853,14 @@ export class CheckoutSession { } } - await mergeFromBase(cwd, { - baseRef: msg.baseRef, - requireCleanTarget: msg.requireCleanTarget ?? true, - }); + await mergeFromBase( + cwd, + { + baseRef: msg.baseRef, + requireCleanTarget: msg.requireCleanTarget ?? true, + }, + { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, + ); await this.gitMutation.notifyGitMutation(cwd, "merge-from-base", { invalidateForge: true }); this.scheduleDiffRefresh(cwd); @@ -933,6 +972,7 @@ export class CheckoutSession { base: msg.baseRef, }, service, + { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, ); await this.gitMutation.notifyGitMutation(cwd, "create-pr", { invalidateForge: true }); @@ -1413,10 +1453,6 @@ export class CheckoutSession { } cleanup(): void { - for (const unsubscribe of this.diffSubscriptions.values()) { - unsubscribe(); - } - this.diffSubscriptions.clear(); this.statusUpdateFingerprints.clear(); } } diff --git a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts index ff274d9019..81c41f3421 100644 --- a/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-update-session-controller.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; @@ -176,3 +177,48 @@ describe("DaemonSelfUpdateSessionController", () => { expect(restartIntents).toEqual([]); }); }); + +test("self-update progress is request-owned through the final delivery boundary", async () => { + const source = {}, + sibling = {}; + const frames: SessionOutboundMessage[] = []; + const delivery = new SessionDelivery((socket, message) => { + expect(socket).toBe(source); + expect(delivery.permits(source, message)).toBe(true); + expect(delivery.permits(sibling, message)).toBe(false); + frames.push(message); + }); + delivery.attach(source, true); + delivery.attach(sibling, true); + let lateProgress: (() => void) | undefined; + const controller = new DaemonSelfUpdateSessionController({ + clientId: "safe-update", + daemonVersion: "0.8.0", + sessionLogger: createTestLogger(), + emit: (message) => { + delivery.reply(message); + }, + emitLifecycleIntent: () => { + throw new Error("No restart is allowed in this test"); + }, + updater: { + async update(input) { + input.onProgress("starting"); + await Promise.resolve(); + input.onProgress("installing"); + lateProgress = () => input.onProgress("starting"); + return { success: false, error: "Safe injected update stopped", newVersion: null }; + }, + }, + }); + await delivery.request(source, updateRequest, async () => { + await controller.dispatch(updateRequest); + }); + expect(frames.map((message) => message.type)).toEqual([ + "daemon.update.progress", + "daemon.update.progress", + "daemon.update.response", + ]); + lateProgress?.(); + expect(frames).toHaveLength(3); +}); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts index faf9491f50..711fd11d80 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.test.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, test } from "vitest"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, onTestFinished, test } from "vitest"; import { DaemonSelfUpdateInProgressError, DaemonSelfUpdater, type DaemonSelfUpdateRuntime, type DaemonSelfUpdatePhase, } from "./daemon-self-updater.js"; -import type { CommandResult, NpmGlobalPaseoInstall } from "./npm-global-cli.js"; +import { + DefaultNpmGlobalPaseoCli, + type CommandResult, + type NpmGlobalPaseoInstall, +} from "./npm-global-cli.js"; interface TestLogger { errors: Array<{ obj: object; msg?: string }>; @@ -100,6 +107,47 @@ async function runUpdate(input: { } describe("DaemonSelfUpdater", () => { + test("probes and updates the running install's prefix when npm defaults elsewhere", async () => { + const prefix = mkdtempSync(path.join(tmpdir(), "custom npm prefix ")); + onTestFinished(() => rmSync(prefix, { recursive: true, force: true })); + const root = process.platform === "win32" ? prefix : path.join(prefix, "lib"); + const packagePath = path.join(root, "node_modules", "@getpaseo", "cli"); + mkdirSync(packagePath, { recursive: true }); + const commands: string[][] = []; + let version = "0.1.15"; + const npm = new DefaultNpmGlobalPaseoCli(async (_command, args) => { + commands.push(args); + if (args[args.indexOf("--prefix") + 1] !== prefix) { + return { exitCode: 1, stdout: "{}", stderr: "No CLI in npm's default prefix" }; + } + if (args.includes("install")) version = "0.1.96"; + return { + exitCode: 0, + stderr: "", + stdout: JSON.stringify({ + path: root, + dependencies: { "@getpaseo/cli": { version, path: packagePath } }, + }), + }; + }); + const { result } = await runUpdate({ + runtime: { + npm, + installOrigin: { + resolveCurrentServerPackageRoot: () => + path.join(packagePath, "node_modules", "@getpaseo", "server"), + }, + }, + }); + expect(result).toEqual({ success: true, error: null, newVersion: "0.1.96" }); + expect(commands).toHaveLength(3); + expect(commands.map((args) => args.slice(-2))).toEqual([ + ["--prefix", prefix], + ["--prefix", prefix], + ["--prefix", prefix], + ]); + }); + test("refuses a Desktop-managed daemon without touching npm", async () => { const calls: RuntimeCall[] = []; const runtime = createRuntime({ calls, inspections: [] }); diff --git a/packages/server/src/server/session/daemon/daemon-self-updater.ts b/packages/server/src/server/session/daemon/daemon-self-updater.ts index b7ca60eda3..91b5b166d4 100644 --- a/packages/server/src/server/session/daemon/daemon-self-updater.ts +++ b/packages/server/src/server/session/daemon/daemon-self-updater.ts @@ -1,6 +1,7 @@ import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { daemonInstallOriginRuntime, + resolveNpmGlobalPrefix, validateDaemonInstallOrigin, type DaemonInstallOriginRuntime, } from "./install-origin.js"; @@ -63,7 +64,12 @@ export class DaemonSelfUpdater { this.inProgress = true; try { input.onProgress("starting"); - const install = await this.runtime.npm.inspect(); + const npmOptions = { + prefix: resolveNpmGlobalPrefix( + this.runtime.installOrigin.resolveCurrentServerPackageRoot(), + ), + }; + const install = await this.runtime.npm.inspect(npmOptions); const unsupportedReason = validateDaemonInstallOrigin( install, input.daemonVersion, @@ -76,7 +82,7 @@ export class DaemonSelfUpdater { input.onProgress("downloading"); input.onProgress("installing"); - const result = await this.runtime.npm.installLatest(); + const result = await this.runtime.npm.installLatest(npmOptions); if (result.exitCode !== 0) { const error = result.stderr.trim() || result.stdout.trim() || `npm exited with code ${result.exitCode}`; @@ -87,7 +93,7 @@ export class DaemonSelfUpdater { return { success: false, error, newVersion: null }; } - const updatedInstall = await this.runtime.npm.inspect().catch((error: unknown) => { + const updatedInstall = await this.runtime.npm.inspect(npmOptions).catch((error: unknown) => { input.logger.warn({ err: error }, "Unable to read updated npm package version"); return null; }); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 8c3aa2f167..95caafc58b 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -49,6 +49,7 @@ export interface DaemonSessionOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; hubRelationships?: HubRelationshipManagement; reloadConfig: () => DaemonConfigReloadResult; @@ -73,6 +74,7 @@ export class DaemonSession { private readonly listWorkspaces: () => Promise; private readonly listProviderAvailability: () => Promise; private readonly getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + private readonly getObservationMetrics: DaemonSessionOptions["getObservationMetrics"]; private readonly logger: pino.Logger; private readonly selfUpdate: DaemonSelfUpdateSessionController; private readonly hubRelationships: HubRelationshipManagement | null; @@ -90,6 +92,7 @@ export class DaemonSession { this.listWorkspaces = options.listWorkspaces; this.listProviderAvailability = options.listProviderAvailability; this.getWebSocketRuntimeMetrics = options.getWebSocketRuntimeMetrics ?? (() => null); + this.getObservationMetrics = options.getObservationMetrics; this.logger = options.logger; this.hubRelationships = options.hubRelationships ?? null; this.reloadConfig = options.reloadConfig; @@ -283,6 +286,7 @@ export class DaemonSession { listWorkspaces: this.listWorkspaces, listProviderAvailability: this.listProviderAvailability, getWebSocketRuntimeMetrics: this.getWebSocketRuntimeMetrics, + getObservationMetrics: this.getObservationMetrics, logger: this.logger, }); this.host.emit({ diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index 87ea2b2faa..9cff35cb3b 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -25,6 +25,7 @@ export interface DaemonDiagnosticsOptions { listWorkspaces: () => Promise; listProviderAvailability: () => Promise; getWebSocketRuntimeMetrics: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null; + getObservationMetrics?: () => Record; logger: pino.Logger; } @@ -68,6 +69,16 @@ export async function collectDaemonDiagnostics(options: DaemonDiagnosticsOptions ]), ]; + if (options.getObservationMetrics) + sections.push( + formatSection( + "Session observations", + Object.entries(options.getObservationMetrics()).map(([label, value]) => ({ + label, + value: String(value), + })), + ), + ); sections.push( await safeSection("Daemon process", () => collectProcessEntries(options), options.logger), ); diff --git a/packages/server/src/server/session/daemon/install-origin.ts b/packages/server/src/server/session/daemon/install-origin.ts index 3f305ab3ad..cddd905e47 100644 --- a/packages/server/src/server/session/daemon/install-origin.ts +++ b/packages/server/src/server/session/daemon/install-origin.ts @@ -15,6 +15,21 @@ export const daemonInstallOriginRuntime: DaemonInstallOriginRuntime = { resolveCurrentServerPackageRoot, }; +export function resolveNpmGlobalPrefix(serverPackageRoot: string | null): string | undefined { + if (!serverPackageRoot) return undefined; + let directory = serverPackageRoot; + let prefix: string | undefined; + while (path.dirname(directory) !== directory) { + if (path.basename(directory) === "node_modules") { + const root = path.dirname(directory); + if (process.platform === "win32") prefix = root; + else if (path.basename(root) === "lib") prefix = path.dirname(root); + } + directory = path.dirname(directory); + } + return prefix; +} + export function validateDaemonInstallOrigin( install: NpmGlobalPaseoInstall, daemonVersion: string | null, diff --git a/packages/server/src/server/session/daemon/npm-global-cli.test.ts b/packages/server/src/server/session/daemon/npm-global-cli.test.ts index aece7eb09a..ba852e232e 100644 --- a/packages/server/src/server/session/daemon/npm-global-cli.test.ts +++ b/packages/server/src/server/session/daemon/npm-global-cli.test.ts @@ -1,5 +1,7 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { DefaultNpmGlobalPaseoCli } from "./npm-global-cli.js"; interface CommandCall { @@ -10,25 +12,65 @@ interface CommandCall { } const globalRoot = path.join(path.sep, "global", "lib"); -const globalNodeModules = path.join(globalRoot, "node_modules"); -const cliPackagePath = path.join(globalNodeModules, "@getpaseo", "cli"); -function npmGlobalPaseoCliJson(version: string, options?: { linked?: boolean }): string { - return JSON.stringify({ - name: "lib", - path: globalRoot, - dependencies: { - "@getpaseo/cli": { - version, - path: cliPackagePath, - link: options?.linked === true, - }, - }, - }); +interface GlobalInstall { + root: string; + packagePath: string; + // npm 7+ `ls --json --long` marks neither kind of install with a link flag. + npmLsJson: string; } describe("DefaultNpmGlobalPaseoCli", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function createGlobalInstall(options: { linked: boolean }): GlobalInstall { + const dir = mkdtempSync(path.join(tmpdir(), "paseo-npm-global-")); + tempDirs.push(dir); + const root = path.join(dir, "prefix", "lib"); + const packagePath = path.join(root, "node_modules", "@getpaseo", "cli"); + const checkoutCli = path.join(dir, "checkout", "packages", "cli"); + if (options.linked) { + mkdirSync(checkoutCli, { recursive: true }); + mkdirSync(path.dirname(packagePath), { recursive: true }); + symlinkSync(checkoutCli, packagePath, "junction"); + } else { + mkdirSync(packagePath, { recursive: true }); + } + const npmLsJson = JSON.stringify({ + name: "lib", + path: root, + dependencies: { + "@getpaseo/cli": { + version: "0.1.15", + resolved: options.linked + ? `file:${path.relative(root, checkoutCli)}` + : "https://registry.npmjs.org/@getpaseo/cli/-/cli-0.1.15.tgz", + path: packagePath, + }, + }, + }); + return { root, packagePath, npmLsJson }; + } + + test("reports a global install linked to a local checkout as linked", async () => { + const install = createGlobalInstall({ linked: true }); + const cli = new DefaultNpmGlobalPaseoCli(async () => ({ + exitCode: 0, + stdout: install.npmLsJson, + stderr: "", + })); + + await expect(cli.inspect()).resolves.toMatchObject({ isLinked: true }); + }); + test("inspects the npm global cli install with npm -g ls", async () => { + const install = createGlobalInstall({ linked: false }); const calls: CommandCall[] = []; const cli = new DefaultNpmGlobalPaseoCli(async (command, args, options) => { calls.push({ @@ -37,13 +79,13 @@ describe("DefaultNpmGlobalPaseoCli", () => { timeout: options?.timeout, maxBuffer: options?.maxBuffer, }); - return { exitCode: 0, stdout: npmGlobalPaseoCliJson("0.1.15"), stderr: "" }; + return { exitCode: 0, stdout: install.npmLsJson, stderr: "" }; }); await expect(cli.inspect()).resolves.toEqual({ version: "0.1.15", - packagePath: cliPackagePath, - globalRootPath: globalRoot, + packagePath: install.packagePath, + globalRootPath: install.root, isLinked: false, }); expect(calls).toEqual([ diff --git a/packages/server/src/server/session/daemon/npm-global-cli.ts b/packages/server/src/server/session/daemon/npm-global-cli.ts index d1e0444058..6133dcd9d1 100644 --- a/packages/server/src/server/session/daemon/npm-global-cli.ts +++ b/packages/server/src/server/session/daemon/npm-global-cli.ts @@ -1,3 +1,4 @@ +import { lstatSync } from "node:fs"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { z } from "zod"; import { execCommand } from "../../../utils/spawn.js"; @@ -19,7 +20,6 @@ const NpmGlobalCliPackageSchema = z .object({ version: z.string(), path: z.string(), - link: z.boolean().optional(), }) .passthrough(); @@ -50,8 +50,12 @@ export interface NpmGlobalPaseoInstall { } export interface NpmGlobalPaseoCli { - inspect(): Promise; - installLatest(): Promise; + inspect(options?: NpmGlobalOptions): Promise; + installLatest(options?: NpmGlobalOptions): Promise; +} + +interface NpmGlobalOptions { + prefix?: string; } export type CommandRunner = ( @@ -108,17 +112,20 @@ function parseNpmGlobalPaseoInstall(stdout: string): NpmGlobalPaseoInstall | nul version: cliPackage.data.version, packagePath: cliPackage.data.path, globalRootPath: list.data.path ?? null, - isLinked: cliPackage.data.link === true, + // npm links an install by making its node_modules entry a symlink (a junction + // on Windows), and `npm ls --json` reports no flag for it. + isLinked: lstatSync(cliPackage.data.path).isSymbolicLink(), }; } export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli { constructor(private readonly runCommand: CommandRunner = runExternalCommand) {} - async inspect(): Promise { + async inspect(options: NpmGlobalOptions = {}): Promise { + const prefixArgs = options.prefix ? ["--prefix", options.prefix] : []; const result = await this.runCommand( "npm", - ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long"], + ["-g", "ls", PASEO_CLI_PACKAGE, "--json", "--depth=0", "--long", ...prefixArgs], { timeout: NPM_PROBE_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER_BYTES, @@ -136,8 +143,9 @@ export class DefaultNpmGlobalPaseoCli implements NpmGlobalPaseoCli { return install; } - installLatest(): Promise { - return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`], { + installLatest(options: NpmGlobalOptions = {}): Promise { + const prefixArgs = options.prefix ? ["--prefix", options.prefix] : []; + return this.runCommand("npm", ["install", "-g", `${PASEO_CLI_PACKAGE}@latest`, ...prefixArgs], { timeout: NPM_INSTALL_TIMEOUT_MS, maxBuffer: NPM_MAX_BUFFER_BYTES, }); diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts index 6d27509fbe..43c517453b 100644 --- a/packages/server/src/server/session/files/workspace-files-session.test.ts +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "../owned-subscriptions/index.js"; import { existsSync, mkdtempSync, @@ -555,14 +556,22 @@ describe("WorkspaceFilesSession", () => { test("round-trips an upload through transfer frames", async () => { const { subsystem, emitted, paseoHome } = makeSubsystem(); - subsystem.handleFileUploadRequest({ + const source = {}; + const ownership = new SessionDelivery((_source, message) => { + emitted.push(message); + }); + ownership.attach(source, true); + const request = { type: "file.upload.request", fileName: "notes.txt", mimeType: "text/plain", size: 11, modifiedAt: "2026-05-02T00:00:00.000Z", requestId: "req-upload", - }); + } as const; + await ownership.request(source, request, async () => + subsystem.handleFileUploadRequest(request, ownership), + ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileBegin, @@ -575,6 +584,7 @@ describe("WorkspaceFilesSession", () => { fileName: "notes.txt", }, }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ @@ -582,9 +592,11 @@ describe("WorkspaceFilesSession", () => { requestId: "req-upload", payload: new TextEncoder().encode("hello world"), }), + source, ); await subsystem.handleFileTransferFrame( uploadFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-upload" }), + source, ); const message = emitted.find((entry) => entry.type === "file.upload.response"); @@ -593,8 +605,10 @@ describe("WorkspaceFilesSession", () => { } expect(message.payload.error).toBeNull(); expect(message.payload.file?.fileName).toBe("notes.txt"); - expect(readFileSync(join(paseoHome, "uploads", "upload_req-upload", "notes.txt"), "utf8")).toBe( - "hello world", - ); + const file = message.payload.file; + if (!file) throw new Error("Expected uploaded file"); + expect(file.path.startsWith(join(paseoHome, "uploads"))).toBe(true); + expect(readFileSync(file.path, "utf8")).toBe("hello world"); + await ownership.close(); }); }); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index 0f833479c2..a0bcb6ed2d 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -1,4 +1,6 @@ import type pino from "pino"; +import type { SessionDelivery } from "../owned-subscriptions/index.js"; +import type { FileVersion } from "@getpaseo/protocol/messages"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { encodeFileTransferFrame, @@ -68,7 +70,6 @@ export class WorkspaceFilesSession { private readonly logger: pino.Logger; private readonly fileUploads: FileUploadStore; private readonly fileObserver: FileObserver; - private readonly fileSubscriptions = new Map void>(); constructor(options: WorkspaceFilesSessionOptions) { this.host = options.host; @@ -78,32 +79,59 @@ export class WorkspaceFilesSession { this.fileObserver = options.fileObserver ?? workspaceFileObserver; } - async handleFileSubscribeRequest(request: FileSubscribeRequest): Promise { - this.fileSubscriptions.get(request.subscriptionId)?.(); + async handleFileSubscribeRequest( + request: FileSubscribeRequest, + ownership: SessionDelivery, + ): Promise { + let bootstrap: ReturnType | undefined; + const owner = ownership.begin( + "files", + request.subscriptionId, + async () => { + await bootstrap?.then( + (subscription) => subscription.unsubscribe(), + () => undefined, + ); + }, + `file:${request.subscriptionId}`, + ); + let ready = false; + let pending: FileVersion | null = null; + const emitVersion = (version: FileVersion) => + owner.emit({ + type: "fs.file.update", + payload: { subscriptionId: owner.responseId, version }, + }); try { - const subscription = await this.fileObserver.subscribe( + bootstrap = this.fileObserver.subscribe( { cwd: request.cwd, path: request.path }, (version) => { - this.host.emit({ - type: "fs.file.update", - payload: { subscriptionId: request.subscriptionId, version }, - }); + if (owner.signal.aborted) return; + if (ready) emitVersion(version); + else pending = version; }, ); - this.fileSubscriptions.set(request.subscriptionId, subscription.unsubscribe); + const subscription = await bootstrap; + if (owner.signal.aborted) { + subscription.unsubscribe(); + return; + } this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: subscription.initial, requestId: request.requestId, }, }); + ready = true; + if (pending) emitVersion(pending); } catch (error) { + await owner.release(); this.host.emit({ type: "fs.file.subscribe.response", payload: { - subscriptionId: request.subscriptionId, + subscriptionId: owner.responseId, initial: { status: "error", cwd: request.cwd, @@ -116,9 +144,11 @@ export class WorkspaceFilesSession { } } - handleFileUnsubscribeRequest(request: FileUnsubscribeRequest): void { - this.fileSubscriptions.get(request.subscriptionId)?.(); - this.fileSubscriptions.delete(request.subscriptionId); + async handleFileUnsubscribeRequest( + request: FileUnsubscribeRequest, + ownership: SessionDelivery, + ): Promise { + await ownership.release(request.subscriptionId); this.host.emit({ type: "fs.file.unsubscribe.response", payload: { subscriptionId: request.subscriptionId, requestId: request.requestId }, @@ -213,11 +243,6 @@ export class WorkspaceFilesSession { }); } - dispose(): void { - for (const unsubscribe of this.fileSubscriptions.values()) unsubscribe(); - this.fileSubscriptions.clear(); - } - async handleFileExplorerRequest(request: FileExplorerRequest, source?: object): Promise { const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; const cwd = workspaceCwd.trim(); @@ -349,15 +374,23 @@ export class WorkspaceFilesSession { } } - handleFileUploadRequest(request: FileUploadRequest): void { - this.fileUploads.beginUpload(request); + handleFileUploadRequest(request: FileUploadRequest, ownership: SessionDelivery): void { + let cancel: (() => Promise) | undefined; + const operation = ownership.operation( + (message) => + message.type === "file.upload.response" && message.payload.requestId === request.requestId, + () => cancel?.(), + ); + cancel = this.fileUploads.beginUpload(request, operation.source, (response) => { + if (response) operation.emit(response); + void operation + .release() + .catch((error) => this.logger.error({ err: error }, "Upload cleanup failed")); + }); } - async handleFileTransferFrame(frame: FileTransferFrame): Promise { - const response = await this.fileUploads.receiveFrame(frame); - if (response) { - this.host.emit(response); - } + async handleFileTransferFrame(frame: FileTransferFrame, source: object): Promise { + await this.fileUploads.receiveFrame(frame, source); } async handleProjectIconRequest( diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts index 1910722765..0a16cd2c8d 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.test.ts @@ -85,6 +85,43 @@ function headBranch(dir: string): string { return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }).toString().trim(); } +// A clone whose local branch tracks origin/main. Upstream inheritance only shows up +// against a real remote, so these fixtures cross the git boundary like the happy paths above. +function initClonedRepo(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "git-mutation-clone-"))); + tempRepos.push(root); + const remote = join(root, "remote.git"); + const seed = join(root, "seed"); + const work = join(root, "work"); + function run(cwd: string, ...args: string[]): void { + execFileSync("git", args, { cwd, stdio: "pipe" }); + } + execFileSync("git", ["init", "--bare", "-b", "main", remote], { cwd: root, stdio: "pipe" }); + execFileSync("git", ["clone", "--quiet", remote, seed], { cwd: root, stdio: "pipe" }); + run(seed, "config", "user.email", "test@example.com"); + run(seed, "config", "user.name", "Paseo Test"); + writeFileSync(join(seed, "README.md"), "hello\n"); + run(seed, "add", "-A"); + run(seed, "commit", "-m", "init"); + run(seed, "push", "--quiet", "-u", "origin", "main"); + execFileSync("git", ["clone", "--quiet", remote, work], { cwd: root, stdio: "pipe" }); + run(work, "config", "user.email", "test@example.com"); + run(work, "config", "user.name", "Paseo Test"); + return work; +} + +// Always exits 0 and prints an empty line when the branch has no upstream, so a git or +// process failure surfaces as a throw instead of being read as "no upstream". +function upstreamOf(dir: string, branch: string): string { + return execFileSync( + "git", + ["for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`], + { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }, + ) + .toString() + .trim(); +} + afterEach(() => { while (tempRepos.length > 0) { const dir = tempRepos.pop(); @@ -189,6 +226,40 @@ describe("createBranchFromBase", () => { expect(invalidateCalls).toEqual([]); expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "create-branch" }); }); + + // https://github.com/getpaseo/paseo/issues/5213 — a branch that tracks origin/main sends the + // next push to main: `git push` under push.default=tracking and Paseo's own push both read + // branch..merge. A branch Paseo creates must carry no upstream, exactly as the worktree + // path already guarantees with `git worktree add -b --no-track `. + test("leaves no upstream when the base is a remote-tracking ref (real repo)", async () => { + const dir = initClonedRepo(); + const { service } = buildService({ + resolution: { kind: "remote-only", name: "main", remoteRef: "origin/main" }, + }); + await service.createBranchFromBase({ + cwd: dir, + baseBranch: "origin/main", + newBranchName: "feature-from-remote", + }); + expect(headBranch(dir)).toBe("feature-from-remote"); + expect(upstreamOf(dir, "feature-from-remote")).toBe(""); + }); + + test("leaves no upstream when git is configured to inherit tracking (real repo)", async () => { + const dir = initClonedRepo(); + execFileSync("git", ["config", "branch.autoSetupMerge", "inherit"], { + cwd: dir, + stdio: "pipe", + }); + const { service } = buildService({ resolution: { kind: "local", name: "main" } }); + await service.createBranchFromBase({ + cwd: dir, + baseBranch: "main", + newBranchName: "feature-inherited", + }); + expect(headBranch(dir)).toBe("feature-inherited"); + expect(upstreamOf(dir, "feature-inherited")).toBe(""); + }); }); describe("notifyGitMutation", () => { diff --git a/packages/server/src/server/session/git-mutation/git-mutation-service.ts b/packages/server/src/server/session/git-mutation/git-mutation-service.ts index 7611ba89bc..42c19668b5 100644 --- a/packages/server/src/server/session/git-mutation/git-mutation-service.ts +++ b/packages/server/src/server/session/git-mutation/git-mutation-service.ts @@ -118,7 +118,12 @@ export function createGitMutationService(deps: { } await ensureCleanWorkingTree(cwd); - await runGitCommand(["checkout", "-b", newBranchName, baseBranch], { + // --no-track: a branch we create is not a continuation of its base, so it gets no + // upstream. Without it git hands the new branch `origin/` whenever the base is a + // remote-tracking ref or branch.autoSetupMerge asks for inheritance, and every later + // push then reads that upstream and lands the work on the default branch. The worktree + // path makes the same promise with `git worktree add -b --no-track `. + await runGitCommand(["checkout", "-b", newBranchName, "--no-track", baseBranch], { cwd, timeout: 120_000, }); diff --git a/packages/server/src/server/session/owned-subscriptions/index.test.ts b/packages/server/src/server/session/owned-subscriptions/index.test.ts new file mode 100644 index 0000000000..8e8e33a4ce --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/index.test.ts @@ -0,0 +1,171 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { SessionDelivery } from "./index.js"; + +function retainedPromiseBytes(cycles: number): number { + const fixture = fileURLToPath(new URL("./test-utils/memory-repro.ts", import.meta.url)); + const output = execFileSync( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, String(cycles)], + { + encoding: "utf8", + }, + ); + return Number(output); +} + +test("closed deliveries do not increase memory used by unrelated promises", () => { + const control = retainedPromiseBytes(0); + const afterChurn = retainedPromiseBytes(40); + expect(control).toBeGreaterThan(0); + expect(afterChurn).toBeLessThan(control * 4); +}); + +test("concurrent requests keep their source through asynchronous work", async () => { + const delivery = new SessionDelivery(() => {}); + const sources = [{}, {}]; + for (const source of sources) delivery.attach(source, true); + + const requests: Promise[] = []; + for (const [index, source] of sources.entries()) { + requests.push( + delivery.request(source, { type: "ping", requestId: String(index) }, async () => { + await Promise.resolve(); + expect(delivery.currentSource).toBe(source); + expect(delivery.forSource(sources[1 - index], () => delivery.currentSource)).toBe( + sources[1 - index], + ); + expect(delivery.currentSource).toBe(source); + }), + ); + } + await Promise.all(requests); + await delivery.close(); +}); + +test("an asynchronous projection keeps its source after close without delaying cleanup", async () => { + const delivery = new SessionDelivery(() => {}); + const other = new SessionDelivery(() => {}); + const source = {}; + const otherSource = {}; + delivery.attach(source, true); + other.attach(otherSource, true); + let resume!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const projection = delivery.forSource(source, async () => { + await paused; + expect(delivery.currentSource).toBe(source); + expect(other.currentSource).toBeUndefined(); + }); + + await delivery.close(); + await other.request(otherSource, { type: "ping", requestId: "other" }, async () => { + await Promise.resolve(); + expect(other.currentSource).toBe(otherSource); + expect(delivery.currentSource).toBeUndefined(); + }); + resume(); + await projection; + await other.close(); +}); + +test("close finishes with a paused request and late work sees cancellation without leaking a reply", async () => { + const sent: string[] = []; + const delivery = new SessionDelivery((_source, message) => sent.push(message.type)); + const other = new SessionDelivery((_source, message) => sent.push(`other:${message.type}`)); + const source = {}; + const otherSource = {}; + delivery.attach(source, true); + other.attach(otherSource, true); + let resume!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + let stopped = false; + const request = delivery.request(source, { type: "ping", requestId: "pending" }, async () => { + delivery.begin("test", undefined, () => { + stopped = true; + }); + await paused; + expect(delivery.currentSource).toBe(source); + expect(delivery.requestSignal.aborted).toBe(true); + expect(other.currentSource).toBeUndefined(); + expect( + delivery.reply({ + type: "pong", + payload: { requestId: "pending", clientSentAt: 1, serverReceivedAt: 1, serverSentAt: 1 }, + }), + ).toBe(true); + }); + const closing = delivery.close(); + let closed = false; + void closing.then(() => { + closed = true; + return undefined; + }); + const outcome = await Promise.race([ + closing.then(() => "closed"), + new Promise((resolve) => setImmediate(() => resolve("blocked"))), + ]); + resume(); + await request; + await closing; + expect(outcome).toBe("closed"); + expect(closed).toBe(true); + expect(stopped).toBe(true); + expect(delivery.registrationCount).toBe(0); + await other.request(otherSource, { type: "ping", requestId: "other" }, async () => { + await Promise.resolve(); + expect(other.currentSource).toBe(otherSource); + expect(delivery.currentSource).toBeUndefined(); + expect( + other.reply({ + type: "pong", + payload: { requestId: "other", clientSentAt: 1, serverReceivedAt: 1, serverSentAt: 1 }, + }), + ).toBe(true); + }); + expect(sent).toEqual(["other:pong"]); + await other.close(); +}); + +test("a failed in-flight request does not fail delivery teardown", async () => { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + let resume!: () => void; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const request = delivery.request(source, { type: "ping", requestId: "failed" }, async () => { + await paused; + throw new Error("request canceled"); + }); + + const closing = delivery.close(); + resume(); + await expect(request).rejects.toThrow("request canceled"); + await expect(closing).resolves.toBeUndefined(); +}); + +test("failed teardown still closes the delivery and rejects late work", async () => { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + await delivery.request(source, { type: "ping", requestId: "subscribe" }, async () => { + delivery.begin("test", undefined, () => { + throw new Error("teardown failed"); + }); + }); + + await expect(delivery.close()).rejects.toThrow("teardown failed"); + await expect(delivery.close()).rejects.toThrow("teardown failed"); + expect(() => delivery.attach({}, true)).toThrow("Session delivery is closed"); + expect(() => delivery.forSource(source, () => {})).toThrow("Session delivery is closed"); + await expect( + delivery.request(source, { type: "ping", requestId: "late" }, async () => {}), + ).rejects.toThrow("Session delivery is closed"); +}); diff --git a/packages/server/src/server/session/owned-subscriptions/index.ts b/packages/server/src/server/session/owned-subscriptions/index.ts new file mode 100644 index 0000000000..6ee5c91fa5 --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/index.ts @@ -0,0 +1,468 @@ +import { isReply } from "./replies.js"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +interface Source { + socket: object; + modern: boolean; + active: boolean; + handshakePending: boolean; + cancellation: AbortController; + subscriptions: Map; + operations: Set; +} +interface DeliveryOwner { + source: Source; + active: boolean; +} +interface RequestOwner extends DeliveryOwner { + scope: object; + request: SessionInboundMessage; +} +interface ProjectionOwner { + scope: object; + source: object; +} +interface RetainedOwner extends DeliveryOwner { + cancellation: AbortController; + stop(): void | Promise; + forget(): void; + release?: Promise; +} +interface SubscriptionOwner extends RetainedOwner { + id: string; + family: string; + legacySlot: string; + responseId: string; +} + +export interface OwnedOperation { + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + release(): Promise; +} + +export interface OwnedSubscription { + readonly id: string; + readonly responseId: string; + readonly source: object; + readonly signal: AbortSignal; + emit(message: SessionOutboundMessage): void; + emitBinary(frame: Uint8Array): void; + release(): Promise; +} + +// One registration per context kind; per-session registrations outlive closed sessions in Node. +const requests = new AsyncLocalStorage(); +const projectionSource = new AsyncLocalStorage(); + +/** Source provenance and lifetimes. Domain producers own snapshots, filters and buffering. */ +export class SessionDelivery { + private readonly sources = new Map(); + private readonly proofs = new WeakMap(); + private readonly defaultSource = {}; + private readonly scope = {}; + private closing?: Promise; + + constructor( + private readonly send: (source: object, message: SessionOutboundMessage) => void, + private readonly sendBinary: (source: object, frame: Uint8Array) => void = () => {}, + private readonly project: ( + source: object, + message: SessionOutboundMessage, + ) => SessionOutboundMessage = (_source, message) => message, + private readonly unexpectedReply: ( + request: SessionInboundMessage, + message: SessionOutboundMessage, + ) => void = () => {}, + ) {} + + attach(socket: object, modern: boolean): void { + if (this.closing) throw new Error("Session delivery is closed"); + const existing = this.sources.get(socket); + if (existing) { + if (existing.modern !== modern) + throw new Error("Cannot renegotiate subscription ownership on an active source"); + return; + } + this.sources.set(socket, { + socket, + modern, + active: true, + handshakePending: true, + cancellation: new AbortController(), + subscriptions: new Map(), + operations: new Set(), + }); + } + + get currentSource(): object | undefined { + const projection = projectionSource.getStore(); + if (projection?.scope === this.scope) return projection.source; + return this.currentRequest?.source.socket; + } + + private get currentRequest(): RequestOwner | undefined { + const owner = requests.getStore(); + return owner?.scope === this.scope ? owner : undefined; + } + + get requestSignal(): AbortSignal { + const request = this.currentRequest; + if (!request) throw new Error("Operation has no requesting source"); + return request.source.cancellation.signal; + } + + forSource(source: object, project: () => T): T { + if (this.closing) throw new Error("Session delivery is closed"); + return projectionSource.run({ scope: this.scope, source }, project); + } + + hasLegacySources(): boolean { + return [...this.sources.values()].some((source) => source.active && !source.modern); + } + + hasDemand(family: string): boolean { + return [...this.sources.values()].some( + (source) => + source.active && + [...source.subscriptions.values()].some((owner) => owner.active && owner.family === family), + ); + } + + subscriptionIds(source: object, family: string): string[] { + return [...(this.sources.get(source)?.subscriptions.values() ?? [])] + .filter((owner) => owner.active && owner.family === family) + .map((owner) => owner.id); + } + + get registrationCount(): number { + return [...this.sources.values()].reduce( + (count, source) => count + source.subscriptions.size + source.operations.size, + 0, + ); + } + + sourceRegistrationCount(socket = this.currentSource): number { + const source = socket ? this.sources.get(socket) : undefined; + return source ? source.subscriptions.size + source.operations.size : 0; + } + + async releaseFamily(family: string): Promise { + await Promise.all( + [...this.sources.values()].flatMap((source) => + [...source.subscriptions.values()] + .filter((owner) => owner.family === family) + .map((owner) => this.releaseOwner(owner)), + ), + ); + } + + isModern(socket: object | undefined): boolean { + return socket ? this.sources.get(socket)?.modern === true : false; + } + + async detach(socket: object): Promise { + const source = this.sources.get(socket); + if (!source) return; + source.active = false; + source.cancellation.abort(); + await Promise.all( + [...source.subscriptions.values(), ...source.operations].map((owner) => + this.releaseOwner(owner), + ), + ); + this.sources.delete(socket); + } + + close(): Promise { + this.closing ??= Promise.resolve().then(() => this.dispose()); + return this.closing; + } + + private async dispose(): Promise { + const releases = [...this.sources.keys()].map((socket) => this.detach(socket)); + const releaseResults = await Promise.allSettled(releases); + for (const result of releaseResults) { + if (result.status === "rejected") throw result.reason; + } + } + + async request( + socket: object | undefined, + request: SessionInboundMessage, + run: () => Promise, + ): Promise { + if (this.closing) throw new Error("Session delivery is closed"); + socket ??= this.defaultSource; + if (!this.sources.has(socket)) this.attach(socket, false); + const source = this.sources.get(socket)!; + const owner: RequestOwner = { scope: this.scope, source, request, active: true }; + try { + await requests.run(owner, run); + } finally { + owner.active = false; + } + } + + protocolFailure( + socket: object, + failure: { + requestId?: string; + requestType?: string; + error: string; + code: "invalid_message" | "unknown_schema"; + }, + ): void { + const source = this.sources.get(socket); + if (!source?.active) return; + const message: SessionOutboundMessage = + failure.requestId !== undefined + ? { type: "rpc_error", payload: { ...failure, requestId: failure.requestId } } + : { type: "status", payload: { status: "error", message: failure.error } }; + const owner: DeliveryOwner = { source, active: true }; + try { + this.sendOwned(owner, message); + } finally { + owner.active = false; + } + } + + authorizeReply(message: SessionOutboundMessage, socket: object): SessionOutboundMessage { + message = this.project(socket, message); + const owner = this.currentRequest; + if ( + owner?.active && + owner.source.active && + owner.source.socket === socket && + isReply(owner.request, message) + ) + this.proofs.set(message, owner); + return message; + } + + reply(message: SessionOutboundMessage): boolean { + const owner = this.currentRequest; + if (!owner) return false; + if (!isReply(owner.request, message)) { + if ( + "requestId" in owner.request && + "payload" in message && + "requestId" in message.payload && + message.payload.requestId === owner.request.requestId + ) { + this.unexpectedReply(owner.request, message); + } + return false; + } + if (owner.active && owner.source.active) { + const reply = owner.source.modern ? message : legacyMessage(message); + this.sendOwned(owner, reply); + } + return true; + } + + begin( + family: string, + requestedId: string | undefined, + stop: (id: string) => void | Promise, + legacySlot = family, + ): OwnedSubscription { + const request = this.currentRequest; + if (!request?.active || !request.source.active) + throw new Error("Subscription source is closed"); + const source = request.source; + if (source.modern && (!("requestId" in request.request) || !request.request.requestId)) + throw new Error("Owned subscriptions require a requestId"); + if (source.modern && requestedId !== undefined) + throw new Error("Subscription IDs are assigned by the host"); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) { + for (const prior of source.subscriptions.values()) { + // Keep failed teardown registered so disconnect/close can report it. + if (prior.legacySlot === legacySlot) void this.releaseOwner(prior).catch(() => {}); + } + } + const id = randomUUID(); + const responseId = source.modern ? id : requestedId?.trim() || id; + const owner: SubscriptionOwner = { + id, + responseId, + source, + family, + legacySlot, + active: true, + cancellation: new AbortController(), + stop: () => stop(id), + forget: () => { + source.subscriptions.delete(id); + }, + }; + source.subscriptions.set(id, owner); + return { + id, + responseId, + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active) return; + const tagged = + source.modern || + message.type === "fs.file.update" || + message.type === "checkout_diff_update" + ? withSubscriptionId(message, responseId) + : legacyMessage(message); + this.sendOwned(owner, tagged); + }, + emitBinary: (frame) => { + if (!owner.active || !source.active) return; + this.proofs.set(frame, owner); + this.sendBinary(source.socket, frame); + }, + release: () => this.releaseOwner(owner), + }; + } + + /** A domain extends the originating request until its asynchronous operation ends. */ + operation( + accepts: (message: SessionOutboundMessage) => boolean, + stop: () => void | Promise, + ): OwnedOperation { + const request = this.currentRequest; + if (!request?.active || !request.source.active) throw new Error("Operation source is closed"); + const source = request.source; + const owner: RetainedOwner = { + source, + active: true, + cancellation: new AbortController(), + stop, + forget: () => { + source.operations.delete(owner); + }, + }; + source.operations.add(owner); + return { + source: source.socket, + signal: owner.cancellation.signal, + emit: (message) => { + if (!owner.active || !source.active || !accepts(message)) return; + this.sendOwned(owner, message); + }, + release: () => this.releaseOwner(owner), + }; + } + + async release(id: string): Promise { + const source = this.currentRequest?.source; + const owner = + source?.subscriptions.get(id) ?? + (source && !source.modern + ? [...source.subscriptions.values()].find((candidate) => candidate.responseId === id) + : undefined); + if (owner) await this.releaseOwner(owner); + } + + async releaseLegacySlot(slot: string): Promise { + const source = this.currentRequest?.source; + if (!source) return; + if (source.modern) + throw new Error("Release subscriptions using the server-assigned subscription ID"); + const owner = [...source.subscriptions.values()].find( + (candidate) => candidate.legacySlot === slot, + ); + if (owner) await this.releaseOwner(owner); + } + + authorizeFileReply(frame: Uint8Array, socket: object): void { + const owner = this.currentRequest; + if ( + owner?.active && + owner.source.socket === socket && + owner.request.type === "file_explorer_request" + ) + this.proofs.set(frame, owner); + } + + permitsBinary(socket: object, frame: Uint8Array): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + const owner = this.proofs.get(frame); + return owner?.active === true && owner.source === source; + } + + permits(socket: object, message: SessionOutboundMessage): boolean { + const source = this.sources.get(socket); + if (!source?.active) return false; + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!source.modern) return true; + if ( + message.type === "status" && + message.payload.status === "server_info" && + source.handshakePending + ) { + source.handshakePending = false; + return true; + } + const owner = this.proofs.get(message); + return owner?.active === true && owner.source === source; + } + + private sendOwned(owner: DeliveryOwner, message: SessionOutboundMessage): void { + const projected = this.project(owner.source.socket, message); + this.proofs.set(projected, owner); + this.send(owner.source.socket, projected); + } + + private releaseOwner(owner: RetainedOwner): Promise { + if (owner.release) return owner.release; + owner.active = false; + let resolve!: () => void; + let reject!: (error: unknown) => void; + owner.release = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + owner.cancellation.abort(); + try { + void Promise.resolve(owner.stop()).then(() => { + owner.forget(); + resolve(); + return undefined; + }, reject); + } catch (error) { + reject(error); + } + return owner.release; + } +} + +function withSubscriptionId( + message: T, + subscriptionId: string, +): T { + return "payload" in message + ? { ...message, payload: { ...message.payload, subscriptionId } } + : { ...message, subscriptionId }; +} + +// COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. +function legacyMessage(message: SessionOutboundMessage): SessionOutboundMessage { + if (message.type === "terminals_changed") { + const { workspaceId: _workspaceId, ...payload } = message.payload; + return { ...message, payload }; + } + if ( + message.type !== "subscribe_terminal_response" && + message.type !== "session.events.set_subscription.response" && + message.type !== "agent.timeline.set_subscription.response" && + message.type !== "workspace.label.list.response" + ) + return message; + if (!("subscriptionId" in message.payload)) return message; + const { subscriptionId: _subscriptionId, ...payload } = message.payload; + return { ...message, payload } as SessionOutboundMessage; +} diff --git a/packages/server/src/server/session/owned-subscriptions/replies.ts b/packages/server/src/server/session/owned-subscriptions/replies.ts new file mode 100644 index 0000000000..cf8a44b3cc --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/replies.ts @@ -0,0 +1,70 @@ +import type { + KnownStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "../../messages.js"; + +type Request = Extract; +type ConventionalReply = T extends `${infer P}.request` + ? `${P}.response` + : T extends `${infer P}_request` + ? `${P}_response` + : T extends `${string}/${string}` + ? `${T}/response` + : `${T}_response`; +type CorrelatedType = M extends { type: infer T; payload: infer P } + ? "requestId" extends keyof P + ? T + : never + : never; +type StatusReply = Extract["status"]; +type ExceptionalReply = Exclude< + CorrelatedType>, + ConventionalReply | "rpc_error" +>; + +// Naming is a protocol convention, not proof. Every nonconforming correlated output +// must declare its originating request; additions to the wire union fail typecheck here. +const exceptions = { + pong: ["ping"], + agent_archived: ["archive_agent_request"], + agent_deleted: ["delete_agent_request"], + agent_permission_resolved: ["agent_permission_response"], + terminals_changed: ["subscribe_terminals_request"], + "usage.list_reports.update": ["usage.list_reports.request"], + "daemon.update.progress": ["daemon.update.request"], + // Transcription belongs to the explicitly retained voice operation, not a request ID. + transcription_result: [], + // This legacy requestId is a producer marker; checkout updates require event demand. + checkout_status_update: [], +} satisfies Record; +const statuses = { + agent_created: ["create_agent_request"], + agent_create_failed: ["create_agent_request", "import_agent_request"], + agent_resumed: ["resume_agent_request", "import_agent_request"], + agent_refreshed: ["refresh_agent_request"], + restart_requested: ["restart_server_request"], + shutdown_requested: ["shutdown_server_request"], +} satisfies Record; + +export function isReply(request: SessionInboundMessage, message: SessionOutboundMessage): boolean { + if ( + !("requestId" in request) || + !("payload" in message) || + !("requestId" in message.payload) || + message.payload.requestId !== request.requestId + ) + return false; + if (message.type === "rpc_error") return message.payload.requestType === request.type; + const declared: Readonly> = + message.type === "status" ? statuses : exceptions; + const key = message.type === "status" ? message.payload.status : message.type; + if (key in declared) return declared[key].includes(request.type); + if (message.type === "status") return false; + if (request.type.endsWith(".request")) + return message.type === request.type.replace(/\.request$/, ".response"); + if (request.type.endsWith("_request")) + return message.type === request.type.replace(/_request$/, "_response"); + if (request.type.includes("/")) return message.type === `${request.type}/response`; + return message.type === `${request.type}_response`; +} diff --git a/packages/server/src/server/session/owned-subscriptions/test-utils/memory-repro.ts b/packages/server/src/server/session/owned-subscriptions/test-utils/memory-repro.ts new file mode 100644 index 0000000000..7bf86b4642 --- /dev/null +++ b/packages/server/src/server/session/owned-subscriptions/test-utils/memory-repro.ts @@ -0,0 +1,21 @@ +import { SessionDelivery } from "../index.js"; + +const cycles = Number(process.argv[2]); +const count = 4096; +for (let index = 0; index <= cycles; index++) { + const delivery = new SessionDelivery(() => {}); + const source = {}; + delivery.attach(source, true); + delivery.forSource(source, () => {}); + await delivery.request(source, { type: "ping", requestId: String(index) }, async () => {}); + await delivery.close(); +} + +if (!global.gc) throw new Error("Run with --expose-gc"); +global.gc(); +const before = process.memoryUsage().heapUsed; +const held = Array.from({ length: count }, () => new Promise(() => {})); +global.gc(); +const retainedBytes = process.memoryUsage().heapUsed - before; +process.stdout.write(String(retainedBytes)); +if (held.length !== count) throw new Error("Promise allocation failed"); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 63000ca0d0..c1cec0b42a 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -5,24 +5,24 @@ import { type ProviderCatalogSessionHost, } from "./provider-catalog-session.js"; import { createStub } from "../../test-utils/class-mocks.js"; -import { findByType } from "../../test-utils/session-stubs.js"; +import { createProviderSnapshot, findByType } from "../../test-utils/session-stubs.js"; import type { SessionOutboundMessage } from "../../messages.js"; import { GLOBAL_PROVIDER_SNAPSHOT_KEY, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; -import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; -type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void; +type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void; interface MakeOptions { visibleProviders?: Set; supportsCustomModeIcons?: boolean; supportsCompactProviderSnapshots?: boolean; - snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown }; - usage?: { [K in keyof ProviderUsageService]?: unknown }; + snapshot?: Partial; host?: Partial; } @@ -51,6 +51,11 @@ function makeSubsystem(options: MakeOptions = {}) { emit: (msg) => emitted.push(msg), isProviderVisibleToClient: (provider) => visible.has(provider), supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, + supportsProviderSnapshotReferences: () => false, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, listProviderAvailability: async () => [], listDraftFeatures: async () => [], @@ -66,15 +71,14 @@ function makeSubsystem(options: MakeOptions = {}) { const subsystem = new ProviderCatalogSession({ host, providerSnapshotManager, - providerUsageService: createStub(options.usage ?? {}), logger: pino({ level: "silent" }), }); function pushSnapshotChange( - entries: ProviderSnapshotEntry[], - cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, + current: ProviderSnapshot, + previous = createProviderSnapshot([], current.cwd), ): void { if (!changeHandler) throw new Error("start() must run before a snapshot change"); - changeHandler(entries, cwd); + changeHandler({ previous, current }); } return { subsystem, emitted, pushSnapshotChange }; } @@ -87,7 +91,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries.map((entry) => entry.provider)).toEqual(["codex"]); @@ -101,11 +105,11 @@ describe("ProviderCatalogSession", () => { const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ visibleProviders: new Set(["codex"]), supportsCustomModeIcons: false, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); await subsystem.handleGetProvidersSnapshotRequest({ type: "get_providers_snapshot_request", requestId: "g1", @@ -117,7 +121,7 @@ describe("ProviderCatalogSession", () => { }); it("returns the canonical cwd used by snapshot updates", async () => { - const getSnapshot = vi.fn((_cwd?: string) => makeEntries()); + const getSnapshot = vi.fn((cwd?: string) => createProviderSnapshot(makeEntries(), cwd)); const { subsystem, emitted } = makeSubsystem({ snapshot: { getSnapshot }, }); @@ -140,7 +144,7 @@ describe("ProviderCatalogSession", () => { }); subsystem.start(); - pushSnapshotChange(makeEntries()); + pushSnapshotChange(createProviderSnapshot(makeEntries())); const push = findByType(emitted, "providers_snapshot_update"); expect(push?.payload.entries).toEqual([]); @@ -151,7 +155,7 @@ describe("ProviderCatalogSession", () => { it("preserves custom mode icons when the client supports them", async () => { const { subsystem, emitted } = makeSubsystem({ supportsCustomModeIcons: true, - snapshot: { getSnapshot: () => makeEntries() }, + snapshot: { getSnapshot: () => createProviderSnapshot(makeEntries()) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -184,7 +188,7 @@ describe("ProviderCatalogSession", () => { ]; const { subsystem, emitted } = makeSubsystem({ supportsCompactProviderSnapshots: true, - snapshot: { getSnapshot: () => entries }, + snapshot: { getSnapshot: () => createProviderSnapshot(entries) }, }); await subsystem.handleGetProvidersSnapshotRequest({ @@ -221,7 +225,10 @@ describe("ProviderCatalogSession", () => { // warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called, // so the disabled short-circuit is proven by the absence of a throw. const { subsystem, emitted } = makeSubsystem({ - snapshot: { getSnapshot: () => [{ provider: "codex", status: "loading", enabled: false }] }, + snapshot: { + getSnapshot: () => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: false }]), + }, }); await subsystem.handleListProviderModelsRequest({ @@ -237,22 +244,23 @@ describe("ProviderCatalogSession", () => { it("hides compatibility-only entries from list_provider_models", async () => { const { subsystem, emitted } = makeSubsystem({ snapshot: { - getSnapshot: () => [ - { - provider: "codex", - status: "ready", - enabled: true, - models: [ - { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, - { - provider: "codex", - id: "gpt-5.4-legacy", - label: "GPT 5.4 legacy", - isSelectable: false, - }, - ], - }, - ], + getSnapshot: () => + createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, + { + provider: "codex", + id: "gpt-5.4-legacy", + label: "GPT 5.4 legacy", + isSelectable: false, + }, + ], + }, + ]), }, }); @@ -267,7 +275,9 @@ describe("ProviderCatalogSession", () => { }); it("preserves missing cwd as the semantic global snapshot for model list reads", async () => { - const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]); + const getSnapshot = vi.fn(() => + createProviderSnapshot([{ provider: "codex", status: "loading", enabled: true }]), + ); const warmUpSnapshotForCwd = vi.fn(async () => {}); const { subsystem } = makeSubsystem({ snapshot: { getSnapshot, warmUpSnapshotForCwd }, @@ -286,25 +296,6 @@ describe("ProviderCatalogSession", () => { }); }); - it("surfaces a usage-list failure as an rpc_error envelope", async () => { - const { subsystem, emitted } = makeSubsystem({ - usage: { - listUsage: async () => { - throw new Error("quota service down"); - }, - }, - }); - - await subsystem.handleProviderUsageListRequest({ - type: "provider.usage.list.request", - requestId: "u1", - }); - - const err = findByType(emitted, "rpc_error"); - expect(err?.payload.code).toBe("provider_usage_list_failed"); - expect(err?.payload.requestId).toBe("u1"); - }); - it("surfaces a feature-list failure inline, not as an rpc_error", async () => { const { subsystem, emitted } = makeSubsystem({ host: { @@ -326,3 +317,333 @@ describe("ProviderCatalogSession", () => { expect(res?.payload.requestId).toBe("f1"); }); }); + +it("announces shared content without retransmitting models or hashing discovery freshness", async () => { + const { ProviderSnapshotManager: Manager } = + await import("../../agent/provider-snapshot-manager.js"); + let probes = 0; + let fetches = 0; + const manager = new Manager({ + logger: pino({ level: "silent" }), + providerOverrides: Object.fromEntries( + ["codex", "claude", "pi", "opencode", "copilot", "omp"].map((provider) => [ + provider, + { enabled: provider === "codex" }, + ]), + ), + extraClients: { + codex: { + provider: "codex", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + async getCatalogCacheKey() { + return "host"; + }, + async isAvailable() { + probes++; + return true; + }, + async fetchCatalog() { + fetches++; + return { models: [{ provider: "codex", id: "astra", label: "Astra" }], modes: [] }; + }, + async createSession() { + throw new Error("Not used"); + }, + async resumeSession() { + throw new Error("Not used"); + }, + }, + }, + }); + const createSession = (emitted: SessionOutboundMessage[], references: boolean) => + new ProviderCatalogSession({ + providerSnapshotManager: manager, + logger: pino({ level: "silent" }), + host: { + emit(message) { + emitted.push(message); + }, + isProviderVisibleToClient: () => true, + supportsCustomModeIcons: () => true, + supportsCompactProviderSnapshots: () => true, + publishSnapshot: (project) => { + const message = project(); + if (message) emitted.push(message); + }, + supportsProviderSnapshotReferences: () => references, + listProviderAvailability: async () => [], + listDraftFeatures: async () => [], + }, + }); + const emitted: SessionOutboundMessage[] = []; + const peerMessages: SessionOutboundMessage[] = []; + const session = createSession(emitted, true); + const peer = createSession(peerMessages, false); + try { + await Promise.all( + [undefined, "/tmp/catalog-a", "/tmp/catalog-b"].map((cwd) => + manager.getProvider({ provider: "codex", cwd, wait: true }), + ), + ); + expect({ probes, fetches }).toEqual({ probes: 1, fetches: 1 }); + session.start(); + peer.start(); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect( + first.compactSnapshot?.entries.find((entry) => entry.provider === "codex")?.fetchedAt, + ).toBeUndefined(); + expect(first.fetchedAt?.codex).toEqual(expect.any(String)); + await vi.waitFor(() => expect(Date.now()).toBeGreaterThan(Date.parse(first.fetchedAt!.codex!))); + emitted.length = 0; + await manager.refreshSnapshotForCwd({ cwd: "/tmp/catalog-a", providers: ["codex"] }); + expect({ probes, fetches }).toEqual({ probes: 2, fetches: 2 }); + const pushes = emitted.filter((message) => message.type === "providers_snapshot_update"); + expect(pushes).toHaveLength(3); + const peerPushes = peerMessages.filter( + (message) => message.type === "providers_snapshot_update", + ); + expect(peerPushes).toHaveLength(3); + expect( + peerPushes.every(({ payload }) => + payload.compactSnapshot?.entries.some( + (entry) => entry.provider === "codex" && entry.models?.[0]?.id === "astra", + ), + ), + ).toBe(true); + expect(peerPushes[0]!.payload.snapshotHash).not.toBe(pushes[0]!.payload.snapshotHash); + expect(pushes.map(({ payload }) => payload.snapshotHash)).toEqual( + Array(3).fill(first.snapshotHash), + ); + expect( + pushes.flatMap(({ payload }) => (payload.compactSnapshot ? [payload.compactSnapshot] : [])), + ).toEqual([]); + expect(pushes.flatMap(({ payload }) => payload.entries)).toEqual([]); + await session.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "warm", + ifNoneMatch: first.snapshotHash, + }); + const warm = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(warm.notModified).toBe(true); + expect(warm.compactSnapshot).toBeUndefined(); + expect(warm.fetchedAt?.codex).toEqual(expect.any(String)); + } finally { + session.dispose(); + peer.dispose(); + manager.destroy(); + } +}); + +it.each([false, true])( + "conditional hits do not read model bodies (references=%s)", + async (references) => { + let modelReads = 0; + const entries: ProviderSnapshotEntry[] = [ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [{ id: "default", label: "Default", icon: "Sparkles" }], + get models() { + modelReads++; + return [{ provider: "codex", id: "astra", label: "Astra" }]; + }, + }, + ]; + const snapshot: ProviderSnapshot = { + cwd: GLOBAL_PROVIDER_SNAPSHOT_KEY, + records: [{ entry: entries[0]!, contentHash: "fixture" }], + }; + const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }); + subsystem.start(); + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "cold", + }); + const first = findByType(emitted, "get_providers_snapshot_response")!.payload; + expect(modelReads).toBeGreaterThan(0); + modelReads = 0; + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "hit", + ifNoneMatch: first.snapshotHash, + }); + expect(modelReads).toBe(0); + pushSnapshotChange(snapshot); + expect(modelReads === 0).toBe(references); + subsystem.dispose(); + }, +); + +it.each([false, true])( + "snapshot identity follows the client view and its freshness policy (references=%s)", + async (references) => { + const entries = makeEntries(); + entries[0]!.fetchedAt = "2026-09-06T10:00:00.000Z"; + entries[0]!.models = [{ provider: "codex", id: "astra", label: "Astra" }]; + let snapshot = createProviderSnapshot(entries); + const clients = [ + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + supportsCustomModeIcons: true, + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + visibleProviders: new Set(["codex", "claude"]), + host: { supportsProviderSnapshotReferences: () => references }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + const read = async (client: ReturnType, ifNoneMatch?: string) => { + client.emitted.length = 0; + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "view", + ifNoneMatch, + }); + return findByType(client.emitted, "get_providers_snapshot_response")!.payload; + }; + const views = await Promise.all(clients.map((client) => read(client))); + expect(views[0]!.snapshotHash).toBe(views[1]!.snapshotHash); + expect(views[0]!.compactSnapshot).toEqual(views[1]!.compactSnapshot); + expect(new Set(views.map((view) => view.snapshotHash)).size).toBe(3); + expect(expandProviderSnapshot(views[0]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe( + "ShieldCheck", + ); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)[0]!.modes![0]!.icon).toBe("Sparkles"); + expect( + expandProviderSnapshot(views[3]!.compactSnapshot!).map((entry) => entry.provider), + ).toEqual(["codex", "claude"]); + expect(views[0]!.compactSnapshot!.entries[0]!.fetchedAt).toBe( + references ? undefined : entries[0]!.fetchedAt, + ); + + // Hidden provider changes do not invalidate the visible result. + snapshot = createProviderSnapshot([ + entries[0]!, + { ...entries[1]!, status: "error", error: "hidden" }, + ]); + expect((await read(clients[0]!, views[0]!.snapshotHash)).notModified).toBe(true); + // Same content hash, new embedded timestamp: only reference views can reuse the body. + snapshot = createProviderSnapshot([ + { ...entries[0]!, fetchedAt: "2026-09-06T10:01:00.000Z" }, + entries[1]!, + ]); + expect(snapshot.records[0]!.contentHash).toBe( + createProviderSnapshot(entries).records[0]!.contentHash, + ); + const fresh = await read(clients[0]!, views[0]!.snapshotHash); + expect(fresh.snapshotHash === views[0]!.snapshotHash).toBe(references); + expect(fresh.notModified === true).toBe(references); + expect(fresh.fetchedAt?.codex).toBe(references ? "2026-09-06T10:01:00.000Z" : undefined); + + snapshot = { ...snapshot, records: snapshot.records.toReversed() }; + expect((await read(clients[3]!)).snapshotHash).not.toBe(views[3]!.snapshotHash); + }, +); + +it("reference capability alone preserves full legacy responses and does not alias embedded compact hashes", async () => { + const snapshot = createProviderSnapshot(makeEntries()); + const clients = [ + makeSubsystem({ + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + snapshot: { getSnapshot: () => snapshot }, + }), + makeSubsystem({ + supportsCompactProviderSnapshots: true, + host: { supportsProviderSnapshotReferences: () => true }, + snapshot: { getSnapshot: () => snapshot }, + }), + ]; + for (const client of clients) { + client.subsystem.start(); + client.pushSnapshotChange(snapshot); + await client.subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "format", + }); + client.subsystem.dispose(); + } + const views = clients.map( + ({ emitted }) => findByType(emitted, "get_providers_snapshot_response")!.payload, + ); + expect(views[0]!.snapshotHash).toBeUndefined(); + expect(views[0]!.entries).toEqual( + findByType(clients[0]!.emitted, "providers_snapshot_update")!.payload.entries, + ); + expect(views[0]!.entries).toHaveLength(1); + expect(views[1]!.snapshotHash).not.toBe(views[2]!.snapshotHash); + expect(expandProviderSnapshot(views[1]!.compactSnapshot!)).toEqual(views[0]!.entries); + expect(expandProviderSnapshot(views[2]!.compactSnapshot!)).toEqual(views[0]!.entries); +}); + +it.each(["full", "embedded", "references"])( + "suppresses hidden-only transitions but publishes visible freshness for %s clients", + (format) => { + const before = createProviderSnapshot([ + { + provider: "codex", + status: "ready", + enabled: true, + fetchedAt: "2026-09-06T10:00:00.000Z", + models: [{ provider: "codex", id: "astra", label: "Astra" }], + }, + { provider: "claude", status: "loading", enabled: true }, + ]); + const hidden = createProviderSnapshot([ + before.records[0]!.entry, + { provider: "claude", status: "error", enabled: true, error: "hidden" }, + ]); + const fresh = createProviderSnapshot([ + { ...before.records[0]!.entry, fetchedAt: "2026-09-06T10:01:00.000Z" }, + hidden.records[1]!.entry, + ]); + const client = makeSubsystem({ + supportsCompactProviderSnapshots: format !== "full", + host: { supportsProviderSnapshotReferences: () => format === "references" }, + }); + client.subsystem.start(); + client.pushSnapshotChange(hidden, before); + expect(client.emitted).toEqual([]); + client.pushSnapshotChange(fresh, hidden); + const update = findByType(client.emitted, "providers_snapshot_update")!.payload; + expect(client.emitted).toHaveLength(1); + const representedFreshness = + update.fetchedAt?.codex ?? + update.compactSnapshot?.entries[0]?.fetchedAt ?? + update.entries[0]?.fetchedAt; + expect(representedFreshness).toBe("2026-09-06T10:01:00.000Z"); + client.subsystem.dispose(); + }, +); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index ed7509a916..af04199bca 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -1,15 +1,15 @@ import type pino from "pino"; import { createHash } from "node:crypto"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; -import { - compactProviderSnapshot, - type CompactProviderSnapshot, -} from "@getpaseo/protocol/provider-snapshot-codec"; +import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import { isGlobalProviderSnapshotKey, resolveSnapshotCwd, + sameSnapshotRecords, type ProviderSnapshotManager, + type ProviderSnapshot, + type ProviderSnapshotTransition, } from "../../agent/provider-snapshot-manager.js"; import { filterSelectableAgentModels, @@ -19,7 +19,6 @@ import { type ProviderSnapshotEntry, } from "../../agent/agent-sdk-types.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; -import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; import { expandTilde } from "../../../utils/path.js"; // COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any @@ -47,6 +46,8 @@ export interface ProviderCatalogSessionHost { // COMPAT(customModeIcons): reads clientCapabilities live. supportsCustomModeIcons(): boolean; supportsCompactProviderSnapshots(): boolean; + supportsProviderSnapshotReferences(): boolean; + publishSnapshot(project: () => SessionOutboundMessage | null): void; listProviderAvailability(): Promise; listDraftFeatures(config: AgentSessionConfig): Promise; } @@ -54,26 +55,12 @@ export interface ProviderCatalogSessionHost { export interface ProviderCatalogSessionOptions { host: ProviderCatalogSessionHost; providerSnapshotManager: ProviderSnapshotManager; - providerUsageService: ProviderUsageService; logger: pino.Logger; } -interface EncodedProviderSnapshot { - compactSnapshot: CompactProviderSnapshot; - snapshotHash: string; -} - -function encodeProviderSnapshot(entries: ProviderSnapshotEntry[]): EncodedProviderSnapshot { - const compactSnapshot = compactProviderSnapshot(entries); - const snapshotHash = createHash("sha256") - .update(JSON.stringify(compactSnapshot)) - .digest("base64url"); - return { compactSnapshot, snapshotHash }; -} - /** * A client's provider catalog surface: model / mode / feature listing, the providers - * snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and + * snapshot push + pull, and provider diagnostics. The snapshot PUSH (start) and * every PULL handler gate visibility and downgrade mode icons through the SAME predicates, * so an older client sees one consistent provider set across both paths — the COMPAT * invariant the shell could only enforce by code proximity before this carve. @@ -81,45 +68,27 @@ function encodeProviderSnapshot(entries: ProviderSnapshotEntry[]): EncodedProvid export class ProviderCatalogSession { private readonly host: ProviderCatalogSessionHost; private readonly providerSnapshotManager: ProviderSnapshotManager; - private readonly providerUsageService: ProviderUsageService; private readonly logger: pino.Logger; private unsubscribeSnapshotEvents: (() => void) | null = null; constructor(options: ProviderCatalogSessionOptions) { this.host = options.host; this.providerSnapshotManager = options.providerSnapshotManager; - this.providerUsageService = options.providerUsageService; this.logger = options.logger; } + get isObserving(): boolean { + return this.unsubscribeSnapshotEvents !== null; + } + start(): void { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.host.isProviderVisibleToClient(entry.provider), - ); - const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd; - const clientEntries = this.downgradeEntryModesForClient(visibleEntries); - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - this.host.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...encoded, - generatedAt: new Date().toISOString(), - }, - }); - return; - } - this.host.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), - }, + if (this.unsubscribeSnapshotEvents) return; + const handleProviderSnapshotChange = (transition: ProviderSnapshotTransition) => { + this.host.publishSnapshot(() => { + const previous = this.visibleSnapshot(transition.previous); + const current = this.visibleSnapshot(transition.current); + if (sameSnapshotRecords(previous.records, current.records)) return null; + return { type: "providers_snapshot_update", payload: this.snapshotPayload(current) }; }); }; this.providerSnapshotManager.on("change", handleProviderSnapshotChange); @@ -138,20 +107,79 @@ export class ProviderCatalogSession { // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS // map is a closed enum and would render `undefined`, crashing in render). Drop // this and the cap gate when floor >= v0.1.84. - private downgradeModeIconsForClient(modes: T[]): T[] { - if (this.host.supportsCustomModeIcons()) return modes; + private downgradeModeIconsForClient( + modes: T[], + customModeIcons = this.host.supportsCustomModeIcons(), + ): T[] { + if (customModeIcons) return modes; return modes.map((mode) => mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, ); } - private downgradeEntryModesForClient( - entries: T[], - ): T[] { - if (this.host.supportsCustomModeIcons()) return entries; - return entries.map((entry) => - entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, - ); + private visibleSnapshot(snapshot: ProviderSnapshot): ProviderSnapshot { + // COMPAT(providersSnapshot): use the shell's current visibility policy for both sides. + return { + ...snapshot, + records: snapshot.records.filter(({ entry }) => + this.host.isProviderVisibleToClient(entry.provider), + ), + }; + } + + private snapshotPayload(snapshot: ProviderSnapshot, request?: { ifNoneMatch?: string }) { + const { records } = snapshot; + const customModeIcons = this.host.supportsCustomModeIcons(); + const compact = this.host.supportsCompactProviderSnapshots(); + const references = this.host.supportsProviderSnapshotReferences(); + const envelope = { + ...(!isGlobalProviderSnapshotKey(snapshot.cwd) ? { cwd: snapshot.cwd } : {}), + generatedAt: new Date().toISOString(), + }; + const clientEntries = () => + records.map(({ entry }) => + entry.modes && !customModeIcons + ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes, customModeIcons) } + : entry, + ); + if (!compact) { + return { ...envelope, entries: clientEntries() }; + } + + // COMPAT(providerSnapshotReferences): added in v0.7.2, remove legacy full + // pushes and embedded freshness after 2027-03-06 once the client floor supports references. + const snapshotHash = createHash("sha256") + .update( + JSON.stringify([ + "paseo.providers-snapshot/1", + references ? "references" : "embedded", + customModeIcons ? "icons" : "legacy-icons", + records.map(({ entry, contentHash }) => [ + entry.provider, + contentHash, + references ? null : (entry.fetchedAt ?? null), + ]), + ]), + ) + .digest("base64url"); + const fetchedAt = references + ? Object.fromEntries( + records.flatMap(({ entry }) => + entry.fetchedAt ? [[entry.provider, entry.fetchedAt]] : [], + ), + ) + : undefined; + const identified = { ...envelope, entries: [], snapshotHash, fetchedAt }; + if (request && request.ifNoneMatch === snapshotHash) { + return { ...identified, notModified: true as const }; + } + if (!request && references) return identified; + + const entries = clientEntries(); + const content = references + ? entries.map(({ fetchedAt: _fetchedAt, ...entry }) => entry) + : entries; + return { ...identified, compactSnapshot: compactProviderSnapshot(content) }; } private emitProviderDisabledResponse( @@ -290,7 +318,7 @@ export class ProviderCatalogSession { ): Promise { const manager = this.providerSnapshotManager; const findEntry = () => - manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); + manager.getSnapshot(cwd).records.find(({ entry }) => entry.provider === provider)?.entry; let entry = findEntry(); if (entry && !entry.enabled) { @@ -391,37 +419,12 @@ export class ProviderCatalogSession { async handleGetProvidersSnapshotRequest( msg: Extract, ): Promise { - // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. - const snapshotCwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; - const entries = this.providerSnapshotManager - .getSnapshot(snapshotCwd) - .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); - const clientEntries = this.downgradeEntryModesForClient(entries); - - if (this.host.supportsCompactProviderSnapshots()) { - const encoded = encodeProviderSnapshot(clientEntries); - const notModified = msg.ifNoneMatch === encoded.snapshotHash; - this.host.emit({ - type: "get_providers_snapshot_response", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: [], - ...(!notModified ? { compactSnapshot: encoded.compactSnapshot } : {}), - snapshotHash: encoded.snapshotHash, - ...(notModified ? { notModified: true } : {}), - generatedAt: new Date().toISOString(), - requestId: msg.requestId, - }, - }); - return; - } - + const cwd = msg.cwd?.trim() ? resolveSnapshotCwd(expandTilde(msg.cwd)) : undefined; + const snapshot = this.visibleSnapshot(this.providerSnapshotManager.getSnapshot(cwd)); this.host.emit({ type: "get_providers_snapshot_response", payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: clientEntries, - generatedAt: new Date().toISOString(), + ...this.snapshotPayload(snapshot, { ifNoneMatch: msg.ifNoneMatch }), requestId: msg.requestId, }, }); @@ -479,34 +482,6 @@ export class ProviderCatalogSession { }); } } - - async handleProviderUsageListRequest( - msg: Extract, - ): Promise { - try { - const usage = await this.providerUsageService.listUsage(); - this.host.emit({ - type: "provider.usage.list.response", - payload: { - requestId: msg.requestId, - fetchedAt: usage.fetchedAt, - providers: usage.providers, - }, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error({ err }, "Failed to list provider usage"); - this.host.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: `Failed to list provider usage: ${err.message}`, - code: "provider_usage_list_failed", - }, - }); - } - } } function resolveCatalogRequestCwd(cwd?: string | null): string | undefined { diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts new file mode 100644 index 0000000000..2aa65992b8 --- /dev/null +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -0,0 +1,74 @@ +import pino from "pino"; +import { expect, test } from "vitest"; +import type { SessionOutboundMessage } from "../../messages.js"; +import { UsageSession } from "./usage-session.js"; + +test("lists reports from usage sources", async () => { + const emitted: SessionOutboundMessage[] = []; + const requested: Array<{ forceRefresh?: boolean; reportIds?: string[] }> = []; + const entry = { + id: "fixture:one", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", + sourceId: "fixture", + sourceLabel: "Fixture", + report: { status: "available" as const, windows: [] }, + }; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + runtime: { + async listUsageReports(options) { + requested.push({ forceRefresh: options.forceRefresh, reportIds: options.reportIds }); + options.onReport?.(entry); + return [entry]; + }, + async listLegacyUsage() { + return { fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }; + }, + }, + logger: pino({ level: "silent" }), + }); + + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" }); + expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]); + expect(emitted).toEqual([ + { type: "usage.list_reports.update", payload: { requestId: "list", report: entry } }, + { type: "usage.list_reports.response", payload: { requestId: "list", error: null } }, + ]); +}); + +test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + runtime: { + async listUsageReports() { + return []; + }, + async listLegacyUsage(): Promise { + throw new Error("quota service down"); + }, + }, + logger: pino({ level: "silent" }), + }); + await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "u1" }); + expect(emitted[0]).toMatchObject({ + type: "rpc_error", + payload: { requestId: "u1", code: "provider_usage_list_failed" }, + }); +}); + +test("request failures terminate with an error response and no updates", async () => { + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "failed" }); + expect(emitted).toEqual([ + { + type: "usage.list_reports.response", + payload: { requestId: "failed", error: "Plugin runtime is unavailable" }, + }, + ]); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts new file mode 100644 index 0000000000..500c03a088 --- /dev/null +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -0,0 +1,77 @@ +import type { ListUsageReportsOptions } from "../../plugins/usage-sources/index.js"; +import type pino from "pino"; +import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +export interface UsageSessionOptions { + emit(message: SessionOutboundMessage): void; + runtime?: { + listUsageReports(options: ListUsageReportsOptions): Promise; + listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; + }; + logger: pino.Logger; +} + +export class UsageSession { + constructor(private readonly options: UsageSessionOptions) {} + + async handleListReports( + msg: Extract, + ): Promise { + try { + if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); + await this.options.runtime.listUsageReports({ + forceRefresh: msg.forceRefresh, + reportIds: msg.reportIds, + agentId: msg.agentId, + onReport: (report) => + this.options.emit({ + type: "usage.list_reports.update", + payload: { requestId: msg.requestId, report }, + }), + }); + this.options.emit({ + type: "usage.list_reports.response", + payload: { requestId: msg.requestId, error: null }, + }); + } catch (error) { + this.options.emit({ + type: "usage.list_reports.response", + payload: { + requestId: msg.requestId, + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + // COMPAT(providerUsageList): added in v0.9.3, remove after 2027-03-26. + async handleLegacyList( + msg: Extract, + ): Promise { + try { + if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); + const usage = await this.options.runtime.listLegacyUsage(); + this.options.emit({ + type: "provider.usage.list.response", + payload: { + requestId: msg.requestId, + fetchedAt: usage.fetchedAt, + providers: usage.providers, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.options.logger.error({ err }, "Failed to list provider usage"); + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to list provider usage: ${err.message}`, + code: "provider_usage_list_failed", + }, + }); + } + } +} diff --git a/packages/server/src/server/session/voice/index.test.ts b/packages/server/src/server/session/voice/index.test.ts new file mode 100644 index 0000000000..043b759d1e --- /dev/null +++ b/packages/server/src/server/session/voice/index.test.ts @@ -0,0 +1,58 @@ +import { EventEmitter } from "node:events"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSessions } from "./index.js"; +import type { StreamingTranscriptionSession } from "../../speech/speech-provider.js"; + +test("a failed speech close reports the failure without retaining observation demand", async () => { + class BrokenCloseStream extends EventEmitter implements StreamingTranscriptionSession { + readonly requiredSampleRate = 16000; + async connect() {} + appendPcm16() {} + commit() {} + clear() {} + close() { + throw new Error("Speech worker close failed"); + } + } + const source = {}; + const delivery = new SessionDelivery(() => {}); + delivery.attach(source, true); + const demands: boolean[] = []; + const voice = new VoiceSessions( + { + sessionId: "failed-speech-close", + logger: pino({ level: "silent" }), + host: { + emit() {}, + async loadAgent() { + throw new Error("Unexpected agent load"); + }, + async reloadAgentSession() { + throw new Error("Unexpected agent reload"); + }, + async sendSpokenInput() { + throw new Error("Unexpected spoken input"); + }, + async interruptAgentIfRunning() {}, + hasActiveAgentRun: () => false, + }, + stt: null, + tts: null, + dictation: { stt: { id: "local", createSession: () => new BrokenCloseStream() } }, + }, + delivery, + () => demands.push(voice.hasDemand), + ); + const request = { + type: "dictation_stream_start" as const, + dictationId: "recording", + format: "audio/pcm;rate=16000;bits=16", + }; + await delivery.request(source, request, () => voice.handleMessage(request)); + expect(voice.hasDemand).toBe(true); + await expect(delivery.detach(source)).rejects.toThrow(); + expect(voice.hasDemand).toBe(false); + expect(demands.at(-1)).toBe(false); +}); diff --git a/packages/server/src/server/session/voice/index.ts b/packages/server/src/server/session/voice/index.ts new file mode 100644 index 0000000000..1832da1c65 --- /dev/null +++ b/packages/server/src/server/session/voice/index.ts @@ -0,0 +1,146 @@ +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { OwnedOperation, SessionDelivery } from "../owned-subscriptions/index.js"; +import { VoiceSession, type VoiceSessionOptions } from "./voice-session.js"; + +type VoiceMessage = Extract< + SessionInboundMessage, + { + type: + | "voice_audio_chunk" + | "abort_request" + | "audio_played" + | "set_voice_mode" + | "dictation_stream_start" + | "dictation_stream_chunk" + | "dictation_stream_finish" + | "dictation_stream_cancel"; + } +>; + +interface ActiveVoiceSource { + voice: VoiceSession; + owner: OwnedOperation; + pending: Set>; + closing: boolean; +} + +/** Audio conversations belong to their requesting socket, including bootstrap and finalization. */ +export class VoiceSessions { + private readonly sources = new Map(); + + constructor( + private readonly options: VoiceSessionOptions, + private readonly delivery: SessionDelivery, + private readonly demandChanged: () => void, + ) {} + + get hasDemand(): boolean { + return [...this.sources.values()].some((source) => !source.closing); + } + + isActiveForAgent(agentId: string): boolean { + return [...this.sources.values()].some( + (source) => !source.closing && source.voice.isActiveForAgent(agentId), + ); + } + + handleMessage(message: VoiceMessage): Promise { + const source = this.delivery.currentSource; + if (!source) throw new Error("Voice request has no source"); + const existing = this.sources.get(source); + if (existing?.closing) return existing.owner.release().then(() => this.handleMessage(message)); + const active = existing ?? this.startSource(source); + const pending = Promise.resolve().then(() => { + return active.closing ? undefined : this.dispatch(active.voice, message); + }); + active.pending.add(pending); + const finished = () => { + active.pending.delete(pending); + this.releaseIdle(active); + return undefined; + }; + void pending.then(finished, finished); + return pending; + } + + private startSource(source: object): ActiveVoiceSource { + const owner = this.delivery.operation(isVoiceOutput, async () => { + active.closing = true; + this.demandChanged(); + // Cancel input immediately; restore agent configuration after bootstrap has settled. + try { + active.voice.cancel(); + } finally { + await Promise.allSettled(active.pending); + await active.voice.cleanup(); + } + if (this.sources.get(source) === active) this.sources.delete(source); + }); + const active: ActiveVoiceSource = { + owner, + pending: new Set(), + closing: false, + voice: new VoiceSession({ + ...this.options, + host: { ...this.options.host, emit: (message) => owner.emit(message) }, + onIdle: () => this.releaseIdle(active), + }), + }; + this.sources.set(source, active); + this.demandChanged(); + return active; + } + + private releaseIdle(source: ActiveVoiceSource): void { + if (source.closing || source.pending.size > 0 || source.voice.hasDemand) return; + void source.owner + .release() + .catch((error) => + this.options.logger.error({ err: error }, "Failed to release voice source"), + ); + } + + private async dispatch(voice: VoiceSession, message: VoiceMessage): Promise { + switch (message.type) { + case "voice_audio_chunk": + return voice.handleAudioChunk(message); + case "abort_request": + return voice.handleAbort(); + case "audio_played": + return voice.handleAudioPlayed(message.id); + case "set_voice_mode": + return voice.handleSetVoiceMode(message.enabled, message.agentId, message.requestId); + case "dictation_stream_start": + return voice.handleDictationStreamStart(message); + case "dictation_stream_chunk": + return voice.handleDictationChunk({ + dictationId: message.dictationId, + seq: message.seq, + audioBase64: message.audio, + format: message.format, + }); + case "dictation_stream_finish": + return voice.handleDictationFinish(message.dictationId, message.finalSeq); + case "dictation_stream_cancel": + return voice.handleDictationCancel(message.dictationId); + } + } +} + +function isVoiceOutput(message: SessionOutboundMessage): boolean { + switch (message.type) { + case "audio_output": + case "transcription_result": + case "voice_input_state": + case "set_voice_mode_response": + case "dictation_stream_ack": + case "dictation_stream_partial": + case "dictation_stream_final": + case "dictation_stream_error": + case "dictation_stream_finish_accepted": + case "activity_log": + return true; + default: + return false; + } +} diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index d6232ff4d8..1511d6f2fd 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -1,3 +1,5 @@ +import { Readable } from "node:stream"; +import type { VoiceSpeakHandler } from "../../voice-types.js"; import { EventEmitter } from "node:events"; import pino from "pino"; import { describe, expect, test, vi } from "vitest"; @@ -7,6 +9,7 @@ import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { SpeechToTextProvider, + TextToSpeechProvider, StreamingTranscriptionCommittedEvent, StreamingTranscriptionEvent, StreamingTranscriptionSession, @@ -79,7 +82,8 @@ function createFakeHost(): FakeVoiceHost { }; } -function createVoiceSession() { +function createVoiceSession(tts: TextToSpeechProvider | null = null) { + let speakHandler: VoiceSpeakHandler | undefined; const detector = new FakeVoiceTurnDetectionSession(); const sttSession = new FakeVoiceSttSession(); const stt: SpeechToTextProvider = { @@ -96,11 +100,33 @@ function createVoiceSession() { logger: pino({ level: "silent" }), sessionId: "voice-session-test", sttLanguage: "en", - tts: null, + tts, + voiceBridge: { + registerVoiceSpeakHandler: (_id, handler) => { + speakHandler = handler; + }, + }, stt, voice: { turnDetection }, }); - return { voiceSession, detector, sttSession, host }; + return { + voiceSession, + detector, + sttSession, + host, + speak: (args: Parameters[0]) => { + if (!speakHandler) throw new Error("Voice speak handler not registered"); + return speakHandler(args); + }, + }; +} + +function isAudioOutput(message: SessionOutboundMessage): boolean { + return message.type === "audio_output"; +} + +async function waitForAudioOutput(host: FakeVoiceHost): Promise { + await vi.waitFor(() => expect(host.emitted.filter(isAudioOutput)).toHaveLength(1)); } async function settle(): Promise { @@ -110,6 +136,71 @@ async function settle(): Promise { } describe("VoiceSession streaming transcription", () => { + test("interrupts playback and the agent on speech detection without partial transcripts", async () => { + const tts: TextToSpeechProvider = { + async synthesizeSpeech() { + return { stream: Readable.from([Buffer.from("audio")]), format: "pcm;rate=24000" }; + }, + }; + const { voiceSession, detector, host, speak } = createVoiceSession(tts); + const interrupted: string[] = []; + async function recordInterruption(agentId: string) { + interrupted.push(agentId); + } + host.interruptAgentIfRunning = recordInterruption; + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + const playback = speak({ text: "A spoken response." }); + try { + await waitForAudioOutput(host); + detector.emit("speech_started"); + await settle(); + expect(host.emitted).toContainEqual({ + type: "voice_input_state", + payload: { isSpeaking: true }, + }); + expect(interrupted).toEqual([VOICE_AGENT_ID]); + await playback; + } finally { + await voiceSession.cleanup(); + await playback.catch(() => {}); + } + }); + + test("session abort stops later audio even when the speak tool supplies its own signal", async () => { + const tts: TextToSpeechProvider = { + async synthesizeSpeech() { + return { stream: Readable.from([Buffer.from("audio")]), format: "pcm;rate=24000" }; + }, + }; + const { voiceSession, host, speak } = createVoiceSession(tts); + const external = new AbortController(); + let abortRequested = false; + const emit = host.emit; + function acknowledgeLaterAudio(message: SessionOutboundMessage) { + emit(message); + if (message.type === "audio_output" && abortRequested) + voiceSession.handleAudioPlayed(message.payload.id); + } + host.emit = acknowledgeLaterAudio; + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + const playback = speak({ + text: "First sentence. Second sentence. Third sentence.", + signal: external.signal, + }); + try { + await waitForAudioOutput(host); + abortRequested = true; + await voiceSession.handleAbort(); + await playback; + expect(host.emitted.filter(isAudioOutput)).toHaveLength(1); + expect(external.signal.aborted).toBe(false); + } finally { + external.abort(); + await voiceSession.cleanup(); + await playback.catch(() => {}); + } + }); + test("surfaces a refused voice-mode agent interruption", async () => { const { voiceSession, host } = createVoiceSession(); host.interruptAgentIfRunning = vi.fn(async () => { diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..95537dd021 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -135,6 +135,7 @@ export interface VoiceSessionOptions { host: VoiceSessionHost; logger: pino.Logger; sessionId: string; + onIdle?: () => void; sttLanguage?: string; tts: Resolvable; stt: Resolvable; @@ -169,6 +170,8 @@ export class VoiceSession { private readonly sttLanguage: string; private abortController: AbortController; + private closed = false; + private readonly onIdle: (() => void) | undefined; private processingPhase: ProcessingPhase = "idle"; private isVoiceMode = false; @@ -211,6 +214,7 @@ export class VoiceSession { const { host, logger, sessionId, sttLanguage, tts, stt, voice, voiceBridge, dictation } = options; this.host = host; + this.onIdle = options.onIdle; this.sessionLogger = logger; this.sessionId = sessionId; this.sttLanguage = sttLanguage ?? "en"; @@ -234,9 +238,20 @@ export class VoiceSession { stt: dictation?.stt ?? null, language: dictation?.sttLanguage, finalTimeoutMs: dictation?.finalTimeoutMs, + onIdle: this.onIdle, }); } + get hasDemand(): boolean { + return ( + this.isVoiceMode || + this.dictationStreamManager.hasDemand || + this.processingPhase !== "idle" || + this.audioBuffer !== null || + this.pendingAudioSegments.length > 0 + ); + } + isActiveForAgent(agentId: string): boolean { return this.isVoiceMode && this.voiceModeAgentId === agentId; } @@ -374,6 +389,10 @@ export class VoiceSession { ); const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId); this.voiceModeAgentId = refreshedAgentId; + if (this.closed) { + await this.disableVoiceModeForActiveAgent(true); + return; + } this.sessionLogger.info( { agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode agent enable complete", @@ -389,7 +408,7 @@ export class VoiceSession { { agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt }, "set_voice_mode voice turn controller started", ); - this.isVoiceMode = true; + this.isVoiceMode = !this.closed; this.sessionLogger.info( { agentId: this.voiceModeAgentId, @@ -473,6 +492,7 @@ export class VoiceSession { const startedAt = Date.now(); this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); const existing = await this.host.loadAgent(agentId); + if (this.closed) throw new Error("Voice source is closed"); this.sessionLogger.info( { agentId, elapsedMs: Date.now() - startedAt }, "enableVoiceModeForAgent.ensureAgentLoaded.done", @@ -516,9 +536,6 @@ export class VoiceSession { return; } - this.unregisterVoiceSpeakHandler?.(agentId); - this.unregisterVoiceCallerContext?.(agentId); - if (restoreAgentConfig && this.voiceModeBaseConfig) { const baseConfig = this.voiceModeBaseConfig; try { @@ -533,6 +550,8 @@ export class VoiceSession { } } + this.unregisterVoiceSpeakHandler?.(agentId); + this.unregisterVoiceCallerContext?.(agentId); this.voiceModeBaseConfig = null; this.voiceModeAgentId = null; } @@ -568,13 +587,9 @@ export class VoiceSession { sttLanguage: this.sttLanguage, callbacks: { onSpeechStarted: async () => { + // Voice STT providers return final transcripts only. Use the detector's + // confirmed speech event so interruption does not wait for transcription. this.sessionLogger.debug("Voice VAD speech_started"); - }, - onPartialTranscript: async ({ segmentId, transcript }) => { - this.sessionLogger.info( - { segmentId, transcriptLength: transcript.trim().length }, - "voice_input_state emitting isSpeaking=true", - ); this.emit({ type: "voice_input_state", payload: { @@ -583,6 +598,12 @@ export class VoiceSession { }); await this.handleVoiceSpeechStart(); }, + onPartialTranscript: async ({ segmentId, transcript }) => { + this.sessionLogger.debug( + { segmentId, transcriptLength: transcript.trim().length }, + "Voice partial transcript", + ); + }, onSpeechStopped: async () => { this.handleVoiceSpeechStopped(); this.setPhase("transcribing"); @@ -636,8 +657,9 @@ export class VoiceSession { }); this.sessionLogger.info("startVoiceTurnController connecting controller"); - await controller.start(); this.voiceTurnController = controller; + await controller.start(); + if (this.closed) await controller.stop(); this.sessionLogger.info("startVoiceTurnController connected"); } @@ -1038,7 +1060,9 @@ export class VoiceSession { }, "Voice speak tool call received by session handler", ); - const abortSignal = signal ?? this.abortController.signal; + const abortSignal = signal + ? AbortSignal.any([signal, this.abortController.signal]) + : this.abortController.signal; await this.ttsManager.generateAndWaitForPlayback( text, (msg) => this.emit(msg), @@ -1191,6 +1215,7 @@ export class VoiceSession { */ private setPhase(phase: ProcessingPhase): void { this.processingPhase = phase; + if (phase === "idle") this.onIdle?.(); this.sessionLogger.debug({ phase }, `Phase: ${phase}`); } @@ -1238,6 +1263,7 @@ export class VoiceSession { * debug persistence before forwarding to the session emitter. */ private emit(msg: SessionOutboundMessage): void { + if (this.closed) return; if ( msg.type === "audio_output" && (process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) && @@ -1289,21 +1315,40 @@ export class VoiceSession { this.host.emit(msg); } - /** - * Tear down all voice resources. - */ - async cleanup(): Promise { + /** Stop input synchronously, including a source disconnected during bootstrap. */ + cancel(): void { + if (this.closed) return; + this.closed = true; this.abortController.abort(); this.clearBufferTimeout(); this.pendingAudioSegments = []; this.audioBuffer = null; - await this.stopVoiceTurnController(); + const failures: unknown[] = []; + for (const cleanup of [ + () => this.ttsManager.cleanup(), + () => this.sttManager.cleanup(), + () => this.dictationStreamManager.cleanupAll(), + ]) { + try { + cleanup(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) throw new AggregateError(failures, "Voice input cleanup failed"); + } - this.ttsManager.cleanup(); - this.sttManager.cleanup(); - this.dictationStreamManager.cleanupAll(); - - await this.disableVoiceModeForActiveAgent(true); - this.isVoiceMode = false; + /** Restore the agent only after the caller has drained its in-flight requests. */ + async cleanup(): Promise { + try { + this.cancel(); + } finally { + try { + await this.stopVoiceTurnController(); + } finally { + await this.disableVoiceModeForActiveAgent(true); + this.isVoiceMode = false; + } + } } } diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts index 1eb08a7022..04bcb75996 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.test.ts @@ -7,7 +7,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; import { createWorkspaceGitObserverService } from "./workspace-git-observer-service.js"; // Watch targets are keyed by resolve(cwd), which is platform-dependent (POSIX vs Windows @@ -46,10 +45,6 @@ function makeSnapshot(cwd: string, currentBranch: string | null): WorkspaceGitRu return { cwd, git: { currentBranch } } as unknown as WorkspaceGitRuntimeSnapshot; } -function makeRecord(workspaceId: string): PersistedWorkspaceRecord { - return { workspaceId } as unknown as PersistedWorkspaceRecord; -} - function flushMicrotasks(): Promise { return new Promise((done) => setImmediate(done)); } @@ -59,12 +54,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const registerCalls: string[] = []; const unsubscribeCalls: string[] = []; const emitCwdCalls: string[] = []; - const emitWorkspaceIdCalls: string[] = []; const statusCalls: Array<{ cwd: string; branch: string | null }> = []; const branchChanges: Array<[string, string | null, string | null]> = []; const warnCalls: unknown[][] = []; - const describeCalls: PersistedWorkspaceRecord[] = []; - let describeResult: WorkspaceDescriptorPayload | null = null; const workspaceGitService: Pick = { registerWorkspace({ cwd }, listener) { @@ -81,22 +73,12 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { const service = createWorkspaceGitObserverService({ workspaceGitService, - describeWorkspaceRecordWithGitData: async (workspace) => { - describeCalls.push(workspace); - if (!describeResult) { - throw new Error("describeResult not set"); - } - return describeResult; - }, emitWorkspaceUpdateForCwd: async (cwd) => { emitCwdCalls.push(cwd); if (opts.emitCwdRejects) { throw new Error("emit boom"); } }, - emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { - emitWorkspaceIdCalls.push(workspaceId); - }, emitStatusUpdate: (cwd, snapshot) => { statusCalls.push({ cwd, branch: snapshot.git.currentBranch ?? null }); }, @@ -120,14 +102,9 @@ function buildHarness(opts: { emitCwdRejects?: boolean } = {}) { registerCalls, unsubscribeCalls, emitCwdCalls, - emitWorkspaceIdCalls, statusCalls, branchChanges, warnCalls, - describeCalls, - setDescribeResult: (descriptor: WorkspaceDescriptorPayload) => { - describeResult = descriptor; - }, }; } @@ -267,30 +244,6 @@ describe("git snapshot listener", () => { }); }); -describe("shouldSkipUpdate", () => { - test("returns false when no observer exists for the workspace", () => { - const h = buildHarness(); - expect(h.service.shouldSkipUpdate("unknown", null)).toBe(false); - }); - - test("skips a repeat descriptor state and re-emits when it changes", () => { - const h = buildHarness(); - h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); - const a = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - const b = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "feature" }); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(false); - expect(h.service.shouldSkipUpdate("ws1", a)).toBe(true); - expect(h.service.shouldSkipUpdate("ws1", b)).toBe(false); - }); - - test("starts from the descriptor state recorded during observer sync", () => { - const h = buildHarness(); - const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - h.service.syncObservers([descriptor]); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(true); - }); -}); - describe("recordDescriptorState", () => { test("does not treat a workspace title as a branch change", () => { const h = buildHarness(); @@ -385,26 +338,7 @@ describe("teardown", () => { h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]); h.service.dispose(); const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1, name: "main" }); - expect(h.service.shouldSkipUpdate("ws1", descriptor)).toBe(false); h.service.recordDescriptorState("ws1", descriptor); expect(h.branchChanges).toEqual([]); }); }); - -describe("syncObserverForWorkspace / warmGitData", () => { - test("describes the record then registers the observer", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.syncObserverForWorkspace(makeRecord("ws1")); - expect(h.describeCalls).toEqual([makeRecord("ws1")]); - expect(h.registerCalls).toEqual([WS1]); - }); - - test("warmGitData registers the observer and emits a workspace update", async () => { - const h = buildHarness(); - h.setDescribeResult(makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })); - await h.service.warmGitData(makeRecord("ws1")); - expect(h.registerCalls).toEqual([WS1]); - expect(h.emitWorkspaceIdCalls).toEqual(["ws1"]); - }); -}); diff --git a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts index dfc87b312b..d657c63957 100644 --- a/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts +++ b/packages/server/src/server/session/workspace-git-observer/workspace-git-observer-service.ts @@ -5,9 +5,6 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, } from "../../workspace-git-service.js"; -import type { PersistedWorkspaceRecord } from "../../workspace-registry.js"; - -const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; interface WorkspaceGitWatchTarget { workspaceIds: Set; @@ -15,7 +12,6 @@ interface WorkspaceGitWatchTarget { interface WorkspaceGitWatchState { cwd: string; - latestDescriptorStateKey: string | null; lastBranchName: string | null; } @@ -39,11 +35,11 @@ export interface WorkspaceGitObserverMetrics { */ export interface WorkspaceGitObserverService { syncObservers(workspaces: Iterable): void; - syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise; - warmGitData(workspace: PersistedWorkspaceRecord): Promise; - // Check-and-record dedupe gate: returns true when the descriptor state is unchanged - // for this workspace, and otherwise advances the recorded state key as a side effect. - shouldSkipUpdate(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): boolean; + reconcileObservers( + workspaces: Iterable< + Pick + >, + ): void; recordDescriptorState(workspaceId: string, workspace: WorkspaceDescriptorPayload | null): void; handleBranchSnapshot(cwd: string, branchName: string | null): void; getMetrics(): WorkspaceGitObserverMetrics; @@ -53,11 +49,7 @@ export interface WorkspaceGitObserverService { export function createWorkspaceGitObserverService(deps: { workspaceGitService: Pick; - describeWorkspaceRecordWithGitData: ( - workspace: PersistedWorkspaceRecord, - ) => Promise; emitWorkspaceUpdateForCwd: (cwd: string) => Promise; - emitWorkspaceUpdateForWorkspaceId: (workspaceId: string) => Promise; emitStatusUpdate: (cwd: string, snapshot: WorkspaceGitRuntimeSnapshot) => void; onBranchChanged?: ( workspaceId: string, @@ -68,9 +60,7 @@ export function createWorkspaceGitObserverService(deps: { }): WorkspaceGitObserverService { const { workspaceGitService, - describeWorkspaceRecordWithGitData, emitWorkspaceUpdateForCwd, - emitWorkspaceUpdateForWorkspaceId, emitStatusUpdate, onBranchChanged, logger, @@ -80,16 +70,6 @@ export function createWorkspaceGitObserverService(deps: { const workspaceStates = new Map(); const subscriptions = new Map void>(); - function descriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string { - if (!workspace) { - return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY; - } - return JSON.stringify([ - workspace.name, - workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null, - ]); - } - function rememberDescriptorState( workspaceId: string, workspace: WorkspaceDescriptorPayload | null, @@ -98,7 +78,6 @@ export function createWorkspaceGitObserverService(deps: { if (!state) { return; } - state.latestDescriptorStateKey = descriptorStateKey(workspace); const currentBranch = workspace?.gitRuntime?.currentBranch; if (currentBranch !== undefined) { state.lastBranchName = currentBranch; @@ -168,7 +147,6 @@ export function createWorkspaceGitObserverService(deps: { if (!workspaceStates.has(options.workspaceId)) { workspaceStates.set(options.workspaceId, { cwd: normalizedCwd, - latestDescriptorStateKey: null, lastBranchName: null, }); } @@ -206,33 +184,20 @@ export function createWorkspaceGitObserverService(deps: { } } - async function syncObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { - const descriptor = await describeWorkspaceRecordWithGitData(workspace); - syncObservers([descriptor]); - } - return { + reconcileObservers(workspaces) { + const retained = new Map([...workspaces].map((workspace) => [workspace.id, workspace])); + for (const workspaceId of workspaceStates.keys()) { + if (!retained.has(workspaceId)) removeForWorkspaceId(workspaceId); + } + for (const workspace of retained.values()) { + syncObserver(workspace.workspaceDirectory, { + isGit: workspace.workspaceKind !== "directory", + workspaceId: workspace.id, + }); + } + }, syncObservers, - syncObserverForWorkspace, - - async warmGitData(workspace) { - await syncObserverForWorkspace(workspace); - await emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId); - }, - - shouldSkipUpdate(workspaceId, workspace) { - const state = workspaceStates.get(workspaceId); - if (!state) { - return false; - } - const nextStateKey = descriptorStateKey(workspace); - if (state.latestDescriptorStateKey === nextStateKey) { - return true; - } - state.latestDescriptorStateKey = nextStateKey; - return false; - }, - recordDescriptorState(workspaceId, nextWorkspace) { const state = workspaceStates.get(workspaceId); const newBranchName = nextWorkspace?.gitRuntime?.currentBranch; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 0dee0a08d0..2692250b49 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -1,9 +1,10 @@ import os from "node:os"; import path from "node:path"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; +import { getCheckoutStatus } from "../../../utils/checkout-git.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createNoGitWorkspaceRuntimeSnapshot, @@ -16,6 +17,7 @@ import { createPersistedWorkspaceRecord, type WorkspaceRegistry, } from "../../workspace-registry.js"; +import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { createWorkspaceProvisioningService, @@ -31,6 +33,15 @@ const logger = createTestLogger(); const ARCHIVED_AT = "2026-01-01T00:00:00.000Z"; const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; +// The real filesystem, so "this directory is gone" is observed rather than modelled. +async function isDirectory(target: string): Promise { + try { + return statSync(target).isDirectory(); + } catch { + return false; + } +} + let tmpDir: string; let gitRoots: Set; let gitBranches: Map; @@ -106,6 +117,7 @@ beforeEach(async () => { workspaceRegistry, projectRegistry, workspaceGitService: gitService(), + isDirectory, logger, }); }); @@ -133,6 +145,46 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { + const dir = path.join(tmpDir, "plain"); + const events: string[] = []; + provisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: gitService(), + isDirectory, + logger, + lifecycle: { + emit: (name) => { + events.push(name); + }, + before: async (_name, request) => request, + }, + }); + const parent = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + background: true, + }); + const child = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + }); + const visible = await provisioning.createWorkspaceForDirectory(dir, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const human = await provisioning.createWorkspaceForDirectory(dir); + expect(parent.background).toBe(true); + expect(child.background).toBe(true); + expect(visible.background).toBe(false); + expect(human.background).toBe(false); + expect(events).toEqual(Array(4).fill("workspace.created")); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect((await reloaded.get(child.workspaceId))?.background).toBe(true); +}); + test("re-opening an active workspace by exact path returns the same record without duplicating", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -161,6 +213,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.upsert({ ...first, title: "Pinned work" }); gitRoots.add(repo); @@ -187,6 +240,7 @@ test("persists manual worktree ownership separately from its workspace kind", as const manualWorktreeProvisioning = createWorkspaceProvisioningService({ workspaceRegistry, projectRegistry, + isDirectory, workspaceGitService: createNoopWorkspaceGitService({ peekSnapshot: () => null, getCheckout: async () => ({ @@ -222,8 +276,33 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps expect(reopened.archivedAt).toBeNull(); }); +test("default directory open skips archived background workspaces while explicit recovery preserves their visibility", async () => { + const cwd = path.join(tmpDir, "repo"); + mkdirSync(cwd, { recursive: true }); + const background = await provisioning.createWorkspaceForDirectory(cwd, undefined, undefined, { + background: true, + }); + await workspaceRegistry.archive(background.workspaceId, ARCHIVED_AT); + + const opened = await provisioning.findOrCreateWorkspaceForDirectory(cwd); + expect(opened).toMatchObject({ background: false, archivedAt: null }); + expect(opened.workspaceId).not.toBe(background.workspaceId); + const archived = await workspaceRegistry.get(background.workspaceId); + expect(archived).toMatchObject({ background: true, archivedAt: ARCHIVED_AT }); + const recovered = await provisioning.ensureWorkspaceRecordUnarchived(archived!); + expect(recovered).toMatchObject({ + workspaceId: background.workspaceId, + background: true, + archivedAt: null, + }); + expect((await provisioning.findOrCreateWorkspaceForDirectory(cwd)).workspaceId).toBe( + opened.workspaceId, + ); +}); + test("reopening archived exact-root records restores the fresh Git project", async () => { const cwd = path.join(tmpDir, "repo"); + mkdirSync(cwd, { recursive: true }); const project = await projectRegistry.getOrCreateActiveByRoot({ rootPath: cwd, kind: "non_git", @@ -249,6 +328,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy const archivedProvisioning = createWorkspaceProvisioningService({ workspaceRegistry, projectRegistry, + isDirectory, workspaceGitService: createNoopWorkspaceGitService({ peekSnapshot: () => null, getCheckout: async () => ({ @@ -279,6 +359,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy test("uses one workspace snapshot when reopening an archived workspace", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); @@ -300,6 +381,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( workspaceRegistry: snapshotRegistry, projectRegistry, workspaceGitService: gitService(), + isDirectory, }); const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo); @@ -310,6 +392,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( test("reopening an archived workspace refreshes placement without renaming it", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" }); @@ -430,6 +513,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged test("does not unarchive either record when checkout refresh fails", async () => { const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); await projectRegistry.archive(created.projectId, ARCHIVED_AT); @@ -767,3 +851,136 @@ test.each(["missing", "archived"] as const)( expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []); }, ); + +test("a failed import keeps an archived worktree's placement when its directory is gone", async () => { + // The git port here is the production read: getCheckoutStatus against the real + // filesystem, so "the worktree directory is gone" is observed, not modelled. + const mainRepoRoot = path.join(tmpDir, "main-repo"); + mkdirSync(mainRepoRoot, { recursive: true }); + const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example"); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath: mainRepoRoot, + kind: "git", + displayName: "main-repo", + timestamp: ARCHIVED_AT, + }); + const archived = createPersistedWorkspaceRecord({ + workspaceId: "ws-archived-worktree", + projectId: project.projectId, + cwd: worktreeCwd, + kind: "worktree", + displayName: "feature/example", + branch: "feature/example", + worktreeRoot: worktreeCwd, + baseBranch: "main", + isPaseoOwnedWorktree: true, + mainRepoRoot, + createdAt: ARCHIVED_AT, + updatedAt: ARCHIVED_AT, + archivedAt: ARCHIVED_AT, + }); + await workspaceRegistry.upsert(archived); + const realCheckoutProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + logger, + isDirectory, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async (cwd: string) => { + const status = await getCheckoutStatus(cwd, { logger }); + return checkoutLiteFromGitSnapshot( + cwd, + status.isGit + ? { + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + repoRoot: status.repoRoot, + isPaseoOwnedWorktree: status.isPaseoOwnedWorktree, + mainRepoRoot: status.mainRepoRoot, + } + : { + isGit: false, + currentBranch: null, + remoteUrl: null, + repoRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + ); + }, + }), + }); + + await expect( + realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => { + throw new Error("provider resume failed"); + }), + ).rejects.toThrow("provider resume failed"); + + expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({ + kind: "worktree", + branch: "feature/example", + worktreeRoot: worktreeCwd, + mainRepoRoot, + isPaseoOwnedWorktree: true, + }); +}); + +test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => { + const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); + gitRoots.add(repo); + gitBranches.set(repo, "feature/away"); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" }); + rmSync(repo, { recursive: true, force: true }); + gitRoots.delete(repo); + + const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + kind: "local_checkout", + branch: "feature/away", + }); +}); + +test("a directory that goes away while the git read is in flight keeps its placement", async () => { + const repo = path.join(tmpDir, "repo"); + mkdirSync(repo, { recursive: true }); + gitRoots.add(repo); + gitBranches.set(repo, "feature/vanishing"); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" }); + const vanishingProvisioning = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + isDirectory, + logger, + workspaceGitService: createNoopWorkspaceGitService({ + peekSnapshot: () => null, + getCheckout: async (cwd: string) => { + rmSync(repo, { recursive: true, force: true }); + return { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + }, + }), + }); + + const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo); + + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + kind: "local_checkout", + branch: "feature/vanishing", + }); +}); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index eb9905016d..6dbcabdda6 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -1,4 +1,7 @@ +import type { PluginLifecycle } from "../../plugins/lifecycle/index.js"; +import { describeHookWorkspace } from "../../plugins/lifecycle/index.js"; import { basename, resolve } from "node:path"; +import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import type { Logger } from "pino"; import { generateWorkspaceId, @@ -23,6 +26,8 @@ export interface ResolveOrCreateWorkspaceIdInput { requestedWorkspaceId?: string; cwd: string; initialTitle: string | null; + background?: boolean; + callerWorkspaceId?: string; } export interface ImportWorkspaceInput { @@ -38,6 +43,7 @@ export interface ImportWorkspaceResult { export interface CreateWorktreeWorkspaceInput { sourceCwd: string; projectId?: string; + workspaceId?: string; repoRoot: string; cwd: string; worktreeRoot: string; @@ -46,6 +52,8 @@ export interface CreateWorktreeWorkspaceInput { title: string | null; expectsInitialAgent?: boolean; untrustedSource?: UntrustedWorkspaceSource; + background?: boolean; + callerWorkspaceId?: string; } export interface WorkspaceProvisioningService { @@ -59,7 +67,12 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -91,10 +104,27 @@ export function createWorkspaceProvisioningService(deps: { workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; workspaceGitService: Pick; + isDirectory: (path: string) => Promise; logger: Logger; + lifecycle?: PluginLifecycle; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + /** + * Placement facts at a workspace directory, or null when there is nothing + * there to read. A git read answers "not a checkout" for a plain directory + * and for a directory that is gone, and only the first is evidence that a + * worktree stopped being one. That answer therefore counts only while the + * directory is there on both sides of the read, so a worktree removed or + * unmounted while the read is in flight stays an absence. + */ + async function observeWorkspaceCheckout(cwd: string): Promise { + if (!(await deps.isDirectory(cwd))) return null; + const checkout = await workspaceGitService.getCheckout(cwd); + if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null; + return checkout; + } + async function runInImportWorkspace( input: ImportWorkspaceInput, operation: (workspace: PersistedWorkspaceRecord) => Promise, @@ -194,11 +224,27 @@ export function createWorkspaceProvisioningService(deps: { return project; } + async function resolveBackground(input?: { + background?: boolean; + callerWorkspaceId?: string; + }): Promise { + if (input?.background !== undefined) return input.background; + if (!input?.callerWorkspaceId) return false; + const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); + if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); + return callerWorkspace.background; + } + async function createWorkspaceForDirectory( cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { + expectsInitialAgent?: boolean; + workspaceId?: string; + background?: boolean; + callerWorkspaceId?: string; + }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -208,14 +254,18 @@ export function createWorkspaceProvisioningService(deps: { await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: context?.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, + background: await resolveBackground(context), }); - await workspaceRegistry.upsert(workspace, context); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); return workspace; } @@ -233,7 +283,7 @@ export function createWorkspaceProvisioningService(deps: { }); const timestamp = new Date().toISOString(); const workspace = createPersistedWorkspaceRecord({ - workspaceId: generateWorkspaceId(), + workspaceId: input.workspaceId ?? generateWorkspaceId(), projectId: project.projectId, ...initialWorkspacePlacement({ source: "created_worktree", @@ -247,13 +297,19 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), + background: await resolveBackground(input), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, }); + emitWorkspaceCreated(workspace); return workspace; } + function emitWorkspaceCreated(workspace: PersistedWorkspaceRecord): void { + deps.lifecycle?.emit("workspace.created", { workspace: describeHookWorkspace(workspace) }); + } + async function resolveSourceProjectForWorktree(input: { sourceCwd: string; projectId?: string; @@ -297,26 +353,19 @@ export function createWorkspaceProvisioningService(deps: { async function findOrCreateWorkspaceForDirectory(cwd: string): Promise { const normalizedCwd = resolve(cwd); - const workspaces = await workspaceRegistry.list(); - const active = workspaces + // Path-based discovery selects public work; explicit-ID recovery bypasses this selector. + const workspaces = (await workspaceRegistry.list()) .filter( - (workspace) => !workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), + (workspace) => !workspace.background && areEquivalentPaths(workspace.cwd, normalizedCwd), ) .sort( (left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.workspaceId.localeCompare(right.workspaceId), - )[0]; + ); + const active = workspaces.find((workspace) => !workspace.archivedAt); if (active) return refreshWorkspaceRecord(active); - const archived = workspaces - .filter( - (workspace) => workspace.archivedAt && areEquivalentPaths(workspace.cwd, normalizedCwd), - ) - .sort( - (left, right) => - Date.parse(left.createdAt) - Date.parse(right.createdAt) || - left.workspaceId.localeCompare(right.workspaceId), - )[0]; + const archived = workspaces.find((workspace) => workspace.archivedAt); if (archived) { const project = await projectRegistry.get(archived.projectId); if (project && !project.archivedAt) return ensureWorkspaceRecordUnarchived(archived); @@ -332,6 +381,8 @@ export function createWorkspaceProvisioningService(deps: { return ( await createWorkspaceForDirectory(input.cwd, input.initialTitle, undefined, { expectsInitialAgent: true, + background: input.background, + callerWorkspaceId: input.callerWorkspaceId, }) ).workspaceId; } @@ -360,12 +411,12 @@ export function createWorkspaceProvisioningService(deps: { const timestamp = new Date().toISOString(); const checkout = workspace.archivedAt || project.archivedAt - ? await workspaceGitService.getCheckout(workspace.cwd) + ? await observeWorkspaceCheckout(workspace.cwd) : null; const autoArchivedChangeRequestUrl = await resolveRestoredAutoArchiveChangeRequestUrl(workspace); let next: PersistedWorkspaceRecord | null = null; - if (workspace.archivedAt && checkout) { + if (workspace.archivedAt) { const placementUpdate = reconcileWorkspacePlacement({ workspace, checkout, @@ -378,10 +429,11 @@ export function createWorkspaceProvisioningService(deps: { updatedAt: timestamp, }; } - if (checkout && (project.archivedAt || workspace.archivedAt)) { - const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd) - ? checkout - : await workspaceGitService.getCheckout(project.rootPath); + if (project.archivedAt || workspace.archivedAt) { + const projectCheckout = + checkout && areEquivalentPaths(project.rootPath, workspace.cwd) + ? checkout + : await workspaceGitService.getCheckout(project.rootPath); const kind = projectCheckout.isGit ? "git" : "non_git"; const projectKey = deriveProjectKey({ rootPath: project.rootPath, @@ -408,10 +460,10 @@ export function createWorkspaceProvisioningService(deps: { async function refreshWorkspaceRecord( workspace: PersistedWorkspaceRecord, ): Promise { - const checkout = await workspaceGitService.getCheckout(workspace.cwd); + const checkout = await observeWorkspaceCheckout(workspace.cwd); const project = await projectRegistry.get(workspace.projectId); if (project && !project.archivedAt) { - await refreshProjectKind(project, workspace.cwd, checkout); + await refreshProjectKind(project, workspace.cwd, checkout ?? undefined); } const update = reconcileWorkspacePlacement({ workspace, diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 91631a5631..964f305eb8 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -13,8 +13,16 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { createWorktree } from "../../../utils/worktree.js"; +import pino from "pino"; +import { archivePersistedWorkspaceRecord } from "../../workspace-archive-service.js"; +import { getCheckoutStatus } from "../../../utils/checkout-git.js"; import { + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, +} from "../../../utils/worktree-metadata.js"; +import { createWorktree, deletePaseoWorktree } from "../../../utils/worktree.js"; +import { + FileBackedWorkspaceRegistry, createPersistedProjectRecord, createPersistedWorkspaceRecord, type PersistedProjectRecord, @@ -286,3 +294,186 @@ function createGitRepository(): { tempDir: string; repoDir: string } { execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); return { tempDir, repoDir }; } + +async function createBaseRecoveryFixture(baseBranch: string | null) { + const { tempDir, repoDir } = createGitRepository(); + const paseoHome = join(tempDir, "paseo-home"); + const created = await createWorktree({ + cwd: repoDir, + paseoHome, + worktreeSlug: "base-recovery", + source: { kind: "branch-off", branchName: "feature", baseBranch: "main" }, + runSetup: false, + }); + writeFileSync(join(created.worktreePath, "feature.txt"), "feature\n"); + execFileSync("git", ["add", "."], { cwd: created.worktreePath }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature"], { + cwd: created.worktreePath, + stdio: "pipe", + }); + const workspace = createWorkspace({ + workspaceId: "base-recovery", + cwd: created.worktreePath, + worktreeRoot: created.worktreePath, + mainRepoRoot: repoDir, + branch: "feature", + baseBranch, + archivedAt: null, + }); + const project = createProject({ rootPath: repoDir }); + const registry = new FileBackedWorkspaceRegistry( + join(tempDir, "workspaces.json"), + pino({ level: "silent" }), + ); + await registry.upsert(workspace); + const service = createWorkspaceRecoveryService({ + paseoHome, + getWorkspace: (id) => registry.get(id), + getProject: async () => project, + isDirectory: async (target) => existsSync(target) && statSync(target).isDirectory(), + unarchiveWorkspace: async (record) => { + await registry.update(record.workspaceId, (value) => ({ ...value, archivedAt: null })); + }, + }); + async function archiveAndRemove() { + await archivePersistedWorkspaceRecord({ + workspaceId: workspace.workspaceId, + workspaceRegistry: registry, + }); + await deletePaseoWorktree({ cwd: repoDir, worktreePath: workspace.cwd, paseoHome }); + expect(existsSync(workspace.cwd)).toBe(false); + } + return { workspace, registry, service, paseoHome, repoDir, tempDir, archiveAndRemove }; +} + +test("preserves ordinary checkout behavior when no separate base was recorded", async () => { + const fixture = await createBaseRecoveryFixture(null); + writePaseoWorktreeMetadata(fixture.workspace.cwd, { baseRefName: "feature" }); + await fixture.archiveAndRemove(); + await fixture.service.restore(fixture.workspace.workspaceId); + expect((await fixture.registry.get(fixture.workspace.workspaceId))?.baseBranch).toBeNull(); + expect( + await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), + ).toMatchObject({ currentBranch: "feature", baseRef: "feature" }); +}); + +test("restores a branchless workspace from its recorded base so work can continue", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/release"); + execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + writeFileSync(join(fixture.repoDir, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" }); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "restore", + branch: null, + }); + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + + const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }); + expect(checkout.currentBranch).toBeTruthy(); + expect(checkout.currentBranch).not.toBe("feature"); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "release"], { + cwd: fixture.repoDir, + encoding: "utf8", + }).trim(), + ); +}); + +test("restores a branchless workspace from the repository default when its base is missing", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base"); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(), + ); +}); + +test("restores the branch HEAD with a base name when the exact base is missing", async () => { + const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main"); + const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(); + await fixture.archiveAndRemove(); + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + expect(existsSync(fixture.workspace.cwd)).toBe(true); + expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).toBeNull(); + const metadata = readPaseoWorktreeMetadata(fixture.workspace.cwd); + expect(metadata).toMatchObject({ baseRefName: "main" }); + expect(metadata?.baseRef).toBeUndefined(); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe(headBeforeArchive); +}); + +test("restore fetches a deleted local branch from origin and retains its base", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/main"); + const remote = join(fixture.tempDir, "remote.git"); + execFileSync("git", ["init", "--bare", remote], { stdio: "pipe" }); + execFileSync("git", ["remote", "add", "origin", remote], { cwd: fixture.repoDir }); + execFileSync("git", ["push", "origin", "feature"], { cwd: fixture.repoDir, stdio: "pipe" }); + await fixture.archiveAndRemove(); + execFileSync("git", ["branch", "-D", "feature"], { cwd: fixture.repoDir }); + await fixture.service.restore(fixture.workspace.workspaceId); + expect(readPaseoWorktreeMetadata(fixture.workspace.cwd)?.baseRef).toBe("refs/heads/main"); + expect( + await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }), + ).toMatchObject({ + currentBranch: "feature", + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + }); +}); + +test("restore rejects a branch checked out elsewhere without inventing another branch", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/main"); + await fixture.archiveAndRemove(); + execFileSync("git", ["switch", "feature"], { cwd: fixture.repoDir, stdio: "pipe" }); + await expect(fixture.service.restore(fixture.workspace.workspaceId)).rejects.toThrow( + /already checked out/, + ); + expect(existsSync(fixture.workspace.cwd)).toBe(false); + expect((await fixture.registry.get(fixture.workspace.workspaceId))?.archivedAt).not.toBeNull(); + expect( + execFileSync("git", ["branch", "--list", "feature*"], { + cwd: fixture.repoDir, + encoding: "utf8", + }).trim(), + ).toBe("* feature"); +}); diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 1d98a9128c..8b0e9a853a 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,5 +1,7 @@ +import { randomUUID } from "node:crypto"; import { basename } from "node:path"; +import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; import { @@ -7,6 +9,7 @@ import { isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, rollbackCreatedPaseoWorktree, + type WorktreeSource, } from "../../../utils/worktree.js"; import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js"; import { @@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: { message: "The archived workspace directory no longer exists and cannot be recreated.", }; } - if (!workspace.branch) { - return { - kind: "unavailable", - workspaceId, - reason: "worktree_branch_missing", - message: "The archived worktree has no branch recorded, so it cannot be restored.", - }; - } - // COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110 // lack placement ownership; remove the project-root fallback after 2027-01-17. const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath; @@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: { workspace: PersistedWorkspaceRecord, sourceRepoRoot: string, ): Promise { - const branch = workspace.branch; - if (!branch) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Workspace ${workspace.workspaceId} has no branch to restore`, - }); - } + const source: WorktreeSource = workspace.branch + ? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch } + : { + kind: "restore-from-base", + branchName: `restored/${randomUUID().slice(0, 8)}`, + baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch), + }; try { await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 }); @@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: { const result = await createWorktree({ cwd: sourceRepoRoot, worktreeSlug: basename(previousWorktreePath), - source: { kind: "checkout-branch", branchName: branch }, + source, runSetup: false, paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, @@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: { return { inspect, restore }; } +async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise { + if (recordedBase) { + try { + await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot }); + return recordedBase; + } catch { + // The saved base was removed; use the repository default for continued work. + } + } + const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot); + if (!defaultBranch) { + throw new WorktreeRequestError({ + code: "unknown", + message: + "The saved base is unavailable and the repository has no default branch to restore from.", + }); + } + return defaultBranch; +} + function createRecoveryPlan( input: | { action: "unarchive"; workspace: PersistedWorkspaceRecord } diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 0ba8a0169b..aa366a3495 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pino } from "pino"; import { afterEach, describe, expect, test } from "vitest"; -import type { SessionOutboundMessage, StartWorkspaceScriptRequest } from "../../messages.js"; +import type { + ScriptStatusUpdateMessage, + SessionOutboundMessage, + StartWorkspaceScriptRequest, +} from "../../messages.js"; import { createServiceProxySubsystem, type ServiceProxySubsystem } from "../../service-proxy.js"; import type { TerminalManager } from "../../../terminal/terminal-manager.js"; import type { @@ -82,6 +86,7 @@ interface BuildOptions { function buildService(options: BuildOptions = {}) { const emitted: SessionOutboundMessage[] = []; + const published: ScriptStatusUpdateMessage[] = []; const spawnCalls: SpawnWorkspaceScriptOptions[] = []; const workspace = options.workspace === undefined @@ -108,6 +113,7 @@ function buildService(options: BuildOptions = {}) { resolveScriptHealth: null, logger, emit: (message) => emitted.push(message), + publishStatusUpdate: (message) => published.push(message), async spawnWorkspaceScript(spawnOptions): Promise { spawnCalls.push(spawnOptions); if (options.spawnThrows) { @@ -126,7 +132,7 @@ function buildService(options: BuildOptions = {}) { }, }); - return { service, emitted, spawnCalls }; + return { service, emitted, published, spawnCalls }; } const request: StartWorkspaceScriptRequest = { @@ -215,10 +221,11 @@ describe("buildSnapshot", () => { }); describe("emitStatusUpdate", () => { - test("emits one script_status_update carrying the snapshot", async () => { - const { service, emitted } = buildService(); + test("publishes one script_status_update carrying the snapshot", async () => { + const { service, emitted, published } = buildService(); await service.emitStatusUpdate("ws-1", "/tmp/repo"); - expect(emitted).toEqual([ + expect(emitted).toEqual([]); + expect(published).toEqual([ { type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] } }, ]); }); @@ -332,7 +339,7 @@ describe("start", () => { }); test("spawns the script with resolved git metadata and reports success", async () => { - const { service, emitted, spawnCalls } = buildService(); + const { service, emitted, published, spawnCalls } = buildService(); await service.start(request); expect(spawnCalls).toHaveLength(1); @@ -345,7 +352,7 @@ describe("start", () => { daemonPort: 6767, daemonListenHost: "127.0.0.1", }); - expect(emitted).toContainEqual({ + expect(published).toContainEqual({ type: "script_status_update", payload: { workspaceId: "ws-1", scripts: [] }, }); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 7dee9364f9..d449a48855 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -1,5 +1,6 @@ import type pino from "pino"; import type { + ScriptStatusUpdateMessage, SessionOutboundMessage, StartWorkspaceScriptRequest, WorkspaceDescriptorPayload, @@ -31,7 +32,7 @@ type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"]; /** * The service-proxy-backed scripts a workspace exposes: build the scripts payload - * snapshot, emit a script_status_update to clients, and start a script. + * snapshot, publish a script_status_update to every client, and start a script. * * The workspace descriptor builder, the script-status emission path, and the * start-script RPC all funnel through one assembly of buildWorkspaceScriptPayloads' @@ -66,6 +67,8 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts?: PaseoServicePortAllocation; logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; + // Script status is daemon state, so it reaches every client, not only the requester. + publishStatusUpdate: (message: ScriptStatusUpdateMessage) => void; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; assertAutomationAllowed: (workspaceId: string) => Promise; }): WorkspaceScriptsService { @@ -83,6 +86,7 @@ export function createWorkspaceScriptsService(deps: { globalServicePorts, logger, emit, + publishStatusUpdate, spawnWorkspaceScript, assertAutomationAllowed, } = deps; @@ -133,7 +137,7 @@ export function createWorkspaceScriptsService(deps: { const workspace = await workspaceRegistry.get(workspaceId); if (!workspace) return; const project = await projectRegistry.get(workspace.projectId); - emit({ + publishStatusUpdate({ type: "script_status_update", payload: { workspaceId, scripts: buildSnapshot(workspace, project) }, }); diff --git a/packages/server/src/server/snapshot-mutation-ownership.test.ts b/packages/server/src/server/snapshot-mutation-ownership.test.ts index b08ca5e6d0..2cfe5d3e18 100644 --- a/packages/server/src/server/snapshot-mutation-ownership.test.ts +++ b/packages/server/src/server/snapshot-mutation-ownership.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import os from "node:os"; import path from "node:path"; import { mkdtempSync, rmSync } from "node:fs"; @@ -99,6 +103,8 @@ describe("snapshot mutation ownership boundary", () => { const session = asInternals( new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "test-client", permissions: OWNER_PERMISSIONS, onMessage, diff --git a/packages/server/src/server/speech/providers/local/models.test.ts b/packages/server/src/server/speech/providers/local/models.test.ts new file mode 100644 index 0000000000..e40cc25384 --- /dev/null +++ b/packages/server/src/server/speech/providers/local/models.test.ts @@ -0,0 +1,70 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { describe, expect, onTestFinished, test } from "vitest"; + +import { + ensureLocalSpeechModels, + getLocalSpeechModelDir, + listLocalSpeechModels, + listMissingLocalSpeechModels, +} from "./models.js"; + +const logger = pino({ level: "silent" }); +const modelId = "parakeet-tdt-0.6b-v2-int8"; +const spec = listLocalSpeechModels().find((model) => model.id === modelId)!; + +function writeCompleteModel(modelDir: string): void { + mkdirSync(modelDir, { recursive: true }); + for (const file of spec.requiredFiles) { + writeFileSync(path.join(modelDir, file), `complete ${file}`.padEnd(64_000, ".")); + } +} + +function setupModelsDirWithArchive(): { modelsDir: string; modelDir: string; archive: string } { + const root = mkdtempSync(path.join(tmpdir(), "paseo-local-models-")); + onTestFinished(() => rmSync(root, { recursive: true, force: true })); + const sourceRoot = path.join(root, "source"); + writeCompleteModel(path.join(sourceRoot, spec.extractedDir)); + const modelsDir = path.join(root, "models"); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", sourceRoot, spec.extractedDir]); + return { modelsDir, modelDir: getLocalSpeechModelDir(modelsDir, modelId), archive }; +} + +describe("local speech models", () => { + test("reinstalls a model whose extraction was interrupted", async () => { + // An interrupted extraction leaves the archive in .downloads and a truncated file in place. + const { modelsDir, modelDir, archive } = setupModelsDirWithArchive(); + writeCompleteModel(modelDir); + const encoder = path.join(modelDir, spec.requiredFiles[0]); + truncateSync(encoder, 1_000); + + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([ + modelId, + ]); + + await ensureLocalSpeechModels({ modelsDir, modelIds: [modelId], logger }); + + expect(readFileSync(encoder, "utf8")).toBe( + `complete ${spec.requiredFiles[0]}`.padEnd(64_000, "."), + ); + expect(existsSync(archive)).toBe(false); + expect(await listMissingLocalSpeechModels({ modelsDir, modelIds: [modelId] })).toEqual([]); + }); +}); diff --git a/packages/server/src/server/speech/providers/local/models.ts b/packages/server/src/server/speech/providers/local/models.ts index f6c7cc4e4e..a884be86d7 100644 --- a/packages/server/src/server/speech/providers/local/models.ts +++ b/packages/server/src/server/speech/providers/local/models.ts @@ -1,4 +1,8 @@ -import { ensureSherpaOnnxModels, getSherpaOnnxModelDir } from "./sherpa/model-downloader.js"; +import { + ensureSherpaOnnxModels, + getSherpaOnnxModelDir, + isSherpaOnnxModelInstalled, +} from "./sherpa/model-downloader.js"; import { DEFAULT_LOCAL_STT_MODEL, DEFAULT_LOCAL_TTS_MODEL, @@ -30,6 +34,16 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo return getSherpaOnnxModelDir(modelsDir, modelId); } +export async function listMissingLocalSpeechModels(options: { + modelsDir: string; + modelIds: LocalSpeechModelId[]; +}): Promise { + const installed = await Promise.all( + options.modelIds.map((modelId) => isSherpaOnnxModelInstalled(options.modelsDir, modelId)), + ); + return options.modelIds.filter((_modelId, index) => !installed[index]); +} + export async function ensureLocalSpeechModels(options: { modelsDir: string; modelIds: LocalSpeechModelId[]; 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 993c959b4d..bc2e462a53 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 @@ -1,10 +1,21 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, + existsSync, + truncateSync, + readdirSync, +} from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; import { ensureSherpaOnnxModel, getSherpaOnnxModelDir } from "./model-downloader.js"; +import { getSherpaOnnxModelSpec } from "./model-catalog.js"; function makeTmpDir(): string { return mkdtempSync(path.join(tmpdir(), "paseo-speech-models-")); @@ -13,6 +24,67 @@ function makeTmpDir(): string { const logger = pino({ level: "silent" }); describe("sherpa model downloader", () => { + test("does not expose a partial model when tar extraction fails", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(path.dirname(archive), { recursive: true }); + writeFileSync(path.join(sourceDir, "model.onnx"), Buffer.alloc(32_768, 1)); + execFileSync("tar", [ + "cf", + archive, + "-C", + path.dirname(sourceDir), + `${spec.extractedDir}/model.onnx`, + ]); + truncateSync(archive, 1_024); + await expect(ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger })).rejects.toThrow( + "tar exited", + ); + expect(existsSync(getSherpaOnnxModelDir(modelsDir, spec.id))).toBe(false); + expect(existsSync(archive)).toBe(true); + expect(readdirSync(modelsDir)).toEqual([".downloads"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("repairs a partial legacy extraction when its cached archive remains", async () => { + const root = makeTmpDir(); + const modelsDir = path.join(root, "models"); + const spec = getSherpaOnnxModelSpec("kokoro-en-v0_19"); + const sourceDir = path.join(root, "source", spec.extractedDir); + const modelDir = getSherpaOnnxModelDir(modelsDir, spec.id); + const archive = path.join( + modelsDir, + ".downloads", + path.basename(new URL(spec.archiveUrl).pathname), + ); + try { + for (const dir of [sourceDir, modelDir]) { + mkdirSync(path.join(dir, "espeak-ng-data"), { recursive: true }); + for (const filename of ["model.onnx", "voices.bin", "tokens.txt"]) { + writeFileSync(path.join(dir, filename), "complete model data"); + } + } + mkdirSync(path.dirname(archive), { recursive: true }); + execFileSync("tar", ["cf", archive, "-C", path.dirname(sourceDir), spec.extractedDir]); + writeFileSync(path.join(modelDir, "model.onnx"), "truncated"); + await ensureSherpaOnnxModel({ modelsDir, modelId: spec.id, logger }); + expect(readFileSync(path.join(modelDir, "model.onnx"), "utf8")).toBe("complete model data"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("getSherpaOnnxModelDir maps modelId to extractedDir", () => { const modelsDir = "/tmp/models"; expect(getSherpaOnnxModelDir(modelsDir, "parakeet-tdt-0.6b-v2-int8")).toContain( 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 8ce21f5e01..cdd286389f 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 @@ -90,6 +90,38 @@ async function extractTarArchive( }); } +interface SherpaOnnxModelPaths { + modelDir: string; + archivePath: string; + extractionDir: string; +} + +function getSherpaOnnxModelPaths( + modelsDir: string, + modelId: SherpaOnnxModelId, +): SherpaOnnxModelPaths { + const spec = getSherpaOnnxModelSpec(modelId); + const downloadsDir = path.join(modelsDir, ".downloads"); + return { + modelDir: path.join(modelsDir, spec.extractedDir), + archivePath: path.join(downloadsDir, path.basename(new URL(spec.archiveUrl).pathname)), + extractionDir: path.join(downloadsDir, `${spec.extractedDir}.extracting`), + }; +} + +// The archive is removed only as its model moves into place, so a model whose archive is +// still in .downloads did not finish installing and may hold truncated files. +export async function isSherpaOnnxModelInstalled( + modelsDir: string, + modelId: SherpaOnnxModelId, +): Promise { + const spec = getSherpaOnnxModelSpec(modelId); + const { modelDir, archivePath } = getSherpaOnnxModelPaths(modelsDir, modelId); + return ( + !(await isNonEmptyFile(archivePath)) && (await hasRequiredFiles(modelDir, spec.requiredFiles)) + ); +} + async function isNonEmptyFile(filePath: string): Promise { try { const s = await stat(filePath); @@ -110,18 +142,17 @@ export async function ensureSherpaOnnxModel( }); const spec = getSherpaOnnxModelSpec(options.modelId); - const modelDir = path.join(options.modelsDir, spec.extractedDir); - if (await hasRequiredFiles(modelDir, spec.requiredFiles)) { + const { modelDir, archivePath, extractionDir } = getSherpaOnnxModelPaths( + options.modelsDir, + options.modelId, + ); + if (await isSherpaOnnxModelInstalled(options.modelsDir, options.modelId)) { return modelDir; } logger.info({ modelsDir: options.modelsDir }, "Starting model download"); try { - const downloadsDir = path.join(options.modelsDir, ".downloads"); - const archiveFilename = path.basename(new URL(spec.archiveUrl).pathname); - const archivePath = path.join(downloadsDir, archiveFilename); - if (!(await isNonEmptyFile(archivePath))) { await downloadToFile({ url: spec.archiveUrl, @@ -138,32 +169,40 @@ export async function ensureSherpaOnnxModel( }, "Extracting model archive", ); - await extractTarArchive(archivePath, options.modelsDir, options.signal); - - logger.info( - { - modelId: options.modelId, - modelDir, - }, - "Verifying downloaded model files", - ); - if (!(await hasRequiredFiles(modelDir, spec.requiredFiles))) { - throw new Error( - `Downloaded and extracted ${archiveFilename}, but required files are still missing in ${modelDir}.`, - ); - } - - logger.info( - { - modelId: options.modelId, - archivePath, - }, - "Finalizing model artifacts", - ); + // Extract beside the archive and move into place once complete, so an interrupted + // extraction never leaves partial files at modelDir. + await rm(extractionDir, { recursive: true, force: true }); try { - await rm(archivePath, { force: true }); - } catch { - // ignore + await extractTarArchive(archivePath, extractionDir, options.signal); + const extractedModelDir = path.join(extractionDir, spec.extractedDir); + + logger.info( + { + modelId: options.modelId, + modelDir, + }, + "Verifying downloaded model files", + ); + if (!(await hasRequiredFiles(extractedModelDir, spec.requiredFiles))) { + throw new Error( + `Downloaded and extracted ${path.basename(archivePath)}, but required files are missing.`, + ); + } + + logger.info( + { + modelId: options.modelId, + archivePath, + }, + "Finalizing model artifacts", + ); + // A retained archive marks the model as not installed, so remove it before the model + // is moved into place. If removal fails, nothing at modelDir looks installed. + await rm(modelDir, { recursive: true, force: true }); + await rm(archivePath, { force: true, maxRetries: 3 }); + await rename(extractedModelDir, modelDir); + } finally { + await rm(extractionDir, { recursive: true, force: true }); } logger.info({ modelDir }, "Model download completed"); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts index a7f60d2f29..06d4a5b41d 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.test.ts @@ -1,53 +1,141 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import pino from "pino"; +import { SherpaOnnxTTS } from "./sherpa-tts.js"; -const generate = vi.fn(); -const free = vi.fn(); +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) + rmSync(directory, { recursive: true, force: true }); +}); -vi.mock("node:fs", () => ({ - existsSync: vi.fn(() => true), -})); +function createTts(numThreads?: number, beforeGenerate?: () => Promise) { + const modelDir = mkdtempSync(join(tmpdir(), "paseo-tts-")); + directories.push(modelDir); + for (const name of ["model.onnx", "voices.bin", "tokens.txt"]) + writeFileSync(join(modelDir, name), ""); + mkdirSync(join(modelDir, "espeak-ng-data")); + let nativeConfig: unknown; + const requests: unknown[] = []; + const released: boolean[] = []; + const tts = new SherpaOnnxTTS( + { preset: "kokoro-en-v0_19", modelDir, numThreads }, + pino({ level: "silent" }), + () => ({ + OfflineTts: class { + sampleRate = 24000; + free() { + released.push(true); + } + constructor(config: unknown) { + nativeConfig = config; + } + async generateAsync(request: unknown) { + await beforeGenerate?.(); + return this.generate(request); + } + generate(request: unknown) { + requests.push(request); + return { samples: Float32Array.from([0, 0.5, -0.5, 0.25]), sampleRate: 24000 }; + } + }, + }), + ); + return { tts, requests, nativeConfig, released }; +} -vi.mock("./sherpa-onnx-node-loader.js", () => ({ - loadSherpaOnnxNode: () => ({ - OfflineTts: class { - public readonly sampleRate = 24000; +async function yieldNativeQueue(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} - generate = generate; - free = free; - }, - }), -})); +function holdNativeSynthesis() { + const pending: Array<() => void> = []; + function wait() { + return new Promise((resolve) => pending.push(resolve)); + } + return { pending, wait }; +} describe("SherpaOnnxTTS", () => { - beforeEach(() => { - generate.mockReset(); - free.mockReset(); - }); - - it("disables external buffers when calling sherpa generate", async () => { - generate.mockReturnValue({ - samples: Float32Array.from([0, 0.5, -0.5, 0.25]), - sampleRate: 24000, - }); - - const { SherpaOnnxTTS } = await import("./sherpa-tts.js"); - const tts = new SherpaOnnxTTS( - { - preset: "kokoro-en-v0_19", - modelDir: "/tmp/fake-model", - }, - pino({ level: "silent" }), - ); - + it("requests Electron-compatible copied samples and returns PCM audio", async () => { + const { tts, requests } = createTts(); const result = await tts.synthesizeSpeech("hello"); - - expect(generate).toHaveBeenCalledWith({ - text: "hello", - sid: 0, - speed: 1, - enableExternalBuffer: false, - }); + expect(requests).toEqual([{ text: "hello", sid: 0, speed: 1, enableExternalBuffer: false }]); expect(result.format).toBe("pcm;rate=24000"); + const chunks: Buffer[] = []; + for await (const chunk of result.stream) chunks.push(chunk); + expect(Buffer.concat(chunks).length).toBe(8); }); + + it("keeps the event loop available and serializes native synthesis", async () => { + const { pending, wait } = holdNativeSynthesis(); + const { tts, requests } = createTts(undefined, wait); + const first = tts.synthesizeSpeech("first"); + const second = tts.synthesizeSpeech("second"); + try { + await yieldNativeQueue(); + expect(requests).toEqual([]); + expect(pending).toHaveLength(1); + pending.shift()!(); + const audio = await first; + audio.stream.destroy(); + await yieldNativeQueue(); + expect(requests).toHaveLength(1); + expect(pending).toHaveLength(1); + pending.shift()!(); + (await second).stream.destroy(); + expect(requests.map((request) => (request as { text: string }).text)).toEqual([ + "first", + "second", + ]); + } finally { + for (const release of pending) release(); + await Promise.allSettled([first, second]); + tts.free(); + } + }); + + it("continues queued synthesis after a native failure", async () => { + let attempts = 0; + const { tts, requests } = createTts(undefined, async () => { + attempts += 1; + if (attempts === 1) throw new Error("generation failed"); + }); + const first = tts.synthesizeSpeech("first"); + const second = tts.synthesizeSpeech("second"); + await expect(first).rejects.toThrow("generation failed"); + (await second).stream.destroy(); + expect(requests).toEqual([{ text: "second", sid: 0, speed: 1, enableExternalBuffer: false }]); + tts.free(); + }); + + it("waits for active native synthesis before freeing and drops queued synthesis", async () => { + const { pending, wait } = holdNativeSynthesis(); + const { tts, requests, released } = createTts(undefined, wait); + const first = tts.synthesizeSpeech("first"); + const second = tts.synthesizeSpeech("second"); + const rejected = expect(second).rejects.toThrow("TTS provider closed"); + await yieldNativeQueue(); + tts.free(); + expect(released).toEqual([]); + pending.shift()!(); + (await first).stream.destroy(); + await rejected; + await yieldNativeQueue(); + expect(requests).toHaveLength(1); + expect(released).toEqual([true]); + await expect(tts.synthesizeSpeech("third")).rejects.toThrow("TTS provider closed"); + }); + + it.each([undefined, 4])( + "passes the requested thread count at the native model boundary (%s)", + (threads) => { + const { nativeConfig } = createTts(threads); + expect(nativeConfig).toMatchObject({ model: { numThreads: threads ?? 2, provider: "cpu" } }); + expect(nativeConfig).not.toHaveProperty("numThreads"); + expect(nativeConfig).not.toHaveProperty("provider"); + }, + ); }); diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts index e212ba6afa..a078f81f51 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-tts.ts @@ -25,12 +25,12 @@ function assertFileExists(filePath: string, label: string): void { interface SherpaOfflineTtsNative { sampleRate?: number; - generate: (args: { + generateAsync: (args: { text: string; sid: number; speed: number; enableExternalBuffer: boolean; - }) => { samples?: Float32Array | number[]; sampleRate?: number } | undefined; + }) => Promise<{ samples?: Float32Array | number[]; sampleRate?: number } | undefined>; free?: () => void; } @@ -39,13 +39,22 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { private readonly speakerId: number; private readonly speed: number; private readonly logger: pino.Logger; + private synthesisQueue: Promise = Promise.resolve(); + private closed = false; - constructor(config: SherpaTtsConfig, logger: pino.Logger) { + constructor( + config: SherpaTtsConfig, + logger: pino.Logger, + loadNative: () => Pick< + ReturnType, + "OfflineTts" + > = loadSherpaOnnxNode, + ) { this.logger = logger.child({ module: "speech", provider: "local", component: "tts" }); this.speakerId = config.speakerId ?? 0; this.speed = config.speed ?? 1.0; - const sherpa = loadSherpaOnnxNode(); + const sherpa = loadNative(); if (typeof sherpa.OfflineTts !== "function") { throw new Error("sherpa-onnx-node OfflineTts is unavailable"); } @@ -61,6 +70,9 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { assertFileExists(dataDir, "TTS espeak-ng dataDir"); const modelConfig = { + // The native parser reads these under model, despite the upstream JS typedef. + numThreads: config.numThreads ?? 2, + provider: "cpu", kokoro: { model: modelPath, voices: voicesPath, @@ -72,8 +84,6 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { const offlineTtsConfig = { model: modelConfig, - numThreads: config.numThreads ?? 2, - provider: "cpu", maxNumSentences: 1, }; @@ -92,8 +102,22 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { throw new Error("Cannot synthesize empty text"); } - const audio = this.tts.generate({ - text: trimmed, + // The native handle is not reentrant. Keep prefetch off the worker event loop + // while allowing only one generation at a time on this model. + const result = this.synthesisQueue.then(() => this.generateSpeech(trimmed)); + this.synthesisQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async generateSpeech(text: string): Promise { + if (this.closed) { + throw new Error("TTS provider closed"); + } + const audio = await this.tts.generateAsync({ + text, sid: this.speakerId, speed: this.speed, // Electron rejects native external-backed typed arrays. Request a copied buffer @@ -138,10 +162,19 @@ export class SherpaOnnxTTS implements TextToSpeechProvider { } free(): void { - try { - this.tts?.free?.(); - } catch { - // ignore + if (this.closed) { + return; } + this.closed = true; + // Let the active native call finish before releasing its handle. Queued + // calls observe closed and never enter native code. + void this.synthesisQueue.then(() => { + try { + this.tts.free?.(); + } catch { + // ignore + } + return undefined; + }); } } diff --git a/packages/server/src/server/speech/providers/local/worker-process.local.e2e.test.ts b/packages/server/src/server/speech/providers/local/worker-process.local.e2e.test.ts index 9634a7533e..278b8112da 100644 --- a/packages/server/src/server/speech/providers/local/worker-process.local.e2e.test.ts +++ b/packages/server/src/server/speech/providers/local/worker-process.local.e2e.test.ts @@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; import { parsePcm16MonoWav, wordSimilarity } from "../../../test-utils/dictation-e2e.js"; import { ensureSherpaOnnxModels } from "./sherpa/model-downloader.js"; @@ -30,7 +30,7 @@ function hasParakeetModel(dir: string): boolean { } function fixturePath(fileName: string): string { - return path.resolve(process.cwd(), "..", "app", "e2e", "fixtures", fileName); + return path.resolve(process.cwd(), "..", "app", "e2e", "support", "fixtures", fileName); } function resolveWorkerUrl(): URL { @@ -68,6 +68,10 @@ function forkWorker(): ChildProcess { stdio: ["ignore", "ignore", "pipe", "ipc"], }); worker.setMaxListeners(100); + onTestFinished(() => { + if (worker.connected) worker.disconnect(); + if (!worker.killed) worker.kill(); + }); return worker; } @@ -92,75 +96,133 @@ workerSpeechTest( messages.push(message); }); - try { - const config: LocalSpeechWorkerConfig = { - modelsDir, - voiceSttModel: "parakeet-tdt-0.6b-v2-int8", - dictationSttModel: "parakeet-tdt-0.6b-v2-int8", - voiceTtsModel: "kokoro-en-v0_19", - }; - const sessionId = randomUUID(); + const config: LocalSpeechWorkerConfig = { + modelsDir, + voiceSttModel: "parakeet-tdt-0.6b-v2-int8", + dictationSttModel: "parakeet-tdt-0.6b-v2-int8", + voiceTtsModel: "kokoro-en-v0_19", + }; + const sessionId = randomUUID(); - await sendRequest( - worker, - messages, - { - type: "session.create", - config, - sessionId, - kind: "dictationStt", - }, - () => stderr, + await sendRequest( + worker, + messages, + { + type: "session.create", + config, + sessionId, + kind: "dictationStt", + }, + () => stderr, + ); + + const wav = await readFile(fixturePath("recording.wav")); + const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); + expect(sampleRate).toBe(16000); + + const chunkBytes = 3200; + const appendPromises: Promise[] = []; + for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { + const audio = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); + appendPromises.push( + sendRequest( + worker, + messages, + { + type: "session.append", + sessionId, + audio: bufferToWorkerBytes(audio), + }, + () => stderr, + ), ); - - const wav = await readFile(fixturePath("recording.wav")); - const { sampleRate, pcm16 } = parsePcm16MonoWav(wav); - expect(sampleRate).toBe(16000); - - const chunkBytes = 3200; - const appendPromises: Promise[] = []; - for (let offset = 0; offset < pcm16.length; offset += chunkBytes) { - const audio = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes)); - appendPromises.push( - sendRequest( - worker, - messages, - { - type: "session.append", - sessionId, - audio: bufferToWorkerBytes(audio), - }, - () => stderr, - ), - ); - } - await Promise.all(appendPromises); - - await sendRequest( - worker, - messages, - { - type: "session.commit", - sessionId, - }, - () => stderr, - ); - - const finalTranscript = await waitForFinalTranscript(worker, messages, sessionId, stderr); - const baseline = await readFile(fixturePath("recording.baseline.txt"), "utf8"); - expect(wordSimilarity(finalTranscript, baseline)).toBeGreaterThan(0.45); - } finally { - if (worker.connected) { - worker.disconnect(); - } - if (!worker.killed) { - worker.kill(); - } } + await Promise.all(appendPromises); + + await sendRequest( + worker, + messages, + { + type: "session.commit", + sessionId, + }, + () => stderr, + ); + + const finalTranscript = await waitForFinalTranscript(worker, messages, sessionId, stderr); + const baseline = await readFile(fixturePath("recording.baseline.txt"), "utf8"); + expect(wordSimilarity(finalTranscript, baseline)).toBeGreaterThan(0.45); }, 120_000, ); +test("delivers audio and serves control requests while later TTS is synthesizing", async () => { + const worker = forkWorker(); + const messages: LocalSpeechWorkerToParentMessage[] = []; + let stderr = ""; + worker.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + worker.on("message", (message: LocalSpeechWorkerToParentMessage) => messages.push(message)); + const config: LocalSpeechWorkerConfig = { + modelsDir, + voiceSttModel: "parakeet-tdt-0.6b-v2-int8", + dictationSttModel: "parakeet-tdt-0.6b-v2-int8", + voiceTtsModel: "kokoro-en-v0_19", + }; + await sendRequest( + worker, + messages, + { type: "tts.synthesize", config, text: "Ready." }, + () => stderr, + ); + const completed: string[] = []; + const first = sendRequest( + worker, + messages, + { + type: "tts.synthesize", + config, + text: "I found the issue.", + }, + () => stderr, + ).then(() => completed.push("first")); + const second = sendRequest( + worker, + messages, + { + type: "tts.synthesize", + config, + text: "The voice feature is slow because it waits for the whole sentence to finish generating before sending any audio to your phone, so I am checking whether we can start playback earlier while the rest is still being synthesized.", + }, + () => stderr, + ).then(() => completed.push("second")); + await sendRequest( + worker, + messages, + { + type: "session.close", + sessionId: "responsiveness-probe", + }, + () => stderr, + ); + expect(completed).toEqual([]); + await first; + expect(completed).toEqual(["first"]); + await sendRequest( + worker, + messages, + { + type: "session.close", + sessionId: "responsiveness-probe", + }, + () => stderr, + ); + expect(completed).toEqual(["first"]); + await second; + expect(completed).toEqual(["first", "second"]); +}, 120_000); + type RequestInput = LocalSpeechWorkerRequest extends infer Request ? Request extends LocalSpeechWorkerRequest ? Omit diff --git a/packages/server/src/server/speech/speech-runtime.test.ts b/packages/server/src/server/speech/speech-runtime.test.ts index 428ecd27aa..c9c21b3e89 100644 --- a/packages/server/src/server/speech/speech-runtime.test.ts +++ b/packages/server/src/server/speech/speech-runtime.test.ts @@ -36,8 +36,7 @@ vi.mock("./providers/openai/runtime.js", () => ({ vi.mock("./providers/local/models.js", () => ({ ensureLocalSpeechModels: ensureLocalSpeechModelsMock, - getLocalSpeechModelDir: vi.fn(() => ""), - listLocalSpeechModels: vi.fn(() => []), + listMissingLocalSpeechModels: vi.fn(async ({ modelIds }: { modelIds: string[] }) => modelIds), })); function createStubStt(id: string): SpeechToTextProvider { diff --git a/packages/server/src/server/speech/speech-runtime.ts b/packages/server/src/server/speech/speech-runtime.ts index 6e56a9e955..932f87c077 100644 --- a/packages/server/src/server/speech/speech-runtime.ts +++ b/packages/server/src/server/speech/speech-runtime.ts @@ -1,14 +1,8 @@ -import { stat } from "node:fs/promises"; -import { join } from "node:path"; import type { Logger } from "pino"; import type { PaseoOpenAIConfig, PaseoSpeechConfig } from "../bootstrap.js"; import type { LocalSpeechModelId } from "./providers/local/config.js"; -import { - ensureLocalSpeechModels, - getLocalSpeechModelDir, - listLocalSpeechModels, -} from "./providers/local/models.js"; +import { ensureLocalSpeechModels, listMissingLocalSpeechModels } from "./providers/local/models.js"; import { initializeLocalSpeechServices } from "./providers/local/runtime.js"; import { getOpenAiSpeechAvailability, @@ -76,48 +70,6 @@ function resolveRequestedSpeechProviders( }; } -async function hasRequiredLocalModelFile(filePath: string): Promise { - try { - const fileStat = await stat(filePath); - if (fileStat.isDirectory()) { - return true; - } - return fileStat.isFile() && fileStat.size > 0; - } catch { - return false; - } -} - -async function findMissingRequiredLocalModels(params: { - modelsDir: string | null; - requiredModelIds: LocalSpeechModelId[]; -}): Promise { - const { modelsDir, requiredModelIds } = params; - if (!modelsDir || requiredModelIds.length === 0) { - return []; - } - - const specsById = new Map(listLocalSpeechModels().map((model) => [model.id, model])); - const missing = new Set(); - - const checks = await Promise.all( - requiredModelIds.map(async (modelId) => { - const spec = specsById.get(modelId); - if (!spec) return { modelId, missing: true }; - const modelDir = getLocalSpeechModelDir(modelsDir, modelId); - const filePresence = await Promise.all( - spec.requiredFiles.map((relPath) => hasRequiredLocalModelFile(join(modelDir, relPath))), - ); - return { modelId, missing: !filePresence.every((present) => present) }; - }), - ); - for (const check of checks) { - if (check.missing) missing.add(check.modelId); - } - - return Array.from(missing); -} - function joinModelIds(modelIds: LocalSpeechModelId[]): string { if (modelIds.length === 0) { return "none"; @@ -493,10 +445,12 @@ export function createSpeechService(params: { }; const refreshMissingLocalModels = async (): Promise => { - missingLocalModelIds = await findMissingRequiredLocalModels({ - modelsDir: localModelConfig?.modelsDir ?? null, - requiredModelIds: localModelConfig?.defaultModelIds ?? [], - }); + missingLocalModelIds = localModelConfig + ? await listMissingLocalSpeechModels({ + modelsDir: localModelConfig.modelsDir, + modelIds: localModelConfig.defaultModelIds, + }) + : []; }; const reconcileServices = async (): Promise => { 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 44c6f28457..c872a686dd 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(); - await client.fetchAgents({ subscribe: { subscriptionId: "test" } }); + await client.fetchAgents({ subscribe: {} }); return { daemon, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 4d16f0302e..1c7127d144 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -58,13 +58,18 @@ interface FakeAgentSessionOptions { sessionId?: string; memoryMarker?: string | null; closeSession?: () => Promise; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; + historyEvents?: readonly AgentStreamEvent[]; } export interface TestAgentClientOptions { + beforeCreateSession?: (config: AgentSessionConfig) => Promise; closeSession?: () => Promise; - onStartTurn?: (prompt: AgentPromptInput) => void; + onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void; + beforeTurnComplete?: (prompt: AgentPromptInput) => Promise; supportsMcpServers?: boolean; + historyEvents?: readonly AgentStreamEvent[]; } function createDeferred(): Deferred { @@ -336,7 +341,9 @@ class FakeAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private readonly closeSession: (() => Promise) | undefined; - private readonly onStartTurn: ((prompt: AgentPromptInput) => void) | undefined; + private readonly onStartTurn: TestAgentClientOptions["onStartTurn"]; + private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise) | undefined; + private readonly historyEvents: readonly AgentStreamEvent[] | undefined; constructor(options: FakeAgentSessionOptions) { this.capabilities = { @@ -349,6 +356,8 @@ class FakeAgentSession implements AgentSession { this.memoryMarker = options.memoryMarker ?? null; this.closeSession = options.closeSession; this.onStartTurn = options.onStartTurn; + this.beforeTurnComplete = options.beforeTurnComplete; + this.historyEvents = options.historyEvents; this.historyPath = path.join( tmpdir(), "paseo-fake-provider-history", @@ -439,7 +448,7 @@ class FakeAgentSession implements AgentSession { const turnId = `fake-turn-${this.nextTurnOrdinal++}`; this.activeForegroundTurnId = turnId; - this.onStartTurn?.(prompt); + this.onStartTurn?.(prompt, this.config); void this.emitTurnEvents(prompt); @@ -737,6 +746,16 @@ class FakeAgentSession implements AgentSession { await this.appendHistoryEvent(turnStarted); this.notifySubscribers(turnStarted); + if (textPrompt === "Emit a provider child") { + const child: AgentStreamEvent = { + type: "provider_subagent", + provider: this.providerName, + event: { type: "upsert", id: "fixture-child", title: "Fixture child", status: "running" }, + }; + await this.appendHistoryEvent(child); + this.notifySubscribers(child); + } + if (textPrompt.toLowerCase().includes("emit a turn failure")) { const failed: AgentStreamEvent = { type: "turn_failed", @@ -788,6 +807,7 @@ class FakeAgentSession implements AgentSession { this.notifySubscribers(assistantChunkB); } + await this.beforeTurnComplete?.(prompt); const completed: AgentStreamEvent = { type: "turn_completed", provider: this.providerName, @@ -801,6 +821,10 @@ class FakeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { + if (this.historyEvents) { + yield* this.historyEvents; + return; + } let contents: string; try { contents = await readFile(this.historyPath, "utf8"); @@ -1202,12 +1226,15 @@ class FakeAgentClient implements AgentClient { config: AgentSessionConfig, _launchContext?: AgentLaunchContext, ): Promise { + await this.options.beforeCreateSession?.(config); return new FakeAgentSession({ providerName: this.provider, config: { ...config }, supportsMcpServers: this.options.supportsMcpServers, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } @@ -1233,6 +1260,8 @@ class FakeAgentClient implements AgentClient { memoryMarker: typeof marker === "string" ? marker : null, closeSession: this.options.closeSession, onStartTurn: this.options.onStartTurn, + beforeTurnComplete: this.options.beforeTurnComplete, + historyEvents: this.options.historyEvents, }); } diff --git a/packages/server/src/server/test-utils/paseo-daemon.test.ts b/packages/server/src/server/test-utils/paseo-daemon.test.ts new file mode 100644 index 0000000000..4ee417c221 --- /dev/null +++ b/packages/server/src/server/test-utils/paseo-daemon.test.ts @@ -0,0 +1,61 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +describe("createTestPaseoDaemon", () => { + const cleanups: Array<() => Promise> = []; + + afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + test("keeps a caller-owned home when startup on a taken port fails", async () => { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-owned-home-")); + cleanups.push(() => rm(paseoHomeRoot, { recursive: true, force: true })); + const ownedFile = path.join(paseoHomeRoot, "owned-by-caller"); + await writeFile(ownedFile, "keep"); + const takenPort = await occupyPort(cleanups); + + await expect( + createTestPaseoDaemon({ + listenPort: takenPort, + paseoHomeRoot, + staticDir: path.join(paseoHomeRoot, "static"), + cleanup: false, + mcpEnabled: false, + }), + ).rejects.toMatchObject({ code: "EADDRINUSE" }); + + expect(existsSync(ownedFile)).toBe(true); + }, 60_000); + + test("removes the directories it created when startup on a taken port fails", async () => { + const scratchTmp = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-scratch-")); + cleanups.push(() => rm(scratchTmp, { recursive: true, force: true })); + for (const name of ["TMPDIR", "TMP", "TEMP"]) vi.stubEnv(name, scratchTmp); + const takenPort = await occupyPort(cleanups); + + await expect( + createTestPaseoDaemon({ listenPort: takenPort, cleanup: false, mcpEnabled: false }), + ).rejects.toMatchObject({ code: "EADDRINUSE" }); + + expect(await readdir(scratchTmp)).toEqual([]); + }, 60_000); +}); + +async function occupyPort(cleanups: Array<() => Promise>): Promise { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise((resolve) => server.close(() => resolve()))); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Port holder did not bind a TCP port"); + } + return address.port; +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 9cdce46865..91b125328c 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -1,3 +1,4 @@ +import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import os from "node:os"; import path from "node:path"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; @@ -20,6 +21,7 @@ interface TestPaseoDaemonOptions { downloadTokenTtlMs?: number; corsAllowedOrigins?: string[]; listen?: string; + listenPort?: number; logger?: Parameters[1]; mcpEnabled?: boolean; mcpDebug?: boolean; @@ -49,6 +51,7 @@ interface TestPaseoDaemonOptions { agentProfiles?: AgentProfile[]; autoArchiveAfterMerge?: boolean; pluginsEnabled?: PaseoDaemonConfig["pluginsEnabled"]; + builtinPlugins?: BuiltinPluginLoader; plugins?: PaseoDaemonConfig["plugins"]; } @@ -97,9 +100,11 @@ export async function createTestPaseoDaemon( let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options); + const { config, paseoHomeRoot, paseoHome, staticDir, createdDirs } = + await prepareTestDaemonConfig(options); const logger = options.logger ?? pino({ level: "silent" }); const daemon = await createPaseoDaemon(config, logger, { + builtinPlugins: options.builtinPlugins ?? new BuiltinPluginLoader(undefined, []), serverFeatureOverrides: { daemonStatusRpc: options.daemonStatusRpcCapability, relayConfig: options.relayConfigCapability, @@ -117,10 +122,7 @@ export async function createTestPaseoDaemon( await daemon.agentManager.flush().catch(() => undefined); if (options.cleanup ?? true) { await new Promise((r) => setTimeout(r, 50)); - await Promise.all([ - rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - ]); + await removeDirs([paseoHomeRoot, staticDir]); } }; @@ -135,10 +137,8 @@ export async function createTestPaseoDaemon( } catch (error) { lastError = error; await daemon.stop().catch(() => undefined); - await Promise.all([ - rm(paseoHomeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), - ]); + // A failed attempt removes only what it created: a caller-supplied home keeps its serverId. + await removeDirs(createdDirs); if ( (!isAddressInUseError(error) && !isStartupTimeoutError(error)) || @@ -157,19 +157,33 @@ interface PreparedTestDaemonConfig { paseoHomeRoot: string; paseoHome: string; staticDir: string; + createdDirs: string[]; +} + +async function removeDirs(dirs: string[]): Promise { + await Promise.all( + dirs.map((dir) => rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })), + ); +} + +async function createTempDir(prefix: string, createdDirs: string[]): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); + createdDirs.push(dir); + return dir; } async function prepareTestDaemonConfig( options: TestPaseoDaemonOptions, ): Promise { - const paseoHomeRoot = - options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-"))); + const createdDirs: string[] = []; + const paseoHomeRoot = options.paseoHomeRoot ?? (await createTempDir("paseo-home-", createdDirs)); const paseoHome = path.join(paseoHomeRoot, ".paseo"); await mkdir(paseoHome, { recursive: true }); - const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-"))); + const staticDir = options.staticDir ?? (await createTempDir("paseo-static-", createdDirs)); const listenHost = options.listen ?? "127.0.0.1"; + const listenPort = options.listenPort ?? 0; const config: PaseoDaemonConfig = { - listen: `${listenHost}:0`, + listen: `${listenHost}:${listenPort}`, paseoHome, daemonVersion: options.daemonVersion, desktopManaged: options.desktopManaged, @@ -204,7 +218,7 @@ async function prepareTestDaemonConfig( pluginsEnabled: options.pluginsEnabled, plugins: options.plugins, }; - return { config, paseoHomeRoot, paseoHome, staticDir }; + return { config, paseoHomeRoot, paseoHome, staticDir, createdDirs }; } function isAddressInUseError(error: unknown): boolean { diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 46db4e1749..50c0ee3c76 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -1,24 +1,21 @@ -import { vi } from "vitest"; +import pino from "pino"; +import { randomUUID } from "node:crypto"; +import { afterEach, vi } from "vitest"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CreationService } from "../creation/index.js"; import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; -import type { - AgentMode, - AgentModelDefinition, - AgentProvider, - ProviderSnapshotEntry, -} from "../agent/agent-sdk-types.js"; -import type { - AgentManagerProviderState, - ProviderDiagnosticResult, - ResolvedProviderCreateConfig, +import type { ProviderSnapshotEntry } from "../agent/agent-sdk-types.js"; +import type { ProviderSnapshot } from "../agent/provider-snapshot-manager.js"; +import { + GLOBAL_PROVIDER_SNAPSHOT_KEY, + ProviderSnapshotManager, } from "../agent/provider-snapshot-manager.js"; -import { ProviderSnapshotManager } from "../agent/provider-snapshot-manager.js"; import type { SessionOptions } from "../session.js"; -import type { - HubExecutionAgentValidationIssue, - SessionOutboundMessage, -} from "@getpaseo/protocol/messages"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { asInternals, createStub } from "./class-mocks.js"; // --------------------------------------------------------------------------- @@ -36,7 +33,11 @@ export function asSessionLogger(stub: { export function asAgentManager(stub: { [K in keyof SessionOptions["agentManager"]]?: unknown; }): SessionOptions["agentManager"] { - return createStub(stub); + return createStub({ + listProviderSubagentActivity: () => [], + getRetiredInternalAgent: () => null, + ...stub, + }); } export function asAgentStorage(stub: { @@ -99,15 +100,15 @@ export function asWorkspaceGitService(stub: { } export function asServiceProxy(stub: { - [K in keyof SessionOptions["serviceProxy"]]?: unknown; -}): SessionOptions["serviceProxy"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } export function asWorkspaceScriptRuntimeStore(stub: { - [K in keyof SessionOptions["scriptRuntimeStore"]]?: unknown; -}): SessionOptions["scriptRuntimeStore"] { - return createStub(stub); + [K in keyof NonNullable]?: unknown; +}): NonNullable { + return createStub>(stub); } // --------------------------------------------------------------------------- @@ -148,74 +149,96 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; - refreshSnapshotForCwd: ReturnType>>; - refreshSettingsSnapshot: ReturnType>>; - warmUpSnapshotForCwd: ReturnType>>; - listRegisteredProviderIds: ReturnType>; - hasProvider: ReturnType>; - getProviderLabel: ReturnType>; - getAgentManagerProviderState: ReturnType>; - listProviders: ReturnType>>; - getProvider: ReturnType>>; + getSnapshot: ReturnType>; + refreshSnapshotForCwd: ReturnType>; + refreshSettingsSnapshot: ReturnType< + typeof vi.fn + >; + warmUpSnapshotForCwd: ReturnType>; + listRegisteredProviderIds: ReturnType< + typeof vi.fn + >; + hasProvider: ReturnType>; + getProviderLabel: ReturnType>; + getAgentManagerProviderState: ReturnType< + typeof vi.fn + >; + listProviders: ReturnType>; + getProvider: ReturnType>; validateAgentConfiguration: ReturnType< - typeof vi.fn<[unknown], Promise> + typeof vi.fn >; - listModels: ReturnType>>; - listModes: ReturnType>>; - resolveCreateConfig: ReturnType>>; - resolveDefaultModel: ReturnType>>; - getProviderDiagnostic: ReturnType< - typeof vi.fn<[AgentProvider], Promise> + listModels: ReturnType>; + listModes: ReturnType>; + resolveCreateConfig: ReturnType>; + resolveDefaultModel: ReturnType>; + getProviderDiagnostic: ReturnType>; + applyMutableProviderConfig: ReturnType< + typeof vi.fn >; - applyMutableProviderConfig: ReturnType>; - destroy: ReturnType>; + destroy: ReturnType>; } export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); - const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); - const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); - const listRegisteredProviderIds = vi.fn<[], AgentProvider[]>(() => []); - const hasProvider = vi.fn<[AgentProvider], boolean>(() => false); - const getProviderLabel = vi.fn<[AgentProvider], string>((provider) => { + const getSnapshot = vi.fn((cwd) => + createProviderSnapshot([], cwd), + ); + const refreshSnapshotForCwd = vi.fn( + async () => {}, + ); + const refreshSettingsSnapshot = vi.fn( + async () => {}, + ); + const warmUpSnapshotForCwd = vi.fn( + async () => {}, + ); + const listRegisteredProviderIds = vi.fn( + () => [], + ); + const hasProvider = vi.fn(() => false); + const getProviderLabel = vi.fn((provider) => { try { return getAgentProviderDefinition(provider).label; } catch { return provider; } }); - const getAgentManagerProviderState = vi.fn<[], AgentManagerProviderState>(() => ({ + const getAgentManagerProviderState = vi.fn< + ProviderSnapshotManager["getAgentManagerProviderState"] + >(() => ({ providerDefinitions: {}, clients: {}, })); - const listProviders = vi.fn<[unknown], Promise>(async () => []); - const getProvider = vi.fn<[unknown], Promise>(async () => { + const listProviders = vi.fn(async () => []); + const getProvider = vi.fn(async () => { throw new Error("createProviderSnapshotManagerStub: getProvider not stubbed"); }); - const validateAgentConfiguration = vi.fn<[unknown], Promise>( + const validateAgentConfiguration = vi.fn( async () => [], ); - const listModels = vi.fn<[unknown], Promise>(async () => []); - const listModes = vi.fn<[unknown], Promise>(async () => []); - const resolveCreateConfig = vi.fn<[unknown], Promise>(async () => ({ + const listModels = vi.fn(async () => []); + const listModes = vi.fn(async () => []); + const resolveCreateConfig = vi.fn(async () => ({ modeId: undefined, featureValues: undefined, })); - const resolveDefaultModel = vi.fn<[unknown], Promise>(async () => undefined); - const getProviderDiagnostic = vi.fn<[AgentProvider], Promise>( + const resolveDefaultModel = vi.fn( + async () => undefined, + ); + const getProviderDiagnostic = vi.fn( async (provider) => ({ provider, diagnostic: "No diagnostic available for this provider." }), ); - const applyMutableProviderConfig = vi.fn<[unknown], AgentManagerProviderState>(() => ({ - providerDefinitions: {}, - clients: {}, - })); + const applyMutableProviderConfig = vi.fn( + () => ({ + providerDefinitions: {}, + clients: {}, + }), + ); const on = vi.fn(); const off = vi.fn(); - const destroy = vi.fn<[], void>(); + const destroy = vi.fn(); const stub = { getSnapshot, refreshSnapshotForCwd, @@ -263,3 +286,36 @@ export function createProviderSnapshotManagerStub(): { destroy, }; } + +export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] { + return { + send: (input) => input.send(), + }; +} + +export function createProviderSnapshot( + entries: ProviderSnapshotEntry[], + cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY, +): ProviderSnapshot { + return { + cwd, + records: entries.map((entry) => { + const { fetchedAt: _fetchedAt, ...content } = entry; + return { entry, contentHash: JSON.stringify(content) }; + }), + }; +} + +const creationDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + creationDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); +export function createTestCreationService(): SessionOptions["creationService"] { + const directory = join(tmpdir(), `session-creation-${randomUUID()}`); + creationDirectories.push(directory); + return new CreationService(directory, pino({ level: "silent" })); +} diff --git a/packages/server/src/server/test-utils/versioned-daemon.ts b/packages/server/src/server/test-utils/versioned-daemon.ts new file mode 100644 index 0000000000..f8763e0d6f --- /dev/null +++ b/packages/server/src/server/test-utils/versioned-daemon.ts @@ -0,0 +1,36 @@ +// Browser compatibility tests run a real daemon in a separate Node runtime. +// +// argv: [port] [paseoHomeRoot] +// A fixed port and a caller-owned home let a test stop this daemon and start a +// replacement under the same endpoint and serverId. +import path from "node:path"; +import { createTestPaseoDaemon } from "./paseo-daemon.js"; + +const [, , daemonVersion, portArg, paseoHomeRoot] = process.argv; +const listenPort = portArg ? Number(portArg) : 0; +if (!Number.isInteger(listenPort) || listenPort < 0) { + throw new Error(`Invalid versioned daemon port: ${portArg ?? ""}`); +} + +const daemon = await createTestPaseoDaemon({ + daemonVersion, + ...(listenPort > 0 ? { listenPort } : {}), + ...(paseoHomeRoot + ? { + paseoHomeRoot, + staticDir: path.join(paseoHomeRoot, "static"), + // The caller removes the home so a replacement daemon keeps the same serverId. + cleanup: false, + } + : {}), + pluginsEnabled: true, + mcpEnabled: false, + corsAllowedOrigins: ["*"], +}); +const response = await fetch(`http://127.0.0.1:${daemon.port}/api/status`); +const { serverId } = await response.json(); +process.send?.({ port: daemon.port, serverId }); +process.once("SIGTERM", async () => { + await daemon.close(); + process.exit(0); +}); diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index eca511c72e..53b8a44c1e 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService( suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); @@ -95,6 +96,7 @@ export function createNoopWorkspaceGitService( nativeTrackedFileCount: 0, pendingEventCount: 0, pendingReconciliationWorkCount: 0, + pendingClassificationCount: 0, reconciliationInFlightCount: 0, reconciliationCount: 0, scopedReconciliationCount: 0, diff --git a/packages/server/src/server/utils/diff-highlighter.test.ts b/packages/server/src/server/utils/diff-highlighter.test.ts index cc6754c50b..56f77fc706 100644 --- a/packages/server/src/server/utils/diff-highlighter.test.ts +++ b/packages/server/src/server/utils/diff-highlighter.test.ts @@ -190,6 +190,34 @@ index 1234567..abcdefg 100644 `; describe("parseDiff", () => { + it.each(["-- comment", "++ marker"])( + "preserves hunk content resembling patch headers: %s", + (content) => { + const files = parseDiff(`diff --git a/example.txt b/example.txt +index 1234567..abcdefg 100644 +--- a/example.txt ++++ b/example.txt +@@ -1 +1 @@ +-${content} before ++${content} after +`); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "example.txt", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(files[0].hunks[0].lines).toEqual([ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: `${content} before` }, + { type: "add", content: `${content} after` }, + ]); + }, + ); + it("parses a simple diff with one hunk", () => { const files = parseDiff(SIMPLE_DIFF); diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 4f7f800dc9..97d68d1fcd 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -79,16 +79,6 @@ function extractPathFromDiffHeader(lines: string[]): string { return "unknown"; } -function isMetadataLine(line: string): boolean { - return ( - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("new file mode") || - line.startsWith("deleted file mode") - ); -} - function parseHunkHeader(line: string): DiffHunk | null { const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); if (!hunkMatch) return null; @@ -116,8 +106,6 @@ function parseSectionBody(lines: string[]): ParsedSectionBody { for (let i = 1; i < lines.length; i++) { const line = lines[i]; - if (isMetadataLine(line)) continue; - const newHunk = parseHunkHeader(line); if (newHunk) { if (currentHunk) hunks.push(currentHunk); @@ -332,30 +320,30 @@ export async function highlightDiffWithFileContent( return file; } - const reconstructedTokens = buildReconstructedTokenLookups(file); - let newTokensByLine = reconstructedTokens.newTokensByLine; - let oldTokensByLine = reconstructedTokens.oldTokensByLine; - - if (typeof options.oldFileContent === "string") { - oldTokensByLine = - buildFullFileTokenLookup(options.oldFileContent, file.path) ?? oldTokensByLine; - } - + let newTokensByLine: Map | null = null; + const oldTokensByLine = + typeof options.oldFileContent === "string" + ? buildFullFileTokenLookup(options.oldFileContent, file.path) + : null; if (typeof options.newFileContent === "string") { - newTokensByLine = - buildFullFileTokenLookup(options.newFileContent, file.path) ?? newTokensByLine; + newTokensByLine = buildFullFileTokenLookup(options.newFileContent, file.path); + } else { + try { + const fileContent = await readFile(resolve(cwd, file.path), "utf-8"); + newTokensByLine = buildFullFileTokenLookup(fileContent, file.path); + } catch { + // Deleted or unavailable files use reconstructed tokens below. + } + } + + if (newTokensByLine && oldTokensByLine) return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); - } - - const filePath = resolve(cwd, file.path); - try { - const fileContent = await readFile(filePath, "utf-8"); - newTokensByLine = buildFullFileTokenLookup(fileContent, file.path) ?? newTokensByLine; - } catch { - // If file read fails (deleted file, etc.), fall back to reconstructed new-side tokens. - } - - return applyTokensToHunks(file, newTokensByLine, oldTokensByLine); + const reconstructedTokens = buildReconstructedTokenLookups(file); + return applyTokensToHunks( + file, + newTokensByLine ?? reconstructedTokens.newTokensByLine, + oldTokensByLine ?? reconstructedTokens.oldTokensByLine, + ); } function applyTokensToHunks( diff --git a/packages/server/src/server/voice-local-agent.e2e.test.ts b/packages/server/src/server/voice-local-agent.e2e.test.ts index 9dbe49d74a..c78759459a 100644 --- a/packages/server/src/server/voice-local-agent.e2e.test.ts +++ b/packages/server/src/server/voice-local-agent.e2e.test.ts @@ -31,8 +31,10 @@ function makeErrorHandler(reject: (error: Error) => void) { }; } -function makeSpeakToolHandler(resolve: (value: string) => void) { - return (msg: SessionMessage<"agent_stream">) => { +function makeSpeakToolHandler( + resolve: (value: string) => void, +): Parameters[1] { + return (msg) => { if (msg.type !== "agent_stream") return; if (msg.payload.event.type !== "timeline") return; const item = msg.payload.event.item; @@ -131,7 +133,10 @@ function waitForSignal( }); const speakToolPromise = waitForSignal(120000, (resolve, reject) => { - const offStream = ctx.client.on("agent_stream", makeSpeakToolHandler(resolve)); + const offStream = ctx.client.subscribeAgentTimeline( + targetAgent.id, + makeSpeakToolHandler(resolve), + ); const offError = ctx.client.on("activity_log", makeErrorHandler(reject)); return () => { offStream(); diff --git a/packages/server/src/server/voice-roundtrip.e2e.test.ts b/packages/server/src/server/voice-roundtrip.e2e.test.ts index 8808421b3d..97096c9938 100644 --- a/packages/server/src/server/voice-roundtrip.e2e.test.ts +++ b/packages/server/src/server/voice-roundtrip.e2e.test.ts @@ -225,7 +225,7 @@ for (const targetProvider of [ const timelineToolAgentIds = new Set(); const activityErrors: string[] = []; - const offStream = ctx.client.on("agent_stream", (message) => { + const offStream = ctx.client.subscribeAgentTimeline(voiceAgentId, (message) => { if (message.type !== "agent_stream") { return; } diff --git a/packages/server/src/server/web-ui.ts b/packages/server/src/server/web-ui.ts index e7135a9eab..b59896c159 100644 --- a/packages/server/src/server/web-ui.ts +++ b/packages/server/src/server/web-ui.ts @@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string { .replace(/&/g, "\\u0026"); } +/** + * A Host header carries no port when the client used the scheme's default one, + * which is what every browser sends to a proxy terminating TLS on 443. The app + * parses `listen` with parseHostPort, which requires an explicit port and + * rejects the value outright, so the hint has to spell the port out. + */ +function hasExplicitPort(host: string): boolean { + if (host.startsWith("[")) { + // IPv6 literals are bracketed: [::1] has no port, [::1]:443 does. + const end = host.indexOf("]"); + return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1)); + } + const colon = host.indexOf(":"); + return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1)); +} + function injectConnectionHint( html: string, req: Parameters[0], @@ -257,8 +273,16 @@ function injectConnectionHint( ): string { const host = typeof req.headers.host === "string" ? req.headers.host : ""; const useTls = req.protocol === "https"; + let listen = host; + if (host && !hasExplicitPort(host)) { + let defaultPort = 80; + if (useTls) { + defaultPort = 443; + } + listen = `${host}:${defaultPort}`; + } const hint = { - listen: host, + listen, useTls, label, }; diff --git a/packages/server/src/server/websocket-server.browser-tools.test.ts b/packages/server/src/server/websocket-server.browser-tools.test.ts index ba83e979d1..df0b02ad43 100644 --- a/packages/server/src/server/websocket-server.browser-tools.test.ts +++ b/packages/server/src/server/websocket-server.browser-tools.test.ts @@ -139,10 +139,11 @@ describe("WebSocketServer browser tools wiring", () => { it("keeps browser automation registered when a browser host client resumes", async () => { const harness = await startBrowserToolsDaemonHarness(); const clientId = "browser-host-client-1"; - await harness.connectBrowserHostClient({ + const originalBrowserHost = await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(), }); + await originalBrowserHost.disconnect(); const resumedBrowserHost = await harness.connectBrowserHostClient({ clientId, @@ -182,6 +183,7 @@ describe("WebSocketServer browser tools wiring", () => { await browserHost.nextBrowserRequest(); expect(harness.broker.getPendingRequestCount()).toBe(1); + await browserHost.disconnect(); await harness.connectBrowserHostClient({ clientId, capabilities: browserHostCapabilities(["list_tabs"]), @@ -225,6 +227,11 @@ async function startBrowserToolsDaemonHarness(): Promise client.sendBrowserAutomationExecuteResponse(response), async disconnect() { + await observation.release(); requests.close(); clients.delete(client); await client.close(); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index 15f4aa38d2..8ff692d580 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -191,9 +192,15 @@ function connectClient( options: { subscribed?: boolean } = {}, ) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals(server).sessions.set(ws, { kind: "trusted", - session: createSessionWithActivity(activity, options.subscribed ?? true), + session: { + ...createSessionWithActivity(activity, options.subscribed ?? true), + delivery, + wantsSourceNotification: () => true, + }, clientId: "client-test", appVersion: null, connectionLogger: createLogger(), diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 3839263f52..25acb9946b 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -17,6 +18,7 @@ import { TerminalStreamOpcode, } from "@getpaseo/protocol/terminal-stream-protocol"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import { APPLICATION_SOCKET_LEASE_MS } from "./websocket/physical-socket.js"; type SocketListener = (...args: unknown[]) => void; @@ -46,15 +48,27 @@ const sessionMock = vi.hoisted(() => { const instances: MockSession[] = []; class MockSession { - cleanup = vi.fn(async () => {}); + readonly delivery = new SessionDelivery((source, message) => { + const send = this.args.onMessageToSource as (source: object, message: unknown) => void; + send(source, message); + }); + cleanup = vi.fn(async () => { + await this.delivery.close(); + }); handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); - updateClientCapabilities = vi.fn((capabilities: Record | null) => { - this.args.clientCapabilities = capabilities; + updateClientCapabilities = vi.fn( + (capabilities: Record | null, source: object) => { + this.args.clientCapabilities = capabilities; + this.delivery.attach(source, capabilities?.owned_subscriptions === true); + }, + ); + clearAgentTimelineSubscription = vi.fn((source: object) => { + void this.delivery.detach(source); }); - clearAgentTimelineSubscription = vi.fn(); getClientActivity = vi.fn(() => null); + wantsSourceEvent = (source: object) => !this.delivery.isModern(source); getSessionId = vi.fn(() => "mock-session-id"); getPermissions = vi.fn(() => this.args.permissions as string[]); allowsInbound = vi.fn(() => true); @@ -228,6 +242,7 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | null; logger?: ReturnType; startPaused?: boolean; + auth?: { password: string; localCredential: () => string | null }; }) { const speechReadiness = options?.speechReadiness ?? null; const daemonConfigStore = { @@ -258,7 +273,7 @@ function createServer(options?: { null, { allowedOrigins: new Set(), startPaused: options?.startPaused }, createWorkspaceAutoNameStub(), - undefined, + options?.auth, speechReadiness ? { resolveStt: () => null, @@ -412,7 +427,7 @@ async function attachRelayAndHello(params: { }) { await params.server.attachExternalSocket(params.socket, { transport: "relay" }); params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId))); - expect(params.socket.sent.length).toBeGreaterThan(0); + await vi.waitFor(() => expect(params.socket.sent.length).toBeGreaterThan(0)); const envelope = parseSentEnvelope(params.socket.sent[0]); expect(envelope.type).toBe("session"); const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload); @@ -431,7 +446,7 @@ async function attachDirectAndHello(params: { createDirectRequest(), ); params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId))); - expect(params.socket.sent.length).toBeGreaterThan(0); + await vi.waitFor(() => expect(params.socket.sent.length).toBeGreaterThan(0)); const envelope = parseSentEnvelope(params.socket.sent[0]); expect(envelope.type).toBe("session"); const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload); @@ -471,6 +486,96 @@ function holdSessionCleanup(session: (typeof sessionMock.instances)[number]): { } describe("relay external socket reconnect behavior", () => { + test("closes only a hello socket when post-admission setup throws", async () => { + const server = createServer(); + class ThrowOnceSocket extends MockSocket { + private firstClose = true; + override close(code?: number, reason?: string): void { + if (this.firstClose) { + this.firstClose = false; + throw new Error("socket close failed during hello"); + } + super.close(code, reason); + } + } + const failed = new ThrowOnceSocket(); + try { + await server.attachExternalSocket(failed, { transport: "relay" }); + failed.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin"))); + await vi.waitFor(() => expect(failed.readyState).toBe(3)); + const initiallyFailed = new ThrowOnceSocket(); + await server.attachExternalSocket( + initiallyFailed, + { transport: "relay" }, + null, + createHelloMessage("plugin:initially-invalid"), + ); + await vi.waitFor(() => expect(initiallyFailed.readyState).toBe(3)); + const healthy = new MockSocket(); + await attachRelayAndHello({ server, socket: healthy, clientId: "still-running" }); + } finally { + await server.close(); + } + }); + + test("does not log malformed pre-admission credential frames", async () => { + const logger = createLogger(); + const server = createServer({ logger }); + const socket = new MockSocket(); + try { + await server.attachExternalSocket(socket, { transport: "relay" }); + socket.emit("message", '{"type":"hello","auth":{"kind":"password","password":"log-secret"},'); + await vi.waitFor(() => expect(socket.readyState).toBe(3)); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain("log-secret"); + } finally { + await server.close(); + } + }); + test("admits a hello password and an old relay hello, but rejects a wrong password", async () => { + const server = createServer({ + auth: { + password: "$2b$12$OLxyuuP9uLK30Uzc4wQX0O6liuU/Q1t5P2b0Ebf36mULvpVK3DRZW", + localCredential: () => "local-token", + }, + }); + try { + const passwordSocket = new MockSocket(); + await server.attachExternalSocket(passwordSocket, { transport: "relay" }); + passwordSocket.emit( + "message", + JSON.stringify({ + ...createHelloMessage("relay-password"), + auth: { kind: "password", password: "correct-password" }, + }), + ); + await vi.waitFor(() => expect(sentServerInfoEnvelopes(passwordSocket)).toHaveLength(1)); + + const legacySocket = new MockSocket(); + await server.attachExternalSocket(legacySocket, { transport: "relay" }); + legacySocket.emit("message", JSON.stringify(createHelloMessage("relay-legacy"))); + await vi.waitFor(() => expect(sentServerInfoEnvelopes(legacySocket)).toHaveLength(1)); + + const wrongSocket = new MockSocket(); + await server.attachExternalSocket(wrongSocket, { transport: "relay" }); + wrongSocket.emit( + "message", + JSON.stringify({ + ...createHelloMessage("relay-wrong"), + auth: { kind: "password", password: "wrong" }, + }), + ); + await vi.waitFor(() => expect(wrongSocket.readyState).toBe(3)); + expect(wrongSocket.sent).toContain( + JSON.stringify({ + type: "hello.rejected", + reason: "incorrect_password", + accepts: ["password"], + }), + ); + } finally { + await server.close(); + } + }); beforeEach(() => { sessionMock.instances.length = 0; vi.useFakeTimers(); @@ -521,7 +626,7 @@ describe("relay external socket reconnect behavior", () => { const secondAttachment = await server.attachPluginSocket("exclusive", secondSocket); secondSocket.emit("message", JSON.stringify(createHelloMessage("plugin:exclusive"))); - expect(sessionMock.instances).toHaveLength(2); + await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(2)); firstSocket.emit("close", 1000, "plugin stopped"); await firstAttachment.closed; expect(sessionMock.instances[0]?.cleanup).toHaveBeenCalledOnce(); @@ -533,13 +638,40 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("keeps a plugin socket whose heartbeat stalls past the application lease", async () => { + const server = createServer(); + const socket = new MockSocket(); + await server.attachPluginSocket("stalled", socket); + socket.emit("message", JSON.stringify(createHelloMessage("plugin:stalled"))); + await vi.waitFor(() => expect(sentServerInfoEnvelopes(socket)).toHaveLength(1)); + socket.emit("message", JSON.stringify({ type: "ping" })); + + // Event loop starved past the lease: no further ping arrives. + await vi.advanceTimersByTimeAsync(APPLICATION_SOCKET_LEASE_MS * 2); + + expect(socket.readyState).toBe(1); + await server.close(); + }); + + test("still reaps an ordinary socket whose heartbeat stalls past the application lease", async () => { + const server = createServer(); + const socket = new MockSocket(); + await attachRelayAndHello({ server, socket, clientId: "cid-stalled" }); + socket.emit("message", JSON.stringify({ type: "ping" })); + + await vi.advanceTimersByTimeAsync(APPLICATION_SOCKET_LEASE_MS * 2); + + expect(socket.readyState).toBe(3); + await server.close(); + }); + test("rejects ordinary sockets that claim the reserved plugin client id", async () => { const server = createServer(); const socket = new MockSocket(); await server.attachExternalSocket(socket, { transport: "relay" }); socket.emit("message", JSON.stringify(createHelloMessage("plugin:not-a-plugin"))); - expect(socket.readyState).toBe(3); + await vi.waitFor(() => expect(socket.readyState).toBe(3)); expect(sessionMock.instances).toHaveLength(0); await server.close(); }); @@ -557,7 +689,7 @@ describe("relay external socket reconnect behavior", () => { }), ), ); - expect(sessionMock.instances).toHaveLength(1); + await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(1)); const session = sessionMock.instances[0]; expect(session.args.clientCapabilities).toEqual({ [CLIENT_CAPS.reasoningMergeEnum]: true, @@ -605,7 +737,7 @@ describe("relay external socket reconnect behavior", () => { const pluginSocket = new MockSocket(); const attachment = await server.attachPluginSocket("startup", pluginSocket); pluginSocket.emit("message", JSON.stringify(createHelloMessage("plugin:startup"))); - expect(sessionMock.instances).toHaveLength(1); + await vi.waitFor(() => expect(sessionMock.instances).toHaveLength(1)); server.beginAcceptingConnections(); const readySocket = new MockSocket(); @@ -696,6 +828,7 @@ describe("relay external socket reconnect behavior", () => { { principalId: "hub:daemon-1", permissions: ["hub.execute"] }, ); hubSocket.emit("message", JSON.stringify(createHelloMessage(clientId))); + await vi.waitFor(() => expect(hubSocket.sent.length).toBeGreaterThan(0)); const hubEnvelope = parseSentEnvelope(hubSocket.sent[0]); const hubInfo = parseServerInfoStatusPayload(hubEnvelope.message?.payload); @@ -742,6 +875,7 @@ describe("relay external socket reconnect behavior", () => { relayConnectionId: "relay-conn-1", }); socket.emit("message", JSON.stringify(createHelloMessage("cid-control-log"))); + await vi.waitFor(() => expect(sentServerInfoEnvelopes(socket)).toHaveLength(1)); socket.emit( "message", JSON.stringify({ @@ -889,7 +1023,7 @@ describe("relay external socket reconnect behavior", () => { payload: { requestId: "failing-provider-diagnostic", requestType: "provider_diagnostic_request", - error: "Invalid message", + error: "Invalid message: handler exploded", code: "invalid_message", }, }, @@ -1008,6 +1142,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); expect(serverInfo.features?.pluginLogs).toBe(true); + expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true); expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts index 0f4e7f25ff..3f77a0482e 100644 --- a/packages/server/src/server/websocket-server.terminal-notifications.test.ts +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -1,3 +1,4 @@ +import { SessionDelivery } from "./session/owned-subscriptions/index.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Server as HTTPServer } from "http"; import type pino from "pino"; @@ -194,9 +195,13 @@ function createOpenSocket() { function connectClient(server: VoiceAssistantWebSocketServer, subscribed = true) { const ws = createOpenSocket(); + const delivery = new SessionDelivery(() => {}); + delivery.attach(ws, false); asInternals<{ sessions: Map }>(server).sessions.set(ws, { kind: "trusted", session: { + delivery, + wantsSourceNotification: () => true, getClientActivity: vi.fn(() => null), subscribesToTerminalDirectory: vi.fn(async () => subscribed), }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 8cf4af4e2e..219e04b0c2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,7 +1,11 @@ +import { stat } from "node:fs/promises"; +import type { CreationSnapshot } from "@getpaseo/protocol/messages"; +import { CreationService } from "./creation/index.js"; +import { MessageReceipts } from "./message-receipts/index.js"; import { WebSocket, WebSocketServer } from "ws"; import type { IncomingMessage, Server as HTTPServer } from "http"; import { join } from "path"; -import { hostname as getHostname } from "node:os"; +import { getHostName } from "./host-name.js"; import { randomUUID } from "node:crypto"; import { monitorEventLoopDelay } from "node:perf_hooks"; import type { AgentManager, AgentMetricsSnapshot } from "./agent/agent-manager.js"; @@ -16,6 +20,7 @@ import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-m import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, + type ScriptStatusUpdateMessage, type SessionOutboundMessage, type WorkspaceSetupSnapshot, type WSHelloMessage, @@ -74,28 +79,25 @@ import { import { createGitHubService } from "../services/github-service.js"; import type { ForgeService } from "../services/forge-service.js"; import { + extractHttpBearerToken, extractWsBearerProtocol, extractWsBearerToken, - isBearerTokenValid, + isBearerTokenValidAsync, type DaemonAuthConfig, } from "./auth.js"; +import { resolveSessionAdmission } from "./session-admission-auth.js"; import { WebSocketRuntimeMetricsWindow, type WebSocketRuntimeCounters, type WebSocketRuntimeDiagnosticSnapshot, } from "./websocket/runtime-metrics.js"; -import { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { getProcessMemoryDiagnostics, getProcessUptimeSeconds } from "./process-diagnostics.js"; import { CLIENT_SHUTDOWN_RPC_REASON, normalizeClientRestartRpcReason, } from "./lifecycle-reasons.js"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; -import type { BrowserAutomationExecuteResponse } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { - BrowserAutomationHostCapabilitySchema, - type BrowserAutomationHostCapability, -} from "@getpaseo/protocol/browser-automation/capabilities"; + import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DirectorySyncService } from "./directory-sync/index.js"; @@ -130,7 +132,8 @@ interface PendingConnection { connectionLogger: pino.Logger; helloTimeout: ReturnType | null; identity: WebSocketConnectionIdentity; - admission: SessionAdmission; + admission: SessionAdmission | null; + authenticating: boolean; } interface WebSocketConnectionIdentity { @@ -243,6 +246,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createFallbackWorkspaceGitSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); @@ -282,6 +286,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { nativeTrackedFileCount: 0, pendingEventCount: 0, pendingReconciliationWorkCount: 0, + pendingClassificationCount: 0, reconciliationInFlightCount: 0, reconciliationCount: 0, scopedReconciliationCount: 0, @@ -417,15 +422,6 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe return Buffer.from(data); } -function getBrowserHostCapability( - capabilities: Record | null, -): BrowserAutomationHostCapability | null { - const parsed = BrowserAutomationHostCapabilitySchema.safeParse( - capabilities?.[CLIENT_CAPS.browserHost], - ); - return parsed.success ? parsed.data : null; -} - export interface WebSocketLike { readyState: number; bufferedAmount?: number; @@ -462,11 +458,6 @@ interface PluginSessionConnection extends SessionConnectionBase { type SessionConnection = ReconnectableSessionConnection | PluginSessionConnection; -interface BrowserToolsRegistration { - capabilitySignature: string; - unregister: () => void; -} - interface SocketSessionOptions { clientId: string; appVersion: string | null; @@ -477,7 +468,7 @@ interface SocketSessionOptions { onMessageToSource?: (source: object, message: SessionOutboundMessage) => void; onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise; - getTransportBufferedAmount?: () => number | null; + getTransportBufferedAmount?: (source?: object) => number | null; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; hubExecutionAgents?: HubExecutionAgents; hubRelationships?: HubRelationshipManagement; @@ -546,6 +537,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; + private readonly messageReceipts: MessageReceipts; + private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceLabelService: WorkspaceLabelService | null; @@ -556,6 +549,8 @@ export class VoiceAssistantWebSocketServer { private readonly workspaceAutoName: WorkspaceAutoName; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; + private readonly passwordHash: string | undefined; + private readonly credentialSource: DaemonAuthConfig | undefined; private readonly worktreesRoot: string | undefined; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; @@ -590,11 +585,9 @@ export class VoiceAssistantWebSocketServer { private eventLoopDelayMonitor: ReturnType | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeDaemonConfigChange: (() => void) | null = null; - private readonly providerUsageService: ProviderUsageService; private unsubscribeTerminalActivity: (() => void) | null = null; private readonly browserToolsBroker: BrowserToolsBroker | null; private readonly hubRelationships: HubRelationshipManagement | null; - private readonly browserToolsRegistrations = new Map(); private connectionLifecycle: "starting" | "accepting" | "stopping" = "accepting"; private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseRelayConfig: boolean; @@ -602,6 +595,25 @@ export class VoiceAssistantWebSocketServer { private readonly pluginRuntime: SessionOptions["pluginRuntime"]; private readonly orchestrationSkills: SessionOptions["orchestrationSkills"]; + private async validateCompletedCreation(snapshot: CreationSnapshot): Promise { + if (snapshot.workspace && snapshot.kind === "workspace") { + const workspace = await this.workspaceRegistry.get(snapshot.workspace.id); + if (!workspace) throw new Error("Previously created workspace no longer exists"); + const directory = await stat(workspace.cwd).catch(() => null); + if (!directory?.isDirectory()) + throw Object.assign(new Error(`Directory not found: ${workspace.cwd}`), { + code: "directory_not_found", + }); + } + if ( + snapshot.agentId && + !this.agentManager.getAgent(snapshot.agentId) && + !(await this.agentStorage.get(snapshot.agentId)) + ) { + throw new Error("Previously created agent no longer exists"); + } + } + constructor( server: HTTPServer, logger: pino.Logger, @@ -659,6 +671,7 @@ export class VoiceAssistantWebSocketServer { throw new MissingDaemonVersionError(); } this.daemonVersion = daemonVersion.trim(); + this.credentialSource = auth; this.daemonRuntimeConfig = daemonRuntimeConfig; this.browserToolsBroker = browserToolsBroker ?? null; this.hubRelationships = hubRelationships ?? null; @@ -666,6 +679,13 @@ export class VoiceAssistantWebSocketServer { this.orchestrationSkills = orchestrationSkills; this.agentManager = agentManager; this.agentStorage = agentStorage; + this.messageReceipts = new MessageReceipts(join(paseoHome, "agent-requests")); + this.creationService = new CreationService( + join(paseoHome, "creations"), + this.logger.child({ module: "creation" }), + (snapshot) => this.validateCompletedCreation(snapshot), + join(paseoHome, "agent-requests"), + ); this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.workspaceLabelService = workspaceLabelService ?? null; @@ -680,6 +700,7 @@ export class VoiceAssistantWebSocketServer { this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; + this.passwordHash = auth?.password; this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; @@ -733,10 +754,6 @@ export class VoiceAssistantWebSocketServer { }); }); - this.providerUsageService = new ProviderUsageService({ - logger: this.logger, - }); - this.wss = this.createWebSocketServer(server, wsConfig, auth); this.startRuntimeMetricsInterval(); this.startApplicationSocketLeaseInterval(); @@ -900,23 +917,39 @@ export class VoiceAssistantWebSocketServer { request: IncomingMessage, password: string | undefined, ): Promise { - if (password) { - const requestMetadata = extractSocketRequestMetadata(request); + // Header validation is asynchronous. Buffer frames until the socket has a + // pending hello handler so an eager client cannot lose its first message. + ws.pause(); + try { + // COMPAT(headerAuth): added in v0.9.1, remove after 2027-03-24. const protocol = extractWsBearerProtocol(request.headers["sec-websocket-protocol"]); - const token = extractWsBearerToken(protocol); - const isAuthorized = isBearerTokenValid({ password, token }); - if (!isAuthorized) { - const reason = token === null ? "Password required" : "Incorrect password"; - this.logger.warn( - { ...requestMetadata, hasToken: token !== null }, - "Rejected WebSocket connection with invalid daemon password", - ); - ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason); - return; + const token = + extractHttpBearerToken(request.headers.authorization) ?? extractWsBearerToken(protocol); + const hasHeaderCredential = token !== null; + if (password && hasHeaderCredential) { + const requestMetadata = extractSocketRequestMetadata(request); + const isAuthorized = await isBearerTokenValidAsync({ password, token }); + if (!isAuthorized) { + const reason = "Incorrect password"; + this.logger.warn( + { ...requestMetadata, hasToken: true }, + "Rejected WebSocket connection with invalid daemon password", + ); + ws.close(WS_CLOSE_DAEMON_AUTH_FAILED, reason); + return; + } } - } - await this.attachSocket(ws, request); + await this.attachSocket( + ws, + request, + undefined, + false, + hasHeaderCredential ? OWNER_SESSION_ADMISSION : null, + ); + } finally { + ws.resume(); + } } public broadcast(message: WSOutboundMessage): void { @@ -947,6 +980,12 @@ export class VoiceAssistantWebSocketServer { } } + public publishScriptStatusUpdate(message: ScriptStatusUpdateMessage): void { + for (const session of this.listSessions()) { + session.emitServerMessage(message); + } + } + public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { this.updateServerCapabilities(buildServerCapabilities({ readiness })); } @@ -963,7 +1002,7 @@ export class VoiceAssistantWebSocketServer { public async attachExternalSocket( ws: WebSocketLike, metadata?: ExternalSocketMetadata, - admission: SessionAdmission = OWNER_SESSION_ADMISSION, + admission: SessionAdmission | null = null, initialHello?: WSHelloMessage, ): Promise { if (metadata?.transport === "relay") { @@ -986,7 +1025,7 @@ export class VoiceAssistantWebSocketServer { this.pluginSocketIds.set(ws, pluginId); this.pluginSocketCleanup.set(ws, resolve); try { - await this.attachSocket(ws, undefined, undefined, true); + await this.attachSocket(ws, undefined, undefined, true, OWNER_SESSION_ADMISSION); } catch (error) { this.pluginSocketIds.delete(ws); this.finishPluginSocketCleanup(ws); @@ -1000,14 +1039,13 @@ export class VoiceAssistantWebSocketServer { permissions: readonly DaemonPermission[], ): void { for (const pending of this.pendingConnections.values()) { - if (pending.admission.principalId === principalId) { + if (pending.admission?.principalId === principalId) { pending.admission = { ...pending.admission, permissions }; } } for (const connection of new Set(this.externalSessionsByKey.values())) { if (connection.principalId === principalId) { connection.session.setPermissions(permissions); - this.syncBrowserToolsClientRegistration(connection); } } } @@ -1100,18 +1138,38 @@ export class VoiceAssistantWebSocketServer { this.sessions.clear(); this.socketIdentities.clear(); this.externalSessionsByKey.clear(); - for (const clientId of this.browserToolsRegistrations.keys()) { - this.unregisterBrowserToolsClient(clientId); - } this.wss.close(); } private sendToClient(ws: WebSocketLike, message: WSOutboundMessage): void { - this.sendMessageToSockets([ws], message); + this.sendMessageToSockets([ws], message, true); } - private sendMessageToSockets(sockets: Iterable, message: WSOutboundMessage): void { - const writableSockets = [...sockets].filter((ws) => this.ensureOutboundCapacity(ws, 0)); + private sendMessageToSockets( + sockets: Iterable, + message: WSOutboundMessage, + reportRejection = false, + ): void { + const writableSockets = [...sockets].filter((ws) => { + if ( + message.type === "session" && + this.sessions.get(ws)?.session.delivery.permits(ws, message.message) !== true + ) { + this.incrementRuntimeCounter("applicationJsonRejected"); + // Legacy broadcasts intentionally exclude modern sockets. Targeted failures are bugs. + if (reportRejection) + this.logger.warn( + { + messageType: message.message.type, + status: + message.message.type === "status" ? message.message.payload.status : undefined, + }, + "ws_application_delivery_denied", + ); + return false; + } + return this.ensureOutboundCapacity(ws, 0); + }); if (writableSockets.length === 0) { return; } @@ -1132,13 +1190,24 @@ export class VoiceAssistantWebSocketServer { } } - private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array): void { + private sendBinaryToClient(ws: WebSocketLike, frame: Uint8Array, reportRejection = true): void { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + if (reportRejection) + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } this.sendFrameToClient(ws, frame, outboundFrameByteLength(frame), () => { this.runtimeMetrics.recordOutboundBinaryFrame(ws.bufferedAmount); }); } private async sendBinaryToClientAndWait(ws: WebSocketLike, frame: Uint8Array): Promise { + if (!this.sessions.get(ws)?.session.delivery.permitsBinary(ws, frame)) { + this.incrementRuntimeCounter("applicationBinaryRejected"); + this.logger.warn({ bytes: frame.byteLength }, "ws_application_binary_delivery_denied"); + return; + } try { const sent = await sendBoundedPhysicalFrameAndWait({ socket: ws, @@ -1229,7 +1298,7 @@ export class VoiceAssistantWebSocketServer { private sendBinaryToConnection(connection: SessionConnection, frame: Uint8Array): void { for (const ws of connection.sockets) { - this.sendBinaryToClient(ws, frame); + this.sendBinaryToClient(ws, frame, false); } } @@ -1238,7 +1307,7 @@ export class VoiceAssistantWebSocketServer { request?: unknown, metadata?: ExternalSocketMetadata, allowDuringStartup = false, - admission: SessionAdmission = OWNER_SESSION_ADMISSION, + admission: SessionAdmission | null = null, initialHello?: WSHelloMessage, ): Promise { if ( @@ -1263,6 +1332,7 @@ export class VoiceAssistantWebSocketServer { helloTimeout: null, identity, admission, + authenticating: false, }; const timeout = setTimeout(() => { if (this.pendingConnections.get(ws) !== pending) { @@ -1294,9 +1364,7 @@ export class VoiceAssistantWebSocketServer { }, "Client connected; awaiting hello", ); - if (initialHello) { - this.handleHello({ ws, message: initialHello, pending }); - } + if (initialHello) await this.handleHelloSafely({ ws, message: initialHello, pending }); } private createSessionConnection(params: { @@ -1342,10 +1410,11 @@ export class VoiceAssistantWebSocketServer { } await this.sendBinaryToClientAndWait(source as WebSocketLike, frame); }, - getTransportBufferedAmount: () => { + getTransportBufferedAmount: (source) => { if (!connection) { return null; } + if (source) return (source as WebSocketLike).bufferedAmount ?? null; // Relay-attached sockets are a WebSocketLike that doesn't expose // bufferedAmount. Return null when no socket gives a signal so the // terminal fallback can't mistake "no signal" for "client keeping up"; @@ -1379,12 +1448,13 @@ export class VoiceAssistantWebSocketServer { lifecycle.kind === "ephemeral-plugin" ? { ...base, lifecycle: "ephemeral-plugin", pluginId: lifecycle.pluginId } : { ...base, lifecycle: "reconnectable", externalDisconnectCleanupTimeout: null }; - session.updateClientCapabilities(clientCapabilities, ws); + session.updateClientCapabilities(clientCapabilities, ws, appVersion); return connection; } private createSocketSession(options: SocketSessionOptions): Session { return new Session({ + browserToolsBroker: this.browserToolsBroker, clientId: options.clientId, appVersion: options.appVersion, clientCapabilities: options.clientCapabilities, @@ -1403,12 +1473,15 @@ export class VoiceAssistantWebSocketServer { ), ); }, + publishScriptStatusUpdate: (message) => this.publishScriptStatusUpdate(message), downloadTokenStore: this.downloadTokenStore, pushNotifications: this.pushNotifications, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, agentManager: this.agentManager, agentStorage: this.agentStorage, + messageReceipts: this.messageReceipts, + creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, workspaceLabelService: this.workspaceLabelService ?? undefined, @@ -1427,7 +1500,6 @@ export class VoiceAssistantWebSocketServer { tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, providerSnapshotManager: this.providerSnapshotManager, - providerUsageService: this.providerUsageService, hubExecutionAgents: options.hubExecutionAgents, hubRelationships: options.hubRelationships, serviceProxy: this.serviceProxy ?? undefined, @@ -1444,6 +1516,8 @@ export class VoiceAssistantWebSocketServer { }, voiceBridge: { registerVoiceSpeakHandler: (agentId, handler) => { + if (this.voiceSpeakHandlers.has(agentId)) + throw new Error("Voice mode is already active for this agent"); this.voiceSpeakHandlers.set(agentId, handler); }, unregisterVoiceSpeakHandler: (agentId) => { @@ -1485,14 +1559,14 @@ export class VoiceAssistantWebSocketServer { return pending; } - private handleHello(params: { + private async handleHello(params: { ws: WebSocketLike; message: WSHelloMessage; pending: PendingConnection; - }): void { + }): Promise { const { ws, message, pending } = params; - if (message.protocolVersion !== WS_PROTOCOL_VERSION) { + if (message.protocolVersion < 1) { this.clearPendingConnection(ws); pending.connectionLogger.warn( { @@ -1501,14 +1575,13 @@ export class VoiceAssistantWebSocketServer { }, "Rejected hello due to protocol version mismatch", ); - try { - ws.close(WS_CLOSE_INCOMPATIBLE_PROTOCOL, "Incompatible protocol version"); - } catch { - // ignore close errors - } + await this.rejectHello(ws, message, "incompatible_protocol"); return; } + pending.authenticating = true; + if (!pending.admission && !(await this.admitPendingHello(ws, message, pending))) return; + const clientId = message.clientId.trim(); if (clientId.length === 0) { this.clearPendingConnection(ws); @@ -1534,11 +1607,13 @@ export class VoiceAssistantWebSocketServer { } this.clearPendingConnection(ws); + const admitted = pending.admission; + if (!admitted) throw new Error("Admitted hello has no session admission"); pending.identity.clientId = clientId; if (message.appVersion) { pending.identity.appVersion = message.appVersion; } - const sessionKey = sessionConnectionKey(pending.admission.principalId, clientId); + const sessionKey = sessionConnectionKey(admitted.principalId, clientId); const existing = pluginId ? undefined : this.externalSessionsByKey.get(sessionKey); if (existing) { this.resumeSession({ ws, message, pending, existing }); @@ -1554,14 +1629,13 @@ export class VoiceAssistantWebSocketServer { clientCapabilities: message.capabilities ?? null, connectionLogger, lifecycle: pluginId ? { kind: "ephemeral-plugin", pluginId } : { kind: "reconnectable" }, - admission: pending.admission, + admission: admitted, }); this.sessions.set(ws, connection); if (connection.lifecycle === "reconnectable") { this.externalSessionsByKey.set(sessionKey, connection); } pending.identity.sessionId = connection.session.getSessionId(); - this.syncBrowserToolsClientRegistration(connection); this.sendToClient(ws, this.createServerInfoMessage(connection.session)); connection.connectionLogger.info( { @@ -1573,6 +1647,93 @@ export class VoiceAssistantWebSocketServer { ); } + private handleHelloSafely(params: { + ws: WebSocketLike; + message: WSHelloMessage; + pending: PendingConnection; + }): Promise { + return this.handleHello(params).catch((error: unknown) => { + try { + this.handleRawMessageError({ + ws: params.ws, + data: "", + error, + log: params.pending.connectionLogger, + }); + } catch { + // The error reporter must not turn a connection failure into a process failure. + } finally { + try { + params.ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello"); + } catch { + // The transport may already be closed. + } + } + }); + } + + private async admitPendingHello( + ws: WebSocketLike, + message: WSHelloMessage, + pending: PendingConnection, + ): Promise { + if (pending.admission) return true; + try { + const resolved = await resolveSessionAdmission({ + credential: message.auth, + passwordHash: this.passwordHash, + localCredential: this.credentialSource?.localCredential?.() ?? null, + transport: pending.identity.transport === "relay" ? "relay" : "direct", + }); + if (this.pendingConnections.get(ws) !== pending) return false; + if ("rejection" in resolved) { + this.clearPendingConnection(ws); + await this.rejectHello(ws, message, resolved.rejection); + return false; + } + pending.admission = resolved.admission; + return true; + } catch (error) { + pending.connectionLogger.error({ err: error }, "Failed to resolve hello credential"); + if (this.pendingConnections.get(ws) === pending) { + this.clearPendingConnection(ws); + await this.rejectHello(ws, message, "incorrect_password"); + } + return false; + } + } + + private async rejectHello( + ws: WebSocketLike, + hello: WSHelloMessage, + reason: "password_required" | "incorrect_password" | "incompatible_protocol", + ): Promise { + // Older clients discard unknown frames, then use the close code and reason. + // They never send hello.auth, so avoid sending an unknown envelope to them. + if (hello.auth || hello.capabilities?.[CLIENT_CAPS.helloRejection] === true) { + try { + await ws.send(JSON.stringify({ type: "hello.rejected", reason, accepts: ["password"] })); + } catch { + // The close reason remains the compatibility signal. + } + } + const closeReason = { + password_required: "Password required", + incorrect_password: "Incorrect password", + incompatible_protocol: "Incompatible protocol version", + }[reason]; + try { + ws.close( + reason === "incompatible_protocol" + ? WS_CLOSE_INCOMPATIBLE_PROTOCOL + : WS_CLOSE_DAEMON_AUTH_FAILED, + closeReason, + ); + } catch { + // Ignore a transport that closed while the rejection was sent. + } + } + private resumeSession(params: { ws: WebSocketLike; message: WSHelloMessage; @@ -1594,18 +1755,16 @@ export class VoiceAssistantWebSocketServer { // COMPAT(selectiveAgentTimeline): added in v0.1.106. Every capable resumed // hello resets membership before server_info so stale retained-session // state cannot leak. Remove after 2027-01-12. - existing.session.updateClientCapabilities(newClientCapabilities, ws); + existing.session.updateClientCapabilities(newClientCapabilities, ws, newAppVersion); if ( JSON.stringify(existing.clientCapabilities ?? null) !== JSON.stringify(newClientCapabilities ?? null) ) { existing.clientCapabilities = newClientCapabilities; - this.syncBrowserToolsClientRegistration(existing); } existing.sockets.add(ws); this.sessions.set(ws, existing); pending.identity.sessionId = existing.session.getSessionId(); - this.syncBrowserToolsClientRegistration(existing); this.sendToClient(ws, this.createServerInfoMessage(existing.session)); pending.connectionLogger.info( { @@ -1620,14 +1779,22 @@ export class VoiceAssistantWebSocketServer { private buildServerInfoStatusPayload(session: Session): ServerInfoStatusPayload { return { status: "server_info", + protocolVersion: WS_PROTOCOL_VERSION, serverId: this.serverId, - hostname: getHostname(), + hostname: getHostName(), version: this.daemonVersion, permissions: session.getPermissions(), // COMPAT(desktopManaged): added in v0.1.X, remove optional parsing after 2027-01-16. desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), features: { + usageSources: true, + ownedSubscriptions: true, + agentRequestReceipts: true, + workspaceRequestReceipts: true, + creationLifecycle: true, + hubAgentRpc: true, + backgroundWorkspaces: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. @@ -1670,9 +1837,12 @@ export class VoiceAssistantWebSocketServer { plugins: true, pluginManagement: true, pluginGitManagement: true, + pluginSourceInstallation: true, + pluginSourceUpdates: true, pluginLogs: true, // COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20. pluginThemes: true, + pluginSettings: true, pluginTimelineItems: true, // COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16. skillManagement: true, @@ -1682,6 +1852,7 @@ export class VoiceAssistantWebSocketServer { "terminal-input-mode-replay": true, // COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02. "terminal-size-ownership": true, + workspaceTerminals: true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, // COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X. @@ -1720,8 +1891,14 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. + projectedSubagentTimeline: true, + // COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04. + providerSubagentNesting: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, + // COMPAT(workspaceMarkUnread): added in v0.5.0, remove after 2027-08-20. + workspaceMarkUnread: true, // COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X. hubRelationship: true, // COMPAT(projectGithubClone): added in v0.1.108, remove gate after 2027-01-15. @@ -1745,8 +1922,10 @@ export class VoiceAssistantWebSocketServer { forgeProviders: true, // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: true, + explicitEventSubscriptions: true, // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: true, + agentMessageProvenance: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. @@ -1789,7 +1968,16 @@ export class VoiceAssistantWebSocketServer { private broadcastCapabilitiesUpdate(): void { for (const connection of new Set(this.sessions.values())) { - this.sendToConnection(connection, this.createServerInfoMessage(connection.session)); + if ( + ![...connection.sockets].some((socket) => + connection.session.wantsSourceEvent(socket, "status.server_info"), + ) + ) + continue; + connection.session.publish({ + type: "status", + payload: this.buildServerInfoStatusPayload(connection.session), + }); } } @@ -1881,7 +2069,6 @@ export class VoiceAssistantWebSocketServer { this.socketIdentities.delete(ws); if (connection.sockets.size === 0) { - this.unregisterBrowserToolsClient(connection); if (connection.lifecycle === "ephemeral-plugin") { this.pluginSocketIds.delete(ws); await this.cleanupConnection(connection, "Plugin session disconnected"); @@ -1959,7 +2146,6 @@ export class VoiceAssistantWebSocketServer { this.externalSessionsByKey.delete(connection.sessionKey); } } - this.unregisterBrowserToolsClient(connection); connection.connectionLogger.trace( { clientId: connection.clientId, totalSessions: this.sessions.size }, @@ -1968,54 +2154,6 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } - private syncBrowserToolsClientRegistration(connection: SessionConnection): void { - if (!this.browserToolsBroker) { - return; - } - const registrationKey = connection.sessionKey; - if (!connection.session.allowsPermission("workspace.write")) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const browserHostCapability = getBrowserHostCapability(connection.clientCapabilities); - if (!browserHostCapability) { - this.unregisterBrowserToolsClient(registrationKey); - return; - } - const capabilitySignature = JSON.stringify(browserHostCapability); - const existing = this.browserToolsRegistrations.get(registrationKey); - if (existing?.capabilitySignature === capabilitySignature) { - return; - } - if (existing) { - this.browserToolsRegistrations.delete(registrationKey); - existing.unregister(); - } - - const unregister = this.browserToolsBroker.registerClient({ - id: connection.principalId === "owner" ? connection.clientId : registrationKey, - hostKind: browserHostCapability.hostKind, - supportedCommands: browserHostCapability.supportedCommands, - sendBrowserAutomationRequest: (request) => { - this.sendToConnection(connection, wrapSessionMessage(request)); - }, - }); - this.browserToolsRegistrations.set(registrationKey, { - capabilitySignature, - unregister, - }); - } - - private unregisterBrowserToolsClient(connection: SessionConnection | string): void { - const registrationKey = typeof connection === "string" ? connection : connection.sessionKey; - const registration = this.browserToolsRegistrations.get(registrationKey); - if (!registration) { - return; - } - this.browserToolsRegistrations.delete(registrationKey); - registration.unregister(); - } - private handleInvalidInboundMessage(args: { ws: WebSocketLike; parsed: unknown; @@ -2058,35 +2196,13 @@ export class VoiceAssistantWebSocketServer { "WS inbound message validation failed", ); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: isUnknownSchema - ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` - : "Invalid message", - code: isUnknownSchema ? "unknown_schema" : "invalid_message", - }, - }), - ); - return; - } - - const errorMessage = `Invalid message: ${parsedMessage.error.message}`; - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: errorMessage, - }, - }), - ); + activeConnection?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: isUnknownSchema + ? `Unknown request, try upgrading the daemon (currently v${this.daemonVersion})` + : `Invalid message: ${parsedMessage.error.message}`, + code: isUnknownSchema ? "unknown_schema" : "invalid_message", + }); } private maybeHandleBinaryFrame(params: { @@ -2115,7 +2231,7 @@ export class VoiceAssistantWebSocketServer { } return true; } - void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame)).catch( + void Promise.resolve(activeConnection.session.handleBinaryFrame(decodedFrame, ws)).catch( (error: unknown) => { this.handleRawMessageError({ ws, @@ -2134,8 +2250,8 @@ export class VoiceAssistantWebSocketServer { pendingConnection: PendingConnection; }): void { const { ws, message, pendingConnection } = params; - if (message.type === "hello") { - this.handleHello({ + if (message.type === "hello" && !pendingConnection.authenticating) { + void this.handleHelloSafely({ ws, message, pending: pendingConnection, @@ -2205,16 +2321,16 @@ export class VoiceAssistantWebSocketServer { const message = parsedMessage.data; this.recordInboundMessageType(message.type); - if (message.type === "ping") { - this.applicationSocketLease.claim(ws); + if (message.type === "ping" && activeConnection) { + // A plugin socket is IPC to a child this daemon already supervises, not + // an abandonable application socket. + if (!this.pluginSocketIds.has(ws)) { + this.applicationSocketLease.claim(ws); + } this.sendToClient(ws, { type: "pong" }); return; } - if (message.type === "recording_state") { - return; - } - if (pendingConnection) { this.handlePendingConnectionMessage({ ws, @@ -2224,6 +2340,10 @@ export class VoiceAssistantWebSocketServer { return; } + if (message.type === "recording_state") { + return; + } + if (!activeConnection) { this.incrementRuntimeCounter("missingConnectionForMessage"); this.logger.error("No connection found for websocket"); @@ -2274,15 +2394,6 @@ export class VoiceAssistantWebSocketServer { "ws_control_rpc_received", ); } - if (message.message.type === "browser.automation.execute.response") { - if (!activeConnection.session.allowsInbound(message.message)) { - await activeConnection.session.handleMessage(message.message, ws); - return; - } - this.browserToolsBroker?.receiveResponse(message.message as BrowserAutomationExecuteResponse); - return; - } - const startMs = performance.now(); await activeConnection.session.handleMessage(message.message, ws); const durationMs = performance.now() - startMs; @@ -2308,21 +2419,8 @@ export class VoiceAssistantWebSocketServer { }): void { const { ws, data, error, log } = params; const err = error instanceof Error ? error : new Error(String(error)); - const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data); - - const trimmedRawPayload = - typeof rawPayload === "string" && rawPayload.length > 2000 - ? `${rawPayload.slice(0, 2000)}... (truncated)` - : rawPayload; - - log.error( - { - err, - rawPayload: trimmedRawPayload, - parsedPayload, - }, - "Failed to parse/handle message", - ); + // Inbound frames can contain hello.auth.password or session secrets. + log.error({ errorName: err.name }, "Failed to parse/handle message"); if (this.pendingConnections.has(ws)) { this.clearPendingConnection(ws); @@ -2334,53 +2432,22 @@ export class VoiceAssistantWebSocketServer { return; } - const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: "Invalid message", - code: "invalid_message", - }, - }), - ); - return; - } - - this.sendToClient( - ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: `Invalid message: ${err.message}`, - }, - }), + const requestInfo = extractRequestInfoFromUnknownWsInbound( + this.decodeRawMessagePayloadForError(data), ); + this.sessions.get(ws)?.session.delivery.protocolFailure(ws, { + ...requestInfo, + error: `Invalid message: ${err.message}`, + code: "invalid_message", + }); } - private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): { - rawPayload: string | null; - parsedPayload: unknown; - } { - let rawPayload: string | null = null; - let parsedPayload: unknown = null; + private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): unknown { try { - const buffer = bufferFromWsData(data); - rawPayload = buffer.toString(); - parsedPayload = JSON.parse(rawPayload); - } catch (payloadError) { - rawPayload = rawPayload ?? ""; - parsedPayload = parsedPayload ?? rawPayload; - const payloadErr = - payloadError instanceof Error ? payloadError : new Error(String(payloadError)); - this.logger.error({ err: payloadErr }, "Failed to decode raw payload"); + return JSON.parse(bufferFromWsData(data).toString()); + } catch { + return null; } - return { rawPayload, parsedPayload }; } private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void { @@ -2485,8 +2552,8 @@ export class VoiceAssistantWebSocketServer { this.logger.info(loggedMetrics, "ws_runtime_metrics"); } - private getClientActivityState(session: Session): ClientPresenceState { - const activity = session.getClientActivity(); + private getClientActivityState(session: Session, source: object): ClientPresenceState { + const activity = session.getClientActivity(source); if (!activity) { return { appVisible: false, @@ -2519,14 +2586,22 @@ export class VoiceAssistantWebSocketServer { }> = []; for (const [ws, connection] of this.sessions) { - if (!(await connection.session.subscribesToAgent(agent))) continue; + if ( + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "agent_attention_required") + : !(await connection.session.subscribesToAgent(agent, ws)) + ) + continue; clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "agent_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ @@ -2551,8 +2626,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const timestamp = new Date().toISOString(); const connection = this.sessions.get(ws); const attentionPayload = { @@ -2563,7 +2640,8 @@ export class VoiceAssistantWebSocketServer { notification, }; const message = wrapSessionMessage( - connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) + connection?.session.delivery.isModern(ws) || + connection?.session.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, ws) ? { type: "agent_attention_required", payload: attentionPayload, @@ -2585,7 +2663,9 @@ export class VoiceAssistantWebSocketServer { }, ); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } @@ -2603,20 +2683,28 @@ export class VoiceAssistantWebSocketServer { for (const [ws, connection] of this.sessions) { if ( - !(await connection.session.subscribesToTerminalDirectory({ - cwd: params.cwd, - ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), - })) + connection.session.delivery.isModern(ws) + ? !connection.session.wantsSourceEvent(ws, "terminal_attention_required") + : !(await connection.session.subscribesToTerminalDirectory( + { + cwd: params.cwd, + ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + }, + ws, + )) ) { continue; } clientEntries.push({ ws, - state: this.getClientActivityState(connection.session), + state: this.getClientActivityState(connection.session, ws), }); } - const allStates = clientEntries.map((e) => e.state); + const notificationEntries = clientEntries.filter(({ ws }) => + this.sessions.get(ws)?.session.wantsSourceNotification(ws, "terminal_attention_required"), + ); + const allStates = notificationEntries.map((e) => e.state); const nowMs = Date.now(); const workspaceId = params.workspaceId; @@ -2650,8 +2738,10 @@ export class VoiceAssistantWebSocketServer { }); } - for (const [clientIndex, { ws }] of clientEntries.entries()) { - const shouldNotify = clientIndex === plan.inAppRecipientIndex; + for (const { ws } of clientEntries) { + const shouldNotify = + plan.inAppRecipientIndex !== null && + notificationEntries[plan.inAppRecipientIndex]?.ws === ws; const message = wrapSessionMessage({ type: "terminal_attention_required", payload: { @@ -2665,7 +2755,9 @@ export class VoiceAssistantWebSocketServer { shouldNotify, }, }); - this.sendToClient(ws, message); + if (message.type === "session" && this.sessions.get(ws)?.session.delivery.isModern(ws)) + this.sessions.get(ws)!.session.publishToSource(ws, message.message); + else this.sendToClient(ws, message); } } } diff --git a/packages/server/src/server/websocket/runtime-metrics.ts b/packages/server/src/server/websocket/runtime-metrics.ts index 8e7c5c6ffd..0031ad69bb 100644 --- a/packages/server/src/server/websocket/runtime-metrics.ts +++ b/packages/server/src/server/websocket/runtime-metrics.ts @@ -17,6 +17,8 @@ export interface WebSocketRuntimeCounters { relayExternalSocketAttached: number; originRejected: number; hostRejected: number; + applicationJsonRejected: number; + applicationBinaryRejected: number; } export interface WebSocketRuntimeMetricsSnapshot { @@ -232,6 +234,8 @@ function createRuntimeCounters(): WebSocketRuntimeCounters { relayExternalSocketAttached: 0, originRejected: 0, hostRejected: 0, + applicationJsonRejected: 0, + applicationBinaryRejected: 0, }; } diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 08cba47149..34abdebc3f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -1,3 +1,7 @@ +import { + createMessageReceiptsStub, + createTestCreationService, +} from "./test-utils/session-stubs.js"; import pino from "pino"; import { z } from "zod"; import { describe, expect, test } from "vitest"; @@ -184,6 +188,7 @@ function createSessionForWireCompatTest(options?: { clientCapabilities?: Record | null; directorySync?: DirectorySyncService; messages?: SessionOutboundMessage[]; + onMessageToSource?: SessionOptions["onMessageToSource"]; rows?: AgentTimelineRow[]; }): Session { const messages = options?.messages ?? []; @@ -206,10 +211,13 @@ function createSessionForWireCompatTest(options?: { ]; const session = new Session({ + messageReceipts: createMessageReceiptsStub(), + creationService: createTestCreationService(), clientId: "wire-compat-client", permissions: OWNER_PERMISSIONS, clientCapabilities: options?.clientCapabilities ?? null, onMessage: (message) => messages.push(message), + onMessageToSource: options?.onMessageToSource ?? ((_source, message) => messages.push(message)), logger: pino({ level: "silent" }), downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushNotifications: {} as SessionOptions["pushNotifications"], @@ -273,6 +281,7 @@ function createSessionForWireCompatTest(options?: { terminalManager: null, }); + session.updateClientCapabilities(options?.clientCapabilities ?? null, {}); return session; } @@ -338,6 +347,8 @@ describe("wire compatibility", () => { { type: "project.update", payload: { + generation: expect.any(String), + seq: 1, kind: "upsert", project: { projectId: "project-1", @@ -352,7 +363,7 @@ describe("wire compatibility", () => { }, { type: "project.update", - payload: { kind: "remove", projectId: "project-1" }, + payload: { kind: "remove", projectId: "project-1", generation: expect.any(String), seq: 2 }, }, ]); }); @@ -541,3 +552,92 @@ describe("wire compatibility", () => { }); }); }); + +test("setup progress is adapted per socket without changing the canonical snapshot", async () => { + const legacy = {}; + const capable = {}; + const modern = {}; + const modernCapable = {}; + const delivered = new Map(); + const session = createSessionForWireCompatTest({ + onMessageToSource: (source, message) => + delivered.set(source, [...(delivered.get(source) ?? []), message]), + }); + for (const [source, blocked, owned] of [ + [legacy, false, false], + [capable, true, false], + [modern, false, true], + [modernCapable, true, true], + ] as const) { + session.updateClientCapabilities( + { + explicit_event_subscriptions: true, + owned_subscriptions: owned, + workspace_setup_blocked: blocked, + }, + source, + ); + await session.handleMessage( + { + type: "session.events.set_subscription.request", + requestId: "setup", + events: ["workspace_setup_progress"], + }, + source, + ); + expect(delivered.get(source)).toContainEqual( + expect.objectContaining({ type: "session.events.set_subscription.response" }), + ); + } + delivered.clear(); + const message = { + type: "workspace_setup_progress" as const, + payload: { + workspaceId: "fork-workspace", + status: "blocked" as const, + error: null, + detail: { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "fork", + log: "", + commands: [], + }, + blockedSource: { + kind: "change_request" as const, + forge: "github", + number: 42, + headRepository: "contributor/project", + }, + }, + }; + session.publish(message); + expect(delivered.get(capable)).toEqual([message]); + expect(delivered.get(legacy)).toEqual([ + { + ...message, + payload: { + ...message.payload, + status: "failed", + error: + "Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.", + }, + }, + ]); + expect(delivered.get(modernCapable)).toEqual([ + { ...message, payload: { ...message.payload, subscriptionId: expect.any(String) } }, + ]); + expect(delivered.get(modern)).toEqual([ + { + ...message, + payload: { + ...message.payload, + status: "failed", + error: expect.stringContaining("Update Paseo"), + subscriptionId: expect.any(String), + }, + }, + ]); + expect(message.payload.status).toBe("blocked"); + await session.cleanup(); +}); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 7e2b0c2064..87cb758ac5 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,10 +1,13 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createWorktree } from "../utils/worktree.js"; import { createDaemonTestContext, DaemonClient, @@ -54,6 +57,32 @@ function createGitRepo(): string { return repoDir; } +async function runCli(args: string[], cwd: string): Promise { + const result = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${ctx.daemon.port}`, + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + PASEO_HOME: ctx.daemon.paseoHome, + }, + timeout: 20000, + }, + ); + return result.stdout; +} + async function createLocalWorkspace(cwd: string, title: string): Promise { const result = await ctx.client.createWorkspace({ source: { kind: "directory", path: cwd }, @@ -205,8 +234,8 @@ test("archiving a workspace removes it from every subscribed client", async () = const removals = collectWorkspaceRemovals(observer); try { - await ctx.client.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-initiator" } }); - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await ctx.client.fetchWorkspaces({ subscribe: {} }); + await observer.fetchWorkspaces({ subscribe: {} }); const archive = await ctx.client.archiveWorkspace(workspaceId); await observer.ping({ requestId: "archive-observer-barrier" }); @@ -233,7 +262,7 @@ test("renaming a workspace updates every subscribed client", async () => { const titles = collectWorkspaceTitles(observer); try { - await observer.fetchWorkspaces({ subscribe: { subscriptionId: "workspace-observer" } }); + await observer.fetchWorkspaces({ subscribe: {} }); const renamed = await ctx.client.setWorkspaceTitle(workspaceId, "Renamed workspace"); await observer.ping({ requestId: "rename-observer-barrier" }); @@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test.each(["worktree", "workspace"] as const)( + "%s archive immediately removes the deleted worktree from a warm CLI listing", + async (command) => { + const repoDir = createGitRepo(); + const created = await ctx.client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "cached-worktree", + baseBranch: "main", + }, + }); + const workspace = created.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(created.error ?? "Failed to create worktree workspace"); + } + const worktreePath = workspace.workspaceDirectory; + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli( + [command, "archive", command === "worktree" ? name : workspace.id, "--json"], + repoDir, + ); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + console.log( + `${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); + }, + 60000, +); + +test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => { + const sourceRepo = createGitRepo(); + const repoRoot = makeTempDir("workspace-archive-bare-"); + const bareRepo = path.join(repoRoot, "repo.git"); + const mainCheckout = path.join(repoRoot, "main"); + execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" }); + execFileSync("git", ["worktree", "add", mainCheckout, "main"], { + cwd: bareRepo, + stdio: "pipe", + }); + const { worktreePath } = await createWorktree({ + cwd: mainCheckout, + source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, + worktreeSlug: "cached-bare-worktree", + runSetup: false, + paseoHome: ctx.daemon.paseoHome, + }); + expect(await activeWorkspaceIds()).toEqual(new Set()); + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli(["worktree", "archive", name, "--json"], mainCheckout); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + console.log( + `bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); +}, 60000); + test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index c3b77f7070..d1cfb8ada7 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { github: createGitHubServiceStub(), workspaceGitService: { getSnapshot: vi.fn(async () => null), - } as unknown as Pick, + invalidateWorktrees: () => {}, + } as unknown as Pick, agentManager: { listAgents: () => [], getAgent: () => null, @@ -617,28 +618,37 @@ describe("archiveByScope", () => { expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled(); }); - test("worktree scope removes an owned directory with zero matching records", async () => { - const { tempDir, repoDir } = createGitRepo(); - const paseoHome = path.join(tempDir, ".paseo"); - const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + test.each(["intact", "missing"] as const)( + "worktree scope removes an owned directory with zero matching records and %s Git administration", + async (gitAdministration) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + if (gitAdministration === "missing") { + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: worktree.worktreePath, + }) + .toString() + .trim(); + rmSync(gitDir, { recursive: true, force: true }); + } + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const invalidateWorktrees = vi.fn(); + dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees; - const result = await archiveByScope( - createArchiveDeps({ - paseoHome, - activeWorkspaces: [], - }), - { + const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, requestId: "req-zero-records", - }, - ); + }); - assertArchiveResult(result, { - archivedWorkspaceIds: [], - removedDirectory: true, - }); - expect(existsSync(worktree.worktreePath)).toBe(false); - }); + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect(invalidateWorktrees).toHaveBeenCalledTimes(1); + }, + ); test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => { const { tempDir, repoDir } = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index a02c97eda8..e9e58681a1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -32,7 +32,7 @@ export interface ArchiveDependencies { // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; - workspaceGitService: Pick; + workspaceGitService: Pick; agentManager: Pick; agentStorage: Pick; // Resolves the worktree at a path to its workspaceId for archive-by-path. The @@ -408,6 +408,7 @@ async function maybeRemoveDirectory( paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); + dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { @@ -466,7 +467,7 @@ export async function archiveWorkspaceContents( const archivedAgents = new Set(); const liveAgents = dependencies.agentManager - .listAgents() + .listAgents({ includeInternal: true }) .filter((agent) => agent.workspaceId === workspaceId); for (const agent of liveAgents) { archivedAgents.add(agent.id); diff --git a/packages/server/src/server/workspace-create-errors.e2e.test.ts b/packages/server/src/server/workspace-create-errors.e2e.test.ts index bc42b77596..a5c442fcd6 100644 --- a/packages/server/src/server/workspace-create-errors.e2e.test.ts +++ b/packages/server/src/server/workspace-create-errors.e2e.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "vitest"; -import { rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => { rmSync(missingDir, { recursive: true, force: true }); } }, 180000); + +test("workspace creation replay rejects a checkout removed after completion", async () => { + const daemon = await createTestPaseoDaemon(); + const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-")); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + try { + await client.connect(); + const request = { + source: { kind: "directory" as const, path: directory }, + idempotencyKey: "removed-checkout", + }; + const created = await client.createWorkspace(request); + expect(created.error).toBeNull(); + expect(created.workspace).not.toBeNull(); + rmSync(directory, { recursive: true, force: true }); + const replayed = await client.createWorkspace(request); + expect(replayed.workspace).toBeNull(); + expect(replayed.errorCode).toBe("directory_not_found"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(directory, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts index 82f580ef1b..30e7043b64 100644 --- a/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts +++ b/packages/server/src/server/workspace-create-git-fanout.local.e2e.test.ts @@ -145,10 +145,10 @@ async function startObservedFixture(siblingCount: number): Promise file.path)).toEqual(["README.md"]); startGitCommandMetrics(); await waitForGitCommandMetricsIdle({ quietMs: 1_500, timeoutMs: 30_000 }); @@ -460,7 +459,7 @@ test("refreshes every workspace that depends on a changed shared base ref", asyn status: 2, "symbolic-ref": 3, }); - client?.unsubscribeCheckoutDiff(subscriptionId); + await subscription.release(); }, 120_000); test("records the Git command ledger for repository metadata business rules", async () => { @@ -627,7 +626,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const created = await client.createWorkspace({ source: { kind: "worktree", @@ -644,7 +643,7 @@ test("workspace archive is admitted while 52 sibling observations hydrate", asyn await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); const archiveStartedAt = Date.now(); @@ -670,10 +669,10 @@ test("workspace create is admitted while 100 sibling observations hydrate", asyn appVersion: "0.3.0-beta.2", }); await client.connect(); - await client.fetchAgents({ subscribe: { subscriptionId: "agents" } }); + await client.fetchAgents({ subscribe: {} }); const snapshot = await client.fetchWorkspaces({ page: { limit: 200 }, - subscribe: { subscriptionId: "workspaces" }, + subscribe: {}, }); expect(snapshot.entries).toHaveLength(SIBLING_COUNT); expect(snapshotGitCommandRuntimeMetrics().pending).toBeGreaterThan(0); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index a63d2e4a82..b377c22374 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -1,8 +1,8 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "vitest"; +import { expect, onTestFinished, test } from "vitest"; import { DaemonClient } from "./test-utils/index.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; @@ -64,6 +64,48 @@ test("workspace.create worktree source forwards action=checkout + refName into t } }, 180000); +test("existing branch changes remain visible after archive and restore", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + onTestFinished(async () => { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + runGit(repoDir, "switch", "feature/existing-branch"); + commitFile(repoDir, "feature.txt", "feature change\n"); + runGit(repoDir, "switch", "main"); + await client.connect(); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "checkout", + refName: "feature/existing-branch", + }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const cwd = workspace.workspaceDirectory; + const head = runGit(cwd, "rev-parse", "HEAD"); + const before = await client.getCheckoutDiff(cwd, { mode: "base" }); + expect(before.error).toBeNull(); + expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); + expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main"); + expect((await client.archiveWorkspace(workspace.id)).error).toBeNull(); + expect(existsSync(cwd)).toBe(false); + await client.restoreWorkspace(workspace.id); + expect(runGit(cwd, "rev-parse", "HEAD")).toBe(head); + expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main"); + const after = await client.getCheckoutDiff(cwd, { mode: "base" }); + expect(after.error).toBeNull(); + expect(after.files.map((file) => file.path)).toEqual(["feature.txt"]); +}, 180000); + test("workspace.create keeps a branch-off name separate from its worktree slug", async () => { const daemon = await createTestPaseoDaemon(); const { repoDir, tempRoot } = createGitRepoWithBranch(); @@ -258,3 +300,173 @@ test("workspace.create suffixes an occupied checkout branch", async () => { rmSync(tempRoot, { recursive: true, force: true }); } }, 180000); + +function runGit(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); +} +function commitFile(cwd: string, name: string, content: string): void { + writeFileSync(path.join(cwd, name), content); + runGit(cwd, "add", name); + runGit(cwd, "-c", "commit.gpgsign=false", "commit", "-m", name); +} + +test.each([ + ["refs/heads/main", "refs/remotes/origin/main"], + ["refs/remotes/origin/main", "refs/heads/main"], +])( + "restore preserves %s through comparison, update and local merge", + async (baseRef, conflictingRef) => { + const workspace = await createRestorableWorkspace(baseRef); + await archiveAndRestoreWorkspace(workspace); + await expectRestoredComparison(workspace, conflictingRef); + await updateFromOriginalBase(workspace); + await expectMergeIntoLocalBase(workspace); + }, + 180000, +); + +test("restore preserves upstream comparison and update but rejects a missing local merge target", async () => { + const workspace = await createRestorableWorkspace("refs/remotes/upstream/main"); + await archiveAndRestoreWorkspace(workspace); + await expectRestoredComparison(workspace, "refs/heads/main"); + await updateFromOriginalBase(workspace); + await expectMissingLocalMergeTarget(workspace); +}, 180000); + +async function createRestorableWorkspace(baseRef: string) { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + onTestFinished(async () => { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + createDivergentBases(repoDir); + await client.connect(); + const created = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + baseBranch: baseRef, + branchName: "feature/restore", + worktreeSlug: "restore-base", + }, + }); + expect(created.error).toBeNull(); + const workspace = created.workspace!; + const cwd = workspace.workspaceDirectory; + commitFile(cwd, "feature.txt", "feature change\n"); + const head = runGit(cwd, "rev-parse", "HEAD"); + await client.checkoutRefresh(cwd); + const before = await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" }); + expect(before.error).toBeNull(); + expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]); + expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main"); + return { client, daemon, repoDir, workspace, cwd, head, before, baseRef }; +} + +type RestorableWorkspace = Awaited>; + +function createDivergentBases(repoDir: string): void { + const root = runGit(repoDir, "rev-parse", "HEAD"); + // Same displayed name, three different commit streams. + commitFile(repoDir, "local.txt", "local base\n"); + runGit(repoDir, "switch", "--detach", root); + commitFile(repoDir, "origin.txt", "origin base\n"); + runGit(repoDir, "update-ref", "refs/remotes/origin/main", "HEAD"); + runGit(repoDir, "switch", "--detach", root); + commitFile(repoDir, "upstream.txt", "upstream base\n"); + runGit(repoDir, "update-ref", "refs/remotes/upstream/main", "HEAD"); + runGit(repoDir, "switch", "main"); +} + +async function archiveAndRestoreWorkspace({ client, workspace, cwd }: RestorableWorkspace) { + expect((await client.archiveWorkspace(workspace.id)).error).toBeNull(); + expect(existsSync(cwd)).toBe(false); + expect(await client.inspectWorkspaceRecovery(workspace.id)).toMatchObject({ action: "restore" }); + await client.restoreWorkspace(workspace.id); +} + +async function expectRestoredComparison( + { client, daemon, workspace, cwd, head, before, baseRef }: RestorableWorkspace, + conflictingRef: string, +) { + const restoredStatus = await client.getCheckoutStatus(cwd); + await client.checkoutRefresh(cwd); + const after = await client.getCheckoutDiff(cwd, { + mode: "base", + baseRef: restoredStatus.baseRef ?? undefined, + }); + expect(after.error).toBeNull(); + expect(after.files).toEqual(before.files); + expect(runGit(cwd, "rev-parse", "HEAD")).toBe(head); + expect(runGit(cwd, "branch", "--show-current")).toBe("feature/restore"); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + }); + const history = await client.listCheckoutCommits(cwd); + expect(history.baseRef).toBe(baseRef); + expect(history.commits.filter((commit) => !commit.isOnBase).map((commit) => commit.sha)).toEqual([ + head, + ]); + expect((await client.getCommitFileDiff(cwd, head, "feature.txt")).file?.path).toBe("feature.txt"); + const records = JSON.parse( + readFileSync(path.join(daemon.paseoHome, "projects/workspaces.json"), "utf8"), + ); + expect( + records.find((record: { workspaceId: string }) => record.workspaceId === workspace.id) + .baseBranch, + ).toBe(baseRef); + expect( + (await client.getCheckoutDiff(cwd, { mode: "base", baseRef: conflictingRef })).error?.message, + ).toContain("Base ref mismatch"); +} + +async function updateFromOriginalBase({ client, repoDir, cwd, baseRef }: RestorableWorkspace) { + // Advance only the chosen base; Update must not merge a same-named alternative. + runGit(repoDir, "switch", "--detach", baseRef); + commitFile(repoDir, "base-update.txt", `${baseRef}\n`); + const baseUpdate = runGit(repoDir, "rev-parse", "HEAD"); + runGit(repoDir, "update-ref", baseRef, baseUpdate); + runGit(repoDir, "switch", "main"); + await client.checkoutRefresh(cwd); + expect(await client.getCheckoutStatus(cwd)).toMatchObject({ + aheadBehind: { ahead: 1, behind: 1 }, + }); + expect((await client.checkoutMergeFromBase(cwd, { baseRef: "main" })).error).toBeNull(); + expect(readFileSync(path.join(cwd, "base-update.txt"), "utf8")).toBe(`${baseRef}\n`); + expect(runGit(cwd, "merge-base", baseUpdate, "HEAD")).toBe(baseUpdate); + expect( + (await client.getCheckoutDiff(cwd, { mode: "base", baseRef: "main" })).files.map( + (file) => file.path, + ), + ).toEqual(["feature.txt"]); +} + +async function expectMergeIntoLocalBase({ client, cwd, repoDir }: RestorableWorkspace) { + expect( + ( + await client.checkoutMerge(cwd, { + baseRef: "main", + strategy: "merge", + requireCleanTarget: true, + }) + ).error, + ).toBeNull(); + expect(runGit(repoDir, "show", "main:feature.txt")).toBe("feature change"); +} + +async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspace) { + expect( + ( + await client.checkoutMerge(cwd, { + baseRef: "main", + strategy: "merge", + requireCleanTarget: true, + }) + ).error?.message, + ).toContain("No local merge target is recorded"); +} diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 3f2a150b99..e317b5427d 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -82,6 +82,7 @@ class WorkspaceStatus { workspaceDirectory: workspace.cwd, projectKind: "git", workspaceKind: workspace.kind, + background: workspace.background, name: workspace.displayName, archivingAt: null, status: "done", @@ -571,7 +572,9 @@ describe("WorkspaceDirectory empty projects", () => { workspaceDirectory: workspace.cwd, projectKind: "non_git", workspaceKind: workspace.kind, + background: workspace.background, name: workspace.displayName, + ...(workspace.internal ? { background: true } : {}), archivingAt: null, status: "done", activityAt: null, @@ -644,4 +647,88 @@ describe("WorkspaceDirectory empty projects", () => { expect(result.emptyProjects.map((p) => p.projectId)).toEqual(["empty"]); }); + + test("hides background workspaces and counts their project as empty unless includeBackground", async () => { + const directory = makeDirectory({ + projects: [project({ projectId: "hidden-only" })], + workspaces: [ + { + workspaceId: "ws-internal", + projectId: "hidden-only", + cwd: "/workspace/hidden-only", + kind: "directory", + displayName: "helper", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + background: true, + }, + ], + }); + + const hidden = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r1", + filter: { query: "helper" }, + }); + expect(hidden.entries).toEqual([]); + expect(hidden.emptyProjects.map((p) => p.projectId)).toEqual(["hidden-only"]); + + const shown = await directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "r2", + filter: { includeBackground: true }, + }); + expect(shown.entries.map((entry) => entry.id)).toEqual(["ws-internal"]); + expect(shown.emptyProjects).toEqual([]); + }); +}); + +test("Git observation targets exclude archived records without hydrating app descriptors", async () => { + const workspace = ( + id: string, + projectId: string, + archivedAt: string | null = null, + ): PersistedWorkspaceRecord => ({ + workspaceId: id, + projectId, + cwd: `/workspace/${id}`, + kind: "local_checkout", + displayName: id, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const project = (id: string, archivedAt: string | null = null): PersistedProjectRecord => ({ + projectId: id, + rootPath: `/workspace/${id}`, + kind: "git", + displayName: id, + customName: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt, + }); + const unexpectedHydration = async (): Promise => { + throw new Error("Watcher reconciliation hydrated app data"); + }; + const directory = new WorkspaceDirectory({ + logger: createTestLogger(), + projectRegistry: { list: async () => [project("active"), project("archived", NOW)] }, + workspaceRegistry: { + list: async () => [ + workspace("observed", "active"), + workspace("hidden", "active", NOW), + workspace("hidden-project", "archived"), + ], + }, + listAgentPayloads: unexpectedHydration, + listProviderSubagentActivity: unexpectedHydration, + listTerminalActivityContributions: unexpectedHydration, + buildWorkspaceDescriptor: unexpectedHydration, + isProviderVisibleToClient: () => true, + }); + expect(await directory.listObservationTargets()).toEqual([ + { id: "observed", workspaceDirectory: "/workspace/observed", workspaceKind: "local_checkout" }, + ]); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index a50713e16f..8ccb4fa341 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -153,12 +153,24 @@ export function workspaceIdsForProjects( return Array.from(workspaceIds); } +function activeWorkspaceRecords( + workspaces: PersistedWorkspaceRecord[], + projects: PersistedProjectRecord[], +): PersistedWorkspaceRecord[] { + const archivedProjects = new Set( + projects.filter((project) => project.archivedAt).map((project) => project.projectId), + ); + return workspaces.filter( + (workspace) => !workspace.archivedAt && !archivedProjects.has(workspace.projectId), + ); +} + export class WorkspaceDirectory { private readonly archivingByWorkspaceId = new Map(); /** * Per-workspace last-seen winning bucket + entered-at. Persists across * `buildDescriptorMap` calls inside the daemon process; reset on cold start. - * Server-internal; never crosses the wire. + * Server-private; never crosses the wire. */ private readonly bucketHistoryByWorkspaceId = new Map(); @@ -223,12 +235,7 @@ export class WorkspaceDirectory { .filter((project) => !project.archivedAt) .map((project) => [project.projectId, project] as const), ); - const archivedProjectIds = new Set( - persistedProjects.filter((project) => project.archivedAt).map((project) => project.projectId), - ); - const activeRecords = persistedWorkspaces.filter( - (workspace) => !workspace.archivedAt && !archivedProjectIds.has(workspace.projectId), - ); + const activeRecords = activeWorkspaceRecords(persistedWorkspaces, persistedProjects); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null; const activeWorkspaceIds = new Set(activeRecords.map((workspace) => workspace.workspaceId)); @@ -550,14 +557,20 @@ export class WorkspaceDirectory { // Project parents that have no active workspaces. The wire field is the // sidebar projection bucket for projects whose workspace list is currently // empty; it is not a separate domain record. - async listEmptyProjects(): Promise { + async listEmptyProjects(options?: { + includeBackground?: boolean; + }): Promise { const [persistedWorkspaces, persistedProjects] = await Promise.all([ this.deps.workspaceRegistry.list(), this.deps.projectRegistry.list(), ]); + // A project whose only workspaces are hidden reads as empty to that caller. const projectIdsWithActiveWorkspaces = new Set( persistedWorkspaces - .filter((workspace) => !workspace.archivedAt) + .filter( + (workspace) => + !workspace.archivedAt && (options?.includeBackground === true || !workspace.background), + ) .map((workspace) => workspace.projectId), ); return persistedProjects @@ -575,6 +588,22 @@ export class WorkspaceDirectory { })); } + async listObservationTargets(): Promise< + Pick[] + > { + const [workspaces, projects] = await Promise.all([ + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + ]); + return activeWorkspaceRecords(workspaces, projects).map((workspace) => ({ + id: workspace.workspaceId, + workspaceDirectory: workspace.cwd, + workspaceKind: workspace.kind, + })); + } + + // Every active workspace, background ones included. Callers that answer a + // listing go through matchesFilter so background workspaces stay opt-in. async listDescriptors(): Promise { return Array.from( ( @@ -590,6 +619,13 @@ export class WorkspaceDirectory { filter: FetchWorkspacesRequestFilter | undefined; }): boolean { const { workspace, filter } = input; + if ( + workspace.background && + filter?.includeBackground !== true && + filter?.query !== workspace.id + ) { + return false; + } if (!filter) { return true; } @@ -645,7 +681,7 @@ export class WorkspaceDirectory { const projectIdFilter = filter?.projectId?.trim(); const emptyProjects = cursorToken ? [] - : (await this.listEmptyProjects()).filter( + : (await this.listEmptyProjects({ includeBackground: filter?.includeBackground })).filter( (project) => !projectIdFilter || project.projectId === projectIdFilter, ); @@ -693,7 +729,7 @@ function groupAgentsByWorkspaceId( return byWorkspaceId; } -function resolveWorkspaceRootAgent( +export function resolveWorkspaceRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, ): AgentSnapshotPayload | null { diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index eb54262b7b..a98dcda0fd 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -1,13 +1,14 @@ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; -import type pino from "pino"; +import pino from "pino"; import { afterEach, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { createFileObserver } from "./file-observer/index.js"; import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; -import type { FileChange, SubscribeToFileChanges } from "./file-observer/index.js"; +import type { FileChange, FileObserver, SubscribeToFileChanges } from "./file-observer/index.js"; function createLogger(): pino.Logger { const logger = { @@ -413,3 +414,90 @@ test("recursive observation updates tracked state and prunes ignored storms", as expect(getCheckoutDiff).not.toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); }, 30_000); + +test("a late Git-ignored tree is pruned while tracked changes still notify consumers", async () => { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-real-ignore-"))); + const repoDir = path.join(tempDir, "repo"); + mkdirSync(repoDir); + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repoDir, encoding: "utf8" }).trim(); + git("init", "-q"); + writeFileSync(path.join(repoDir, ".gitignore"), "node_modules/\n"); + const trackedPath = path.join(repoDir, "tracked.txt"); + writeFileSync(trackedPath, "before\n"); + git("add", ".gitignore", "tracked.txt"); + git("-c", "user.name=Test", "-c", "user.email=test@localhost", "commit", "-qm", "fixture"); + + const observer = createFileObserver(); + const ignoredDir = path.join(repoDir, "node_modules"); + const completedIgnoreUpdates: string[][] = []; + let notifications = 0; + let expectTrackedEdit = false; + let trackedBatchReachedConsumer = false; + // Observe the real subscription without replacing Git, filesystem, or watcher behavior. + const fileObserver: FileObserver = { + subscribe: async (directory, callback, options) => { + const realSubscription = await observer.subscribe( + directory, + (error, events) => { + const includesTrackedEdit = + expectTrackedEdit && events.some((e) => e.path === trackedPath); + const before = notifications; + callback(error, events); + if (includesTrackedEdit && notifications > before) trackedBatchReachedConsumer = true; + }, + options, + ); + return { + updateIgnore: async (paths) => { + await realSubscription.updateIgnore(paths); + completedIgnoreUpdates.push([...paths]); + }, + unsubscribe: () => realSubscription.unsubscribe(), + }; + }, + getDiagnostics: () => observer.getDiagnostics(), + close: () => observer.close(), + }; + const service = new WorkspaceGitServiceImpl({ + logger: pino({ enabled: false }), + paseoHome: path.join(tempDir, "home"), + fileObserver, + }); + let subscription: Awaited> | undefined; + try { + subscription = await service.requestWorkingTreeWatch(repoDir, () => { + notifications += 1; + }); + for (let index = 0; index < 20; index += 1) { + const directory = path.join(ignoredDir, `dependency-${index}`); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "index.js"), "first"); + } + expect(git("ls-files", "-o", "-i", "--directory", "--exclude-standard")).toBe("node_modules/"); + await expect + .poll(() => completedIgnoreUpdates.some((paths) => paths.includes(ignoredDir)), { + timeout: 5_000, + }) + .toBe(true); + + const atIgnoreBoundary = observer.getDiagnostics(); + for (let index = 0; index < 20; index += 1) { + writeFileSync(path.join(ignoredDir, `dependency-${index}`, "second.js"), "second"); + } + expectTrackedEdit = true; + writeFileSync(trackedPath, "after\n"); + await expect.poll(() => trackedBatchReachedConsumer, { timeout: 5_000 }).toBe(true); + const afterTrackedEdit = observer.getDiagnostics(); + expect(afterTrackedEdit.nativeHandleCount).toBe(atIgnoreBoundary.nativeHandleCount); + expect(afterTrackedEdit.nativeTrackedFileCount).toBe(atIgnoreBoundary.nativeTrackedFileCount); + expect( + afterTrackedEdit.nativeClassificationCount - atIgnoreBoundary.nativeClassificationCount, + ).toBeLessThanOrEqual(1); + } finally { + subscription?.unsubscribe(); + await service.dispose(); + await observer.close(); + rmSync(tempDir, { recursive: true, force: true }); + } +}, 15_000); diff --git a/packages/server/src/server/workspace-git-service.observation.test.ts b/packages/server/src/server/workspace-git-service.observation.test.ts index 8469d300c9..8285304570 100644 --- a/packages/server/src/server/workspace-git-service.observation.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.test.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; import type pino from "pino"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js"; @@ -8,6 +9,8 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js"; const REPO_CWD = path.resolve("/tmp/paseo-observation-repo"); const GIT_DIR = path.join(REPO_CWD, ".git"); +// Checkout observation must not depend on installed forge CLIs or host-auth probes. +const REMOTE_URL = pathToFileURL(path.join(REPO_CWD, "remote.git")).href; const WORKTREE_A = path.resolve("/tmp/paseo-observation-worktree-a"); const WORKTREE_B = path.resolve("/tmp/paseo-observation-worktree-b"); @@ -145,6 +148,50 @@ function getWatcherSubscribeCallCount( .length; } +// Recovery backoff ladder: base 30s, doubling each attempt, capped at 300s +// (base * 2 ** 4). Mirrors WATCH_RECOVERY_BASE_DELAY_MS / +// WATCH_RECOVERY_MAX_BACKOFF_STEPS / WATCH_RECOVERY_MAX_DELAY_MS in +// workspace-git-service.ts. +const WATCH_RECOVERY_LADDER_DELAYS_MS = [30_000, 60_000, 120_000, 240_000, 300_000, 300_000]; + +/** + * Drives a watch target (working tree or repository metadata) through six + * recovery cycles, proving three things about `advanceWatchRecoveryLadder`: + * - recovery keeps retrying well past the old cap of 3 attempts (this walks + * it to 6, i.e. 7 total subscriptions), + * - the delay between attempts follows 30s/60s/120s/240s and then plateaus + * at 300s rather than growing without bound, and + * - a recovered subscription that emits an event (proving it is live) and + * then errors again immediately — before surviving the 300s durability + * window — does NOT reset the ladder back to the 30s base. Each "advance + * by 30s only" check below would observe a premature retry if a reset had + * happened. + */ +async function driveWatchRecoveryLadder( + watcher: ReturnType, + directory: string, +): Promise { + for (const [index, delayMs] of WATCH_RECOVERY_LADDER_DELAYS_MS.entries()) { + const expectedCallCount = index + 2; + if (delayMs > 30_000) { + await vi.advanceTimersByTimeAsync(30_000); + // Not yet reset to the 30s base: no new attempt after only 30s. + expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount - 1); + await vi.advanceTimersByTimeAsync(delayMs - 30_000); + } else { + await vi.advanceTimersByTimeAsync(delayMs); + } + await vi.waitFor(() => { + expect(getWatcherSubscribeCallCount(watcher, directory)).toBe(expectedCallCount); + }); + const recoveredWatcher = getWatcherRecordsForDirectory(watcher, directory)[index + 1]; + recoveredWatcher?.callback(null, [ + { path: path.join(directory, `recovered-${index + 1}.txt`), type: "update" }, + ]); + recoveredWatcher?.callback(new Error(`recovered watcher stopped ${index + 1}`), []); + } +} + function createService( watcher: ReturnType, overrides?: Record, @@ -216,6 +263,35 @@ describe("WorkspaceGitService checkout observation", () => { vi.useRealTimers(); }); + test("waits for the initial watcher inventory before building a cold diff", async () => { + const watcher = createWatcherHarness(); + const inventoryFinished = createDeferred(); + let builds = 0; + const service = createService(watcher, { + subscribe: async (...args: Parameters) => { + await inventoryFinished.promise; + return watcher.subscribe(...args); + }, + getCheckoutDiff: async () => { + builds += 1; + return { diff: "", structured: [] }; + }, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, () => {}); + try { + const read = service.getCheckoutDiff(REPO_CWD, { mode: "uncommitted" }); + await flushPromises(); + expect(builds).toBe(0); + inventoryFinished.resolve(); + await read; + expect(builds).toBe(1); + } finally { + inventoryFinished.resolve(); + subscription.unsubscribe(); + await service.dispose(); + } + }); + test("dispose waits for file observation to finish closing", async () => { const watcher = createWatcherHarness(); const closeFinished = createDeferred(); @@ -500,7 +576,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -525,7 +601,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -569,7 +645,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -626,7 +702,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const runGitFetch = vi.fn(async (_cwd, observer) => { if (phase === "before") { @@ -681,7 +757,7 @@ describe("WorkspaceGitService checkout observation", () => { const releaseFetch = createDeferred(); const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, })); const getCheckoutRefDerivedState = vi.fn(); const service = createService(watcher, { @@ -731,7 +807,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -791,7 +867,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -820,7 +896,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -864,7 +940,7 @@ describe("WorkspaceGitService checkout observation", () => { const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ ...createCheckoutFacts(cwd), currentBranch: "feature", - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, resolvedBaseRef: "main", comparisonBaseRef: "origin/main", })); @@ -875,7 +951,7 @@ describe("WorkspaceGitService checkout observation", () => { currentBranch: "feature", baseRef: "main", hasRemote: true, - remoteUrl: "https://example.com/repo.git", + remoteUrl: REMOTE_URL, }), ), hasOriginRemote: vi.fn(async () => true), @@ -889,9 +965,13 @@ describe("WorkspaceGitService checkout observation", () => { }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await fetchSnapshotRead.promise; + // Observation setup also reads facts. Wait for the initial snapshot to be + // published before injecting an event that must produce a second refresh. await vi.waitFor(() => { - expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature"); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); }); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); await vi.waitFor(() => { expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); @@ -916,27 +996,44 @@ describe("WorkspaceGitService checkout observation", () => { test("origin/main refreshes a main checkout without configured upstream", async () => { const watcher = createWatcherHarness(); - const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({ - ...createCheckoutFacts(cwd), - currentBranch: "main", - remoteUrl: "https://example.com/repo.git", - resolvedBaseRef: "main", - comparisonBaseRef: null, - branchRemoteName: null, - branchMergeRef: null, - upstreamStatus: null, + const releaseInitialFacts = createDeferred(); + const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => { + await releaseInitialFacts.promise; + return { + ...createCheckoutFacts(cwd), + currentBranch: "main", + remoteUrl: REMOTE_URL, + resolvedBaseRef: "main", + comparisonBaseRef: null, + branchRemoteName: null, + branchMergeRef: null, + upstreamStatus: null, + }; + }); + const runGitFetch = vi.fn(async () => ({ + changes: [], + nonRemoteRefsChanged: false, + error: null, })); - const service = createService(watcher, { getCheckoutSnapshotFacts }); + const service = createService(watcher, { getCheckoutSnapshotFacts, runGitFetch }); const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); }); - watcher.records - .find((record) => record.directory === GIT_DIR) - ?.callback(null, [ - { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" }, - ]); + releaseInitialFacts.resolve(); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().fetchInFlightCount).toBe(0); + expect(runGitFetch).toHaveBeenCalledTimes(1); + }); + expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1); + const repoWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]!; + repoWatcher.callback(null, [ + { path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" }, + ]); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2); @@ -1459,6 +1556,87 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); + test("degraded polling backs off while the snapshot is unchanged and resets on a change", async () => { + // A repository large enough to defeat the recursive watcher makes every degraded refresh + // expensive, so a fixed cadence keeps the daemon shelling out Git on an untouched workspace. + const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) }); + let isDirty = false; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); + const service = createService(watcher, { getCheckoutStatus }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1); + expect(getCheckoutStatus).toHaveBeenCalledTimes(1); + }); + + // Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling. + for (const [elapsedMs, expectedCalls] of [ + [5_000, 2], + [10_000, 3], + [20_000, 4], + [40_000, 5], + [60_000, 6], + [60_000, 7], + ] as const) { + await vi.advanceTimersByTimeAsync(elapsedMs); + expect(getCheckoutStatus).toHaveBeenCalledTimes(expectedCalls); + } + + // A fixed 5s cadence would have run 39 polls over the same 195s. + expect(getCheckoutStatus.mock.calls.length).toBeLessThan(10); + + // A real change snaps the loop back to the base interval. + isDirty = true; + await vi.advanceTimersByTimeAsync(60_000); + expect(getCheckoutStatus).toHaveBeenCalledTimes(8); + await vi.advanceTimersByTimeAsync(5_000); + expect(getCheckoutStatus).toHaveBeenCalledTimes(9); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("degraded repository-metadata polling backs off and resets on a change", async () => { + // Only the Git-directory watcher fails, so the metadata fallback is the one poll loop running. + const watcher = createWatcherHarness({ failDirectories: new Set([GIT_DIR]) }); + let isDirty = false; + const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty })); + const service = createService(watcher, { getCheckoutStatus }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0); + }); + const callsBeforePolling = getCheckoutStatus.mock.calls.length; + function pollCount(): number { + return getCheckoutStatus.mock.calls.length - callsBeforePolling; + } + + // Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling. + for (const [elapsedMs, expectedPolls] of [ + [5_000, 1], + [10_000, 2], + [20_000, 3], + [40_000, 4], + [60_000, 5], + [60_000, 6], + ] as const) { + await vi.advanceTimersByTimeAsync(elapsedMs); + expect(pollCount()).toBe(expectedPolls); + } + + isDirty = true; + await vi.advanceTimersByTimeAsync(60_000); + expect(pollCount()).toBe(7); + await vi.advanceTimersByTimeAsync(5_000); + expect(pollCount()).toBe(8); + + subscription.unsubscribe(); + service.dispose(); + }); + test("non-Git fallback promotes an externally initialized checkout", async () => { const watcher = createWatcherHarness(); let isGit = false; @@ -1672,17 +1850,17 @@ describe("WorkspaceGitService checkout observation", () => { expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); }); const statusCallsAfterSetup = getCheckoutStatus.mock.calls.length; - await vi.advanceTimersByTimeAsync(5_000); - await vi.waitFor(() => { - expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup); - }); - await vi.advanceTimersByTimeAsync(24_000); + await vi.advanceTimersByTimeAsync(29_000); expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1); await vi.advanceTimersByTimeAsync(1_000); await vi.waitFor(() => { expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(2); }); expect(erroredUnsubscribe).toHaveBeenCalledTimes(1); + // Recovery schedules a debounced refresh. Advance the fake clock past the debounce + // explicitly; vi.waitFor alone runs out of real time before it on a slow runner. + await vi.advanceTimersByTimeAsync(2_000); + expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup); subscription.unsubscribe(); service.dispose(); @@ -1752,7 +1930,7 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); - test("watcher recovery remains capped when recovered subscriptions emit events before failing", async () => { + test("watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => { const watcher = createWatcherHarness(); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService(watcher, { @@ -1767,50 +1945,27 @@ describe("WorkspaceGitService checkout observation", () => { const checkoutWatcher = getWatcherRecordsForDirectory(watcher, REPO_CWD)[0]; checkoutWatcher?.callback(new Error("watcher stopped"), []); - await vi.advanceTimersByTimeAsync(30_000); - await vi.waitFor(() => { - expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(2); - }); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback(null, [ - { path: path.join(REPO_CWD, "recovered-1.txt"), type: "update" }, - ]); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[1]?.callback( - new Error("recovered watcher stopped"), - [], - ); - await vi.advanceTimersByTimeAsync(60_000); - await vi.waitFor(() => { - expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(3); - }); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback(null, [ - { path: path.join(REPO_CWD, "recovered-2.txt"), type: "update" }, - ]); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[2]?.callback( - new Error("recovered watcher stopped again"), - [], - ); - await vi.advanceTimersByTimeAsync(120_000); - await vi.waitFor(() => { - expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4); - }); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback(null, [ - { path: path.join(REPO_CWD, "recovered-3.txt"), type: "update" }, - ]); - getWatcherRecordsForDirectory(watcher, REPO_CWD)[3]?.callback( - new Error("last recovered watcher stopped"), - [], - ); + await driveWatchRecoveryLadder(watcher, REPO_CWD); - const statusCallsAtCap = getCheckoutStatus.mock.calls.length; + // Past the old cap of 3 recovery attempts (4 total subscriptions), the + // service keeps retrying instead of giving up permanently. + expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe( + WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1, + ); + const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length; await vi.advanceTimersByTimeAsync(300_000); - expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(4); - expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap); + await vi.waitFor(() => { + expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe( + WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2, + ); + }); + expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder); subscription.unsubscribe(); service.dispose(); }); - test("repository watcher recovery remains capped after recovered subscriptions emit events", async () => { + test("repository watcher recovery keeps retrying past the old attempt cap and the ladder does not reset on immediate re-failure", async () => { const watcher = createWatcherHarness(); const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); const service = createService(watcher, { @@ -1826,21 +1981,21 @@ describe("WorkspaceGitService checkout observation", () => { new Error("repository watcher stopped"), [], ); + await driveWatchRecoveryLadder(watcher, GIT_DIR); - for (const [recoveryIndex, delayMs] of [30_000, 60_000, 120_000].entries()) { - await vi.advanceTimersByTimeAsync(delayMs); - await vi.waitFor(() => { - expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(recoveryIndex + 2); - }); - const recoveredWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[recoveryIndex + 1]; - recoveredWatcher?.callback(null, [{ path: path.join(GIT_DIR, "HEAD"), type: "update" }]); - recoveredWatcher?.callback(new Error("recovered repository watcher stopped"), []); - } - - const statusCallsAtCap = getCheckoutStatus.mock.calls.length; + // Past the old cap of 3 recovery attempts (4 total subscriptions), the + // service keeps retrying instead of giving up permanently. + expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe( + WATCH_RECOVERY_LADDER_DELAYS_MS.length + 1, + ); + const statusCallsAfterLadder = getCheckoutStatus.mock.calls.length; await vi.advanceTimersByTimeAsync(300_000); - expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe(4); - expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAtCap); + await vi.waitFor(() => { + expect(getWatcherSubscribeCallCount(watcher, GIT_DIR)).toBe( + WATCH_RECOVERY_LADDER_DELAYS_MS.length + 2, + ); + }); + expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterLadder); subscription.unsubscribe(); service.dispose(); @@ -1946,6 +2101,441 @@ describe("WorkspaceGitService checkout observation", () => { service.dispose(); }); + test("pruneKnownDirectories case-folds a Windows-shaped watch root", () => { + // `pruneKnownDirectories` only needs to fold case when the watch root + // *looks* like a Windows path (drive letter or UNC) — see + // `looksLikeDefiniteWindowsPath` in `../utils/path.js`, the same rule + // `isPathInsideRoot`/`isRealpathInsideRoot` used before this method was + // rewritten from realpath comparisons to string prefixes (see the + // class-level comment on `pruneKnownDirectories`). macOS's real + // filesystem is already case-insensitive, so a test built from real + // directories on disk would pass whether or not that fold logic + // exists — a synthetic Windows-shaped root exercises the comparison + // logic itself instead of relying on host filesystem behavior. + // + // Driven directly against the private target rather than through + // `registerWorkspace`, for two independent reasons, both checked before + // settling on this shape: + // + // 1. Reachability: `registerWorkspace` resolves `cwd` through + // `node:path`'s `resolve()` before anything else runs, and on this + // POSIX test host that rewrites a drive-letter path into an ordinary + // POSIX one, destroying the shape this test depends on. This step can + // be routed around — mocking `git rev-parse --show-toplevel`'s stdout + // lets `target.watchPath` carry the raw Windows-shaped string, since + // `parseGitRevParsePath` does not call `resolve()` — but + // `loadIgnoredDirs` then calls `resolve(rootPath, rel)` on that same + // watch path to build `ignoredDirectories`, which on POSIX prefixes it + // with `process.cwd()` and breaks the shared prefix the fold logic + // compares against. Two independent `resolve()` calls, each assuming + // host-platform semantics, block the public path from both sides. + // 2. Observability: even granting reachability, `knownDirectories` has + // exactly one reader outside this method — the Set-lookup fast path in + // `noteWorkingTreeDirectories` — and that method's fallback branch + // re-derives ignored-ness via `isPathInsideRoot`, which already folds + // case correctly on its own. Verified by deleting this method's fold + // logic (forcing `foldCase = false`) and running this file plus the + // integration suite: only this test failed. No public-surface + // assertion distinguishes a correctly pruned directory from one left + // stale, because the redundant check downstream produces the same + // outcome either way. + const service = createService(createWatcherHarness()); + const target = { + watchPath: "C:/Users/dev/Repo", + knownDirectories: new Set(["C:/Users/dev/Repo/Deps/package-a"]), + ignoredDirectories: new Set(["C:/Users/dev/Repo/deps"]), + }; + + ( + service as unknown as { pruneKnownDirectories: (t: typeof target) => void } + ).pruneKnownDirectories(target); + + expect(target.knownDirectories.has("C:/Users/dev/Repo/Deps/package-a")).toBe(false); + + service.dispose(); + }); + + test("a previously unseen directory refreshes the ignore set", async () => { + const watcher = createWatcherHarness(); + // A fresh worktree has no ignored directories on disk yet. + let ignoredDirectories = ""; + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + expect(checkoutWatcher?.ignore).not.toContain(path.join(REPO_CWD, "deps")); + + // Dependencies get installed. No .gitignore is touched. + ignoredDirectories = "deps/\n"; + checkoutWatcher?.callback(null, [ + { path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(2_000); + + await vi.waitFor(() => { + expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith( + expect.arrayContaining([path.join(REPO_CWD, "deps")]), + ); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("a failed ignore refresh does not permanently mark a directory known", async () => { + // Regression for the failure path reintroducing the original #4558 bug: + // `noteWorkingTreeDirectories` marks a newly seen directory known and + // schedules a debounced refresh, but `loadIgnoredDirs` deliberately keeps + // the previous ignore set when `git ls-files` fails (a transient failure + // must not un-ignore the whole dependency tree). If that success/failure + // distinction is not carried back, the directory stays "known" forever + // with the fast-path Set lookup in `noteWorkingTreeDirectories` skipping + // it on every later event — so no refresh is ever attempted again and + // the directory is watched forever, same as before #4558 was fixed. + const watcher = createWatcherHarness(); + let ignoredDirectories = ""; + let lsFilesCallCount = 0; + const runGitCommand = vi.fn(async (args: string[]) => { + if (args[0] === "rev-parse") { + return { stdout: `${REPO_CWD}\n`, stderr: "", truncated: false, exitCode: 0, signal: null }; + } + lsFilesCallCount += 1; + // The seed load at registration (call 1) succeeds. The refresh + // triggered by the newly discovered "deps" directory (call 2) fails + // transiently. + if (lsFilesCallCount === 2) { + throw new Error("git ls-files timed out"); + } + return { + stdout: ignoredDirectories, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + }; + }); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + expect(lsFilesCallCount).toBe(1); + + // Dependencies get installed. No .gitignore is touched, so this only + // schedules a refresh via directory discovery — the one wired to fail. + ignoredDirectories = "deps/\n"; + checkoutWatcher?.callback(null, [ + { path: path.join(REPO_CWD, "deps", "package-a", "index.js"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount).toBe(2); + }); + // The failed refresh must not have applied "deps" to the live watcher. + expect(checkoutWatcher?.updateIgnore).not.toHaveBeenCalled(); + + // A later write under the same directory. If the directory were left + // permanently "known" by the failed refresh, `noteWorkingTreeDirectories` + // would silently skip it here and no second refresh would ever happen. + checkoutWatcher?.callback(null, [ + { path: path.join(REPO_CWD, "deps", "package-a", "index2.js"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount).toBe(3); + expect(checkoutWatcher?.updateIgnore).toHaveBeenCalledWith( + expect.arrayContaining([path.join(REPO_CWD, "deps")]), + ); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("repeat writes in known directories do not re-run git ls-files", async () => { + const watcher = createWatcherHarness(); + let ignoredDirectories = ""; + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + // Registration itself performs one ls-files call to seed the ignore set. Measure the + // burst's effect as a delta from this baseline, not an absolute count, so the assertion + // cannot pass vacuously off of setup's own call. + const lsFilesAtSetup = runGitCommand.mock.calls.filter( + (call) => call[0][0] === "ls-files", + ).length; + + // One burst in a new directory: one debounced refresh, not one per event. + ignoredDirectories = "deps/\n"; + for (let index = 0; index < 500; index += 1) { + checkoutWatcher?.callback(null, [ + { path: path.join(REPO_CWD, "deps", `module-${index}.js`), type: "create" }, + ]); + } + await vi.advanceTimersByTimeAsync(3_000); + const lsFilesAfterBurst = runGitCommand.mock.calls.filter( + (call) => call[0][0] === "ls-files", + ).length; + // The burst must have refreshed the ignore set at least once... + expect(lsFilesAfterBurst - lsFilesAtSetup).toBeGreaterThanOrEqual(1); + // ...but debounced into at most 2 calls, not one per event. (The brief's original absolute + // bound was <= 3 total calls with a baseline of 1 from setup, i.e. at most 2 burst-triggered + // refreshes — preserve that numeric intent here as a delta.) + expect(lsFilesAfterBurst - lsFilesAtSetup).toBeLessThanOrEqual(2); + + // The directory is known now. Further writes trigger nothing. + for (let index = 0; index < 200; index += 1) { + checkoutWatcher?.callback(null, [ + { path: path.join(REPO_CWD, "src", `file-${index}.ts`), type: "update" }, + ]); + } + await vi.advanceTimersByTimeAsync(3_000); + const lsFilesAtEnd = runGitCommand.mock.calls.filter( + (call) => call[0][0] === "ls-files", + ).length; + expect(lsFilesAtEnd).toBeLessThanOrEqual(lsFilesAfterBurst + 1); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("a deleted known directory is dropped, so a later event under it re-runs git ls-files", async () => { + // Regression for the finding that `knownDirectories` only ever grows: + // nothing previously removed an entry once its directory was deleted + // from disk, so a long-lived daemon over a churning tree (deps + // installed and removed, build output cycling) would accumulate + // tombstones for the life of the target and slow every future + // `pruneKnownDirectories` pass. `removeDeletedKnownDirectories` must + // drop a directory (and anything nested under it) once a `delete` + // event reports the directory path itself — proven here by making a + // write reappear under the same path afterward and asserting it costs + // a fresh `git ls-files` refresh rather than being silently skipped by + // the known-directory fast path. + const watcher = createWatcherHarness(); + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + const lsFilesCallCount = () => + runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length; + const lsFilesAtSetup = lsFilesCallCount(); + + const depsDir = path.join(REPO_CWD, "deps"); + // First sight of `deps`: marks it known and schedules one debounced refresh. + checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index.js"), type: "create" }]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup); + }); + const lsFilesAfterDepsDiscovery = lsFilesCallCount(); + + // A nested directory under `deps` becomes known too. A directory delete + // does not guarantee a delete event for everything beneath it, so + // removal must drop this along with `deps` itself. + const nestedDir = path.join(depsDir, "nested"); + checkoutWatcher?.callback(null, [{ path: path.join(nestedDir, "index.js"), type: "create" }]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsDiscovery); + }); + const lsFilesAfterNestedDiscovery = lsFilesCallCount(); + + // Steady state: another write in a known directory triggers nothing. + checkoutWatcher?.callback(null, [{ path: path.join(depsDir, "index2.js"), type: "update" }]); + await vi.advanceTimersByTimeAsync(2_000); + expect(lsFilesCallCount()).toBe(lsFilesAfterNestedDiscovery); + + // `deps` (the parent) is deleted from disk. The watcher reports the + // directory path itself, not just files under it. + checkoutWatcher?.callback(null, [{ path: depsDir, type: "delete" }]); + + // A write reappears directly under `deps`. If the delete had not + // dropped it from `knownDirectories`, the known-directory fast path + // would skip this event and no refresh would run. + checkoutWatcher?.callback(null, [ + { path: path.join(depsDir, "reinstalled.js"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterNestedDiscovery); + }); + const lsFilesAfterDepsRediscovery = lsFilesCallCount(); + + // The formerly nested `deps/nested` must have been dropped too, even + // though only `deps` itself received a delete event. + checkoutWatcher?.callback(null, [ + { path: path.join(nestedDir, "reinstalled.js"), type: "create" }, + ]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterDepsRediscovery); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("a large event batch refreshes ignored roots without scanning every new directory", async () => { + const watcher = createWatcherHarness(); + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : "", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + const lsFilesCallCount = () => + runGitCommand.mock.calls.filter((call) => call[0][0] === "ls-files").length; + const lsFilesAtSetup = lsFilesCallCount(); + + // Establish one directory as known and steady before the flood. + const markerDir = path.join(REPO_CWD, "marker"); + checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file.js"), type: "create" }]); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAtSetup); + }); + const lsFilesAfterMarker = lsFilesCallCount(); + + // Steady state: another write under the marker directory triggers nothing. + checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file2.js"), type: "update" }]); + await vi.advanceTimersByTimeAsync(2_000); + expect(lsFilesCallCount()).toBe(lsFilesAfterMarker); + + // A single discovery refreshes Git's complete ignored-directory inventory. + // The other directories in this batch need not enter the known cache. + const floodEvents = Array.from({ length: 50_000 }, (_, index) => ({ + path: path.join(REPO_CWD, "gen", `d${index}`, "file.js"), + type: "create" as const, + })); + checkoutWatcher?.callback(null, floodEvents); + await vi.advanceTimersByTimeAsync(2_000); + await vi.waitFor(() => { + expect(lsFilesCallCount()).toBeGreaterThan(lsFilesAfterMarker); + }); + const lsFilesAfterFlood = lsFilesCallCount(); + + // The flood must not evict an unrelated known directory by processing + // every entry before the cap is checked. Its later edit stays on the + // cache fast path and does not trigger a redundant Git refresh. + checkoutWatcher?.callback(null, [{ path: path.join(markerDir, "file3.js"), type: "create" }]); + await vi.advanceTimersByTimeAsync(2_000); + expect(lsFilesCallCount()).toBe(lsFilesAfterFlood); + + subscription.unsubscribe(); + service.dispose(); + }); + + test("watcher recovery keeps retrying after repeated failures", async () => { + // The initial subscribe must succeed so the target starts out healthy; only subscribe + // attempts made *after* that (i.e. recovery attempts) should fail. `failDirectories` is + // read live on every `subscribe()` call, so mutating it after registration is enough — + // no need to fork the harness. + const failDirectories = new Set(); + const watcher = createWatcherHarness({ failDirectories }); + let ignoredDirectories = "node_modules/\n"; + const runGitCommand = vi.fn(async (args: string[]) => ({ + stdout: args[0] === "rev-parse" ? `${REPO_CWD}\n` : ignoredDirectories, + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })); + const service = createService(watcher, { + getWorkspaceGitSelfHealPhaseMs: () => 1_000, + runGitCommand, + }); + const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn()); + await vi.waitFor(() => { + expect(getWatcherRecordsForDirectory(watcher, REPO_CWD)).toHaveLength(1); + expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0); + }); + const checkoutWatcher = watcher.records.find((record) => record.directory === REPO_CWD); + const subscribeCallsBeforeFailure = getWatcherSubscribeCallCount(watcher, REPO_CWD); + + // From here on, every further subscribe attempt for this directory fails — a watcher that + // cannot be re-established (permission error, unmounted volume, etc). + failDirectories.add(REPO_CWD); + + // Force the live subscription to fail and enter recovery. A rejected updateIgnore is the + // most direct trigger: it nulls target.subscription and schedules recovery. + checkoutWatcher?.updateIgnore.mockRejectedValueOnce(new Error("update failed")); + ignoredDirectories = "node_modules/\nbuild/\n"; + checkoutWatcher?.callback(null, [{ path: path.join(REPO_CWD, ".gitignore"), type: "update" }]); + + // Recovery backs off at WATCH_RECOVERY_BASE_DELAY_MS * 2**(attempt-1) with base 30_000ms, + // landing at 30s, 60s, 120s, 240s for the first 4 attempts (WATCH_RECOVERY_MAX_BACKOFF_STEPS + // caps the exponent at 4, and WATCH_RECOVERY_MAX_DELAY_MS's 300s ceiling doesn't bind until + // the 5th). A 4th recovery attempt needs the first four delays to have elapsed: + // 30_000 + 60_000 + 120_000 + 240_000 = 450_000ms. Advance past that with headroom. + await vi.advanceTimersByTimeAsync(500_000); + + const subscribeCallsAfterRecoveryWindow = getWatcherSubscribeCallCount(watcher, REPO_CWD); + // 1 initial success + 3 capped recovery attempts = 4 total. A 5th call would prove recovery + // keeps retrying past the hard cap instead of giving up on this target forever. + expect(subscribeCallsAfterRecoveryWindow).toBeGreaterThan(subscribeCallsBeforeFailure + 3); + + subscription.unsubscribe(); + service.dispose(); + }); + test("removing an ignored directory updates the watcher without replacement", async () => { const watcher = createWatcherHarness(); let ignoredDirectories = "node_modules/\nbuild/\n"; diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 65eb4a0ad5..ce052cdd7c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1819,6 +1819,46 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); + test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => { + const worktrees = [ + { + path: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + }, + ]; + const readStarted = createDeferred(); + const oldRead = createDeferred(); + const alternateCheckout = resolvePath("/tmp/alternate-checkout"); + let reads = 0; + const service = createService({ + listPaseoWorktrees: async () => { + reads += 1; + if (reads <= 2) return worktrees; + if (reads === 3) { + readStarted.resolve(); + return oldRead.promise; + } + return []; + }, + }); + try { + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees); + const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" }); + await readStarted.promise; + service.invalidateWorktrees(); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]); + oldRead.resolve(worktrees); + await expect(pending).resolves.toEqual(worktrees); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + } finally { + oldRead.resolve(worktrees); + await service.dispose(); + } + }); + test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => { const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-"))); const repoDir = join(tempDir, "repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index fc1d211f33..2f3483cb6f 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { LRUCache } from "lru-cache"; +import { CheckoutDiffCache } from "./checkout-diff-cache.js"; import pLimit from "p-limit"; import type pino from "pino"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; @@ -44,7 +45,9 @@ import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { createRealpathAwarePathMatcher, getRealpathAwareRelativePath, + isPathInsideRoot, isRealpathInsideRoot, + looksLikeDefiniteWindowsPath, } from "../utils/path.js"; import { createRunGitCommand, @@ -83,19 +86,45 @@ const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000; const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000; const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000; const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000; +// Degraded polling shells out a full Git refresh per tick, and the repositories that defeat the +// recursive watcher are the ones where that refresh is expensive. At a fixed cadence the daemon +// therefore burns a core forever on a workspace nobody has touched. Double the gap after every +// tick that produced an identical snapshot, and drop back to the base interval as soon as one +// does not. Explicit reads still force a refresh, so this only bounds background discovery of +// changes made outside Paseo. +const DEGRADED_GIT_POLL_MAX_INTERVAL_MS = 60_000; +// A dependency install creates directories for minutes. Collapse the burst into +// a couple of `git ls-files` runs rather than one per event. +const WORKING_TREE_IGNORE_REFRESH_QUIET_MS = 300; +const WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS = 2_000; +// `knownDirectories` is a memoisation of the fast path in +// `noteWorkingTreeDirectories`, not authoritative state — every entry is +// rediscoverable from a later watcher event under the same path. Deletion +// handling (`removeDeletedKnownDirectories`) keeps it close to the live +// directory count, but this cap is the backstop for what that cannot cover +// (a missed or coalesced delete event, a backend quirk): clearing the whole +// set outright on overflow is always safe, since the only cost is one extra +// `git ls-files` refresh the next time a directory under this root is +// touched again. Set well below `MAX_TRACKED_ENTRIES` (250_000, the +// native-recursive.ts cap on combined file+directory entries per watched +// root) because directories are a minority of any tracked tree, and the +// observer itself already fails into polling before a single root's +// combined entry count could approach that cap. +const WORKING_TREE_KNOWN_DIRECTORIES_MAX = 50_000; // Keep whole workspace pipelines below the lower-level Git process pool so daemon control work // retains subprocess and event-loop headroom during large workspace reconciliation bursts. export const WORKSPACE_GIT_REFRESH_CONCURRENCY = 4; export const WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY = 2; export const WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS = 10_000; const WATCH_RECOVERY_BASE_DELAY_MS = 30_000; -const WATCH_RECOVERY_MAX_ATTEMPTS = 3; +// Giving up permanently leaves polling as the only behaviour until a daemon +// restart, which kills running agents. Back off, but keep trying. +const WATCH_RECOVERY_MAX_DELAY_MS = 300_000; +const WATCH_RECOVERY_MAX_BACKOFF_STEPS = 4; // Auxiliary reads may reuse cached values within this window; snapshots do not expire on read. const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000; // Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it. const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000; -// Heavy values (multi-MB highlighted diffs); cap aggressively. Ephemeral worktree cwds would otherwise pile up forever. -const WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX = 64; // Small values (booleans, short strings, small arrays); generous cap. const WORKSPACE_GIT_AUXILIARY_CACHE_MAX = 256; @@ -212,6 +241,7 @@ export interface WorkspaceGitService { cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise; + invalidateWorktrees(): void; getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; @@ -420,6 +450,7 @@ interface RepoGitTarget { subscription: FileObserverSubscription | null; fallbackPolling: boolean; fallbackPollTimer: NodeJS.Timeout | null; + fallbackPollQuietTickCount: number; recovery: WatchRecoveryState; intervalId: NodeJS.Timeout | null; fetchInFlight: boolean; @@ -445,12 +476,41 @@ interface WorkingTreeWatchTarget { repoRoot: string | null; subscription: FileObserverSubscription | null; ignoredDirectories: Set; + // What the live subscription actually accepted, as of the last successful + // `updateIgnore`/subscribe — the full resolved ignore list (the watch root's + // `.git` plus gitignore-derived `ignoredDirectories`), not just + // `ignoredDirectories`. The resolved list can move ahead of this (a fresh + // `git ls-files` result) while the subscription still runs on the old set; + // comparing against this field instead of the last-computed value keeps a + // skipped update from latching forever. + appliedIgnoreList: string[]; ignoredDirectoriesRefreshPromise: Promise | null; ignoredDirectoriesRefreshRequested: boolean; + // Bounded two ways so a long-lived daemon over a churning tree cannot grow + // this without limit: `removeDeletedKnownDirectories` drops an entry (and + // anything nested under it) once its own path is reported deleted, and + // `enforceKnownDirectoriesCap` clears the whole set outright past + // `WORKING_TREE_KNOWN_DIRECTORIES_MAX`. See both for why clearing is safe. + knownDirectories: Set; + // Directories added to `knownDirectories` since the last ignore-list + // refresh that actually completed (successfully or not). A failed refresh + // (`loadIgnoredDirs` falling back to the previous set) must not leave a + // newly seen directory permanently marked known — that would silently + // reintroduce the original "watched forever" bug through the failure path. + // `replaceWorkingTreeIgnoredDirectories` rolls this set back into + // `knownDirectories` on failure so a later event under the same directory + // is treated as unseen again and re-arms the refresh; on success it is + // simply cleared, since a successful refresh reflects the full current + // ignore state and `pruneKnownDirectories` already removes anything that + // turned out to be ignored. + pendingKnownDirectories: Set; + ignoreRefreshTimer: NodeJS.Timeout | null; + ignoreRefreshDeadlineMs: number | null; aliases: Set; workspaceKeys: Set; fallbackPolling: boolean; fallbackPollTimer: NodeJS.Timeout | null; + fallbackPollQuietTickCount: number; recovery: WatchRecoveryState; listeners: Set<() => void>; closed: boolean; @@ -459,6 +519,13 @@ interface WorkingTreeWatchTarget { interface WatchRecoveryState { attemptCount: number; timer: NodeJS.Timeout | null; + // Set when a subscription is accepted, cleared when it is scheduled for + // recovery. `attemptCount` only resets to 0 once the subscription this + // replaces is proven to have survived at least `WATCH_RECOVERY_MAX_DELAY_MS` + // — otherwise a filesystem that accepts `subscribe()` but errors on first + // use (SMB/NFS, Docker virtiofs, some FUSE) resets the ladder on every + // attempt and the backoff never engages. + establishedAt: number | null; } function setsEqual(left: ReadonlySet, right: ReadonlySet): boolean { @@ -566,10 +633,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { string, WorkspaceGitAuxiliaryReadCacheEntry >({ max: WORKSPACE_GIT_AUXILIARY_CACHE_MAX }); - private readonly checkoutDiffCache = new LRUCache< - string, - WorkspaceGitAuxiliaryReadCacheEntry - >({ max: WORKSPACE_GIT_CHECKOUT_DIFF_CACHE_MAX }); + private readonly checkoutDiffCache = new CheckoutDiffCache(() => this.deps.now().getTime()); private watcherErrorCallbackCount = 0; constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); @@ -726,7 +790,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null; } - getCheckoutDiff( + async getCheckoutDiff( cwd: string, options: CheckoutDiffCompare, readOptions?: WorkspaceGitReadOptions, @@ -734,13 +798,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.assertNotDisposed(); const normalizedCwd = resolve(cwd); const normalizedOptions = this.normalizeCheckoutDiffOptions(options); - const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions); - return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () => - this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { - paseoHome: this.paseoHome, - worktreesRoot: this.worktreesRoot, - }), - ); + // Initial inventory events cover the watcher setup gap. Process them before + // starting a cold diff, rather than invalidating that build halfway through. + await this.workspaceTargets.get(normalizedCwd)?.observationSetupPromise; + this.assertNotDisposed(); + return this.checkoutDiffCache.read({ + cwd: normalizedCwd, + compare: normalizedOptions, + ...readOptions, + load: () => + this.deps.getCheckoutDiff(normalizedCwd, normalizedOptions, { + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + }), + }); } private normalizeCheckoutDiffOptions(options: CheckoutDiffCompare): CheckoutDiffCompare { @@ -754,26 +825,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { }; } - private buildCheckoutDiffCacheKey(cwd: string, options: CheckoutDiffCompare): string { - // Diff content varies by compare signature. Keep the cache per exact diff read shape so - // hot diff panes coalesce while base refs and rendering options never share stale patches. - return JSON.stringify([ - "checkout-diff", - cwd, - options.mode, - options.mode === "base" ? (options.baseRef ?? null) : null, - options.ignoreWhitespace === true, - options.includeStructured === true, - ]); - } - private invalidateCheckoutDiffCache(cwd: string, mode: CheckoutDiffCompare["mode"]): void { - for (const key of this.checkoutDiffCache.keys()) { - const [kind, cachedCwd, cachedMode] = JSON.parse(key) as unknown[]; - if (kind === "checkout-diff" && cachedCwd === cwd && cachedMode === mode) { - this.checkoutDiffCache.delete(key); - } - } + this.checkoutDiffCache.invalidate(cwd, mode); } validateBranchRef( @@ -866,6 +919,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (snapshot.git.repoRoot ?? resolve(cwd)); } + invalidateWorktrees(): void { + this.assertNotDisposed(); + // Checkout paths can cache the same list under different roots, including bare repos. + // Detach in-flight reads too, so a pre-mutation result cannot refill the cache. + this.worktreeListCache.clear(); + } + async resolveDefaultBranch( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, @@ -1289,20 +1349,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { watchPath: string, repoRoot: string | null, ): Promise { - const ignoredDirectories = repoRoot ? await this.loadIgnoredDirs(watchPath) : new Set(); + const ignoredDirectories = repoRoot + ? (await this.loadIgnoredDirs(watchPath)).ignored + : new Set(); const target: WorkingTreeWatchTarget = { cwd, watchPath, repoRoot, subscription: null, ignoredDirectories, + appliedIgnoreList: [], ignoredDirectoriesRefreshPromise: null, ignoredDirectoriesRefreshRequested: false, + knownDirectories: new Set(), + pendingKnownDirectories: new Set(), + ignoreRefreshTimer: null, + ignoreRefreshDeadlineMs: null, aliases: new Set([cwd]), workspaceKeys: new Set(), fallbackPolling: false, fallbackPollTimer: null, - recovery: { attemptCount: 0, timer: null }, + fallbackPollQuietTickCount: 0, + recovery: { attemptCount: 0, timer: null, establishedAt: null }, listeners: new Set(), closed: false, }; @@ -1405,11 +1473,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } + // Shared by startWorkingTreeSubscription and + // replaceWorkingTreeIgnoredDirectories's applied-vs-desired gate so both + // compute the same resolved ignore list. + private workingTreeIgnoreList(target: WorkingTreeWatchTarget): string[] { + return [join(target.watchPath, ".git"), ...target.ignoredDirectories]; + } + private async startWorkingTreeSubscription( target: WorkingTreeWatchTarget, options?: { replaceFallback?: boolean }, ): Promise { - const ignore = [join(target.watchPath, ".git"), ...target.ignoredDirectories]; + const ignore = this.workingTreeIgnoreList(target); let watcherErrored = false; let subscribeSettled = false; const markSubscribeSettled = () => { @@ -1441,8 +1516,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (watcherErrored) { return; } + const discovered = this.noteWorkingTreeDirectories(target, events); if (events.some((event) => basename(event.path) === ".gitignore")) { void this.refreshWorkingTreeIgnoredDirectories(target); + } else if (discovered) { + this.scheduleWorkingTreeIgnoreRefresh(target); } if (!this.hasRelevantWorkingTreeEvent(target, events)) { return; @@ -1465,6 +1543,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return false; } target.subscription = subscription; + target.appliedIgnoreList = ignore; + // Do not reset `attemptCount` here — `subscribe()` resolving proves + // nothing about the watcher's health on a filesystem where it errors on + // first use. Record when this subscription was established instead; + // `scheduleWorkingTreeWatchRecovery` only resets the ladder once this + // one has survived long enough to prove itself durable. + target.recovery.establishedAt = this.deps.now().getTime(); if (options?.replaceFallback && target.repoRoot !== null) { target.fallbackPolling = false; if (target.fallbackPollTimer) { @@ -1491,6 +1576,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } + private degradedPollDelayMs(quietTickCount: number): number { + return Math.min( + DEGRADED_GIT_POLL_MAX_INTERVAL_MS, + DEGRADED_GIT_POLL_INTERVAL_MS * 2 ** quietTickCount, + ); + } + + // A refresh that throws is swallowed by refreshWorkspaceTarget and leaves the fingerprint + // alone, so it reads as a quiet tick and widens the gap. That is the intended outcome: a + // repository whose Git commands keep failing is the last one to retry every 5 seconds. + private async refreshDegradedPollTarget( + workspaceTarget: WorkspaceGitTarget, + request: WorkspaceGitRefreshRequest, + ): Promise { + const fingerprintBefore = workspaceTarget.latestFingerprint; + await this.refreshWorkspaceTarget(workspaceTarget, request); + return workspaceTarget.latestFingerprint !== fingerprintBefore; + } + private startWorkingTreeWatchFallback( target: WorkingTreeWatchTarget, reason: WorkingTreeWatchFallbackReason, @@ -1499,19 +1603,20 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.fallbackPolling = true; + target.fallbackPollQuietTickCount = 0; const { cwd } = target; const poll = async () => { target.fallbackPollTimer = null; if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) { return; } - await Promise.all( + const changes = await Promise.all( Array.from(target.workspaceKeys, async (workspaceKey) => { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (!workspaceTarget) { - return; + return false; } - await this.refreshWorkspaceTarget(workspaceTarget, { + const changed = await this.refreshDegradedPollTarget(workspaceTarget, { force: false, refreshStructure: target.repoRoot === null, refreshWorktree: true, @@ -1525,11 +1630,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { workspaceTarget.observationSetupComplete = false; this.scheduleWorkspaceObservationSetup(workspaceTarget); } + return changed; }), ); this.notifyWorkingTreeConsumers(target); if (!target.closed && (target.subscription === null || target.repoRoot === null)) { - target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); + target.fallbackPollQuietTickCount = changes.some(Boolean) + ? 0 + : target.fallbackPollQuietTickCount + 1; + target.fallbackPollTimer = setTimeout( + poll, + this.degradedPollDelayMs(target.fallbackPollQuietTickCount), + ); } else { target.fallbackPolling = false; } @@ -1539,6 +1651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { { cwd, intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS, + maxIntervalMs: DEGRADED_GIT_POLL_MAX_INTERVAL_MS, reason, }, "Working tree watcher unavailable; using bounded polling fallback", @@ -1555,17 +1668,38 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.startWorkingTreeWatchFallback(target, reason); } - private scheduleWorkingTreeWatchRecovery(target: WorkingTreeWatchTarget): void { + /** + * Advances the recovery backoff ladder and returns the delay to wait + * before the next attempt. `attemptCount` only resets to 0 here, and only + * when the subscription being replaced was established long enough ago to + * have proven itself durable (>= `WATCH_RECOVERY_MAX_DELAY_MS`) — otherwise + * a filesystem that accepts `subscribe()` but errors immediately on every + * attempt (SMB/NFS, Docker virtiofs, some FUSE) would reset the ladder to 0 + * on every cycle and the backoff would never engage. Shared by the + * working-tree and repo-metadata recovery paths so both apply the same + * rule. + */ + private advanceWatchRecoveryLadder(recovery: WatchRecoveryState): number { if ( - target.closed || - target.subscription || - target.recovery.timer || - target.recovery.attemptCount >= WATCH_RECOVERY_MAX_ATTEMPTS + recovery.establishedAt !== null && + this.deps.now().getTime() - recovery.establishedAt >= WATCH_RECOVERY_MAX_DELAY_MS ) { + recovery.attemptCount = 0; + } + recovery.establishedAt = null; + recovery.attemptCount += 1; + return Math.min( + WATCH_RECOVERY_BASE_DELAY_MS * + 2 ** Math.min(recovery.attemptCount - 1, WATCH_RECOVERY_MAX_BACKOFF_STEPS), + WATCH_RECOVERY_MAX_DELAY_MS, + ); + } + + private scheduleWorkingTreeWatchRecovery(target: WorkingTreeWatchTarget): void { + if (target.closed || target.subscription || target.recovery.timer) { return; } - target.recovery.attemptCount += 1; - const delayMs = WATCH_RECOVERY_BASE_DELAY_MS * 2 ** (target.recovery.attemptCount - 1); + const delayMs = this.advanceWatchRecoveryLadder(target.recovery); target.recovery.timer = setTimeout(() => { target.recovery.timer = null; void this.recoverWorkingTreeWatch(target); @@ -1607,6 +1741,214 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { await this.refreshWorkingTreeIgnoredDirectories(target); } + /** + * `git ls-files` reports ignored directories only once they exist, so the + * ignore set must be re-read when new directory topology appears — not only + * when the rules change. Keyed on `dirname` because FSEvents coalescing makes + * the directory's own creation event unreliable; a file inside it proves it + * exists either way. + */ + private noteWorkingTreeDirectories( + target: WorkingTreeWatchTarget, + events: FileChange[], + ): boolean { + if (target.repoRoot === null) { + return false; + } + this.removeDeletedKnownDirectories(target, events); + const gitDir = join(target.watchPath, ".git"); + // `matchesWatchPath` is realpath-aware because it compares an event-reported + // directory against `target.watchPath` itself — a config-time root that can + // legitimately differ from the watcher's reported form (e.g. a symlinked + // temp dir). It is now gated behind the plain Set lookup below, so it only + // runs for a directory this target has never seen before, not on every + // event. + const matchesWatchPath = createRealpathAwarePathMatcher(target.watchPath); + // One new directory is enough: the scheduled Git query inventories every + // ignored directory that exists by then. Stop before a large event batch + // multiplies containment checks across all its new directories. + for (const event of events) { + const directory = dirname(event.path); + // Cheap Set lookup first — the steady-state case where the directory is + // already known must never reach the realpath-aware matcher below. + if (target.knownDirectories.has(directory) || matchesWatchPath(directory)) { + continue; + } + // `directory` and `gitDir`/`ignoredDirectory` all derive from the same + // `target.watchPath` used to build this subscription, and `directory` + // itself is the same `dirname(event.path)` value already trusted as a + // plain string key by `target.knownDirectories.has()` above — no realpath + // resolution needed for containment among values from this one source. + if (!isPathInsideRoot(target.watchPath, directory)) { + continue; + } + if (isPathInsideRoot(gitDir, directory)) { + continue; + } + let ignored = false; + for (const ignoredDirectory of target.ignoredDirectories) { + if (isPathInsideRoot(ignoredDirectory, directory)) { + ignored = true; + break; + } + } + if (ignored) { + continue; + } + target.knownDirectories.add(directory); + target.pendingKnownDirectories.add(directory); + this.enforceKnownDirectoriesCap(target); + return true; + } + return false; + } + + /** + * `knownDirectories` only ever grows in the loop above — nothing + * previously dropped an entry once its directory was deleted from disk, + * so a long-lived daemon over a tree that creates and deletes directories + * (build output, temp dirs, a churning dependency tree) would accumulate + * tombstones for the life of the target and slow every future + * `pruneKnownDirectories` pass along with it. `FileChange` events arrive + * for files too, and a delete of `dir/file.txt` does not mean `dir` is + * gone, so this only acts once the deleted path is itself confirmed a + * known directory — a cheap `Set.has()` first, so the common case (a file + * delete) costs one lookup and returns, same as the fast path this method + * feeds into. A directory delete does not guarantee a delete event for + * everything beneath it (coalescing, an `rm -rf` of a populated tree), so + * once a deleted path is confirmed known, this drops the whole known + * subtree the same way `pruneKnownDirectories` drops a newly ignored one — + * the deleted path is the containment root here instead of an ignored + * root. + */ + private removeDeletedKnownDirectories( + target: WorkingTreeWatchTarget, + events: FileChange[], + ): void { + if (target.knownDirectories.size === 0) { + return; + } + for (const event of events) { + if (event.type !== "delete" || !target.knownDirectories.has(event.path)) { + continue; + } + for (const directory of target.knownDirectories) { + if (!isPathInsideRoot(event.path, directory)) { + continue; + } + target.knownDirectories.delete(directory); + // Not required for correctness — both refresh outcomes tolerate a + // stale entry that no longer resolves to anything live: a + // successful refresh clears `pendingKnownDirectories` outright, and + // a failed one's `rearmPendingKnownDirectories` no-ops a delete of + // an already-absent key. Dropping it here keeps + // `pendingKnownDirectories` a subset of `knownDirectories` at all + // times instead of "true except right after a deletion." + target.pendingKnownDirectories.delete(directory); + } + } + } + + /** + * See `WORKING_TREE_KNOWN_DIRECTORIES_MAX` for why clearing on overflow is + * always safe. Runs only when this batch discovered at least one new + * directory, since that is the only way `knownDirectories` grows. + */ + private enforceKnownDirectoriesCap(target: WorkingTreeWatchTarget): void { + if (target.knownDirectories.size <= WORKING_TREE_KNOWN_DIRECTORIES_MAX) { + return; + } + target.knownDirectories.clear(); + target.pendingKnownDirectories.clear(); + } + + private scheduleWorkingTreeIgnoreRefresh(target: WorkingTreeWatchTarget): void { + if (this.disposed || target.closed || target.repoRoot === null) { + return; + } + const now = this.deps.now().getTime(); + target.ignoreRefreshDeadlineMs ??= now + WORKING_TREE_IGNORE_REFRESH_MAX_DELAY_MS; + if (target.ignoreRefreshTimer) { + clearTimeout(target.ignoreRefreshTimer); + } + const delayMs = Math.max( + 0, + Math.min(WORKING_TREE_IGNORE_REFRESH_QUIET_MS, target.ignoreRefreshDeadlineMs - now), + ); + target.ignoreRefreshTimer = setTimeout(() => { + target.ignoreRefreshTimer = null; + target.ignoreRefreshDeadlineMs = null; + void this.refreshWorkingTreeIgnoredDirectories(target); + }, delayMs); + } + + /** + * String-prefix containment, not `isRealpathInsideRoot`: every entry on + * both sides is a plain path built from `target.watchPath` by this same + * class (`dirname(event.path)` for `knownDirectories`, `resolve(watchPath, + * ...)` for `ignoredDirectories` in `loadIgnoredDirs`) — no realpath + * divergence to reconcile, per the same reasoning as + * `noteWorkingTreeDirectories`. Normalizing each ignored root once up front + * instead of recomputing per pair turns a 30k-known x 40-ignored scan from + * ~26s of synchronous realpath syscalls into a bounded number of cheap + * string comparisons. + * + * Case-folding still needs to happen for a Windows-looking root, same as + * `isPathInsideRoot`/`isRealpathInsideRoot` in `../utils/path.js` — a + * case-insensitive filesystem can report a known directory whose case + * diverges from the git-reported ignored root. Since both sides derive + * from the single `target.watchPath` for this target, whether to fold is + * one decision for the whole call (`looksLikeDefiniteWindowsPath` + * reused from `../utils/path.js`, not reimplemented here), not a per-pair + * one — so it costs nothing beyond the already-precomputed prefixes. + */ + private pruneKnownDirectories(target: WorkingTreeWatchTarget): void { + if (target.ignoredDirectories.size === 0) { + return; + } + const foldCase = looksLikeDefiniteWindowsPath(target.watchPath); + const comparable = (path: string) => + foldCase ? path.replaceAll("\\", "/").toLowerCase() : path; + const ignoredPrefixes: string[] = []; + const foldedIgnoredExact = foldCase ? new Set() : null; + for (const ignoredDirectory of target.ignoredDirectories) { + const ignored = comparable(ignoredDirectory); + ignoredPrefixes.push(`${ignored}/`); + foldedIgnoredExact?.add(ignored); + } + for (const directory of target.knownDirectories) { + const comparableDirectory = comparable(directory); + const comparableDirectoryWithSep = `${comparableDirectory}/`; + const isIgnored = + (foldedIgnoredExact ?? target.ignoredDirectories).has(comparableDirectory) || + ignoredPrefixes.some((prefix) => comparableDirectoryWithSep.startsWith(prefix)); + if (isIgnored) { + target.knownDirectories.delete(directory); + } + } + } + + /** + * Undoes the "mark known" side effect of `noteWorkingTreeDirectories` for + * every directory discovered since the last refresh that actually + * completed, because this refresh did not — it fell back to the previous + * ignore set (see `loadIgnoredDirs`). Leaving them in `knownDirectories` + * would make the steady-state Set-lookup fast path in + * `noteWorkingTreeDirectories` skip them forever, even though this refresh + * never determined whether they are ignored. Removing them here re-arms + * discovery: the next event under any of them is treated as unseen again, + * so `noteWorkingTreeDirectories` schedules another refresh attempt. + */ + private rearmPendingKnownDirectories(target: WorkingTreeWatchTarget): void { + if (target.pendingKnownDirectories.size === 0) { + return; + } + for (const directory of target.pendingKnownDirectories) { + target.knownDirectories.delete(directory); + } + target.pendingKnownDirectories.clear(); + } + private hasRelevantWorkingTreeEvent( target: WorkingTreeWatchTarget, events: FileChange[], @@ -1664,31 +2006,57 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private async replaceWorkingTreeIgnoredDirectories( target: WorkingTreeWatchTarget, ): Promise { - const ignoredDirectories = await this.loadIgnoredDirs(target.watchPath); - if (target.closed || this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { + const { ignored: ignoredDirectories, failed } = await this.loadIgnoredDirs( + target.watchPath, + target.ignoredDirectories, + ); + if (target.closed) { return; } - - target.ignoredDirectories = ignoredDirectories; + if (failed) { + // A failed `git ls-files` must not leave directories discovered since + // the last completed refresh permanently marked known — see the + // `pendingKnownDirectories` field comment. Re-arm them so the next + // event under any of them is treated as unseen again and schedules + // another refresh attempt. + this.rearmPendingKnownDirectories(target); + } else { + target.pendingKnownDirectories.clear(); + } + if (!this.haveSamePaths(target.ignoredDirectories, ignoredDirectories)) { + target.ignoredDirectories = ignoredDirectories; + this.pruneKnownDirectories(target); + } if (target.fallbackPolling) { return; } const subscription = target.subscription; - if (subscription) { - try { - await subscription.updateIgnore([join(target.watchPath, ".git"), ...ignoredDirectories]); - } catch (error) { - target.subscription = null; - if (!target.closed && !target.fallbackPolling) { - this.startWorkingTreeWatchFallback(target, "watcher_update_failed"); - this.scheduleWorkingTreeWatchRecovery(target); - } - this.logger.warn( - { err: error, cwd: target.cwd }, - "Failed to update working tree watcher ignore paths", - ); - return; + if (!subscription) { + return; + } + // Compare against the resolved list the live subscription actually + // accepted (`appliedIgnoreList`), not what git last reported + // (`ignoredDirectories` alone) — the comparison and the value passed to + // `updateIgnore` have to be the same array, or a skipped update would + // otherwise latch forever. + const next = this.workingTreeIgnoreList(target); + if (this.haveSamePaths(new Set(target.appliedIgnoreList), new Set(next))) { + return; + } + try { + await subscription.updateIgnore(next); + target.appliedIgnoreList = next; + } catch (error) { + target.subscription = null; + if (!target.closed && !target.fallbackPolling) { + this.startWorkingTreeWatchFallback(target, "watcher_update_failed"); + this.scheduleWorkingTreeWatchRecovery(target); } + this.logger.warn( + { err: error, cwd: target.cwd }, + "Failed to update working tree watcher ignore paths", + ); + return; } if (target.closed || target.fallbackPolling) { return; @@ -1785,7 +2153,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { subscription: null, fallbackPolling: false, fallbackPollTimer: null, - recovery: { attemptCount: 0, timer: null }, + fallbackPollQuietTickCount: 0, + recovery: { attemptCount: 0, timer: null, establishedAt: null }, intervalId: null, fetchInFlight: false, bufferedFetchMetadataEvents: [], @@ -1916,6 +2285,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return false; } target.subscription = subscription; + // See `advanceWatchRecoveryLadder`: do not reset `attemptCount` on + // subscribe success alone, only record when this subscription was + // established. + target.recovery.establishedAt = this.deps.now().getTime(); if (options?.replaceFallback) { target.fallbackPolling = false; if (target.fallbackPollTimer) { @@ -1956,16 +2329,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } private scheduleRepoMetadataWatchRecovery(target: RepoGitTarget): void { - if ( - target.closed || - target.subscription || - target.recovery.timer || - target.recovery.attemptCount >= WATCH_RECOVERY_MAX_ATTEMPTS - ) { + if (target.closed || target.subscription || target.recovery.timer) { return; } - target.recovery.attemptCount += 1; - const delayMs = WATCH_RECOVERY_BASE_DELAY_MS * 2 ** (target.recovery.attemptCount - 1); + const delayMs = this.advanceWatchRecoveryLadder(target.recovery); target.recovery.timer = setTimeout(() => { target.recovery.timer = null; void this.recoverRepoMetadataWatch(target); @@ -2287,17 +2654,18 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { return; } target.fallbackPolling = true; + target.fallbackPollQuietTickCount = 0; const poll = async () => { target.fallbackPollTimer = null; if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) { return; } const workingTreeTargets = new Set(); - await Promise.all( + const changes = await Promise.all( Array.from(target.workspaceKeys, async (workspaceKey) => { const workspaceTarget = this.workspaceTargets.get(workspaceKey); if (!workspaceTarget) { - return; + return false; } this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base"); if (workspaceTarget.latestFacts?.isGit) { @@ -2307,7 +2675,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (workingTreeTarget) { workingTreeTargets.add(workingTreeTarget); } - await this.refreshWorkspaceTarget(workspaceTarget, { + return await this.refreshDegradedPollTarget(workspaceTarget, { force: false, refreshStructure: true, refreshWorktree: true, @@ -2324,7 +2692,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { this.notifyWorkingTreeConsumers(workingTreeTarget); } if (!target.closed && target.subscription === null) { - target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS); + target.fallbackPollQuietTickCount = changes.some(Boolean) + ? 0 + : target.fallbackPollQuietTickCount + 1; + target.fallbackPollTimer = setTimeout( + poll, + this.degradedPollDelayMs(target.fallbackPollQuietTickCount), + ); } else { target.fallbackPolling = false; } @@ -2585,7 +2959,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { target.forgePrStatusPollKey = null; } - private async loadIgnoredDirs(rootPath: string): Promise> { + /** + * `previous` is what the caller should get back on failure. `runGitCommand` + * rejects on any non-zero exit or timeout, and a timeout is plausible under + * exactly the churn storm this ignore-refresh path targets. Returning an + * empty set on failure would read to the caller as "nothing is ignored" — + * indistinguishable from a real, freshly-computed empty result — and + * `replaceWorkingTreeIgnoredDirectories` treats its return value as + * authoritative, replacing `target.ignoredDirectories` and re-ingesting + * every directory that was previously ignored. Returning the previous set + * instead means a failed refresh leaves the watcher's ignore state + * unchanged rather than un-ignoring the entire dependency tree. + * + * `failed` lets the caller distinguish "ran fresh and happened to match + * `previous`" from "the git command itself failed" — the two are + * indistinguishable by comparing sets alone, but `replaceWorkingTreeIgnoredDirectories` + * needs to know which one happened to decide whether directories newly + * marked known since the last refresh can be trusted or must be re-armed. + */ + private async loadIgnoredDirs( + rootPath: string, + previous: Set = new Set(), + ): Promise<{ ignored: Set; failed: boolean }> { const ignored = new Set(); try { const result = await this.deps.runGitCommand( @@ -2603,13 +2998,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ignored.add(resolve(rootPath, rel)); } } catch (error) { - this.logger.debug( + this.logger.warn( { err: error, rootPath }, - "Failed to load gitignore directories; falling back to name-based skip only", + "Failed to load gitignore directories; keeping previous ignore set", ); + return { ignored: previous, failed: true }; } - return ignored; + return { ignored, failed: false }; } private async refreshWorkspaceTarget( @@ -3347,6 +3743,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { clearTimeout(target.recovery.timer); target.recovery.timer = null; } + if (target.ignoreRefreshTimer) { + clearTimeout(target.ignoreRefreshTimer); + target.ignoreRefreshTimer = null; + } + target.ignoreRefreshDeadlineMs = null; if (target.subscription) { const subscription = target.subscription; diff --git a/packages/server/src/server/workspace-labels/internal/service.ts b/packages/server/src/server/workspace-labels/internal/service.ts index f560959833..aad0ea2047 100644 --- a/packages/server/src/server/workspace-labels/internal/service.ts +++ b/packages/server/src/server/workspace-labels/internal/service.ts @@ -55,6 +55,12 @@ export class WorkspaceLabelService { await this.catalog.initialize(); } + async list( + cursor?: WorkspaceLabelCursor, + ): Promise> { + return this.exclusive(async () => this.sequence.synchronize(await this.catalog.list(), cursor)); + } + async subscribe(input: { cursor?: WorkspaceLabelCursor; onChange: (change: WorkspaceLabelChange & { generation: string; seq: number }) => void; diff --git a/packages/server/src/server/workspace-reconciliation-observation.test.ts b/packages/server/src/server/workspace-reconciliation-observation.test.ts index a5fa20c97f..8977641bab 100644 --- a/packages/server/src/server/workspace-reconciliation-observation.test.ts +++ b/packages/server/src/server/workspace-reconciliation-observation.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../test-utils/test-logger.js"; import { areEquivalentPaths } from "../utils/path.js"; +import { deriveProjectKey } from "./project-key.js"; import { createPersistedProjectRecord, createPersistedWorkspaceRecord, @@ -336,6 +337,14 @@ class ObservedPlacements { projectId: spec.id, rootPath, kind: "non_git", + // Seeded records already carry the key reconciliation derives, so a fixture's + // first pass reports only what its test is about. + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), displayName: spec.id, createdAt: TIMESTAMP, updatedAt: TIMESTAMP, @@ -473,7 +482,11 @@ describe("observed workspace placement", () => { test("archives missing workspace directories on the periodic pass", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); await observed.deleteWorkspaceDirectory("workspace-one"); @@ -487,7 +500,11 @@ describe("observed workspace placement", () => { test("preserves a periodic full pass queued behind metadata reconciliation", async () => { const observed = new ObservedPlacements([ - { id: "project-one", root: "repo", workspaces: [{ id: "workspace-one", cwd: "repo" }] }, + { + id: "project-one", + root: "repo", + workspaces: [{ id: "workspace-one", cwd: "repo/worktree-one" }], + }, ]); await observed.start(); const metadataRead = observed.holdNextReconciliation(); diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 1a885378db..52c7d41065 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -598,6 +598,11 @@ describe("WorkspaceReconciliationService", () => { }); test("archives workspaces whose directories no longer exist", async () => { + const projectRoot = realpathSync( + mkdtempSync(path.join(tmpdir(), "reconcile-missing-workspace-")), + ); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const archivedWorkspaceIds: string[] = []; @@ -605,7 +610,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -617,7 +623,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -640,7 +646,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); @@ -648,12 +654,59 @@ describe("WorkspaceReconciliationService", () => { expect(workspaces.get("w1")?.archivedAt).toEqual(expect.any(String)); }); + test("keeps workspaces whose project root is missing with them", async () => { + const mountParent = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-unmounted-"))); + tempDirs.push(mountParent); + // The external volume is not mounted, so nothing under it resolves. + const projectRoot = path.join(mountParent, "ExternalSSD", "repo"); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + rootPath: projectRoot, + kind: "non_git", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: projectRoot, + kind: "directory", + displayName: "repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([]); + expect(workspaces.get("w1")?.archivedAt).toBeNull(); + }); + test("keeps a project active after all its workspaces are archived", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-orphan-project-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const project = createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-reconcile-orphan", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "orphan", createdAt: timestamp, @@ -665,7 +718,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", createdAt: timestamp, @@ -685,16 +738,17 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-reconcile-orphan", + directory: missingWorkspace, reason: "directory_missing", }, ]); expect(workspaces.get("w1")).toEqual({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-reconcile-orphan", + cwd: missingWorkspace, kind: "directory", displayName: "orphan", + background: false, title: null, pinnedAt: null, branch: null, @@ -1382,13 +1436,17 @@ describe("WorkspaceReconciliationService", () => { }); test("calls onChanges callback when changes are applied", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-callback-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); projects.set( "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-callback-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1400,7 +1458,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-callback-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1422,13 +1480,16 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-callback-test", + directory: missingWorkspace, reason: "directory_missing", }, ]); }); test("logs reconciliation changes with affected paths and reasons", async () => { + const projectRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "reconcile-log-"))); + const missingWorkspace = path.join(projectRoot, "missing-workspace"); + tempDirs.push(projectRoot); const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); const { logger, infoRecords } = createCapturingLogger(); @@ -1436,7 +1497,8 @@ describe("WorkspaceReconciliationService", () => { "p1", createPersistedProjectRecord({ projectId: "p1", - rootPath: "/tmp/does-not-exist-log-test", + rootPath: projectRoot, + projectKey: canonicalLocalProjectKey(projectRoot), kind: "non_git", displayName: "ghost", createdAt: timestamp, @@ -1448,7 +1510,7 @@ describe("WorkspaceReconciliationService", () => { createPersistedWorkspaceRecord({ workspaceId: "w1", projectId: "p1", - cwd: "/tmp/does-not-exist-log-test", + cwd: missingWorkspace, kind: "directory", displayName: "ghost", createdAt: timestamp, @@ -1473,7 +1535,7 @@ describe("WorkspaceReconciliationService", () => { { kind: "workspace_archived", workspaceId: "w1", - directory: "/tmp/does-not-exist-log-test", + directory: missingWorkspace, reason: "directory_missing", }, ]), diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 59d2f9c124..eb79dbacd2 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -227,6 +227,14 @@ export class WorkspaceReconciliationService { workspace, state: this.inspectDirectory(workspace.cwd), })); + // Project roots are read after the workspace directories, so a volume that + // goes away mid-pass leaves its project unreachable rather than its workspaces + // alone. The skew can only withhold an archive, never produce one. + const reachableProjectIds = new Set( + activeProjects + .filter((project) => this.inspectDirectory(project.rootPath) === "directory") + .map((project) => project.projectId), + ); const workspacesByProject = new Map(); for (const { workspace, state } of workspaceDirectoryStates) { @@ -236,9 +244,16 @@ export class WorkspaceReconciliationService { workspacesByProject.set(workspace.projectId, list); } - // 1. Archive workspaces whose directories no longer exist + // 1. Archive workspaces whose directories no longer exist, but only when the + // project they belong to is still reachable. A missing project root means the + // whole location is unavailable - an unmounted volume, an offline share, a disk + // that has not appeared yet - and absence there proves nothing about the + // workspace. Projects already persist through that; their workspaces do too. const missingWorkspaces = workspaceDirectoryStates - .filter(({ state }) => state === "missing") + .filter( + ({ workspace, state }) => + state === "missing" && reachableProjectIds.has(workspace.projectId), + ) .map(({ workspace }) => workspace); await Promise.all( missingWorkspaces.map(async (workspace) => { @@ -265,7 +280,7 @@ export class WorkspaceReconciliationService { // Projects persist until explicitly removed, even when they currently have // zero active workspaces, so they still reconcile their own metadata. await this.reconcileGitMetadataForProjects( - activeProjects.filter((project) => this.inspectDirectory(project.rootPath) === "directory"), + activeProjects.filter((project) => reachableProjectIds.has(project.projectId)), workspacesByProject, changes, ); diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 1f51a0c4a1..a4ad29568c 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -87,6 +87,31 @@ describe("workspace placement", () => { }); }); + test("keeps the recorded placement when nothing was read at the workspace directory", () => { + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "workspace-one", + projectId: "project-one", + cwd: "/repo-feature", + kind: "worktree", + displayName: "feature/placement", + branch: "feature/placement", + worktreeRoot: "/repo-feature", + baseBranch: "main", + isPaseoOwnedWorktree: true, + mainRepoRoot: "/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + const update = reconcileWorkspacePlacement({ + workspace, + checkout: null, + updatedAt: "2026-03-02T00:00:00.000Z", + }); + + expect(update).toBeNull(); + }); + test("updates live placement while preserving its durable name and base branch", () => { const workspace = createPersistedWorkspaceRecord({ workspaceId: "workspace-one", diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index acc4d49538..181ec96eae 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -109,12 +109,17 @@ export function initialWorkspacePlacement( /** * Applies live placement facts without rewriting the workspace's durable name * or its creation-time base branch. + * + * A null checkout is an absence, not an observation: nothing was read at the + * workspace directory, so the recorded placement stands. Only a directory that + * is there can show that a worktree stopped being one. */ export function reconcileWorkspacePlacement(input: { workspace: PersistedWorkspaceRecord; - checkout: ProjectCheckoutLitePayload; + checkout: ProjectCheckoutLitePayload | null; updatedAt: string; }): WorkspacePlacementUpdate | null { + if (!input.checkout) return null; const observed = initialWorkspacePlacement({ source: "checkout", cwd: input.workspace.cwd, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 80c328ce7b..04ffc1d7ac 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -28,6 +28,21 @@ describe("resolveWorkspaceName", () => { ); }); + test("createPersistedWorkspaceRecord defaults background to false and preserves explicit intent", () => { + const base = { + workspaceId: "ws-1", + projectId: "proj-1", + cwd: "/tmp/repo", + kind: "local_checkout" as const, + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }; + expect(createPersistedWorkspaceRecord(base).background).toBe(false); + expect(createPersistedWorkspaceRecord({ ...base, background: false }).background).toBe(false); + expect(createPersistedWorkspaceRecord({ ...base, background: true }).background).toBe(true); + }); + test("resolveWorkspaceDisplayName applies the same rule over the persisted record", () => { const record = createPersistedWorkspaceRecord({ workspaceId: "ws-1", diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index b4b8d5cdd5..417af353fe 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -75,9 +75,9 @@ const PersistedWorkspaceRecordSchema = z.object({ // archive and recovery do not need the directory to still exist in order to // recover placement. worktreeRoot: z.string().nullable().default(null), - // The base branch the worktree was created from (normalized like worktree.json's - // baseRefName). Only worktree workspaces carry a base branch; checkout-branch - // worktrees and directory/local_checkout workspaces leave it null. + // Comparison base preserved across worktree deletion and restore. New branch-off + // workspaces store the resolved ref (e.g. refs/remotes/upstream/main); legacy and + // PR checkout records may contain a bare branch name. Ordinary checkouts use null. baseBranch: z .string() .nullable() @@ -102,6 +102,8 @@ const PersistedWorkspaceRecordSchema = z.object({ .transform((value) => value ?? null), labels: z.array(z.string()).optional(), untrustedSource: UntrustedWorkspaceSourceSchema.optional(), + // Public discovery visibility. Contents retain their ordinary lifecycle. + background: z.boolean().optional().default(false), }); export type PersistedProjectRecord = z.infer; @@ -684,9 +686,12 @@ export function createPersistedWorkspaceRecord(input: { pinnedAt?: string | null; labels?: string[]; untrustedSource?: UntrustedWorkspaceSource; + background?: boolean; }): PersistedWorkspaceRecord { + const { background, ...rest } = input; return PersistedWorkspaceRecordSchema.parse({ - ...input, + ...rest, + background: background ?? false, title: input.title ?? null, branch: input.branch ?? null, worktreeRoot: input.worktreeRoot ?? null, diff --git a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts index 58bf79c66b..139f8a3795 100644 --- a/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts +++ b/packages/server/src/server/workspace-same-cwd-isolation.e2e.test.ts @@ -374,6 +374,7 @@ test("local workspace auto-title does not broadcast provider snapshot warm-up to try { await client.connect(); + await client.observeEvents(["providers_snapshot_update"]).ready; await client.getProvidersSnapshot({ cwd }); await expect .poll( diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a0a796c83d..41de520d09 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { api: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"", }, web: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"", }, }, }), diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index f4b51e0554..c66e91c5dd 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -5,7 +5,7 @@ import { createWorktree, slugify, validateBranchSlug, - type WorktreeConfig, + type CreatedWorktree, } from "../utils/worktree.js"; import { resolveWorktreeCreationIntent, @@ -41,7 +41,7 @@ export interface CreateWorktreeCoreDeps { } export interface CreateWorktreeCoreResult { - worktree: WorktreeConfig; + worktree: CreatedWorktree; intent: WorktreeCreationIntent; repoRoot: string; created: boolean; diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 384a74a54d..b146c59f31 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -115,7 +115,6 @@ function createWorkflowForRequestTest(options: { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -366,6 +365,7 @@ function createPaseoWorktreeForTest(options: { projectRegistry, workspaceRegistry, workspaceGitService, + isDirectory: async () => true, logger: createLogger(), }); @@ -488,7 +488,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => emitted.push(message), sessionLogger: createLogger(), terminalManager: createTerminalManagerStub().manager, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -539,7 +538,6 @@ describe("create-agent worktree setup boundary", () => { emit: (message) => workspaceSetupEvents.push(message), sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -713,7 +711,6 @@ describe("runWorktreeSetupInBackground", () => { emit: () => {}, sessionLogger: createLogger(), terminalManager: null, - archiveWorkspaceRecord: async () => {}, }, { requestCwd: sourceWorkspaceCwd, @@ -758,7 +755,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -769,7 +765,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -827,11 +822,10 @@ describe("runWorktreeSetupInBackground", () => { }); expect(terminalManager.terminals).toHaveLength(0); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("42"); }); - test("archives the pending workspace and emits a failed snapshot when setup cannot start", async () => { + test("keeps the workspace available and emits a failed snapshot when setup cannot start", async () => { const { tempDir, repoDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -856,7 +850,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); const workspaceId = "ws-broken-feature"; await runWorktreeSetupInBackground( @@ -868,7 +861,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -900,7 +892,8 @@ describe("runWorktreeSetupInBackground", () => { status: "failed", error: expect.stringMatching(/Failed to parse paseo\.json at .*paseo\.json/), }); - expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId); + expect(existsSync(worktreePath)).toBe(true); + expect(existsSync(path.join(worktreePath, "paseo.json"))).toBe(true); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith(workspaceId); }); @@ -931,7 +924,6 @@ describe("runWorktreeSetupInBackground", () => { const snapshots = new Map(); const logger = createLogger(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -942,7 +934,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: null, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1055,7 +1046,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1066,7 +1056,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1115,7 +1104,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("44"); }); @@ -1150,7 +1138,6 @@ describe("runWorktreeSetupInBackground", () => { }, }); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1161,7 +1148,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1201,7 +1187,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("45"); }); @@ -1232,7 +1217,6 @@ describe("runWorktreeSetupInBackground", () => { const logger = createLogger(); const terminalManager = createTerminalManagerStub(); const emitWorkspaceUpdateForWorkspaceId = vi.fn(async () => {}); - const archiveWorkspaceRecord = vi.fn(async () => {}); await runWorktreeSetupInBackground( { @@ -1243,7 +1227,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message) => emitted.push(message), sessionLogger: logger, terminalManager: terminalManager.manager, - archiveWorkspaceRecord, }, { requestCwd: repoDir, @@ -1264,7 +1247,6 @@ describe("runWorktreeSetupInBackground", () => { status: "completed", error: null, }); - expect(archiveWorkspaceRecord).not.toHaveBeenCalled(); expect(emitWorkspaceUpdateForWorkspaceId).toHaveBeenCalledWith("46"); }); @@ -1426,7 +1408,6 @@ describe("runWorktreeSetupInBackground", () => { emit: (message: SessionOutboundMessage) => emitted.push(message), sessionLogger: createLogger(), terminalManager: terminalManager.manager, - archiveWorkspaceRecord: vi.fn(async () => {}), serviceProxy: null, scriptRuntimeStore: null, getDaemonTcpPort: null, @@ -1904,6 +1885,7 @@ describe("handleCreatePaseoWorktreeRequest", () => { requestCwd: repoDir, worktree: { branchName: "response-after-create", + comparisonBaseRef: "refs/heads/main", worktreePath: registeredWorktreePath, }, shouldBootstrap: true, @@ -2035,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2106,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2182,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2258,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index c693bc5380..cdb0119b64 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -4,6 +4,7 @@ import { basename } from "node:path"; import type { AgentSessionConfig } from "./agent/agent-sdk-types.js"; import { type GitSetupOptions, + type CreateAgentWorktreeTarget, type FirstAgentContext, type ChangeRequestCheckoutSource, type SessionInboundMessage, @@ -108,7 +109,6 @@ interface CreatePaseoWorktreeInBackgroundDependencies { emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; - archiveWorkspaceRecord: (workspaceId: string) => Promise; serviceProxy: ServiceProxySubsystem | null; scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; getDaemonTcpPort: (() => number | null) | null; @@ -321,7 +321,42 @@ function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): } } -export function normalizeGitOptions( +export function normalizeAgentCreationGitOptions( + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string, + worktree?: CreateAgentWorktreeTarget, +): NormalizedGitOptions | null { + if (worktree && gitOptions) { + throw new Error("create_agent_request worktree cannot be combined with git options"); + } + if (worktree) { + switch (worktree.mode) { + case "branch-off": + gitOptions = { + createWorktree: true, + createNewBranch: true, + newBranchName: worktree.newBranch, + baseBranch: worktree.base, + action: "branch-off", + refName: worktree.base, + }; + break; + case "checkout-branch": + gitOptions = { createWorktree: true, action: "checkout", refName: worktree.branch }; + break; + case "checkout-pr": + gitOptions = { + createWorktree: true, + action: "checkout", + githubPrNumber: worktree.prNumber, + }; + break; + } + } + return normalizeGitOptions(gitOptions, legacyWorktreeName); +} + +function normalizeGitOptions( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, ): NormalizedGitOptions | null { @@ -466,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest( "emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService" > & { emit: EmitSessionMessage; - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; }, msg: Extract, @@ -825,7 +863,6 @@ export async function runWorktreeSetupInBackground( ): Promise { let worktree: WorktreeConfig = options.worktree; let setupResults: WorktreeSetupCommandResult[] = []; - let setupStarted = false; const progressAccumulator = createWorktreeSetupProgressAccumulator(); const workspaceId = options.workspaceId; @@ -862,7 +899,6 @@ export async function runWorktreeSetupInBackground( const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath; const setupCommands = getWorktreeSetupCommands(workspaceCwd); if (setupCommands.length === 0) { - setupStarted = true; emitSetupProgress("completed", null); } else { const runtimeEnv = await resolveWorktreeRuntimeEnv({ @@ -874,7 +910,6 @@ export async function runWorktreeSetupInBackground( cwd: workspaceCwd, env: runtimeEnv, }); - setupStarted = true; setupResults = await runWorktreeSetupCommands({ worktreePath: workspaceCwd, branchName: worktree.branchName, @@ -906,10 +941,6 @@ export async function runWorktreeSetupInBackground( const message = error instanceof Error ? error.message : String(error); emitSetupProgress("failed", message); - if (!setupStarted) { - await dependencies.archiveWorkspaceRecord(options.workspaceId); - } - dependencies.sessionLogger.error( { err: error, @@ -917,7 +948,6 @@ export async function runWorktreeSetupInBackground( repoRoot: options.repoRoot, worktreeSlug: worktree.branchName, worktreePath: worktree.worktreePath, - setupStarted, }, "Background worktree setup failed", ); diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..4f76cb1887 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit< ArchiveDependencies, "workspaceGitService" > { - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; } export interface ArchiveCommandInput { diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 194a0c7487..ab71115f36 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -25,6 +25,7 @@ const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000; const CURRENT_PR_STATUS_BASE_FIELDS = "number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner"; const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`; +const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork"; interface RunnerCall { args: string[]; @@ -3324,34 +3325,33 @@ describe("ForgeService", () => { name: "parentRepo", parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, }), - JSON.stringify([ - { - number: 41, - url: "https://github.com/parentOwner/parentRepo/pull/41", - title: "Wrong fork owner", - state: "OPEN", - isDraft: false, - baseRefName: "main", - headRefName: "feature/fork", - mergedAt: null, - statusCheckRollup: [], - reviewDecision: "REVIEW_REQUIRED", - headRepositoryOwner: { login: "otherFork" }, - }, - { - number: 42, - url: "https://github.com/parentOwner/parentRepo/pull/42", - title: "Real fork PR", - state: "OPEN", - isDraft: false, - baseRefName: "main", - headRefName: "feature/fork", - mergedAt: null, - statusCheckRollup: [], - reviewDecision: "REVIEW_REQUIRED", - headRepositoryOwner: { login: "forkOwner" }, - }, - ]), + JSON.stringify([{ number: 41 }, { number: 42 }]), + JSON.stringify({ + number: 41, + url: "https://github.com/parentOwner/parentRepo/pull/41", + title: "Wrong fork owner", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "otherFork" }, + }), + JSON.stringify({ + number: 42, + url: "https://github.com/parentOwner/parentRepo/pull/42", + title: "Real fork PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "forkOwner" }, + }), ]); const service = createGitHubService({ runner: runner.runner, @@ -3371,27 +3371,182 @@ describe("ForgeService", () => { title: "Real fork PR", headRefName: "feature/fork", }); - expect(runner.calls.slice(0, 3).map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], - [ - "pr", - "list", - "--repo", - "parentOwner/parentRepo", - "--state", - "all", - "--head", - "forkOwner:feature/fork", - "--limit", - "10", - "--json", - CURRENT_PR_STATUS_FIELDS, - ], + ["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`], + ["pr", "view", "41", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS], + ["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS], ]); }); - it("retries scoped PR list without statusCheckRollup when token permissions are insufficient", async () => { + it("reads the fork PR carrying the checked-out commit past newer attempts on the branch", async () => { + const checkoutSha = "3333333333333333333333333333333333333333"; + const viewed: number[] = []; + const runner: GitHubCommandRunner = async (args) => { + if (args[0] === "repo" && args[1] === "view") { + return { + stdout: JSON.stringify({ + owner: { login: "forkOwner" }, + name: "parentRepo", + parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, + }), + stderr: "", + }; + } + if (args[0] === "api") { + // Newest first, and the checkout is sitting on the oldest attempt. + return { + stdout: JSON.stringify([ + { number: 50, head: { sha: "5".repeat(40) } }, + { number: 49, head: { sha: "4".repeat(40) } }, + { number: 48, head: { sha: "8".repeat(40) } }, + { number: 47, head: { sha: checkoutSha } }, + ]), + stderr: "", + }; + } + if (args[0] === "pr" && args[1] === "view" && /^\d+$/.test(args[2] ?? "")) { + const number = Number(args[2]); + viewed.push(number); + return { + stdout: currentPullRequestJson({ + number, + url: `https://github.com/parentOwner/parentRepo/pull/${number}`, + state: "CLOSED", + headRefOid: number === 47 ? checkoutSha : `${number}`.repeat(20), + headRepositoryOwner: { login: "forkOwner" }, + }), + stderr: "", + }; + } + throw noPullRequestError(args); + }; + const service = createGitHubService({ + runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "feature/fork", + headSha: checkoutSha, + }); + + expect(status).toMatchObject({ + number: 47, + repoOwner: "parentOwner", + repoName: "parentRepo", + }); + expect(viewed).toContain(47); + expect(viewed).toHaveLength(3); + }); + + it("propagates a failed fork PR lookup instead of reporting no pull request", async () => { + const rateLimitError = new GitHubCommandError({ + args: ["api", "repos/parentOwner/parentRepo/pulls"], + cwd: "/repo", + exitCode: 1, + stderr: "API rate limit exceeded", + }); + const runner = createScriptedRunner([ + { error: noPullRequestError() }, + JSON.stringify({ + owner: { login: "forkOwner" }, + name: "parentRepo", + parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, + }), + { error: rateLimitError }, + ]); + const service = createGitHubService({ + runner: runner.runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + await expect( + service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }), + ).rejects.toThrow(GitHubCommandError); + }); + + it("resolves a fork PR whose branch name is crowded in the parent repository", async () => { + // Mirrors what `gh` really does: `pr list --head` matches the string + // against headRefName, so an "owner:branch" head never matches, and a + // popular branch name fills the candidate page with other forks' PRs. + const calls: RunnerCall[] = []; + function parentRepoPullRequest( + number: number, + headRepositoryOwner: string, + ): Record { + return { + number, + url: `https://github.com/parentOwner/parentRepo/pull/${number}`, + title: `PR ${number}`, + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "main", + headRefOid: `${number}`.padStart(40, "0"), + mergedAt: null, + statusCheckRollup: [], + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: headRepositoryOwner }, + }; + } + const crowd = Array.from({ length: 10 }, (_, index) => + parentRepoPullRequest(100 + index, `otherFork${index}`), + ); + const runner: GitHubCommandRunner = async (args, options) => { + calls.push({ args, cwd: options.cwd }); + if (args[0] === "repo" && args[1] === "view") { + return { + stdout: JSON.stringify({ + owner: { login: "forkOwner" }, + name: "parentRepo", + parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, + }), + stderr: "", + }; + } + if (args[0] === "pr" && args[1] === "view" && args[2] === "4984") { + return { stdout: JSON.stringify(parentRepoPullRequest(4984, "forkOwner")), stderr: "" }; + } + if (args[0] === "pr" && args[1] === "view") { + throw noPullRequestError(args); + } + if (args[0] === "pr" && args[1] === "list") { + const headRef = args[args.indexOf("--head") + 1]; + const limit = Number(args[args.indexOf("--limit") + 1]); + const matching = crowd.filter((candidate) => candidate.headRefName === headRef); + return { stdout: JSON.stringify(matching.slice(0, limit)), stderr: "" }; + } + if (args[0] === "api" && args[1]?.startsWith("repos/")) { + expect(args[1]).toContain("head=forkOwner%3Amain"); + return { stdout: JSON.stringify([{ number: 4984 }]), stderr: "" }; + } + return { stdout: "{}", stderr: "" }; + }; + const service = createGitHubService({ + runner, + resolveGhPath: async () => "/usr/bin/gh", + now: () => 100, + }); + + const status = await service.getCurrentPullRequestStatus({ + cwd: "/repo", + headRef: "main", + }); + + expect(status).toMatchObject({ + number: 4984, + repoOwner: "parentOwner", + repoName: "parentRepo", + headRefName: "main", + }); + }); + + it("retries the fork PR view without statusCheckRollup when token permissions are insufficient", async () => { const runner = createScriptedRunner([ currentPullRequestJson({ number: 7, @@ -3404,36 +3559,30 @@ describe("ForgeService", () => { name: "parentRepo", parent: { owner: { login: "parentOwner" }, name: "parentRepo" }, }), + JSON.stringify([{ number: 42 }]), { error: statusCheckRollupPermissionError([ "pr", - "list", + "view", + "42", "--repo", "parentOwner/parentRepo", - "--state", - "all", - "--head", - "forkOwner:feature/fork", "--json", CURRENT_PR_STATUS_FIELDS, - "--limit", - "10", ]), }, - JSON.stringify([ - { - number: 42, - url: "https://github.com/parentOwner/parentRepo/pull/42", - title: "Real fork PR", - state: "OPEN", - isDraft: false, - baseRefName: "main", - headRefName: "feature/fork", - mergedAt: null, - reviewDecision: "REVIEW_REQUIRED", - headRepositoryOwner: { login: "forkOwner" }, - }, - ]), + JSON.stringify({ + number: 42, + url: "https://github.com/parentOwner/parentRepo/pull/42", + title: "Real fork PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + reviewDecision: "REVIEW_REQUIRED", + headRepositoryOwner: { login: "forkOwner" }, + }), ]); const service = createGitHubService({ runner: runner.runner, @@ -3454,34 +3603,17 @@ describe("ForgeService", () => { checks: [], checksStatus: "none", }); - expect(runner.calls.slice(0, 4).map((call) => call.args)).toEqual([ + expect(runner.calls.slice(0, 5).map((call) => call.args)).toEqual([ ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], ["repo", "view", "--json", "owner,name,parent"], + ["api", `repos/parentOwner/parentRepo/pulls?${FORK_PR_QUERY}`], + ["pr", "view", "42", "--repo", "parentOwner/parentRepo", "--json", CURRENT_PR_STATUS_FIELDS], [ "pr", - "list", + "view", + "42", "--repo", "parentOwner/parentRepo", - "--state", - "all", - "--head", - "forkOwner:feature/fork", - "--limit", - "10", - "--json", - CURRENT_PR_STATUS_FIELDS, - ], - [ - "pr", - "list", - "--repo", - "parentOwner/parentRepo", - "--state", - "all", - "--head", - "forkOwner:feature/fork", - "--limit", - "10", "--json", CURRENT_PR_STATUS_BASE_FIELDS, ], @@ -3629,21 +3761,20 @@ describe("ForgeService", () => { name: "repo", parent: { owner: { login: "parentOwner" }, name: "repo" }, }), - JSON.stringify([ - { - number: 88, - url: "https://github.com/parentOwner/repo/pull/88", - title: "Fork-only PR", - state: "OPEN", - isDraft: false, - baseRefName: "main", - headRefName: "feature/fork", - mergedAt: null, - statusCheckRollup: [], - reviewDecision: null, - headRepositoryOwner: { login: "forkOwner" }, - }, - ]), + JSON.stringify([{ number: 88 }]), + JSON.stringify({ + number: 88, + url: "https://github.com/parentOwner/repo/pull/88", + title: "Fork-only PR", + state: "OPEN", + isDraft: false, + baseRefName: "main", + headRefName: "feature/fork", + mergedAt: null, + statusCheckRollup: [], + reviewDecision: null, + headRepositoryOwner: { login: "forkOwner" }, + }), ]); const service = createGitHubService({ runner: runner.runner, @@ -3662,7 +3793,7 @@ describe("ForgeService", () => { repoName: "repo", headRefName: "feature/fork", }); - expect(runner.calls[2]?.args).toContain("forkOwner:feature/fork"); + expect(runner.calls[2]?.args).toEqual(["api", `repos/parentOwner/repo/pulls?${FORK_PR_QUERY}`]); }); it("propagates DNS errors while resolving the current PR view", async () => { diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 4e857d5062..b6de0cbd6e 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -117,6 +117,11 @@ export const GITHUB_POLL_ALIGNMENT_MS = 5_000; const GITHUB_GRAPHQL_RESERVE_RATIO = 0.4; const GITHUB_GRAPHQL_RESET_GRACE_MS = 1_000; const BATCH_PR_CANDIDATE_LIMIT = 10; +// How many of a fork branch's own pull requests are read in full. One is the +// normal case; the rest are earlier attempts on the same branch, and the one +// carrying the checkout's head commit is read whichever attempt it belongs to. +const FORK_PR_VIEW_LIMIT = 3; +const FORK_PR_PAGE_SIZE = 100; const FROZEN_PR_CHECKS_CACHE_MAX_ENTRIES = 512; const GITHUB_ENV = { GIT_TERMINAL_PROMPT: "0", @@ -457,6 +462,16 @@ const GitHubRepoViewSchema = z.object({ .optional(), }); +const ForkPullRequestRefsSchema = z.array( + z.object({ + number: z.number(), + head: z + .object({ sha: z.string().catch("") }) + .nullable() + .optional(), + }), +); + const PullRequestCheckoutTargetSchema = z.object({ data: z.object({ repository: z.object({ @@ -2964,8 +2979,6 @@ async function resolveCurrentPullRequestView(options: { return viewMatch.status; } - let listHeadRef = options.headRef; - let listRepo: string | undefined; let headRepositoryOwner = options.headRepositoryOwner; if (!headRepositoryOwner) { @@ -2977,17 +2990,19 @@ async function resolveCurrentPullRequestView(options: { return null; } if (parentOwner && parentName) { - listHeadRef = `${forkOwner}:${options.headRef}`; - listRepo = `${parentOwner}/${parentName}`; + return resolveForkPullRequestView({ + ...options, + forkOwner, + repo: `${parentOwner}/${parentName}`, + }); } headRepositoryOwner = forkOwner; } const candidates = await listCurrentPullRequestCandidates({ cwd: options.cwd, - headRef: listHeadRef, + headRef: options.headRef, run: options.run, - repo: listRepo, }); const match = pickPullRequestCandidate({ candidates, @@ -2998,6 +3013,77 @@ async function resolveCurrentPullRequestView(options: { return match?.status ?? null; } +// A fork checkout's pull request lives in the parent repository, where only +// its head repository tells it apart from every other fork's pull request on +// the same branch name. `gh pr list --head` cannot ask that question: it +// forwards the whole value as a head branch name, so "owner:branch" matches +// nothing (cli/cli#10945), and the bare branch name loses a common name like +// `main` among the other forks crowding the candidate page. The REST pulls +// endpoint is the only lookup that takes a head repository, so it names the +// pull requests and the ordinary view path reads each one's status. +async function resolveForkPullRequestView(options: { + cwd: string; + headRef: string; + headSha?: string; + forkOwner: string; + repo: string; + run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; +}): Promise { + const numbers = await listForkPullRequestNumbers(options); + const candidates = await Promise.all( + numbers.map((number) => + tryCurrentPullRequestView({ + cwd: options.cwd, + headRef: options.headRef, + run: options.run, + args: ["pr", "view", String(number), "--repo", options.repo], + }), + ), + ); + const match = pickPullRequestCandidate({ + candidates: candidates.filter( + (candidate): candidate is ResolvedPullRequestCandidate => candidate !== null, + ), + headRef: options.headRef, + headSha: options.headSha, + headRepositoryOwner: options.forkOwner, + }); + return match?.status ?? null; +} + +// The endpoint answers with the fork branch's pull requests, newest first, so +// a branch reused for several attempts can carry the checkout's head commit on +// an older one. Those are read first, and the rest of the window is filled +// newest-first, so the full read stays bounded without dropping the attempt the +// checkout is actually sitting on. +async function listForkPullRequestNumbers(options: { + cwd: string; + headRef: string; + headSha?: string; + forkOwner: string; + repo: string; + run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; +}): Promise { + const query = new URLSearchParams({ + state: "all", + per_page: String(FORK_PR_PAGE_SIZE), + head: `${options.forkOwner}:${options.headRef}`, + }); + const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`]; + const stdout = await options.run(args, { cwd: options.cwd }); + const refs = parseGitHubJsonOutput(stdout, ForkPullRequestRefsSchema, { + args, + cwd: options.cwd, + emptyFallback: "[]", + }); + function carriesHeadSha(ref: (typeof refs)[number]): boolean { + return options.headSha !== undefined && ref.head?.sha === options.headSha; + } + return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))] + .slice(0, FORK_PR_VIEW_LIMIT) + .map((ref) => ref.number); +} + async function addCurrentPullRequestGithubFacts(options: { cwd: string; status: CurrentPullRequestStatus | null; @@ -3057,16 +3143,18 @@ async function loadPullRequestGithubFacts(options: { async function tryCurrentPullRequestView(options: { cwd: string; headRef: string; + args?: string[]; run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; }): Promise { + const args = options.args ?? ["pr", "view"]; try { const stdout = await runCurrentPullRequestStatusCommand({ cwd: options.cwd, run: options.run, - args: ["pr", "view"], + args, }); return parseCurrentPullRequestCandidate(stdout, options.headRef, { - args: ["pr", "view", "--json", CURRENT_PR_STATUS_FIELDS], + args: [...args, "--json", CURRENT_PR_STATUS_FIELDS], cwd: options.cwd, }); } catch (error) { @@ -3081,13 +3169,8 @@ async function listCurrentPullRequestCandidates(options: { cwd: string; headRef: string; run: (args: string[], options: GitHubCommandRunnerOptions) => Promise; - repo?: string; }): Promise { - const args = ["pr", "list"]; - if (options.repo) { - args.push("--repo", options.repo); - } - args.push("--state", "all", "--head", options.headRef, "--limit", "10"); + const args = ["pr", "list", "--state", "all", "--head", options.headRef, "--limit", "10"]; try { const stdout = await runCurrentPullRequestStatusCommand({ cwd: options.cwd, diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index c74224e444..371198e447 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -1328,6 +1328,132 @@ describe("createGitLabService", () => { ); }); + it("finds a merge request by number even when its title and description omit it", async () => { + const { service } = makeService((args) => { + // GitLab text search does not search the IID. Model both API behaviors. + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=14")) { + return ok(JSON.stringify([OPEN_MR])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: " 14 ", + kinds: ["change_request"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ + kind: "change_request", + number: 14, + title: OPEN_MR.title, + headRefName: OPEN_MR.source_branch, + baseRefName: OPEN_MR.target_branch, + }), + ]); + }); + + it("finds issues by number through the same search interface", async () => { + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] === "api" && args[1].includes("iids[]=7")) { + return ok(JSON.stringify([OPEN_ISSUE])); + } + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "7", + kinds: ["issue"], + }); + expect(result.items).toEqual([ + expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }), + ]); + }); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])( + "preserves numeric text matches after the exact $kind number, without duplicates", + async ({ kind, item }) => { + const textMatches = [ + { + ...item, + iid: 90, + title: `References ${item.iid}`, + updated_at: "2026-07-01T00:00:00.000Z", + }, + item, + { + ...item, + iid: 91, + description: `References ${item.iid}`, + updated_at: "2026-06-30T00:00:00.000Z", + }, + ]; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok(JSON.stringify(textMatches)); + if (args[0] === "api") return ok(JSON.stringify([item])); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: String(item.iid), + limit: 2, + kinds: [kind], + }); + expect( + result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })), + ).toEqual([ + { kind, number: item.iid }, + { kind, number: 90 }, + ]); + }, + ); + + it.each([ + { kind: "change_request" as const, item: OPEN_MR }, + { kind: "issue" as const, item: OPEN_ISSUE }, + ])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => { + const { service } = makeService((args) => { + if (args.includes("--search")) { + return ok(JSON.stringify([{ ...item, description: "References 2024" }])); + } + if (args[0] === "api") return ok("[]"); + throw new Error(`unexpected glab args: ${args.join(" ")}`); + }); + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query: "2024", + kinds: [kind], + }); + expect(result.items.map(({ number }) => number)).toEqual([item.iid]); + }); + + it("keeps closed and missing merge requests out of number search results", async () => { + const closedMr = { ...OPEN_MR, state: "closed" }; + const { service } = makeService((args) => { + if (args.includes("--search")) return ok("[]"); + if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`); + const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/"); + const matches = + endpoint.searchParams.get("iids[]") === String(closedMr.iid) && + endpoint.searchParams.get("state") !== "opened"; + return ok(JSON.stringify(matches ? [closedMr] : [])); + }); + for (const query of ["14", "999999"]) { + const result = await service.searchIssuesAndPrs({ + cwd: "/repo", + query, + limit: 10, + kinds: ["change_request"], + }); + expect(result.authState).toBe("authenticated"); + expect(result.items).toEqual([]); + } + }); + it("searches issues and merge requests and maps them to neutral results", async () => { const { service, calls } = makeService((args) => { if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE])); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index f0b166cdda..1fff0e2a02 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -882,6 +882,31 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult({ + kind, + input, + schema, + }: { + kind: "mr" | "issue"; + input: ListPullRequestsOptions | ListIssuesOptions; + schema: z.ZodType; + }): Promise { + const textSearch = runJson(buildGitLabListArgs({ kind, input }), { cwd: input.cwd }, schema); + const numberQuery = gitLabNumberQuery(input.query); + if (!numberQuery) return textSearch; + + // GitLab text search omits IIDs, so supplement it with an open-item lookup. + const resource = kind === "mr" ? "merge_requests" : "issues"; + const [numberMatches, textMatches] = await Promise.all([ + runJson( + ["api", `projects/:id/${resource}?state=opened&iids[]=${numberQuery}`], + { cwd: input.cwd }, + schema, + ), + textSearch, + ]); + const seen = new Set(); + const items = [...numberMatches, ...textMatches].filter((item) => { + if (seen.has(item.iid)) return false; + seen.add(item.iid); + return true; + }); + return typeof input.limit === "number" ? items.slice(0, input.limit) : items; + } + async function runMergeRequestList( input: ListPullRequestsOptions, ): Promise { - const args = ["mr", "list", "-F", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const mergeRequests = await runJson( - args, - { cwd: input.cwd }, - z.array(GitLabMergeRequestSchema), - ); + const mergeRequests = await runList({ + kind: "mr", + input, + schema: z.array(GitLabMergeRequestSchema), + }); return mergeRequests.map(toPullRequestSummary); } - /** - * `glab issue list` toggles JSON output with `-O/--output` (text|json); its - * `-F/--output-format` flag means something else (details|ids|urls) and - * silently falls back to the human-readable table for an unknown value. This - * differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using - * the wrong flag here would emit a text table that fails JSON parsing. - */ async function runIssueList(input: ListIssuesOptions): Promise { - const args = ["issue", "list", "-O", "json"]; - const query = input.query?.trim(); - if (query) { - args.push("--search", query); - } - if (typeof input.limit === "number") { - args.push("-P", String(input.limit)); - } - const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema)); + const issues = await runList({ kind: "issue", input, schema: z.array(GitLabIssueSchema) }); return issues.map(toIssueSummary); } @@ -1359,9 +1393,16 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F }); } } - items.sort( - (left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt), - ); + const numberQuery = gitLabNumberQuery(input.query); + items.sort((left, right) => { + if (numberQuery) { + const numberMatchOrder = + Number(right.number === Number(numberQuery)) - + Number(left.number === Number(numberQuery)); + if (numberMatchOrder !== 0) return numberMatchOrder; + } + return parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt); + }); return { items, diff --git a/packages/server/src/services/quota-fetcher/manifest.ts b/packages/server/src/services/quota-fetcher/manifest.ts deleted file mode 100644 index 11578eccd5..0000000000 --- a/packages/server/src/services/quota-fetcher/manifest.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { - ProviderUsageFetcher, - ProviderUsageFetcherFactoryOptions, - ProviderUsageFetcherManifestEntry, -} from "./provider.js"; -import { ClaudeQuotaProvider } from "./providers/claude.js"; -import { CodexQuotaProvider } from "./providers/codex.js"; -import { CopilotQuotaProvider } from "./providers/copilot.js"; -import { CursorQuotaProvider } from "./providers/cursor.js"; -import { GrokQuotaProvider } from "./providers/grok.js"; -import { KimiQuotaProvider } from "./providers/kimi.js"; -import { MiniMaxQuotaProvider } from "./providers/minimax.js"; -import { ZaiQuotaProvider } from "./providers/zai.js"; - -export const PROVIDER_USAGE_FETCHERS: readonly ProviderUsageFetcherManifestEntry[] = [ - { - providerId: "claude", - create: (options) => - new ClaudeQuotaProvider({ - logger: options.logger, - fetch: options.fetch, - }), - }, - { - providerId: "codex", - create: (options) => - new CodexQuotaProvider({ - logger: options.logger, - fetch: options.fetch, - }), - }, - { - providerId: "copilot", - create: (options) => new CopilotQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, - { - providerId: "cursor", - create: (options) => new CursorQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, - { - providerId: "zai", - create: (options) => new ZaiQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, - { - providerId: "grok", - create: (options) => new GrokQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, - { - providerId: "kimi", - create: (options) => new KimiQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, - { - providerId: "minimax", - create: (options) => new MiniMaxQuotaProvider({ logger: options.logger, fetch: options.fetch }), - }, -]; - -export function createProviderUsageFetchers( - options: ProviderUsageFetcherFactoryOptions, -): ProviderUsageFetcher[] { - return PROVIDER_USAGE_FETCHERS.map((entry) => entry.create(options)); -} diff --git a/packages/server/src/services/quota-fetcher/provider.ts b/packages/server/src/services/quota-fetcher/provider.ts deleted file mode 100644 index c8b6687cec..0000000000 --- a/packages/server/src/services/quota-fetcher/provider.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Logger } from "pino"; -import type { ProviderUsage } from "../../server/messages.js"; - -export type ProviderApiFetch = typeof fetch; - -export interface ProviderUsageFetcher { - readonly providerId: string; - readonly displayName: string; - fetchUsage(): Promise; -} - -export interface ProviderUsageFetcherFactoryOptions { - logger: Logger; - fetch?: ProviderApiFetch; -} - -export interface ProviderUsageFetcherManifestEntry { - readonly providerId: string; - create(options: ProviderUsageFetcherFactoryOptions): ProviderUsageFetcher; -} diff --git a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts b/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts deleted file mode 100644 index 0f6be01f32..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/claude-keychain.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { claudeKeychainAccount, readClaudeKeychainCredentials } from "./claude.js"; - -const SERVICE = "Claude Code-credentials"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("claudeKeychainAccount", () => { - it("uses a supported username verbatim", () => { - expect(claudeKeychainAccount("thomas.benoit")).toBe("thomas.benoit"); - }); - - it("falls back to claude-code-user when the username carries a rejected character", () => { - expect(claudeKeychainAccount("first.last@example.com")).toBe("claude-code-user"); - }); - - it("reads the username from $USER when no account is given", () => { - vi.stubEnv("USER", "ci-runner"); - expect(claudeKeychainAccount()).toBe("ci-runner"); - - vi.stubEnv("USER", "first.last@example.com"); - expect(claudeKeychainAccount()).toBe("claude-code-user"); - }); -}); - -describe("readClaudeKeychainCredentials", () => { - it("looks the item up by account and stops there when it exists", async () => { - const run = vi.fn(async () => JSON.stringify({ claudeAiOauth: { accessToken: "at_fresh" } })); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_fresh" } }); - expect(run).toHaveBeenCalledTimes(1); - expect(run).toHaveBeenCalledWith([ - "find-generic-password", - "-a", - "claude-code-user", - "-w", - "-s", - SERVICE, - ]); - }); - - it("falls back to the account-less lookup when no item matches the account", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") ? null : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenNthCalledWith(2, ["find-generic-password", "-w", "-s", SERVICE]); - }); - - it("falls through when the account item carries no access token", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? JSON.stringify({ claudeAiOauth: { subscriptionType: "team" } }) - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - const credentials = await readClaudeKeychainCredentials(run, "claude-code-user"); - - expect(credentials).toEqual({ claudeAiOauth: { accessToken: "at_legacy" } }); - expect(run).toHaveBeenCalledTimes(2); - }); - - it("is null when the Keychain holds no item at all", async () => { - const run = vi.fn(async () => null); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toBeNull(); - }); - - it("tries the next lookup when the item does not parse", async () => { - const run = vi.fn(async (args: string[]) => - args.includes("-a") - ? "not-json" - : JSON.stringify({ claudeAiOauth: { accessToken: "at_legacy" } }), - ); - - expect(await readClaudeKeychainCredentials(run, "claude-code-user")).toEqual({ - claudeAiOauth: { accessToken: "at_legacy" }, - }); - }); -}); diff --git a/packages/server/src/services/quota-fetcher/providers/claude.ts b/packages/server/src/services/quota-fetcher/providers/claude.ts deleted file mode 100644 index 75d6ed74dd..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/claude.ts +++ /dev/null @@ -1,480 +0,0 @@ -import { execFile } from "node:child_process"; -import { existsSync, promises as fs } from "node:fs"; -import { homedir, userInfo } from "node:os"; -import { join } from "node:path"; -import { promisify } from "node:util"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageDetail, - ProviderUsageWindow, -} from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { - ApiNumberSchema, - fetchProviderApi, - toneFromUsedPct, - unavailableUsage, - windowFromUsedPct, -} from "../usage.js"; - -const execFileAsync = promisify(execFile); -const CLAUDE_KEYCHAIN_TIMEOUT_MS = 2_000; -const CLAUDE_OAUTH_BETA = "oauth-2025-04-20"; -const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; - -const ClaudeCredentialsSchema = z.object({ - claudeAiOauth: z - .object({ - accessToken: z.string().optional(), - refreshToken: z.string().optional(), - subscriptionType: z.string().optional(), - rateLimitTier: z.string().optional(), - }) - .optional(), -}); - -const ClaudeUsageWindowSchema = z.object({ - utilization: ApiNumberSchema, - resets_at: z.string().nullish(), -}); - -// Model- and surface-scoped weekly limits live in a `limits[]` array rather than a -// top-level `seven_day_` key. Entries are validated one at a time (see -// scopedLimitsFromResponse) so a single malformed or newly-shaped entry cannot take down -// the windows that already parsed from the top-level keys. -const ClaudeScopeLabelSchema = z - .object({ id: z.string().nullish(), display_name: z.string().nullish() }) - .nullish(); - -const ClaudeLimitSchema = z.object({ - kind: z.string(), - percent: ApiNumberSchema.nullish(), - resets_at: z.string().nullish(), - scope: z.object({ model: ClaudeScopeLabelSchema, surface: ClaudeScopeLabelSchema }).nullish(), -}); - -const ClaudeUsageResponseSchema = z.object({ - five_hour: ClaudeUsageWindowSchema.nullish(), - seven_day: ClaudeUsageWindowSchema.nullish(), - seven_day_opus: ClaudeUsageWindowSchema.nullish(), - seven_day_omelette: ClaudeUsageWindowSchema.nullish(), - // Deliberately permissive: an additive section must never regress the top-level - // windows, so shape validation happens per entry rather than here. - limits: z.array(z.unknown()).nullish(), - extra_usage: z - .object({ - is_enabled: z.boolean().optional(), - }) - .nullish(), -}); - -type ClaudeCredentials = z.infer; -type ClaudeUsageResponse = z.infer; -type ClaudeLimit = z.infer; - -const SCOPED_WEEKLY_KIND = "weekly_scoped"; - -interface ClaudeCredentialRecord { - oauth: { accessToken: string } & NonNullable; -} - -interface ClaudeQuotaProviderOptions { - logger: Logger; - claudeHome?: string; - claudeKeychainReader?: () => Promise; - platform?: typeof process.platform; - fetch?: ProviderApiFetch; -} - -function buildClaudePlan( - subscriptionType: string | undefined, - rateLimitTier: string | undefined, -): string | null { - if (!subscriptionType) return null; - const label = subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1); - const tier = rateLimitTier?.split("_").pop(); - return tier ? `${label} ${tier}` : label; -} - -/** - * A weekly limit scoped to one model or one surface, normalized away from whichever - * shape of the response described it. - * - * The API describes the same limit two ways during the migration: a legacy top-level - * `seven_day_` key, and an entry in `limits[]`. Everything downstream works on - * this one representation so the two shapes are reconciled exactly once, in - * `reconcileScopedLimits`, rather than at each place a window is built. - */ -interface ScopedLimit { - dimension: "model" | "surface"; - /** The API's own identifier. Null on every response observed so far. */ - id: string | null; - /** Display name, or the id when the API sends no display name. */ - name: string; - usedPct: number | null; - resetsAt: string | null; -} - -// Windows that describe no particular model or surface. -const UNSCOPED_WINDOWS: ReadonlyArray<{ - field: "five_hour" | "seven_day"; - id: string; - label: string; -}> = [ - { field: "five_hour", id: "five_hour", label: "Session" }, - { field: "seven_day", id: "weekly", label: "Weekly" }, -]; - -// Scoped windows from before `limits[]` existed. Declaring the dimension here is what -// stops a *surface* named "Omelette" from being mistaken for the legacy Omelette *model* -// window: these keys are model-scoped by definition. -const LEGACY_SCOPED_WINDOWS: ReadonlyArray<{ - field: "seven_day_opus" | "seven_day_omelette"; - name: string; -}> = [ - { field: "seven_day_opus", name: "Opus" }, - { field: "seven_day_omelette", name: "Omelette" }, -]; - -/** Fold a name down to the characters an id is allowed to carry. */ -function normalizeName(name: string): string { - return name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, ""); -} - -/** - * Whether two descriptions denote the same limit. This is the single definition of - * identity for scoped limits; nothing else may compare them, and in particular nothing - * may compare display labels, which are presentation rather than identity. - * - * - Different dimensions are never the same limit, so a surface and a model sharing a - * name stay apart. - * - When both sides carry the API's own id, that id decides, so `fable-pro` and - * `fable_pro` stay apart. - * - Otherwise fall back to the normalized name, which is the only link available between - * a legacy key (never has an id) and its `limits[]` counterpart. - */ -function isSameLimit(a: ScopedLimit, b: ScopedLimit): boolean { - if (a.dimension !== b.dimension) return false; - if (a.id && b.id) return a.id === b.id; - return normalizeName(a.name) === normalizeName(b.name); -} - -/** - * Merge the legacy and `limits[]` descriptions into one limit per identity. - * - * A `limits[]` entry wins on identity because that is the representation the API is - * migrating towards, so a limit keeps the same window id whichever shape carried it. - * Its values are nullable though, so each field falls back to the legacy twin instead of - * discarding a number the response did contain. - */ -function reconcileScopedLimits( - legacy: ScopedLimit[], - fromLimitsArray: ScopedLimit[], -): ScopedLimit[] { - const reconciled = [...legacy]; - for (const limit of fromLimitsArray) { - const index = reconciled.findIndex((candidate) => isSameLimit(candidate, limit)); - if (index === -1) { - reconciled.push(limit); - continue; - } - const twin = reconciled[index]; - reconciled[index] = { - ...limit, - usedPct: limit.usedPct ?? twin?.usedPct ?? null, - resetsAt: limit.resetsAt ?? twin?.resetsAt ?? null, - }; - } - return reconciled; -} - -function scopedLimitFromLegacy( - spec: (typeof LEGACY_SCOPED_WINDOWS)[number], - window: z.infer, -): ScopedLimit { - return { - dimension: "model", - id: null, - name: spec.name, - usedPct: window.utilization, - resetsAt: window.resets_at ?? null, - }; -} - -/** The scope of a `limits[]` entry, or null when it names nothing renderable. */ -function scopedLimitFromEntry(limit: ClaudeLimit): ScopedLimit | null { - for (const dimension of ["model", "surface"] as const) { - const entry = limit.scope?.[dimension]; - const id = entry?.id?.trim() || null; - const name = entry?.display_name?.trim() || id; - if (name) { - return { - dimension, - id, - name, - usedPct: limit.percent ?? null, - resetsAt: limit.resets_at ?? null, - }; - } - } - return null; -} - -// The client uses window ids as React keys, so they must be stable across refreshes and -// unique within a response. An API-supplied id is already an identifier and is used -// verbatim (ids elsewhere carry punctuation too, e.g. MiniMax's `interval_MiniMax-M2.7`); -// only a name fallback is normalized. Normalizing an id would collapse `fable-pro` and -// `fable_pro` into one window. -function scopedWindowId(limit: ScopedLimit): string { - return `weekly_${limit.dimension}_${limit.id ?? normalizeName(limit.name)}`; -} - -// Backstop for the one residual case identity cannot rule out: an entry whose verbatim id -// equals another entry's normalized name. Suffix rather than drop, because a missing bar -// is the bug this change exists to fix. -function uniqueWindowId(candidate: string, taken: Set): string { - if (!taken.has(candidate)) return candidate; - for (let suffix = 2; ; suffix += 1) { - const next = `${candidate}_${suffix}`; - if (!taken.has(next)) return next; - } -} - -function legacyScopedLimits(resp: ClaudeUsageResponse): ScopedLimit[] { - const limits: ScopedLimit[] = []; - for (const spec of LEGACY_SCOPED_WINDOWS) { - const window = resp[spec.field]; - if (window) limits.push(scopedLimitFromLegacy(spec, window)); - } - return limits; -} - -function unscopedWindows(resp: ClaudeUsageResponse): ProviderUsageWindow[] { - const windows: ProviderUsageWindow[] = []; - for (const spec of UNSCOPED_WINDOWS) { - const window = resp[spec.field]; - if (!window) continue; - windows.push( - windowFromUsedPct({ - id: spec.id, - label: spec.label, - utilizationPct: window.utilization, - resetsAt: window.resets_at ?? null, - tone: toneFromUsedPct(window.utilization), - }), - ); - } - return windows; -} - -function scopedWindows(limits: ScopedLimit[]): ProviderUsageWindow[] { - const taken = new Set(); - return limits.map((limit) => { - const id = uniqueWindowId(scopedWindowId(limit), taken); - taken.add(id); - // Emitted even at 0% and inactive: a zero bar answers "how much of this model have I - // used", and the bar must not come and go between refreshes. - return windowFromUsedPct({ - id, - label: `Weekly \u00b7 ${limit.name}`, - utilizationPct: limit.usedPct, - resetsAt: limit.resetsAt, - tone: toneFromUsedPct(limit.usedPct), - }); - }); -} - -type ClaudeKeychainCommandRunner = (args: string[]) => Promise; - -// Keep this in sync with Claude Code's Keychain account derivation. -const CLAUDE_KEYCHAIN_ACCOUNT_PATTERN = /^[a-zA-Z0-9._-]+$/; -const CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT = "claude-code-user"; - -export function claudeKeychainAccount( - user: string = process.env["USER"] || userInfo().username, -): string { - return CLAUDE_KEYCHAIN_ACCOUNT_PATTERN.test(user) ? user : CLAUDE_KEYCHAIN_FALLBACK_ACCOUNT; -} - -async function runSecurityCommand(args: string[]): Promise { - try { - const { stdout } = await execFileAsync("security", args, { - timeout: CLAUDE_KEYCHAIN_TIMEOUT_MS, - }); - return stdout.trim() || null; - } catch { - return null; - } -} - -/** Read Claude Code's account-specific Keychain item, then try the legacy lookup. */ -export async function readClaudeKeychainCredentials( - run: ClaudeKeychainCommandRunner = runSecurityCommand, - account: string = claudeKeychainAccount(), -): Promise { - const lookups = [ - ["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], - ["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE], - ]; - - for (const args of lookups) { - const raw = await run(args); - if (!raw) continue; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - continue; - } - const creds = ClaudeCredentialsSchema.safeParse(parsed); - if (creds.success && creds.data.claudeAiOauth?.accessToken) return parsed; - } - return null; -} - -export class ClaudeQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "claude"; - readonly displayName = "Claude"; - - private readonly logger: Logger; - private readonly claudeHome: string; - private readonly readKeychainCredentials: () => Promise; - private readonly platform: typeof process.platform; - private readonly fetchApi: ProviderApiFetch; - - constructor(options: ClaudeQuotaProviderOptions) { - this.logger = options.logger.child({ module: "claude-quota-provider" }); - this.claudeHome = - options.claudeHome || process.env["CLAUDE_HOME"] || join(homedir(), ".claude"); - this.readKeychainCredentials = options.claudeKeychainReader ?? readClaudeKeychainCredentials; - this.platform = options.platform ?? process.platform; - this.fetchApi = options.fetch ?? fetch; - } - - async fetchUsage(): Promise { - const credentials = await this.readCredentials(); - if (!credentials) { - return unavailableUsage(this); - } - - const { oauth } = credentials; - const plan = buildClaudePlan(oauth.subscriptionType, oauth.rateLimitTier); - const resp = await this.callClaudeApi(oauth.accessToken); - - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Claude CLI owns refresh. See docs/providers.md. - return unavailableUsage(this); - } - - const scoped = reconcileScopedLimits( - legacyScopedLimits(resp), - this.scopedLimitsFromResponse(resp.limits), - ); - const windows = [...unscopedWindows(resp), ...scopedWindows(scoped)]; - - if (windows.length === 0) { - // The response parsed but described nothing. That silence is how the previous - // shape change went unnoticed, so make it greppable. `warn` and not `debug` - // because file logging defaults to `info`. - this.logger.warn("Claude usage response parsed but produced no windows"); - } - - const details: ProviderUsageDetail[] = []; - const extraUsageEnabled = resp.extra_usage?.is_enabled; - if (extraUsageEnabled !== undefined) { - details.push({ - id: "extra_usage", - label: "Extra usage", - value: extraUsageEnabled ? "Enabled" : "Disabled", - }); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: plan, - windows, - balances: [], - details, - error: null, - }; - } - - /** - * Scoped limits carried by `limits[]`. - * - * Entries are validated one at a time so a single malformed or newly-shaped entry - * cannot fail the whole response and take the windows that already parsed with it. - */ - private scopedLimitsFromResponse(limits: ClaudeUsageResponse["limits"]): ScopedLimit[] { - if (!limits) return []; - - const parsed: ScopedLimit[] = []; - for (const entry of limits) { - const result = ClaudeLimitSchema.safeParse(entry); - if (!result.success) { - this.logger.warn({ err: result.error }, "Skipping unparseable Claude usage limit entry"); - continue; - } - if (result.data.kind !== SCOPED_WEEKLY_KIND) continue; - - const limit = scopedLimitFromEntry(result.data); - if (!limit) { - this.logger.warn("Skipping scoped Claude usage limit with no resolvable scope name"); - continue; - } - parsed.push(limit); - } - return parsed; - } - - private async readCredentials(): Promise { - const credPath = join(this.claudeHome, ".credentials.json"); - const fileCredentials = await this.readCredentialFile(credPath); - return ( - fileCredentials ?? (this.platform === "darwin" ? await this.readKeychainCredential() : null) - ); - } - - private async readCredentialFile(path: string): Promise { - if (!existsSync(path)) return null; - try { - return this.toCredentialRecord( - ClaudeCredentialsSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))), - ); - } catch { - return null; - } - } - - private async readKeychainCredential(): Promise { - const parsed = ClaudeCredentialsSchema.safeParse(await this.readKeychainCredentials()); - return parsed.success ? this.toCredentialRecord(parsed.data) : null; - } - - private toCredentialRecord( - credentials: z.infer, - ): ClaudeCredentialRecord | null { - const oauth = credentials.claudeAiOauth; - return oauth?.accessToken ? { oauth: { ...oauth, accessToken: oauth.accessToken } } : null; - } - - private async callClaudeApi(token: string): Promise { - const res = await fetchProviderApi(this.fetchApi, "https://api.anthropic.com/api/oauth/usage", { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - "anthropic-beta": CLAUDE_OAUTH_BETA, - }, - }); - if (res.status === 401 || res.status === 403) return "NEEDS_AUTH"; - if (!res.ok) throw new Error(`Claude usage API returned ${res.status}`); - return ClaudeUsageResponseSchema.parse(await res.json()); - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/codex.ts b/packages/server/src/services/quota-fetcher/providers/codex.ts deleted file mode 100644 index 78a1f7d26b..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/codex.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { existsSync, promises as fs } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageWindow, -} from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { - ApiNumberSchema, - balanceToneFromRemaining, - toneFromUsedPct, - fetchProviderApi, - unavailableUsage, - windowFromUsedPct, -} from "../usage.js"; - -const CodexAuthSchema = z.object({ - tokens: z - .object({ - access_token: z.string().optional(), - refresh_token: z.string().optional(), - account_id: z.string().optional(), - }) - .optional(), -}); - -const CodexWindowSchema = z.object({ - used_percent: ApiNumberSchema.optional(), - reset_at: ApiNumberSchema.optional(), -}); - -const CodexUsageResponseSchema = z.object({ - plan_type: z.string().optional(), - email: z.string().optional(), - rate_limit: z - .object({ - primary_window: CodexWindowSchema.nullish(), - secondary_window: CodexWindowSchema.nullish(), - }) - .nullish(), - code_review_rate_limit: z - .object({ - primary_window: CodexWindowSchema.nullish(), - }) - .nullish(), - credits: z - .object({ - has_credits: z.boolean().optional(), - unlimited: z.boolean().optional(), - balance: ApiNumberSchema.optional(), - }) - .nullish(), -}); - -type CodexAuth = z.infer; -type CodexWindow = z.infer; -type CodexUsageResponse = z.infer; - -interface CodexQuotaProviderOptions { - logger: Logger; - codexHome?: string; - fetch?: ProviderApiFetch; -} - -function codexWindow( - window: CodexWindow | null | undefined, -): { usedPct: number; resetsAt: string | null } | null { - if (!window) return null; - return { - usedPct: window.used_percent ?? 0, - resetsAt: window.reset_at != null ? new Date(window.reset_at * 1000).toISOString() : null, - }; -} - -export class CodexQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "codex"; - readonly displayName = "Codex"; - - private readonly codexHome: string; - private readonly fetchApi: ProviderApiFetch; - - constructor(options: CodexQuotaProviderOptions) { - this.codexHome = options.codexHome || process.env["CODEX_HOME"] || join(homedir(), ".codex"); - this.fetchApi = options.fetch ?? fetch; - } - - async fetchUsage(): Promise { - const auth = await this.readCodexAuth(); - const accessToken = auth?.tokens?.access_token; - if (!auth || !accessToken) { - return unavailableUsage(this); - } - - const { account_id } = auth.tokens ?? {}; - const resp = await this.callCodexApi(accessToken, account_id); - - if (resp === "NEEDS_AUTH") { - // Read-only on credentials; the Codex CLI owns refresh. See docs/providers.md. - return unavailableUsage(this); - } - - return this.toUsage(resp); - } - - private toUsage(resp: CodexUsageResponse): ProviderUsage { - const session = codexWindow(resp.rate_limit?.primary_window); - const weekly = codexWindow(resp.rate_limit?.secondary_window); - const codeReview = codexWindow(resp.code_review_rate_limit?.primary_window); - const windows: ProviderUsageWindow[] = []; - - if (session) { - windows.push( - windowFromUsedPct({ - id: "session", - label: "Session", - utilizationPct: session.usedPct, - resetsAt: session.resetsAt, - tone: toneFromUsedPct(session.usedPct), - }), - ); - } - if (weekly) { - windows.push( - windowFromUsedPct({ - id: "weekly", - label: "Weekly", - utilizationPct: weekly.usedPct, - resetsAt: weekly.resetsAt, - tone: toneFromUsedPct(weekly.usedPct), - }), - ); - } - if (codeReview) { - windows.push( - windowFromUsedPct({ - id: "code_review", - label: "Code review", - utilizationPct: codeReview.usedPct, - resetsAt: codeReview.resetsAt, - tone: toneFromUsedPct(codeReview.usedPct), - }), - ); - } - - const balances: ProviderUsageBalance[] = []; - if (resp.credits?.balance !== undefined) { - balances.push({ - id: "credits", - label: "Credits", - remaining: resp.credits.balance, - unit: "usd", - tone: balanceToneFromRemaining(resp.credits.balance), - }); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: resp.plan_type ?? null, - windows, - balances, - details: [], - error: null, - }; - } - - private async readCodexAuth(): Promise { - const candidates = [ - ...(process.env["CODEX_HOME"] ? [join(process.env["CODEX_HOME"], "auth.json")] : []), - join(homedir(), ".config", "codex", "auth.json"), - join(this.codexHome, "auth.json"), - ]; - for (const path of candidates) { - if (!existsSync(path)) continue; - try { - const auth = CodexAuthSchema.parse(JSON.parse(await fs.readFile(path, "utf8"))); - if (auth.tokens?.access_token) return auth; - } catch { - continue; - } - } - return null; - } - - private async callCodexApi( - token: string, - accountId?: string, - ): Promise { - const headers: Record = { - Authorization: `Bearer ${token}`, - Accept: "application/json", - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", - }; - if (accountId) headers["ChatGPT-Account-Id"] = accountId; - - const res = await fetchProviderApi( - this.fetchApi, - "https://chatgpt.com/backend-api/wham/usage", - { - headers, - }, - ); - if (res.status === 401 || res.status === 403) return "NEEDS_AUTH"; - if (!res.ok) throw new Error(`Codex usage API returned ${res.status}`); - const text = await res.text(); - if (text.trim().startsWith("<")) return "NEEDS_AUTH"; - return CodexUsageResponseSchema.parse(JSON.parse(text)); - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/copilot.ts b/packages/server/src/services/quota-fetcher/providers/copilot.ts deleted file mode 100644 index f8b4f3183e..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/copilot.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { existsSync, promises as fs } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; - -const CopilotUsageResponseSchema = z.object({ - copilot_plan: ApiOptionalStringSchema, - quota_reset_date: ApiOptionalStringSchema, -}); - -interface CopilotQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; -} - -async function readGithubCliToken(): Promise { - const candidates: string[] = []; - if (process.env["APPDATA"]) { - candidates.push(join(process.env["APPDATA"], "GitHub CLI", "hosts.yml")); - } - candidates.push(join(homedir(), ".config", "gh", "hosts.yml")); - - for (const path of candidates) { - if (!existsSync(path)) continue; - try { - const raw = await fs.readFile(path, "utf8"); - const match = raw.match(/oauth_token:\s*["']?([a-zA-Z0-9_-]+)["']?/); - if (match?.[1]) return match[1]; - } catch { - continue; - } - } - return null; -} - -export class CopilotQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "copilot"; - readonly displayName = "GitHub Copilot"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - - constructor(options: CopilotQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - } - - async fetchUsage(): Promise { - const token = - process.env["COPILOT_TOKEN"] || - process.env["GITHUB_TOKEN"] || - process.env["GITHUB_PAT"] || - (await readGithubCliToken()); - - if (!token) return unavailableUsage(this); - - const res = await fetchProviderApi( - this.fetchApi, - "https://api.github.com/copilot_internal/user", - { - headers: { - Authorization: `token ${token}`, - Accept: "application/json", - "Editor-Version": "vscode/1.96.2", - "Editor-Plugin-Version": "copilot-chat/0.26.7", - "User-Agent": "GitHubCopilotChat/0.26.7", - "X-Github-Api-Version": "2025-04-01", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Copilot usage fetch failed"); - return unavailableUsage(this); - } - - const resp = CopilotUsageResponseSchema.parse(await res.json()); - const details: ProviderUsageDetail[] = resp.quota_reset_date - ? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }] - : []; - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: resp.copilot_plan || null, - windows: [], - balances: [], - details, - error: null, - }; - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/cursor.ts b/packages/server/src/services/quota-fetcher/providers/cursor.ts deleted file mode 100644 index fb1cf94939..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/cursor.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { ProviderUsage, ProviderUsageBalance } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { - ApiNullableNumberSchema, - toneFromUsedPct, - usedPctOf, - fetchProviderApi, - toIsoStringOrNull, - unavailableUsage, -} from "../usage.js"; - -// Cursor desktop stores auth in VS Code's ItemTable (state.vscdb). Modern builds keep -// the access token as a plain JWT string under `cursorAuth/accessToken`; older builds -// kept a JSON blob under `cursorAuthStatus`. Read it with node:sqlite so we don't -// depend on a `sqlite3` CLI, which isn't installed by default on Windows (or on many -// Linux hosts) — a missing binary silently rendered Cursor usage unavailable. -// Headless hosts (VPS, cursor-agent only) have no desktop db; their session lives in -// ~/.config/cursor/auth.json instead. -const CURSOR_ACCESS_TOKEN_KEY = "cursorAuth/accessToken"; -const CURSOR_LEGACY_AUTH_KEY = "cursorAuthStatus"; - -// @types/node@20 predates the node:sqlite typings; declare the slice we use. -interface CursorStateStatement { - get(...params: unknown[]): Record | undefined; -} -interface CursorStateDatabase { - prepare(sql: string): CursorStateStatement; - close(): void; -} -interface NodeSqliteModule { - DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => CursorStateDatabase; -} - -const CursorBillingCycleTimestampSchema = z.preprocess( - (value) => (typeof value === "string" || typeof value === "number" ? value : null), - z.union([z.string(), z.number()]).nullable(), -); - -const CursorUsageResponseSchema = z.object({ - planUsage: z - .object({ - totalSpend: ApiNullableNumberSchema, - includedSpend: ApiNullableNumberSchema, - bonusSpend: ApiNullableNumberSchema, - remaining: ApiNullableNumberSchema, - limit: ApiNullableNumberSchema, - }) - .nullish(), - billingCycleStart: CursorBillingCycleTimestampSchema, - billingCycleEnd: CursorBillingCycleTimestampSchema, -}); - -const CursorAuthStatusSchema = z.object({ - accessToken: z.string().optional(), -}); - -type CursorUsageResponse = z.infer; - -interface CursorQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - homeDir?: string; -} - -function parseCursorBillingCycleTimestamp( - value: CursorUsageResponse["billingCycleStart"], -): string | null { - if (value === null) return null; - - const raw = String(value).trim(); - if (!raw) return null; - - const numeric = Number(raw); - if (Number.isFinite(numeric)) { - const timestampMs = Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric; - return toIsoStringOrNull(timestampMs); - } - - return toIsoStringOrNull(new Date(raw).getTime()); -} - -function centsToDollars(value: number | null): number | null { - return value === null ? null : value / 100; -} - -function readItemTableValue(db: CursorStateDatabase, key: string): string | null { - const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key); - const value = row?.["value"]; - if (typeof value === "string") return value; - if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8"); - return null; -} - -function cursorTokenFromDb(db: CursorStateDatabase): string | null { - const modern = readItemTableValue(db, CURSOR_ACCESS_TOKEN_KEY)?.trim(); - if (modern) return modern; - - const legacy = readItemTableValue(db, CURSOR_LEGACY_AUTH_KEY); - if (legacy) { - try { - const parsed = CursorAuthStatusSchema.parse(JSON.parse(legacy)); - if (parsed.accessToken) return parsed.accessToken; - } catch { - // ignore a malformed legacy blob - } - } - return null; -} - -async function readCursorTokenFromSqlite(homeDir: string, logger: Logger): Promise { - const dbPaths: string[] = []; - if (process.env["APPDATA"]) { - dbPaths.push(join(process.env["APPDATA"], "Cursor", "User", "globalStorage", "state.vscdb")); - } - dbPaths.push( - join( - homeDir, - "Library", - "Application Support", - "Cursor", - "User", - "globalStorage", - "state.vscdb", - ), - ); - dbPaths.push(join(homeDir, ".config", "Cursor", "User", "globalStorage", "state.vscdb")); - - // Held in a variable so TypeScript skips module resolution: @types/node@20 has no - // node:sqlite typings yet, while the runtime (Node 22+ / Electron) provides it. - const sqliteSpecifier: string = "node:sqlite"; - let sqlite: NodeSqliteModule; - try { - sqlite = (await import(sqliteSpecifier)) as unknown as NodeSqliteModule; - } catch (err) { - logger.debug({ err }, "node:sqlite unavailable; cannot read Cursor state.vscdb"); - return null; // runtime without node:sqlite - } - - for (const path of dbPaths) { - if (!existsSync(path)) continue; - let db: CursorStateDatabase | undefined; - try { - db = new sqlite.DatabaseSync(path, { readOnly: true }); - const token = cursorTokenFromDb(db); - if (token) return token; - } catch (err) { - // Locked/permission/corrupt/schema failures all land here; log so an - // unavailable Cursor card is diagnosable, then try the next candidate. - logger.debug({ err, path }, "Failed to read Cursor token from state.vscdb"); - } finally { - db?.close(); - } - } - return null; -} - -async function readCursorTokenFromAuthJson( - homeDir: string, - logger: Logger, -): Promise { - const path = join(homeDir, ".config", "cursor", "auth.json"); - if (!existsSync(path)) return null; - try { - const parsed = CursorAuthStatusSchema.parse(JSON.parse(await readFile(path, "utf8"))); - return parsed.accessToken?.trim() || null; - } catch (err) { - logger.debug({ err, path }, "Failed to read Cursor token from auth.json"); - return null; - } -} - -export class CursorQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "cursor"; - readonly displayName = "Cursor"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly homeDir: string; - - constructor(options: CursorQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir ?? homedir(); - } - - async fetchUsage(): Promise { - const token = - process.env["CURSOR_ACCESS_TOKEN"] || - process.env["CURSOR_TOKEN"] || - (await readCursorTokenFromSqlite(this.homeDir, this.logger)) || - (await readCursorTokenFromAuthJson(this.homeDir, this.logger)); - - if (!token) return unavailableUsage(this); - - const res = await fetchProviderApi( - this.fetchApi, - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "Connect-Protocol-Version": "1", - }, - body: JSON.stringify({}), - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Cursor usage fetch failed"); - return unavailableUsage(this); - } - - const resp = CursorUsageResponseSchema.parse(await res.json()); - const billingCycleEnd = parseCursorBillingCycleTimestamp(resp.billingCycleEnd); - const balances: ProviderUsageBalance[] = []; - if (resp.planUsage) { - const totalSpend = centsToDollars(resp.planUsage.totalSpend); - const remaining = centsToDollars(resp.planUsage.remaining); - const limit = centsToDollars(resp.planUsage.limit); - balances.push({ - id: "plan_usage", - label: "Plan usage", - used: totalSpend, - remaining, - limit, - unit: "usd", - resetsAt: billingCycleEnd, - tone: toneFromUsedPct(usedPctOf(totalSpend, limit)), - }); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: null, - windows: [], - balances, - details: [], - error: null, - }; - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/grok.ts b/packages/server/src/services/quota-fetcher/providers/grok.ts deleted file mode 100644 index 83e7fde837..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/grok.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { existsSync, promises as fs } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageWindow, -} from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { - ApiNumberSchema, - ApiOptionalStringSchema, - toneFromUsedPct, - usedPctOf, - fetchProviderApi, - unavailableUsage, - windowFromUsedPct, -} from "../usage.js"; - -const GrokUsageResponseSchema = z.object({ - config: z - .object({ - monthlyLimit: z - .object({ - val: ApiNumberSchema.optional(), - }) - .nullish(), - used: z - .object({ - val: ApiNumberSchema.optional(), - }) - .nullish(), - creditUsagePercent: ApiNumberSchema.optional(), - currentPeriod: z - .object({ - type: ApiOptionalStringSchema, - end: ApiOptionalStringSchema, - }) - .nullish(), - }) - .nullish(), - usage: z - .object({ - creditUsage: ApiNumberSchema.optional(), - }) - .nullish(), -}); - -interface GrokQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - /** Override home directory (tests). Production uses os.homedir(). */ - homeDir?: string; -} - -/** Resolve a Grok CLI token from ~/.grok/auth.json (legacy or current nested shape). */ -export function extractGrokTokenFromAuth(auth: unknown): string | null { - if (auth == null || typeof auth !== "object" || Array.isArray(auth)) return null; - const record = auth as Record; - - const topLevel = record["access_token"]; - if (typeof topLevel === "string" && topLevel.length > 0) { - return topLevel; - } - - const entries = Object.entries(record); - const preferred = entries.filter(([key]) => key.startsWith("https://auth.x.ai::")); - const candidates = preferred.length > 0 ? preferred : entries; - - for (const [, value] of candidates) { - if (value == null || typeof value !== "object" || Array.isArray(value)) continue; - const nestedKey = (value as Record)["key"]; - if (typeof nestedKey === "string" && nestedKey.length > 0) { - return nestedKey; - } - } - - return null; -} - -function grokMonthlyCreditBalance( - response: z.infer, -): ProviderUsageBalance | null { - const limit = response.config?.monthlyLimit?.val ?? null; - const used = response.config?.used?.val ?? response.usage?.creditUsage ?? null; - if (limit === null && used === null) return null; - return { - id: "monthly_credits", - label: "Monthly credits", - used, - remaining: limit !== null && used !== null ? Math.max(0, limit - used) : null, - limit, - unit: "credits", - tone: toneFromUsedPct(usedPctOf(used, limit)), - }; -} - -function grokUsageWindow( - response: z.infer, -): ProviderUsageWindow | null { - const percent = response.config?.creditUsagePercent; - if (typeof percent !== "number") return null; - const period = response.config?.currentPeriod; - const weekly = (period?.type ?? "").toUpperCase().includes("WEEKLY"); - return windowFromUsedPct({ - id: weekly ? "weekly" : "monthly", - label: weekly ? "Weekly" : "Monthly", - utilizationPct: percent, - resetsAt: period?.end ?? null, - tone: toneFromUsedPct(percent), - }); -} - -export class GrokQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "grok"; - readonly displayName = "Grok"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly homeDir: string | undefined; - - constructor(options: GrokQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.homeDir = options.homeDir; - } - - async fetchUsage(): Promise { - const token = - process.env["GROK_API_KEY"] || process.env["GROK_TOKEN"] || (await this.readGrokToken()); - - if (!token) return unavailableUsage(this); - - // The Grok CLI's /usage uses ?format=credits; without it, unified-billing accounts - // get a zeroed legacy monthly shape (monthlyLimit.val 0) instead of real usage. - const res = await fetchProviderApi( - this.fetchApi, - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - { - headers: { - Authorization: `Bearer ${token}`, - "X-XAI-Token-Auth": "xai-grok-cli", - Accept: "application/json", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Grok usage fetch failed"); - return unavailableUsage(this); - } - - const resp = GrokUsageResponseSchema.parse(await res.json()); - const balance = grokMonthlyCreditBalance(resp); - const window = grokUsageWindow(resp); - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: null, - windows: window ? [window] : [], - balances: balance ? [balance] : [], - details: [], - error: null, - }; - } - - private async readGrokToken(): Promise { - // homeDir override is for tests: Windows os.homedir() ignores $HOME (uses USERPROFILE). - const path = join(this.homeDir ?? homedir(), ".grok", "auth.json"); - if (!existsSync(path)) return null; - try { - return extractGrokTokenFromAuth(JSON.parse(await fs.readFile(path, "utf8"))); - } catch { - return null; - } - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/minimax.ts b/packages/server/src/services/quota-fetcher/providers/minimax.ts deleted file mode 100644 index 21e0f05810..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/minimax.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { existsSync, promises as fs } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Logger } from "pino"; -import { z } from "zod"; -import type { ProviderUsage, ProviderUsageWindow } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { - ApiNumberSchema, - ApiOptionalStringSchema, - fetchProviderApi, - unavailableUsage, - windowFromUsedPct, -} from "../usage.js"; - -const MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io"; -const MINIMAX_CN_BASE_URL = "https://api.minimaxi.com"; -const MINIMAX_CREDENTIALS_PATH = join(homedir(), ".mmx", "credentials.json"); -const MINIMAX_CONFIG_PATH = join(homedir(), ".mmx", "config.json"); - -const MiniMaxModelRemainSchema = z.object({ - model_name: ApiOptionalStringSchema, - start_time: ApiNumberSchema.optional(), - end_time: ApiNumberSchema.optional(), - remains_time: ApiNumberSchema.optional(), - current_interval_total_count: ApiNumberSchema.optional(), - current_interval_usage_count: ApiNumberSchema.optional(), - current_interval_remaining_percent: ApiNumberSchema.optional(), - current_weekly_total_count: ApiNumberSchema.optional(), - current_weekly_usage_count: ApiNumberSchema.optional(), - current_weekly_remaining_percent: ApiNumberSchema.optional(), - current_interval_status: ApiNumberSchema.optional(), - current_weekly_status: ApiNumberSchema.optional(), - weekly_start_time: ApiNumberSchema.optional(), - weekly_end_time: ApiNumberSchema.optional(), - weekly_remains_time: ApiNumberSchema.optional(), - weekly_boost_permille: ApiNumberSchema.optional(), -}); - -const MiniMaxQuotaResponseSchema = z.object({ - model_remains: z.array(MiniMaxModelRemainSchema).optional(), -}); - -const MiniMaxCredentialsSchema = z.object({ - access_token: z.string().optional(), - refresh_token: z.string().optional(), - expires_at: ApiOptionalStringSchema, - resource_url: ApiOptionalStringSchema, -}); - -const MiniMaxConfigSchema = z.object({ - api_key: z.string().optional(), - region: z.string().optional(), - base_url: ApiOptionalStringSchema, - oauth: MiniMaxCredentialsSchema.optional(), -}); - -type MiniMaxModelRemain = z.infer; - -interface MiniMaxResolvedAuth { - token: string; - baseUrl: string; -} - -interface MiniMaxQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; - configPath?: string; - credentialsPath?: string; - env?: NodeJS.ProcessEnv; - now?: () => number; -} - -function resolveBaseUrl(input: { baseUrl?: string; region?: string }): string { - const explicit = input.baseUrl; - if (explicit && explicit.startsWith("http")) return explicit; - if (input.region === "cn") return MINIMAX_CN_BASE_URL; - return MINIMAX_GLOBAL_BASE_URL; -} - -function computeUsedPct( - remaining: number | null | undefined, - total: number | null | undefined, -): number | null { - if (typeof remaining !== "number" || typeof total !== "number") return null; - if (!Number.isFinite(total) || total <= 0) return null; - if (!Number.isFinite(remaining)) return null; - const used = total - remaining; - return Math.max(0, Math.min(100, (used / total) * 100)); -} - -function epochMsToIso(value: number | null | undefined): string | null { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; - return new Date(value).toISOString(); -} - -function toneForStatus(status: number | null | undefined): ProviderUsageWindow["tone"] { - if (status === 2) return "danger"; - if (status === 3) return "default"; - return "ok"; -} - -function toIntervalWindow( - modelName: string, - model: MiniMaxModelRemain, -): ProviderUsageWindow | null { - const total = model.current_interval_total_count ?? null; - const used = model.current_interval_usage_count ?? null; - const remainingPercent = model.current_interval_remaining_percent ?? null; - const usedPct = - typeof remainingPercent === "number" && Number.isFinite(remainingPercent) - ? Math.max(0, Math.min(100, 100 - remainingPercent)) - : computeUsedPct( - typeof total === "number" && typeof used === "number" ? total - used : null, - total, - ); - if (usedPct === null) return null; - return windowFromUsedPct({ - id: `interval_${modelName}`, - label: `${modelName} · Interval`, - utilizationPct: usedPct, - resetsAt: epochMsToIso(model.end_time), - tone: toneForStatus(model.current_interval_status), - }); -} - -function toWeeklyWindow(modelName: string, model: MiniMaxModelRemain): ProviderUsageWindow | null { - const total = model.current_weekly_total_count ?? null; - const used = model.current_weekly_usage_count ?? null; - const remainingPercent = model.current_weekly_remaining_percent ?? null; - let usedPct: number | null = null; - if (typeof remainingPercent === "number" && Number.isFinite(remainingPercent)) { - usedPct = Math.max(0, Math.min(100, 100 - remainingPercent)); - } else if (typeof total === "number" && typeof used === "number") { - usedPct = computeUsedPct(total - used, total); - } - if (usedPct === null) return null; - return windowFromUsedPct({ - id: `weekly_${modelName}`, - label: `${modelName} · Weekly`, - utilizationPct: usedPct, - resetsAt: epochMsToIso(model.weekly_end_time), - tone: toneForStatus(model.current_weekly_status), - }); -} - -export class MiniMaxQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "minimax"; - readonly displayName = "MiniMax"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - private readonly configPath: string; - private readonly credentialsPath: string; - private readonly env: NodeJS.ProcessEnv; - private readonly now: () => number; - - constructor(options: MiniMaxQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - this.configPath = options.configPath ?? MINIMAX_CONFIG_PATH; - this.credentialsPath = options.credentialsPath ?? MINIMAX_CREDENTIALS_PATH; - this.env = options.env ?? process.env; - this.now = options.now ?? Date.now; - } - - async fetchUsage(): Promise { - const auth = await this.resolveAuth(); - if (!auth) return unavailableUsage(this); - - const res = await fetchProviderApi(this.fetchApi, `${auth.baseUrl}/v1/token_plan/remains`, { - headers: { - Authorization: `Bearer ${auth.token}`, - Accept: "application/json", - }, - }); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "MiniMax usage fetch failed"); - return unavailableUsage(this); - } - - const resp = MiniMaxQuotaResponseSchema.parse(await res.json()); - const models = resp.model_remains ?? []; - - const windows: ProviderUsageWindow[] = []; - for (const model of models) { - const name = model.model_name ?? "token-plan"; - const intervalWindow = toIntervalWindow(name, model); - if (intervalWindow) windows.push(intervalWindow); - const weeklyWindow = toWeeklyWindow(name, model); - if (weeklyWindow) windows.push(weeklyWindow); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: windows.length > 0 ? "available" : "unavailable", - planLabel: null, - windows, - balances: [], - details: [], - error: null, - }; - } - - private async resolveAuth(): Promise { - const envToken = this.env["MINIMAX_API_KEY"]; - if (envToken) { - const envBase = this.env["MINIMAX_BASE_URL"]; - return { - token: envToken, - baseUrl: resolveBaseUrl({ baseUrl: envBase }), - }; - } - - const credentials = await this.readCredentials(); - if (credentials?.access_token && !this.isExpired(credentials.expires_at)) { - return { - token: credentials.access_token, - baseUrl: resolveBaseUrl({ baseUrl: credentials.resource_url }), - }; - } - - const config = await this.readConfig(); - if (config?.api_key) { - return { - token: config.api_key, - baseUrl: resolveBaseUrl({ - baseUrl: config.base_url, - region: config.region, - }), - }; - } - - if (config?.oauth?.access_token && !this.isExpired(config.oauth.expires_at)) { - return { - token: config.oauth.access_token, - baseUrl: resolveBaseUrl({ - baseUrl: config.oauth.resource_url ?? config.base_url, - region: config.region, - }), - }; - } - - return null; - } - - private isExpired(expiresAt: string | null | undefined): boolean { - if (!expiresAt) return false; - const parsed = Date.parse(expiresAt); - if (!Number.isFinite(parsed)) return false; - return parsed <= this.now(); - } - - private async readCredentials(): Promise | null> { - if (!existsSync(this.credentialsPath)) return null; - try { - const raw = JSON.parse(await fs.readFile(this.credentialsPath, "utf8")); - return MiniMaxCredentialsSchema.parse(raw); - } catch { - return null; - } - } - - private async readConfig(): Promise | null> { - if (!existsSync(this.configPath)) return null; - try { - const raw = JSON.parse(await fs.readFile(this.configPath, "utf8")); - return MiniMaxConfigSchema.parse(raw); - } catch { - return null; - } - } -} diff --git a/packages/server/src/services/quota-fetcher/providers/zai.ts b/packages/server/src/services/quota-fetcher/providers/zai.ts deleted file mode 100644 index e8884cf2c4..0000000000 --- a/packages/server/src/services/quota-fetcher/providers/zai.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Logger } from "pino"; -import { z } from "zod"; -import type { ProviderUsage, ProviderUsageDetail } from "../../../server/messages.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "../provider.js"; -import { ApiOptionalStringSchema, fetchProviderApi, unavailableUsage } from "../usage.js"; - -const ZaiUsageResponseSchema = z.object({ - data: z - .array( - z.object({ - productName: ApiOptionalStringSchema, - status: ApiOptionalStringSchema, - purchaseTime: ApiOptionalStringSchema, - valid: ApiOptionalStringSchema, - }), - ) - .optional(), -}); - -interface ZaiQuotaProviderOptions { - logger: Logger; - fetch?: ProviderApiFetch; -} - -export class ZaiQuotaProvider implements ProviderUsageFetcher { - readonly providerId = "zai"; - readonly displayName = "Z.ai"; - - private readonly logger: Logger; - private readonly fetchApi: ProviderApiFetch; - - constructor(options: ZaiQuotaProviderOptions) { - this.logger = options.logger; - this.fetchApi = options.fetch ?? fetch; - } - - async fetchUsage(): Promise { - const token = process.env["ZAI_API_KEY"] || process.env["GLM_API_KEY"]; - if (!token) return unavailableUsage(this); - - const res = await fetchProviderApi( - this.fetchApi, - "https://api.z.ai/api/biz/subscription/list", - { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/json", - }, - }, - ); - - if (!res.ok) { - this.logger.debug({ status: res.status }, "Z.ai usage fetch failed"); - return unavailableUsage(this); - } - - const resp = ZaiUsageResponseSchema.parse(await res.json()); - const sub = resp.data?.[0]; - if (!sub) return unavailableUsage(this); - - const details: ProviderUsageDetail[] = []; - if (sub.status) details.push({ id: "status", label: "Status", value: sub.status }); - if (sub.valid) details.push({ id: "valid", label: "Valid", value: sub.valid }); - if (sub.purchaseTime) { - details.push({ id: "purchase_time", label: "Purchased", value: sub.purchaseTime }); - } - - return { - providerId: this.providerId, - displayName: this.displayName, - status: "available", - planLabel: sub.productName || null, - windows: [], - balances: [], - details, - error: null, - }; - } -} diff --git a/packages/server/src/services/quota-fetcher/service.test.ts b/packages/server/src/services/quota-fetcher/service.test.ts deleted file mode 100644 index 2c254678fd..0000000000 --- a/packages/server/src/services/quota-fetcher/service.test.ts +++ /dev/null @@ -1,1883 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { mkdtempSync, rmSync } from "node:fs"; -import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProviderUsage } from "../../server/messages.js"; -import type { ProviderUsageFetcher } from "./provider.js"; -import { ClaudeQuotaProvider } from "./providers/claude.js"; -import { CodexQuotaProvider } from "./providers/codex.js"; -import { CopilotQuotaProvider } from "./providers/copilot.js"; -import { CursorQuotaProvider } from "./providers/cursor.js"; -import { GrokQuotaProvider } from "./providers/grok.js"; -import { KimiQuotaProvider } from "./providers/kimi.js"; -import { MiniMaxQuotaProvider } from "./providers/minimax.js"; -import { ZaiQuotaProvider } from "./providers/zai.js"; -import { ProviderUsageService } from "./service.js"; - -function writeClaudeCredentials( - dir: string, - accessToken: string, - refreshToken = "rt_test", - subscriptionType = "pro", - rateLimitTier = "default_1x", -): void { - writeFileSync( - join(dir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { accessToken, refreshToken, subscriptionType, rateLimitTier }, - }), - ); -} - -function writeCodexAuth(dir: string, accessToken: string, refreshToken = "rt_codex"): void { - writeFileSync( - join(dir, "auth.json"), - JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken } }), - ); -} - -function kimiCredentialPath(dir: string): string { - return join(dir, "credentials", "kimi-code.json"); -} - -function writeKimiCredentials(dir: string, accessToken: string, overrides: object = {}): void { - mkdirSync(join(dir, "credentials"), { recursive: true }); - writeFileSync( - kimiCredentialPath(dir), - JSON.stringify({ - access_token: accessToken, - refresh_token: "rt_kimi", - expires_at: 1_798_812_800, - expires_in: 900, - scope: "kimi-code", - token_type: "Bearer", - ...overrides, - }), - ); -} - -// node:sqlite has no @types/node@20 typings; require it with a narrow local type. -const testRequire = createRequire(import.meta.url); -interface TestSqliteDb { - exec(sql: string): void; - prepare(sql: string): { run(...params: unknown[]): void }; - close(): void; -} - -// Cursor builds have stored ItemTable values as both TEXT and BLOB. Keys map to the -// real layouts: a plain modern token or the legacy JSON object. -function writeCursorStateDb(homeDir: string, rows: Record): void { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - const { DatabaseSync } = testRequire("node:sqlite") as { - DatabaseSync: new (path: string) => TestSqliteDb; - }; - const db = new DatabaseSync(join(dir, "state.vscdb")); - db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); - const insert = db.prepare("INSERT INTO ItemTable (key, value) VALUES (?, ?)"); - for (const [key, value] of Object.entries(rows)) { - insert.run(key, value); - } - db.close(); -} - -function writeCursorAuthJson(homeDir: string, accessToken: string): void { - const dir = join(homeDir, ".config", "cursor"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "auth.json"), JSON.stringify({ accessToken })); -} - -function writeGrokAuth(home: string, auth: Record): void { - mkdirSync(join(home, ".grok"), { recursive: true }); - writeFileSync(join(home, ".grok", "auth.json"), JSON.stringify(auth)); -} - -function writeMiniMaxConfig(dir: string, payload: Record): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - writeFileSync(join(dir, ".mmx", "config.json"), JSON.stringify(payload)); -} - -function writeMiniMaxCredentials( - dir: string, - accessToken: string, - expiresAt?: string, - resourceUrl?: string, -): void { - mkdirSync(join(dir, ".mmx"), { recursive: true }); - const payload: Record = { access_token: accessToken }; - if (expiresAt !== undefined) payload["expires_at"] = expiresAt; - if (resourceUrl !== undefined) payload["resource_url"] = resourceUrl; - writeFileSync(join(dir, ".mmx", "credentials.json"), JSON.stringify(payload)); -} - -function makeClaudeResponse( - overrides: Partial<{ - five_hour: { utilization: number | string; resets_at: string }; - seven_day: { utilization: number | string; resets_at: string }; - seven_day_opus: { utilization: number | string; resets_at: string }; - }> = {}, -) { - return { - five_hour: { utilization: 11, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, - seven_day_opus: { utilization: 0.5, resets_at: "2026-06-04T00:00:00Z" }, - ...overrides, - }; -} - -function makeCodexResponse(overrides: object = {}) { - return { - plan_type: "plus", - email: "user@example.com", - rate_limit: { - primary_window: { used_percent: 42, reset_at: 1_748_812_800 }, - secondary_window: { used_percent: 8, reset_at: 1_749_072_000 }, - }, - ...overrides, - }; -} - -function mockFetch(handlers: Map Response>): typeof fetch { - return vi.fn(async (url: RequestInfo | URL) => { - const key = url.toString(); - const handler = handlers.get(key); - if (!handler) throw new Error(`Unmocked fetch: ${key}`); - return handler(); - }) as unknown as typeof fetch; -} - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function createLogger() { - const logger = { - debug: vi.fn(), - warn: vi.fn(), - info: vi.fn(), - error: vi.fn(), - child: () => logger, - }; - return logger as never; -} - -function usageFetcher(usage: ProviderUsage): ProviderUsageFetcher { - return { - providerId: usage.providerId, - displayName: usage.displayName, - fetchUsage: async () => usage, - }; -} - -function findProvider(result: { providers: ProviderUsage[] }, providerId: string): ProviderUsage { - const provider = result.providers.find((candidate) => candidate.providerId === providerId); - if (!provider) { - throw new Error(`Missing provider ${providerId}`); - } - return provider; -} - -describe("ProviderUsageService", () => { - it("returns arbitrary registered providers and windows as normalized usage data", async () => { - const service = new ProviderUsageService({ - logger: createLogger(), - now: () => Date.parse("2026-06-19T00:00:00.000Z"), - fetchers: [ - usageFetcher({ - providerId: "glm", - displayName: "GLM coding plan", - status: "available", - planLabel: "GLM coding plan", - windows: [ - { - id: "biweekly", - label: "Biweekly", - usedPct: 23, - remainingPct: 77, - resetsAt: "2026-07-03T00:00:00.000Z", - }, - ], - }), - ], - }); - - await expect(service.listUsage()).resolves.toEqual({ - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "glm", - displayName: "GLM coding plan", - status: "available", - planLabel: "GLM coding plan", - windows: [ - { - id: "biweekly", - label: "Biweekly", - usedPct: 23, - remainingPct: 77, - resetsAt: "2026-07-03T00:00:00.000Z", - }, - ], - }, - ], - }); - }); - - it("caches usage until forced to refresh", async () => { - let now = Date.parse("2026-06-19T00:00:00.000Z"); - let calls = 0; - const service = new ProviderUsageService({ - logger: createLogger(), - now: () => now, - cacheTtlMs: 60_000, - fetchers: [ - { - providerId: "claude", - displayName: "Claude", - fetchUsage: async () => { - calls += 1; - return { - providerId: "claude", - displayName: "Claude", - status: "available", - planLabel: "Max 20x", - windows: [{ id: "session", label: "Session", usedPct: calls }], - }; - }, - }, - ], - }); - - const first = await service.listUsage(); - now += 30_000; - const cached = await service.listUsage(); - const refreshed = await service.listUsage({ forceRefresh: true }); - - expect(calls).toBe(2); - expect(cached).toBe(first); - expect(refreshed.providers[0]?.windows[0]?.usedPct).toBe(2); - }); - - it("deduplicates concurrent cache misses", async () => { - let calls = 0; - let resolveUsage: ((usage: ProviderUsage) => void) | null = null; - const service = new ProviderUsageService({ - logger: createLogger(), - now: () => Date.parse("2026-06-19T00:00:00.000Z"), - fetchers: [ - { - providerId: "claude", - displayName: "Claude", - fetchUsage: () => { - calls += 1; - return new Promise((resolve) => { - resolveUsage = resolve; - }); - }, - }, - ], - }); - - const first = service.listUsage(); - const second = service.listUsage(); - - expect(calls).toBe(1); - resolveUsage?.({ - providerId: "claude", - displayName: "Claude", - status: "available", - planLabel: "Max 20x", - windows: [{ id: "session", label: "Session", usedPct: 12 }], - }); - - const [firstResult, secondResult] = await Promise.all([first, second]); - expect(firstResult).toBe(secondResult); - expect(calls).toBe(1); - }); - - it("isolates one provider error without dropping other providers", async () => { - const service = new ProviderUsageService({ - logger: createLogger(), - now: () => Date.parse("2026-06-19T00:00:00.000Z"), - fetchers: [ - { - providerId: "claude", - displayName: "Claude", - fetchUsage: async () => { - throw new Error("Claude auth expired"); - }, - }, - usageFetcher({ - providerId: "codex", - displayName: "Codex", - status: "available", - planLabel: "Pro 20x", - windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], - }), - ], - }); - - await expect(service.listUsage()).resolves.toEqual({ - fetchedAt: "2026-06-19T00:00:00.000Z", - providers: [ - { - providerId: "claude", - displayName: "Claude", - status: "error", - planLabel: null, - windows: [], - balances: [], - details: [], - error: "Claude auth expired", - }, - { - providerId: "codex", - displayName: "Codex", - status: "available", - planLabel: "Pro 20x", - windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }], - }, - ], - }); - }); -}); - -describe("real provider usage fetchers", () => { - let claudeHome: string; - let codexHome: string; - let homeDir: string; - let fetchApi: typeof fetch; - let originalEnv: Record; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "usage-test-claude-")); - codexHome = mkdtempSync(join(tmpdir(), "usage-test-codex-")); - homeDir = mkdtempSync(join(tmpdir(), "usage-test-home-")); - fetchApi = mockFetch(new Map()); - originalEnv = { ...process.env }; - process.env["HOME"] = homeDir; - - for (const key of [ - "APPDATA", - "COPILOT_TOKEN", - "GITHUB_TOKEN", - "GITHUB_PAT", - "CURSOR_ACCESS_TOKEN", - "CURSOR_TOKEN", - "ZAI_API_KEY", - "GLM_API_KEY", - "GROK_API_KEY", - "GROK_TOKEN", - "KIMI_TOKEN", - "KIMI_API_KEY", - "KIMI_CODE_HOME", - "CODEX_HOME", - "MINIMAX_API_KEY", - "MINIMAX_BASE_URL", - ]) { - delete process.env[key]; - } - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - rmSync(homeDir, { recursive: true, force: true }); - for (const key in originalEnv) { - process.env[key] = originalEnv[key]; - } - for (const key of Object.keys(process.env)) { - if (!(key in originalEnv)) { - delete process.env[key]; - } - } - }); - - function service( - options: { - platform?: typeof process.platform; - keychain?: () => Promise; - kimiHomeDir?: string; - cursorHomeDir?: string; - miniMaxConfigPath?: string; - miniMaxCredentialsPath?: string; - } = {}, - ) { - const logger = createLogger(); - const fetchThroughTestDouble = ((url: RequestInfo | URL, init?: RequestInit) => - fetchApi(url, init)) as typeof fetch; - return new ProviderUsageService({ - logger, - now: () => Date.parse("2026-06-19T00:00:00.000Z"), - fetchers: [ - new ClaudeQuotaProvider({ - logger, - claudeHome, - claudeKeychainReader: options.keychain ?? (async () => null), - platform: options.platform, - fetch: fetchThroughTestDouble, - }), - new CodexQuotaProvider({ logger, codexHome, fetch: fetchThroughTestDouble }), - new CopilotQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new CursorQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - homeDir: options.cursorHomeDir, - }), - new ZaiQuotaProvider({ logger, fetch: fetchThroughTestDouble }), - new GrokQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // Match Kimi: inject temp HOME so nested auth-file tests work on Windows - // (os.homedir() uses USERPROFILE there and ignores process.env.HOME). - homeDir, - }), - new KimiQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - // Never leave this undefined: the provider would fall back to os.homedir() and - // read — and now write — the developer's real Kimi credentials. - homeDir: options.kimiHomeDir ?? homeDir, - }), - new MiniMaxQuotaProvider({ - logger, - fetch: fetchThroughTestDouble, - configPath: options.miniMaxConfigPath ?? join(homeDir, ".mmx", "config.json"), - credentialsPath: - options.miniMaxCredentialsPath ?? join(homeDir, ".mmx", "credentials.json"), - }), - ], - cacheTtlMs: 0, - }); - } - - it("fetches Claude usage, coerces API numbers, and attaches HTTP timeout signals", async () => { - writeClaudeCredentials(claudeHome, "at_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse( - makeClaudeResponse({ - five_hour: { utilization: "11", resets_at: "2026-06-01T21:00:00Z" }, - }), - ), - ], - ]), - ); - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude).toMatchObject({ - status: "available", - planLabel: "Pro 1x", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "five_hour", usedPct: 11 }), - expect.objectContaining({ id: "weekly", usedPct: 1 }), - expect.objectContaining({ id: "weekly_model_opus", usedPct: 0.5 }), - ]), - }); - expect(fetchApi).toHaveBeenCalledWith( - "https://api.anthropic.com/api/oauth/usage", - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it("accepts a null Claude resets_at when a window has no scheduled reset", async () => { - writeClaudeCredentials(claudeHome, "at_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - five_hour: { utilization: 0, resets_at: null }, - seven_day: { utilization: 1, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ); - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "five_hour", usedPct: 0, resetsAt: null }), - expect.objectContaining({ id: "weekly", usedPct: 1 }), - ]), - }); - }); - - it("returns unavailable Claude usage when credentials are missing", async () => { - fetchApi = vi.fn() as never; - - const result = await service().listUsage(); - const claude = findProvider(result, "claude"); - - expect(claude.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); - - it("returns unavailable on 401 without refreshing or rewriting credentials", async () => { - writeClaudeCredentials(claudeHome, "at_expired", "rt_valid"); - const credPath = join(claudeHome, ".credentials.json"); - const before = readFileSync(credPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://api.anthropic.com/api/oauth/usage") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "claude").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Claude CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - it("does not refresh Claude tokens read from the macOS Keychain", async () => { - const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); - fetchApi = usageFetch as never; - - const result = await service({ - platform: "darwin", - keychain: async () => ({ - claudeAiOauth: { - accessToken: "at_expired", - refreshToken: "rt_valid", - }, - }), - }).listUsage(); - - expect(findProvider(result, "claude").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - expect(usageFetch).toHaveBeenCalledWith( - "https://api.anthropic.com/api/oauth/usage", - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer at_expired" }), - }), - ); - }); - - it("fetches Codex windows and coerces string credit balances", async () => { - writeCodexAuth(codexHome, "at_codex_valid"); - fetchApi = mockFetch( - new Map([ - [ - "https://chatgpt.com/backend-api/wham/usage", - () => - jsonResponse( - makeCodexResponse({ - code_review_rate_limit: null, - credits: { balance: "0" }, - }), - ), - ], - ]), - ); - - const result = await service().listUsage(); - const codex = findProvider(result, "codex"); - - expect(codex).toMatchObject({ - status: "available", - planLabel: "plus", - windows: expect.arrayContaining([ - expect.objectContaining({ id: "session", usedPct: 42 }), - expect.objectContaining({ id: "weekly", usedPct: 8 }), - ]), - balances: [expect.objectContaining({ id: "credits", remaining: 0 })], - }); - }); - - it("treats a Codex HTML usage response as auth failure", async () => { - writeCodexAuth(codexHome, "at_codex_stale"); - fetchApi = mockFetch( - new Map([ - [ - "https://chatgpt.com/backend-api/wham/usage", - () => new Response("Login", { status: 200 }), - ], - ]), - ); - - const result = await service().listUsage(); - - expect(findProvider(result, "codex").status).toBe("unavailable"); - }); - - it("returns unavailable on 401 without refreshing or rewriting auth.json", async () => { - // Regression: the fetcher used to refresh the token and rewrite auth.json - // through a schema that dropped id_token (and OPENAI_API_KEY/last_refresh), - // leaving the file unparseable by the Codex CLI and forcing a re-login. - const authPath = join(codexHome, "auth.json"); - writeFileSync( - authPath, - JSON.stringify({ - OPENAI_API_KEY: null, - tokens: { - id_token: "id_codex", - access_token: "at_codex_stale", - refresh_token: "rt_codex_valid", - account_id: "acct_codex", - }, - last_refresh: "2026-07-04T20:35:00Z", - }), - ); - const before = readFileSync(authPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://chatgpt.com/backend-api/wham/usage") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "codex").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The auth file must be left byte-for-byte untouched for the Codex CLI to own. - expect(readFileSync(authPath, "utf8")).toBe(before); - }); - - it("fetches Copilot usage from COPILOT_TOKEN", async () => { - process.env["COPILOT_TOKEN"] = "copilot_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.github.com/copilot_internal/user", - () => - jsonResponse({ - copilot_plan: "business", - quota_reset_date: "2026-07-01T00:00:00Z", - }), - ], - ]), - ); - - const copilot = findProvider(await service().listUsage(), "copilot"); - - expect(copilot).toMatchObject({ - status: "available", - planLabel: "business", - details: [{ id: "reset", label: "Quota reset", value: "2026-07-01T00:00:00Z" }], - }); - }); - - it("fetches Cursor usage and normalizes malformed billing dates to null", async () => { - process.env["CURSOR_ACCESS_TOKEN"] = "cursor_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - () => - jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "not-a-date", - }), - ], - ]), - ); - - const cursor = findProvider(await service().listUsage(), "cursor"); - - expect(cursor).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "plan_usage", - used: 15, - remaining: 25, - limit: 40, - resetsAt: null, - }), - ], - }); - }); - - it("reads the Cursor token from the modern cursorAuth/accessToken key in state.vscdb", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_state_jwt" }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_state_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("falls back to the legacy cursorAuthStatus JSON blob when the modern key is absent", async () => { - writeCursorStateDb(homeDir, { - cursorAuthStatus: Buffer.from(JSON.stringify({ accessToken: "cursor_legacy_jwt" }), "utf8"), - }); - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_legacy_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("reads the Cursor token from cursor-agent ~/.config/cursor/auth.json when desktop state is absent", async () => { - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { - totalSpend: "1500", - includedSpend: "1000", - bonusSpend: "500", - remaining: "2500", - limit: "4000", - }, - billingCycleStart: "2026-01-14T12:42:14.000Z", - billingCycleEnd: "2026-02-14T12:42:14.000Z", - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_cli_jwt"); - expect(cursor).toMatchObject({ - status: "available", - balances: [expect.objectContaining({ id: "plan_usage", used: 15, remaining: 25, limit: 40 })], - }); - }); - - it("prefers the desktop state.vscdb token over cursor-agent auth.json", async () => { - writeCursorStateDb(homeDir, { "cursorAuth/accessToken": "cursor_desktop_jwt" }); - writeCursorAuthJson(homeDir, "cursor_cli_jwt"); - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - planUsage: { totalSpend: "0", remaining: "100", limit: "100" }, - billingCycleStart: null, - billingCycleEnd: null, - }); - }) as unknown as typeof fetch; - - const cursor = findProvider(await service({ cursorHomeDir: homeDir }).listUsage(), "cursor"); - - expect(authorization).toBe("Bearer cursor_desktop_jwt"); - expect(cursor.status).toBe("available"); - }); - - it("logs a debug diagnostic and stays unavailable when state.vscdb is unreadable", async () => { - const dir = join(homeDir, ".config", "Cursor", "User", "globalStorage"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "state.vscdb"), "not a sqlite database"); - const logger = createLogger(); - const provider = new CursorQuotaProvider({ - logger, - fetch: (() => { - throw new Error("usage API should not be called without a token"); - }) as unknown as typeof fetch, - homeDir, - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("unavailable"); - expect((logger as unknown as { debug: ReturnType }).debug).toHaveBeenCalledWith( - expect.objectContaining({ path: expect.stringContaining("state.vscdb") }), - expect.stringContaining("Failed to read Cursor token"), - ); - }); - - it("fetches Z.ai usage from ZAI_API_KEY", async () => { - process.env["ZAI_API_KEY"] = "zai_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.z.ai/api/biz/subscription/list", - () => - jsonResponse({ - data: [ - { - productName: "GLM Coding Max", - status: "VALID", - purchaseTime: "2026-01-12 16:55:13", - valid: "2026-02-12 16:55:13-2026-03-12 16:55:13", - }, - ], - }), - ], - ]), - ); - - const zai = findProvider(await service().listUsage(), "zai"); - - expect(zai).toMatchObject({ - status: "available", - planLabel: "GLM Coding Max", - details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]), - }); - }); - - it("fetches Grok usage and preserves zero values", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 0 }, used: { val: 0 } }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 0, - remaining: 0, - limit: 0, - }), - ], - }); - }); - - it("fetches Grok usage from live billing shape (config.used.val)", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - monthlyLimit: { val: 150000 }, - used: { val: 37886 }, - billingPeriodStart: "2026-07-01T00:00:00+00:00", - billingPeriodEnd: "2026-08-01T00:00:00+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 37886, - remaining: 112114, - limit: 150000, - unit: "credits", - }), - ], - }); - }); - - it("fetches Grok usage with nested ~/.grok/auth.json key token", async () => { - writeGrokAuth(homeDir, { - "https://auth.x.ai::test-user-id": { - key: "nested_jwt_token", - refresh_token: "rt_nested", - expires_at: "2026-08-01T00:00:00Z", - user_id: "test-user-id", - email: "user@example.com", - }, - }); - - let authorization: string | null = null; - fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => { - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - config: { - monthlyLimit: { val: 100 }, - used: { val: 25 }, - }, - }); - }) as typeof fetch; - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(authorization).toBe("Bearer nested_jwt_token"); - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 25, - remaining: 75, - limit: 100, - }), - ], - }); - }); - - it("still accepts legacy Grok usage.creditUsage when config.used is absent", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { monthlyLimit: { val: 50 } }, - usage: { creditUsage: 10 }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - balances: [ - expect.objectContaining({ - id: "monthly_credits", - used: 10, - remaining: 40, - limit: 50, - }), - ], - }); - }); - - it("fetches Grok unified-billing usage as a weekly window", async () => { - process.env["GROK_API_KEY"] = "grok_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://cli-chat-proxy.grok.com/v1/billing?format=credits", - () => - jsonResponse({ - config: { - currentPeriod: { - type: "USAGE_PERIOD_TYPE_WEEKLY", - start: "2026-08-24T09:41:40.001370+00:00", - end: "2026-08-31T09:41:40.001370+00:00", - }, - creditUsagePercent: 76.0, - isUnifiedBillingUser: true, - billingPeriodStart: "2026-08-24T09:41:40.001370+00:00", - billingPeriodEnd: "2026-08-31T09:41:40.001370+00:00", - }, - }), - ], - ]), - ); - - const grok = findProvider(await service().listUsage(), "grok"); - - expect(grok).toMatchObject({ - status: "available", - windows: [ - { - id: "weekly", - label: "Weekly", - usedPct: 76, - remainingPct: 24, - resetsAt: "2026-08-31T09:41:40.001370+00:00", - tone: "warning", - }, - ], - balances: [], - }); - }); - - it("fetches Kimi usage from KIMI_TOKEN", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.kimi.com/coding/v1/usages", - () => - jsonResponse({ - usage: { - limit: "100", - remaining: "74", - resetTime: "2026-02-11T17:32:50Z", - }, - }), - ], - ]), - ); - - const kimi = findProvider(await service().listUsage(), "kimi"); - - expect(kimi).toMatchObject({ - status: "available", - windows: [ - expect.objectContaining({ - id: "coding_usage", - usedPct: 26, - remainingPct: 74, - resetsAt: "2026-02-11T17:32:50Z", - }), - ], - }); - }); - - it("fetches Kimi usage from the CLI credential home", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token"); - let requestedUrl: string | null = null; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - usage: { - limit: "200", - remaining: "150", - resetTime: "2026-06-23T05:12:17Z", - }, - }); - }) as unknown as typeof fetch; - - const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); - - expect(requestedUrl).toBe("https://api.kimi.com/coding/v1/usages"); - expect(authorization).toBe("Bearer kimi_cli_token"); - expect(kimi).toMatchObject({ - status: "available", - windows: [ - expect.objectContaining({ - id: "coding_usage", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-23T05:12:17Z", - }), - ], - }); - }); - - it("reads Kimi credentials whose optional fields are null", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "kimi_cli_token", { - expires_at: null, - expires_in: null, - scope: null, - token_type: null, - }); - fetchApi = mockFetch( - new Map([ - [ - "https://api.kimi.com/coding/v1/usages", - () => jsonResponse({ usage: { limit: "100", remaining: "60" } }), - ], - ]), - ); - - const kimi = findProvider(await service({ kimiHomeDir: homeDir }).listUsage(), "kimi"); - - expect(kimi.status).toBe("available"); - }); - - it("returns unavailable on 401 without refreshing or rewriting the credential file", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_expired"); - const credPath = kimiCredentialPath(join(homeDir, ".kimi-code")); - const before = readFileSync(credPath, "utf8"); - let usageCalls = 0; - fetchApi = vi.fn(async (url: RequestInfo | URL) => { - const endpoint = url.toString(); - if (endpoint === "https://api.kimi.com/coding/v1/usages") { - usageCalls += 1; - return new Response(null, { status: 401 }); - } - // The read-only fetcher must never hit the OAuth token endpoint. - throw new Error(`Unmocked: ${endpoint}`); - }) as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageCalls).toBe(1); - // The credentials file must be left untouched for the Kimi CLI to own. - expect(readFileSync(credPath, "utf8")).toBe(before); - }); - - it("does not refresh Kimi tokens read from the environment", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const usageFetch = vi.fn(async () => new Response(null, { status: 401 })); - fetchApi = usageFetch as never; - - const result = await service().listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - }); - - it("does not refresh Kimi tokens on a 403", async () => { - writeKimiCredentials(join(homeDir, ".kimi-code"), "at_kimi_forbidden"); - const usageFetch = vi.fn(async () => new Response(null, { status: 403 })); - fetchApi = usageFetch as never; - - const result = await service({ kimiHomeDir: homeDir }).listUsage(); - - expect(findProvider(result, "kimi").status).toBe("unavailable"); - expect(usageFetch).toHaveBeenCalledTimes(1); - }); - - it("fetches MiniMax usage from MINIMAX_API_KEY against the global endpoint", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - let requestedUrl: string | null = null; - let authorization: string | null = null; - fetchApi = (async (url: RequestInfo | URL, init?: RequestInit) => { - requestedUrl = url.toString(); - authorization = (init?.headers as Record | undefined)?.Authorization ?? null; - return jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 1000, - current_interval_usage_count: 250, - current_interval_remaining_percent: 75, - current_weekly_total_count: 5000, - current_weekly_usage_count: 1200, - current_weekly_remaining_percent: 76, - }, - ], - }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimax.io/v1/token_plan/remains"); - expect(authorization).toBe("Bearer minimax_test_token"); - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - label: "MiniMax-M2.7 · Interval", - usedPct: 25, - remainingPct: 75, - resetsAt: "2026-06-19T05:00:00.000Z", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - label: "MiniMax-M2.7 · Weekly", - usedPct: 24, - remainingPct: 76, - resetsAt: "2026-06-26T00:00:00.000Z", - }), - ]), - }); - }); - - it("returns unavailable MiniMax usage when no credentials are configured", async () => { - fetchApi = vi.fn() as never; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax.status).toBe("unavailable"); - expect(fetchApi).not.toHaveBeenCalled(); - }); - - it("reads MiniMax OAuth credentials from the CLI credentials file", async () => { - writeMiniMaxCredentials( - homeDir, - "minimax_oauth_token", - "2030-01-01T00:00:00.000Z", - "https://account.example.com", - ); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - await service().listUsage(); - - expect(requestedUrl).toBe("https://account.example.com/v1/token_plan/remains"); - }); - - it("falls back to MiniMax api_key in the CLI config file", async () => { - writeMiniMaxConfig(homeDir, { - api_key: "minimax_config_key", - region: "cn", - }); - let requestedUrl: string | null = null; - fetchApi = (async (url: RequestInfo | URL) => { - requestedUrl = url.toString(); - return jsonResponse({ model_remains: [] }); - }) as unknown as typeof fetch; - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(requestedUrl).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - expect(miniMax.status).toBe("unavailable"); - }); - - it("marks exhausted MiniMax interval windows with a danger tone", async () => { - process.env["MINIMAX_API_KEY"] = "minimax_test_token"; - fetchApi = mockFetch( - new Map([ - [ - "https://api.minimax.io/v1/token_plan/remains", - () => - jsonResponse({ - model_remains: [ - { - model_name: "MiniMax-M2.7", - end_time: Date.parse("2026-06-19T05:00:00.000Z"), - weekly_end_time: Date.parse("2026-06-26T00:00:00.000Z"), - current_interval_total_count: 100, - current_interval_usage_count: 100, - current_interval_remaining_percent: 0, - current_interval_status: 2, - current_weekly_total_count: 100, - current_weekly_usage_count: 10, - current_weekly_remaining_percent: 90, - current_weekly_status: 1, - }, - ], - }), - ], - ]), - ); - - const miniMax = findProvider(await service().listUsage(), "minimax"); - - expect(miniMax).toMatchObject({ - status: "available", - windows: expect.arrayContaining([ - expect.objectContaining({ - id: "interval_MiniMax-M2.7", - usedPct: 100, - tone: "danger", - }), - expect.objectContaining({ - id: "weekly_MiniMax-M2.7", - tone: "ok", - }), - ]), - }); - }); -}); - -// Regression for #2320: providers hardcoded `tone: "ok"`, which suppressed the client's -// own thresholds (window-bar.tsx reads `window.tone ?? deriveTone(usedPct)`), so a bar -// stayed green at 99%. Codex escalated to "warning" but could never reach "danger". -describe("usage bars escalate as they fill", () => { - let claudeHome: string; - let codexHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-tone-claude-")); - codexHome = mkdtempSync(join(tmpdir(), "paseo-tone-codex-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - rmSync(codexHome, { recursive: true, force: true }); - }); - - function claudeAt(utilization: number) { - writeClaudeCredentials(claudeHome, "at_valid"); - return new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([ - [ - "https://api.anthropic.com/api/oauth/usage", - () => - jsonResponse({ - seven_day: { utilization, resets_at: "2026-06-04T00:00:00Z" }, - }), - ], - ]), - ), - }).fetchUsage(); - } - - it.each([ - [10, "ok"], - [75, "warning"], - [99, "danger"], - ])("a Claude window at %s%% is %s", async (utilization, tone) => { - const usage = await claudeAt(utilization); - expect(usage.windows).toEqual([expect.objectContaining({ id: "weekly", tone })]); - }); - - it("a Codex window can reach danger, not just warning", async () => { - writeCodexAuth(codexHome, "at_codex"); - const usage = await new CodexQuotaProvider({ - logger: createLogger(), - codexHome, - fetch: mockFetch( - new Map([ - [ - "https://chatgpt.com/backend-api/wham/usage", - () => - jsonResponse( - makeCodexResponse({ - rate_limit: { - primary_window: { used_percent: 12, reset_at: 1_748_812_800 }, - secondary_window: { used_percent: 96, reset_at: 1_749_072_000 }, - }, - }), - ), - ], - ]), - ), - }).fetchUsage(); - - expect(usage.windows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "session", tone: "ok" }), - expect.objectContaining({ id: "weekly", tone: "danger" }), - ]), - ); - }); -}); - -// Model- and surface-scoped weekly limits arrive in a `limits[]` array rather than the -// top-level `seven_day_*` keys, which now return null on most accounts. -describe("ClaudeQuotaProvider scoped weekly limits", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-limits-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - function fableLimit(overrides: Record = {}) { - return { - kind: "weekly_scoped", - group: "weekly", - percent: 0, - severity: "normal", - resets_at: "2026-06-04T00:00:00Z", - is_active: false, - scope: { model: { id: null, display_name: "Fable" }, surface: null }, - ...overrides, - }; - } - - function claudeProvider(body: unknown) { - writeClaudeCredentials(claudeHome, "at_valid"); - const logger = createLogger() as unknown as { warn: ReturnType }; - const provider = new ClaudeQuotaProvider({ - logger: logger as never, - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }); - return { provider, logger }; - } - - it("renders a scoped weekly limit as its own window", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", label: "Weekly · Fable" }), - ); - }); - - it("renders a scoped window that is at zero and inactive", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ percent: 0, is_active: false })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_model_fable", usedPct: 0, remainingPct: 100 }), - ); - }); - - it("ignores session and all-models entries so they do not duplicate the top-level windows", async () => { - const { provider } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - { kind: "session", percent: 6, resets_at: "2026-06-01T21:00:00Z", scope: null }, - { kind: "weekly_all", percent: 23, resets_at: "2026-06-04T00:00:00Z", scope: null }, - fableLimit(), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - }); - - it("labels a surface-scoped limit from its surface name", async () => { - const { provider } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [ - fableLimit({ scope: { model: null, surface: { id: "code", display_name: "Code" } } }), - ], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toContainEqual( - expect.objectContaining({ id: "weekly_surface_code", label: "Weekly · Code" }), - ); - }); - - it("skips a scoped limit with no resolvable label rather than rendering an unlabelled bar", async () => { - const { provider, logger } = claudeProvider({ - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [fableLimit({ scope: { model: { id: null, display_name: null }, surface: null } })], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual(["weekly"]); - expect(logger.warn).toHaveBeenCalled(); - }); - - // Regression: an additive section must never take down data that already parsed. - it("keeps the top-level windows when a limits entry is malformed", async () => { - const { provider, logger } = claudeProvider({ - five_hour: { utilization: 6, resets_at: "2026-06-01T21:00:00Z" }, - seven_day: { utilization: 23, resets_at: "2026-06-04T00:00:00Z" }, - limits: [{ percent: "not-a-kind" }, fableLimit()], - }); - - const usage = await provider.fetchUsage(); - - expect(usage.status).toBe("available"); - expect(usage.windows.map((window) => window.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_fable", - ]); - expect(logger.warn).toHaveBeenCalled(); - }); - - it("warns when a successful response describes no windows at all", async () => { - const { provider, logger } = claudeProvider({ limits: [] }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toEqual([]); - expect(logger.warn).toHaveBeenCalledWith( - "Claude usage response parsed but produced no windows", - ); - }); -}); -/** - * The reconciliation matrix. - * - * Four review rounds on PR #2303 each found a different hole in how the two - * representations of one scoped limit get combined, because each fix was tested against - * the case that was reported rather than the space of cases. This walks the space: - * every combination of which representation carries the limit, whether the `limits[]` - * entry supplies values, and whether the two descriptions denote the same limit at all. - */ -describe("ClaudeQuotaProvider scoped limit reconciliation", () => { - let claudeHome: string; - - beforeEach(() => { - claudeHome = mkdtempSync(join(tmpdir(), "paseo-claude-matrix-")); - }); - - afterEach(() => { - rmSync(claudeHome, { recursive: true, force: true }); - }); - - const RESETS = "2026-06-04T00:00:00Z"; - - function scoped(scope: unknown, percent: number | null = 30, resetsAt: string | null = RESETS) { - return { kind: "weekly_scoped", percent, resets_at: resetsAt, scope }; - } - - const model = (name: string | null, id: string | null = null) => ({ - model: { id, display_name: name }, - surface: null, - }); - const surface = (name: string | null, id: string | null = null) => ({ - model: null, - surface: { id, display_name: name }, - }); - - async function windowsFor(body: Record) { - writeClaudeCredentials(claudeHome, "at_valid"); - const usage = await new ClaudeQuotaProvider({ - logger: createLogger(), - claudeHome, - claudeKeychainReader: async () => null, - fetch: mockFetch( - new Map([["https://api.anthropic.com/api/oauth/usage", () => jsonResponse(body)]]), - ), - }).fetchUsage(); - return usage.windows; - } - - it("which representation carries the limit: legacy only", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_omelette", - label: "Weekly · Omelette", - usedPct: 12, - }), - ]); - }); - - it("which representation carries the limit: limits[] only", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), 2)] }); - expect(windows).toEqual([ - expect.objectContaining({ - id: "weekly_model_fable", - label: "Weekly · Fable", - usedPct: 2, - }), - ]); - }); - - it("which representation carries the limit: both, same limit — one bar, scoped identity", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(model("Omelette"), 30)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_omelette", usedPct: 30 }), - ]); - }); - - it("which representation carries the limit: both, different limits — two bars", async () => { - const windows = await windowsFor({ - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [scoped(model("Fable"), 2)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_opus", "weekly_model_fable"]); - }); - - it("which representation carries the limit: neither", async () => { - const windows = await windowsFor({ seven_day: { utilization: 23, resets_at: RESETS } }); - expect(windows.map((w) => w.id)).toEqual(["weekly"]); - }); - - const legacy = { seven_day_omelette: { utilization: 12, resets_at: RESETS } }; - - it("value fallback when the scoped entry is sparse: scoped values win when present", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: percentage falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, "2026-06-09T00:00:00Z")], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: "2026-06-09T00:00:00Z" }); - }); - - it("value fallback when the scoped entry is sparse: reset time falls back per field", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), 30, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 30, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: both fall back when the scoped entry only names the limit", async () => { - const windows = await windowsFor({ - ...legacy, - limits: [scoped(model("Omelette"), null, null)], - }); - expect(windows[0]).toMatchObject({ usedPct: 12, resetsAt: RESETS }); - }); - - it("value fallback when the scoped entry is sparse: stays empty when neither side has a value", async () => { - const windows = await windowsFor({ limits: [scoped(model("Fable"), null, null)] }); - expect(windows[0]).toMatchObject({ id: "weekly_model_fable", usedPct: null }); - }); - - it("identity: a surface never matches a legacy model window of the same name", async () => { - const windows = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - limits: [scoped(surface("Omelette"), 30)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_omelette", "weekly_surface_omelette"]); - expect(windows[0]).toMatchObject({ usedPct: 12 }); - expect(windows[1]).toMatchObject({ usedPct: 30 }); - }); - - it("identity: a model and a surface of the same name stay apart", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Code"), 4), scoped(surface("Code"), 9)], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_code", "weekly_surface_code"]); - }); - - it("identity: ids decide when both sides have one", async () => { - const windows = await windowsFor({ - limits: [ - scoped(model("Fable-Pro", "fable-pro"), 4), - scoped(model("Fable_Pro", "fable_pro"), 9), - ], - }); - expect(windows.map((w) => w.id)).toEqual(["weekly_model_fable-pro", "weekly_model_fable_pro"]); - }); - - it("identity: names decide when ids are absent, so indistinguishable entries merge", async () => { - const windows = await windowsFor({ - limits: [scoped(model("Fable Pro"), 4), scoped(model("Fable-Pro"), 9)], - }); - expect(windows).toEqual([ - expect.objectContaining({ id: "weekly_model_fable_pro", usedPct: 9 }), - ]); - }); - - it("identity: a renamed scope keeps its id when the API supplies one", async () => { - const before = await windowsFor({ limits: [scoped(model("Fable", "fable"), 2)] }); - const after = await windowsFor({ limits: [scoped(model("Fable 5", "fable"), 2)] }); - expect(before[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.id).toBe("weekly_model_fable"); - expect(after[0]?.label).toBe("Weekly · Fable 5"); - }); - - it("identity: a limit keeps one id whichever representation carries it", async () => { - const viaLegacy = await windowsFor({ - seven_day_omelette: { utilization: 12, resets_at: RESETS }, - }); - const viaLimits = await windowsFor({ limits: [scoped(model("Omelette"), 12)] }); - expect(viaLegacy[0]?.id).toBe(viaLimits[0]?.id); - }); - - it("ordering and unscoped windows: puts session and weekly ahead of the scoped bars", async () => { - const windows = await windowsFor({ - five_hour: { utilization: 6, resets_at: RESETS }, - seven_day: { utilization: 23, resets_at: RESETS }, - seven_day_opus: { utilization: 8, resets_at: RESETS }, - limits: [ - { kind: "session", percent: 6, resets_at: RESETS, scope: null }, - { kind: "weekly_all", percent: 23, resets_at: RESETS, scope: null }, - scoped(model("Fable"), 2), - ], - }); - expect(windows.map((w) => w.id)).toEqual([ - "five_hour", - "weekly", - "weekly_model_opus", - "weekly_model_fable", - ]); - }); -}); - -describe("KimiQuotaProvider usage windows", () => { - afterEach(() => { - delete process.env["KIMI_TOKEN"]; - vi.restoreAllMocks(); - }); - - it("normalizes weekly and enforced rolling usage windows", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const fetchApi = vi.fn(async () => - jsonResponse({ - limited: true, - usage: { - limit: "100", - used: "61", - remaining: "39", - resetTime: "2026-08-05T00:01:45Z", - }, - limits: [ - { - window: { - duration: 300, - timeUnit: "TIME_UNIT_MINUTE", - }, - detail: { - limit: "100", - used: "100", - resetTime: "2026-07-31T17:01:45Z", - }, - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage).toMatchObject({ - status: "available", - windows: [ - { - id: "coding_usage", - label: "Weekly limit", - usedPct: 61, - remainingPct: 39, - resetsAt: "2026-08-05T00:01:45Z", - tone: "ok", - }, - { - id: "coding_limit_300_time_unit_minute", - label: "5-hour limit", - usedPct: 100, - remainingPct: 0, - resetsAt: "2026-07-31T17:01:45Z", - tone: "danger", - }, - ], - }); - }); - - it("keeps valid windows when another limits entry is malformed", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const logger = createLogger() as unknown as { debug: ReturnType }; - const fetchApi = vi.fn(async () => - jsonResponse({ - usage: { - limit: "100", - remaining: "75", - resetTime: "2026-08-05T00:01:45Z", - }, - limits: [ - { window: { duration: "invalid" }, detail: {} }, - { - window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, - detail: { limit: "100", remaining: "50" }, - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: logger as never, fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toHaveLength(2); - expect(usage.windows[1]).toMatchObject({ - label: "5-hour limit", - usedPct: 50, - remainingPct: 50, - }); - expect(logger.debug).toHaveBeenCalledWith( - { index: 0 }, - "Ignoring malformed Kimi usage limit window", - ); - }); - - it("accepts direct limit fields, alternate reset keys, and provider labels", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const fetchApi = vi.fn(async () => - jsonResponse({ - usage: null, - limits: [ - { - name: "Burst quota", - limit: "80", - remaining: "20", - reset_at: "2026-08-01T00:00:00Z", - }, - ], - }), - ); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows).toEqual([ - expect.objectContaining({ - id: "coding_limit_burst_quota", - label: "Burst quota", - usedPct: 75, - remainingPct: 25, - resetsAt: "2026-08-01T00:00:00Z", - }), - ]); - }); - - it("keeps window ids unique when Kimi returns duplicate limit descriptors", async () => { - process.env["KIMI_TOKEN"] = "kimi_test_token"; - const duplicate = { - window: { duration: 300, timeUnit: "TIME_UNIT_MINUTE" }, - detail: { limit: "100", used: "10" }, - }; - const fetchApi = vi.fn(async () => jsonResponse({ limits: [duplicate, duplicate] })); - const provider = new KimiQuotaProvider({ logger: createLogger(), fetch: fetchApi }); - - const usage = await provider.fetchUsage(); - - expect(usage.windows.map((window) => window.id)).toEqual([ - "coding_limit_300_time_unit_minute", - "coding_limit_300_time_unit_minute_2", - ]); - }); -}); diff --git a/packages/server/src/services/quota-fetcher/service.ts b/packages/server/src/services/quota-fetcher/service.ts deleted file mode 100644 index dde20fb245..0000000000 --- a/packages/server/src/services/quota-fetcher/service.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Logger } from "pino"; -import type { ProviderUsage } from "../../server/messages.js"; -import { createProviderUsageFetchers } from "./manifest.js"; -import type { ProviderApiFetch, ProviderUsageFetcher } from "./provider.js"; -import { unavailableUsage } from "./usage.js"; - -export interface ProviderUsageServiceOptions { - logger: Logger; - fetchers?: ProviderUsageFetcher[]; - fetch?: ProviderApiFetch; - cacheTtlMs?: number; - now?: () => number; -} - -export interface ProviderUsageListResult { - fetchedAt: string; - providers: ProviderUsage[]; -} - -const DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS = 5 * 60 * 1000; - -export class ProviderUsageService { - private readonly logger: Logger; - private readonly fetchers: ProviderUsageFetcher[]; - private readonly cacheTtlMs: number; - private readonly now: () => number; - private cached: { fetchedAtMs: number; result: ProviderUsageListResult } | null = null; - private inFlight: Promise | null = null; - - constructor(options: ProviderUsageServiceOptions) { - this.logger = options.logger.child({ module: "provider-usage-service" }); - this.fetchers = - options.fetchers ?? - createProviderUsageFetchers({ - logger: this.logger, - fetch: options.fetch, - }); - this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_PROVIDER_USAGE_CACHE_TTL_MS; - this.now = options.now ?? Date.now; - } - - async listUsage(options?: { forceRefresh?: boolean }): Promise { - const nowMs = this.now(); - if ( - !options?.forceRefresh && - this.cached && - nowMs - this.cached.fetchedAtMs < this.cacheTtlMs - ) { - return this.cached.result; - } - - if (this.inFlight) { - return this.inFlight; - } - - const request = this.fetchFreshUsage(nowMs); - this.inFlight = request; - try { - return await request; - } finally { - if (this.inFlight === request) { - this.inFlight = null; - } - } - } - - private async fetchFreshUsage(nowMs: number): Promise { - const settled = await Promise.allSettled(this.fetchers.map((fetcher) => fetcher.fetchUsage())); - const providers = settled.map((result, index) => { - const fetcher = this.fetchers[index]; - if (result.status === "fulfilled") { - return result.value; - } - this.logger.debug( - { err: result.reason, providerId: fetcher.providerId }, - "Provider usage fetch failed", - ); - return unavailableUsage({ - providerId: fetcher.providerId, - displayName: fetcher.displayName, - error: result.reason instanceof Error ? result.reason.message : String(result.reason), - }); - }); - - const result = { fetchedAt: new Date(nowMs).toISOString(), providers }; - this.cached = { fetchedAtMs: nowMs, result }; - return result; - } -} diff --git a/packages/server/src/services/quota-fetcher/usage.test.ts b/packages/server/src/services/quota-fetcher/usage.test.ts deleted file mode 100644 index 8e37583e30..0000000000 --- a/packages/server/src/services/quota-fetcher/usage.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { balanceToneFromRemaining, toneFromUsedPct, usedPctOf } from "./usage.js"; - -describe("toneFromUsedPct", () => { - // Thresholds must match deriveTone in the app's provider-usage/tone.ts, which is what - // the client applies when a window arrives without a tone. - it.each([ - [0, "ok"], - [69.9, "ok"], - [70, "warning"], - [90, "warning"], - [90.1, "danger"], - [100, "danger"], - [150, "danger"], - ])("%s%% used is %s", (usedPct, expected) => { - expect(toneFromUsedPct(usedPct)).toBe(expected); - }); - - it("is neutral when the percentage is unknown", () => { - expect(toneFromUsedPct(null)).toBe("default"); - expect(toneFromUsedPct(undefined)).toBe("default"); - }); -}); - -describe("usedPctOf", () => { - it("computes a percentage of the limit", () => { - expect(usedPctOf(15.79, 42.5)).toBeCloseTo(37.15, 2); - }); - - it("is unknown when either side is missing", () => { - expect(usedPctOf(null, 100)).toBeNull(); - expect(usedPctOf(50, null)).toBeNull(); - }); - - // A zero limit would divide to Infinity and render as a full red bar. - it("is unknown when the limit is zero or negative", () => { - expect(usedPctOf(50, 0)).toBeNull(); - expect(usedPctOf(50, -1)).toBeNull(); - }); -}); - -describe("balanceToneFromRemaining", () => { - // Kept for balances with no limit, where no percentage can be computed. It only - // escalates at exhaustion, which is why anything with a limit should use - // toneFromUsedPct instead. - it("stays ok until nothing is left", () => { - expect(balanceToneFromRemaining(0.01)).toBe("ok"); - expect(balanceToneFromRemaining(0)).toBe("danger"); - expect(balanceToneFromRemaining(null)).toBe("default"); - }); -}); diff --git a/packages/server/src/services/quota-fetcher/usage.ts b/packages/server/src/services/quota-fetcher/usage.ts deleted file mode 100644 index fa766f5778..0000000000 --- a/packages/server/src/services/quota-fetcher/usage.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { z } from "zod"; -import type { - ProviderUsage, - ProviderUsageBalance, - ProviderUsageTone, - ProviderUsageWindow, -} from "../../server/messages.js"; -import type { ProviderApiFetch } from "./provider.js"; - -const PROVIDER_HTTP_TIMEOUT_MS = 15_000; - -export const ApiNumberSchema = z.coerce.number().finite(); -export const ApiNullableNumberSchema = z.preprocess( - (value) => (value == null ? null : value), - ApiNumberSchema.nullable(), -); -export const ApiOptionalStringSchema = z.preprocess( - (value) => (value == null ? undefined : value), - z.coerce.string().optional(), -); - -export function fetchProviderApi( - fetchApi: ProviderApiFetch, - input: RequestInfo | URL, - init: RequestInit = {}, -): Promise { - return fetchApi(input, { - ...init, - signal: init.signal ?? AbortSignal.timeout(PROVIDER_HTTP_TIMEOUT_MS), - }); -} - -export function unavailableUsage(provider: { - providerId: string; - displayName: string; - error?: string | null; -}): ProviderUsage { - return { - providerId: provider.providerId, - displayName: provider.displayName, - status: provider.error ? "error" : "unavailable", - planLabel: null, - windows: [], - balances: [], - details: [], - error: provider.error ?? null, - }; -} - -export function windowFromUsedPct(input: { - id: string; - label: string; - utilizationPct: number | null | undefined; - resetsAt?: string | null; - tone?: ProviderUsageWindow["tone"]; -}): ProviderUsageWindow { - const usedPct = typeof input.utilizationPct === "number" ? input.utilizationPct : null; - const window: ProviderUsageWindow = { - id: input.id, - label: input.label, - usedPct, - remainingPct: usedPct === null ? null : Math.max(0, 100 - usedPct), - resetsAt: input.resetsAt ?? null, - }; - if (input.tone) { - window.tone = input.tone; - } - return window; -} - -/** - * The tone scale for anything measured against a known limit, windows and balances alike. - * - * Thresholds match `deriveTone` in the app's provider-usage/tone.ts, which is what the - * client falls back to when a window arrives without a tone. Healthy is "ok" rather than - * "default" because that is what every provider setting a tone has always sent, and it is - * what the bars render today below their thresholds. - */ -export function toneFromUsedPct(usedPct: number | null | undefined): ProviderUsageTone { - if (typeof usedPct !== "number") return "default"; - if (usedPct > 90) return "danger"; - if (usedPct >= 70) return "warning"; - return "ok"; -} - -/** - * Tone for a balance with no known limit, where a percentage cannot be computed and the - * only signal is whether anything is left. Prefer `toneFromUsedPct` when a limit exists: - * this one stays "ok" until the balance is completely spent. - */ -export function balanceToneFromRemaining( - remaining: number | null | undefined, -): ProviderUsageBalance["tone"] { - if (typeof remaining !== "number") return "default"; - if (remaining <= 0) return "danger"; - return "ok"; -} - -/** Percentage of a limit consumed, or null when either side is unknown. */ -export function usedPctOf( - used: number | null | undefined, - limit: number | null | undefined, -): number | null { - if (typeof used !== "number" || typeof limit !== "number" || limit <= 0) return null; - return (used / limit) * 100; -} - -export function toIsoStringOrNull(timestampMs: number): string | null { - const date = new Date(timestampMs); - return Number.isFinite(date.getTime()) ? date.toISOString() : null; -} diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.ts b/packages/server/src/terminal/agent-hooks/claude/claude.ts index 35bfa9eca5..82ddcf770d 100644 --- a/packages/server/src/terminal/agent-hooks/claude/claude.ts +++ b/packages/server/src/terminal/agent-hooks/claude/claude.ts @@ -40,8 +40,8 @@ function isIdlePrompt(raw: string | null): boolean { try { const notification = JSON.parse(raw) as unknown; if (!notification || typeof notification !== "object") return false; - const payload = notification as { matcher?: unknown; reason?: unknown }; - return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt"; + const payload = notification as { notification_type?: unknown }; + return payload.notification_type === "idle_prompt"; } catch { return false; } diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts index 5fce566cd5..1a76e17a44 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts @@ -1,37 +1,72 @@ import type { AgentHookPluginFileInstallStrategy } from "../agent-hook-installer.js"; +// Both generations discover the same file. Their loaders select the entrypoint: +// OpenCode 1 calls server(); OpenCode 2 calls setup(). Keep their event contracts +// separate: V1 publishes status snapshots, V2 publishes execution transitions. export const OPENCODE_PLUGIN_SOURCE = [ - "const STATUS_EVENTS = {", + "const V1_STATUS_EVENTS = {", ' busy: "session.status.busy",', ' retry: "session.status.retry",', ' idle: "session.status.idle",', "};", "", - "function paseoEventFor(event) {", - ' if (event?.type === "permission.asked") return "permission.asked";', - ' if (event?.type === "permission.replied") return "permission.replied";', - ' if (event?.type !== "session.status") return null;', - " return STATUS_EVENTS[event?.properties?.status?.type] ?? null;", + "const V2_EVENTS = {", + ' "session.execution.started": "session.status.busy",', + ' "session.execution.succeeded": "session.status.idle",', + ' "session.execution.failed": "session.status.idle",', + ' "session.execution.interrupted": "session.status.idle",', + ' "permission.asked": "permission.asked",', + ' "permission.replied": "permission.replied",', + "};", + "", + "function paseoEventForV1(event) {", + " const type = event.type;", + ' if (type === "permission.asked") return "permission.asked";', + ' if (type === "permission.replied") return "permission.replied";', + ' if (type !== "session.status") return null;', + " return V1_STATUS_EVENTS[event.properties.status.type] ?? null;", "}", "", + // CLI processes can finish out of order, especially for immediate failures. + // Both entrypoints enqueue reports so an older busy report cannot overwrite idle. + "let pendingHook = Promise.resolve();", + "", "function runPaseoHook(event) {", " if (!process.env.PASEO_TERMINAL_ID) return;", - " try {", - ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', - ' stdin: "ignore",', - ' stdout: "ignore",', - ' stderr: "ignore",', - " });", - " void child.exited.catch(() => {});", - " } catch {}", + " pendingHook = pendingHook.then(async () => {", + " try {", + ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', + ' stdin: "ignore",', + ' stdout: "ignore",', + ' stderr: "ignore",', + " });", + " await child.exited;", + " } catch {}", + " });", + " return pendingHook;", "}", "", - "export default async () => ({", - " event: async ({ event }) => {", - " const paseoEvent = paseoEventFor(event);", - " if (paseoEvent) runPaseoHook(paseoEvent);", + "export default {", + ' id: "paseo-terminal-activity",', + " server() {", + " return {", + " event: async ({ event }) => {", + " const paseoEvent = paseoEventForV1(event);", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " },", + " };", " },", - "});", + " setup(ctx) {", + " const controller = new AbortController();", + " void (async () => {", + " for await (const event of ctx.event.subscribe({ signal: controller.signal })) {", + " const paseoEvent = V2_EVENTS[event.type];", + " if (paseoEvent) await runPaseoHook(paseoEvent);", + " }", + " })().catch(() => {});", + " return () => controller.abort();", + " },", + "};", "", ].join("\n"); diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts index 6ba28b22ee..7379e5e9d3 100644 --- a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts @@ -1,6 +1,9 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { addAbortSignal, PassThrough, Readable } from "node:stream"; +import { finished } from "node:stream/promises"; +import { runInNewContext } from "node:vm"; import { afterEach, describe, expect, it } from "vitest"; import { agentHooksAreInstalled, @@ -40,18 +43,123 @@ describe("OpenCode terminal agent hooks", () => { expect(agentHooksAreInstalled(opencodeAgentHookProvider, { configDir })).toBe(true); }); - it("writes the plugin that maps OpenCode bus events to paseo hook events", () => { - const configDir = createTempDir("paseo-opencode-config-source-"); - const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); - const source = readFileSync(configPath, "utf8"); + it.each(["succeeded", "failed", "interrupted"])( + "reports OpenCode 2 execution start and %s as running then idle", + async (outcome) => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: `session.execution.${outcome}`, data: { sessionID: "session-1" } }, + ]); - expect(source).toContain('busy: "session.status.busy"'); - expect(source).toContain('retry: "session.status.retry"'); - expect(source).toContain('idle: "session.status.idle"'); - expect(source).toContain('event?.type === "permission.asked"'); - expect(source).toContain('event?.type === "permission.replied"'); - expect(source).toContain('Bun.spawn(["paseo", "hooks", "opencode", event]'); - expect(source).toContain("PASEO_TERMINAL_ID"); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }, + ); + + it("reports OpenCode 1 status and permission events through server()", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const hooks = plugin.server(); + for (const event of [ + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "permission.asked" }, + { type: "permission.replied" }, + { type: "session.status", properties: { status: { type: "retry" } } }, + { type: "session.status", properties: { status: { type: "idle" } } }, + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "message.updated" }, + ]) { + await hooks.event({ event }); + } + + expect(plugin.id).toBe("paseo-terminal-activity"); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ["paseo", "hooks", "opencode", "session.status.retry"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("reports OpenCode 2 permission events and ignores V1 status snapshots", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = Readable.from([ + { type: "permission.asked", data: { sessionID: "session-1" } }, + { type: "permission.replied", data: { sessionID: "session-1" } }, + { type: "session.status", properties: { status: { type: "busy" } } }, + { type: "session.text.delta", data: { sessionID: "session-1" } }, + ]); + + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "permission.asked"], + ["paseo", "hooks", "opencode", "permission.replied"], + ]); + }); + + it("preserves activity order when the host dispatches events concurrently", async () => { + let finishFirstHook = () => {}; + const firstExit = new Promise((resolve) => { + finishFirstHook = () => resolve(0); + }); + const { plugin, commands } = loadInstalledPlugin("terminal-1", firstExit); + const hooks = plugin.server(); + + const working = hooks.event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const idle = hooks.event({ + event: { type: "session.status", properties: { status: { type: "idle" } } }, + }); + await Promise.resolve(); + expect(commands).toEqual([["paseo", "hooks", "opencode", "session.status.busy"]]); + + finishFirstHook(); + await Promise.all([working, idle]); + expect(commands).toEqual([ + ["paseo", "hooks", "opencode", "session.status.busy"], + ["paseo", "hooks", "opencode", "session.status.idle"], + ]); + }); + + it("stops the OpenCode 2 subscription when unloaded", async () => { + const { plugin, commands } = loadInstalledPlugin(); + const events = new PassThrough({ objectMode: true }); + const closed = finished(events); + const dispose = plugin.setup({ + event: { subscribe: ({ signal }) => addAbortSignal(signal, events) }, + }); + + dispose(); + + await expect(closed).rejects.toMatchObject({ name: "AbortError" }); + expect(commands).toEqual([]); + }); + + it("keeps both generations inert outside Paseo terminals", async () => { + const { plugin, commands } = loadInstalledPlugin(""); + await plugin.server().event({ + event: { type: "session.status", properties: { status: { type: "busy" } } }, + }); + const events = Readable.from([ + { type: "session.execution.started", data: { sessionID: "session-1" } }, + { type: "permission.asked", data: { sessionID: "session-1" } }, + ]); + const dispose = plugin.setup({ event: { subscribe: () => events } }); + await finished(events); + dispose(); + + expect(commands).toEqual([]); }); it("uninstalls the OpenCode plugin file", () => { @@ -121,3 +229,39 @@ describe("OpenCode terminal agent hooks", () => { ).resolves.toBe(state); }); }); + +interface OpenCodeEvent { + type: string; + properties?: { status: { type: string } }; + data?: { sessionID: string }; +} + +interface InstalledPlugin { + id: string; + server(): { event(input: { event: OpenCodeEvent }): Promise }; + setup(context: { + event: { subscribe(options: { signal: AbortSignal }): AsyncIterable }; + }): () => void; +} + +function loadInstalledPlugin(terminalId = "terminal-1", exited = Promise.resolve(0)) { + const configDir = createTempDir("paseo-opencode-runtime-"); + const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); + const source = readFileSync(configPath, "utf8"); + const commands: string[][] = []; + // Supply the host runtime at the script boundary; execute the installed source. + const plugin: InstalledPlugin = runInNewContext( + source.replace("export default", "globalThis.plugin ="), + { + AbortController, + process: { env: { PASEO_TERMINAL_ID: terminalId } }, + Bun: { + spawn(command: string[]) { + commands.push(command); + return { exited }; + }, + }, + }, + ); + return { plugin, commands }; +} diff --git a/packages/server/src/terminal/terminal-session-controller.resize.test.ts b/packages/server/src/terminal/terminal-session-controller.resize.test.ts index 63181b2482..c84b03cab7 100644 --- a/packages/server/src/terminal/terminal-session-controller.resize.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.resize.test.ts @@ -5,7 +5,12 @@ import { encodeTerminalResizePayload, type TerminalStreamFrame, } from "@getpaseo/protocol/binary-frames/index"; -import type { SessionOutboundMessage, TerminalState } from "@getpaseo/protocol/messages"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; +import type { + SessionInboundMessage, + SessionOutboundMessage, + TerminalState, +} from "@getpaseo/protocol/messages"; import type pino from "pino"; import { TerminalSessionController } from "./terminal-session-controller.js"; @@ -80,44 +85,57 @@ describe("terminal session controller size ownership", () => { const { terminalManager, appliedSizes } = createFixture(); const controllerA = createController(terminalManager); const controllerB = createController(terminalManager); + const sourceA = {}; + const sourceB = {}; + const ownership = new SessionDelivery(() => {}); + // Legacy binary resize and attach sizing remain source scoped. + const dispatch = ( + controller: TerminalSessionController, + source: object, + message: SessionInboundMessage, + ) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 30, cols: 100, intent: "claim" }, }); - controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 31, cols: 101, intent: "update" }, }); - controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 32, cols: 102, intent: "update" }, }); - await controllerB.dispatch({ + await dispatch(controllerB, sourceB, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-b", restore: { mode: "live" }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" })); - controllerA.dispatch({ + controllerB.handleBinaryFrame(resizeFrame({ rows: 33, cols: 103, intent: "claim" }), sourceB); + await dispatch(controllerA, sourceA, { type: "terminal_input", terminalId: "terminal-1", message: { type: "resize", rows: 34, cols: 104, intent: "update" }, }); - await controllerA.dispatch({ + await dispatch(controllerA, sourceA, { type: "subscribe_terminal_request", terminalId: "terminal-1", requestId: "subscribe-a", restore: { mode: "live", size: { rows: 35, cols: 105 } }, }); - controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" })); + controllerB.handleBinaryFrame(resizeFrame({ rows: 36, cols: 106, intent: "update" }), sourceB); + await ownership.close(); expect(appliedSizes).toEqual(["100x30", "101x31", "103x33", "105x35"]); }); }); diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index 16ff7b64e6..b438af8381 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; -import type pino from "pino"; +import pino from "pino"; -import type { SessionOutboundMessage } from "../server/messages.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../server/messages.js"; import { TerminalStreamOpcode, decodeTerminalStreamFrame, @@ -9,11 +9,41 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import type { ServerMessage, TerminalSession, TerminalStateSnapshot } from "./terminal.js"; -import { TerminalSessionController } from "./terminal-session-controller.js"; +import { + TerminalSessionController, + type TerminalSessionControllerOptions, +} from "./terminal-session-controller.js"; +import { SessionDelivery } from "../server/session/owned-subscriptions/index.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; import { isSameOrDescendantPath } from "../server/path-utils.js"; import { PluginSessionSocket } from "../server/plugins/session-socket.js"; +function createController( + options: TerminalSessionControllerOptions & { emitBinary(frame: Uint8Array): void }, +) { + const source = {}; + const ownership = new SessionDelivery( + (_source, message) => options.emit(message), + (_source, frame) => options.emitBinary(frame), + ); + ownership.attach(source, false); + const controller = new TerminalSessionController({ + ...options, + emit: (message) => { + if (!ownership.reply(message)) options.emit(message); + }, + }); + return { + start: () => controller.start(), + dispatch: (message: SessionInboundMessage) => + ownership.request(source, message, async () => { + await controller.dispatch(message, ownership); + }), + hasDirectorySubscription: (input: { cwd: string; workspaceId?: string }) => + controller.hasDirectorySubscription(input), + }; +} + function deferred(): { promise: Promise; resolve: (value: T) => void; @@ -102,7 +132,7 @@ describe("terminal-session-controller restore", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: (bytes) => { @@ -218,7 +248,7 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -293,14 +323,14 @@ describe("terminal-session-controller legacy terminal creation", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), hasBinaryChannel: () => true, isPathWithinRoot: isSameOrDescendantPath, sessionLogger: createLogger(), - listTerminalWorkspaceRefs: async () => [], + listTerminalWorkspaceRefs: async () => [{ workspaceId: "ws-1", cwd: "/work/repo" }], }); await controller.dispatch({ @@ -329,7 +359,7 @@ async function waitForCoalescerFlush(): Promise { describe("terminal-session-controller wrap-flag gating", () => { function setup(clientSupportsWrapReflow?: () => boolean): { - controller: TerminalSessionController; + controller: ReturnType; getTerminalState: ReturnType; } { const terminal: TerminalSession = { @@ -379,7 +409,7 @@ describe("terminal-session-controller wrap-flag gating", () => { subscribeTerminalActivity: vi.fn(() => vi.fn()), subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), } as unknown as TerminalManager; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: vi.fn(), @@ -391,7 +421,7 @@ describe("terminal-session-controller wrap-flag gating", () => { return { controller, getTerminalState }; } - async function subscribe(controller: TerminalSessionController): Promise { + async function subscribe(controller: ReturnType): Promise { await controller.dispatch({ type: "subscribe_terminal_request", terminalId: "term-1", @@ -457,7 +487,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -521,7 +551,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { subscribeTerminalWorkspaceContributionChanged: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -548,7 +578,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1", workspaceId: "ws-test", activity: null }, + { + id: "root-term", + name: "Terminal 1", + cwd: rootCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-root", }, @@ -558,7 +594,13 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: worktreeCwd, terminals: [ - { id: "worktree-term", name: "Feature", workspaceId: "ws-test", activity: null }, + { + id: "worktree-term", + name: "Feature", + cwd: worktreeCwd, + workspaceId: "ws-test", + activity: null, + }, ], requestId: "req-worktree", }, @@ -605,7 +647,7 @@ describe("terminal-session-controller workspace-scoped subscriptions", () => { }; const outboundMessages: SessionOutboundMessage[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: (message) => outboundMessages.push(message), emitBinary: vi.fn(), @@ -702,7 +744,7 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { } as unknown as TerminalManager; const frames: TerminalStreamFrame[] = []; - const controller = new TerminalSessionController({ + const controller = createController({ terminalManager, emit: vi.fn(), emitBinary: (bytes) => { @@ -795,3 +837,442 @@ describe("terminal-session-controller backpressure snapshot fallback", () => { expect(frames.some((frame) => frame.opcode === TerminalStreamOpcode.Snapshot)).toBe(true); }); }); + +function exitFixture(readSnapshot: () => Promise, backedUp = false) { + const outputs = new Set<(message: ServerMessage) => void>(); + const exits = new Set[0]>(); + const terminal: TerminalSession = { + id: "exit-terminal", + name: "Terminal", + cwd: "/unused", + workspaceId: "workspace", + send() {}, + subscribe(listener) { + outputs.add(listener); + return () => { + outputs.delete(listener); + }; + }, + onExit(listener) { + exits.add(listener); + return () => { + exits.delete(listener); + }; + }, + onCommandFinished: () => () => {}, + onTitleChange: () => () => {}, + onActivityChange: () => () => {}, + getSize: () => ({ rows: 1, cols: 80 }), + getState: () => terminalState("snapshot"), + getStateSnapshot: () => ({ state: terminalState("snapshot"), revision: 0 }), + getReplayPreamble: () => "", + getTitle: () => undefined, + getActivity: () => null, + setActivity() {}, + clearActivityAttention: () => false, + setTitle() {}, + getExitInfo: () => null, + kill() {}, + async killAndWait() {}, + }; + const manager: TerminalManager = { + getTerminal: () => terminal, + getTerminalState: readSnapshot, + getTerminals: async () => [terminal], + createTerminal: async () => terminal, + registerCwdEnv() {}, + validateTerminalActivityToken: () => "unknown", + setTerminalTitle: () => true, + setTerminalActivity: async () => true, + clearTerminalAttention: async () => false, + killTerminal() {}, + async killTerminalAndWait() {}, + captureTerminal: async () => ({ lines: [], totalLines: 0 }), + listDirectories: () => [], + killAll() {}, + subscribeTerminalsChanged: () => () => {}, + subscribeTerminalActivity: () => () => {}, + subscribeTerminalWorkspaceContributionChanged: () => () => {}, + }; + const frames: Array<{ + source: object; + message?: SessionOutboundMessage; + binary?: TerminalStreamFrame; + }> = []; + const delivery = new SessionDelivery( + (source, message) => frames.push({ source, message }), + (source, bytes) => { + const binary = decodeTerminalStreamFrame(bytes); + if (binary) frames.push({ source, binary }); + }, + ); + const controller = new TerminalSessionController({ + terminalManager: manager, + emit: (message) => { + delivery.reply(message); + }, + hasBinaryChannel: () => true, + isPathWithinRoot: () => false, + sessionLogger: pino({ level: "silent" }), + getClientBufferedAmount: () => (backedUp ? 8 * 1024 * 1024 : 0), + }); + return { + frames, + delivery, + controller, + outputs, + exits, + subscribe: async (source: object, modern: boolean, requestId: string) => { + delivery.attach(source, modern); + const request = { + type: "subscribe_terminal_request" as const, + terminalId: terminal.id, + requestId, + }; + await delivery.request(source, request, async () => { + await controller.dispatch(request, delivery); + }); + const reply = frames.find( + (row) => + row.message?.type === "subscribe_terminal_response" && + row.message.payload.requestId === requestId, + )?.message; + if (reply?.type !== "subscribe_terminal_response" || reply.payload.error !== null) + throw new Error("Missing stream acknowledgement"); + return { source, slot: reply.payload.slot, id: reply.payload.subscriptionId }; + }, + output: (data: string, revision: number) => { + for (const listener of outputs) listener({ type: "output", data, revision }); + }, + exit: () => { + for (const listener of exits) listener({ exitCode: 0, signal: null, lastOutputLines: [] }); + }, + }; +} + +describe("terminal output before natural exit", () => { + test.each([true, false])( + "flushes each observer's final bytes before exit (modern=%s)", + async (modern) => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + try { + const source = {}; + const owners = [ + await f.subscribe(source, modern, "first"), + await f.subscribe(modern ? source : {}, modern, "second"), + ]; + await vi.advanceTimersByTimeAsync(0); + f.output("FIRST-OUTPUT", 1); + await vi.advanceTimersByTimeAsync(5); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + for (const owner of owners) { + const ordered = f.frames.filter( + (row) => + row.source === owner.source && + (row.binary?.slot === owner.slot || + (row.message?.type === "terminal_stream_exit" && + row.message.payload.subscriptionId === owner.id)), + ); + expect(ordered.map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(ordered[2].binary!.payload).toString()).toBe("FINAL-BYTES"); + } + if (modern) expect(new Set(owners.map((owner) => owner.id)).size).toBe(2); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("waits for bootstrap snapshot and flushes buffered bytes without starting backpressure work", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture(() => { + reads++; + return snapshot.promise; + }, true); + try { + await f.subscribe({}, true, "bootstrap"); + f.output("x".repeat(300 * 1024), 1); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.slice(1).map((row) => row.binary?.opcode ?? row.message?.type)).toEqual([ + TerminalStreamOpcode.Snapshot, + TerminalStreamOpcode.Output, + "terminal_stream_exit", + ]); + expect(Buffer.from(f.frames[2].binary!.payload).toString()).toBe( + "x".repeat(300 * 1024) + "FINAL-BYTES", + ); + expect(reads).toBe(1); + expect(f.delivery.registrationCount).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test.each([true, false, "rejected"])( + "finishes an in-flight backpressure snapshot (available=%s)", + async (available) => { + vi.useFakeTimers(); + const snapshot = deferred(); + let reads = 0; + const f = exitFixture( + async () => + ++reads === 1 ? { state: terminalState("initial"), revision: 0 } : snapshot.promise, + true, + ); + try { + await f.subscribe({}, true, "pressure"); + await vi.advanceTimersByTimeAsync(0); + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(2); + f.output("FINAL-BYTES", 2); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.some((row) => row.message?.type === "terminal_stream_exit")).toBe(false); + if (available === "rejected") snapshot.reject(new Error("Snapshot failed during exit")); + else + snapshot.resolve( + available ? { state: terminalState("pressure-snapshot"), revision: 1 } : null, + ); + await vi.advanceTimersByTimeAsync(0); + const output = f.frames + .filter((row) => row.binary?.opcode === TerminalStreamOpcode.Output) + .map((row) => Buffer.from(row.binary!.payload).toString()) + .join(""); + expect(output).toBe((available === true ? "" : "x".repeat(300 * 1024)) + "FINAL-BYTES"); + expect(f.frames.at(-1)?.message?.type).toBe("terminal_stream_exit"); + expect(f.delivery.registrationCount).toBe(0); + expect(reads).toBe(2); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during exit's pending snapshot suppresses late delivery", async () => { + vi.useFakeTimers(); + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("FINAL-BYTES", 1); + f.exit(); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.resolve({ state: terminalState("snapshot"), revision: 0 }); + await release; + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + vi.useRealTimers(); + } + }); + + test("explicit release discards only that observer's trailing output", async () => { + vi.useFakeTimers(); + const f = exitFixture(async () => ({ state: terminalState("snapshot"), revision: 0 })); + const source = {}; + try { + const a = await f.subscribe(source, true, "a"); + const b = await f.subscribe(source, true, "b"); + await vi.advanceTimersByTimeAsync(0); + f.output("FINAL-BYTES", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: a.id!, + }; + await f.delivery.request(source, request, () => f.delivery.release(a.id!)); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === a.slot && + row.binary.opcode === TerminalStreamOpcode.Output, + ), + ).toBe(false); + expect( + f.frames.some( + (row) => + row.binary && + row.binary.slot === b.slot && + row.binary.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "FINAL-BYTES", + ), + ).toBe(true); + expect(f.delivery.registrationCount).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }); +}); + +describe("terminal snapshot failure", () => { + test.each(["initial", "backpressure"])( + "releases only the failed observer after %s rejection", + async (phase) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (phase === "backpressure" ? 2 : 1)) + throw new Error("Terminal worker request timed out: getTerminalState"); + return { state: terminalState("healthy"), revision: 0 }; + }, phase === "backpressure"); + try { + const source = {}; + const failed = await f.subscribe(source, true, "failed"); + await vi.advanceTimersByTimeAsync(0); + if (phase === "backpressure") { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + const healthy = await f.subscribe(source, true, "healthy"); + await vi.advanceTimersByTimeAsync(0); + f.output("SIBLING-ALIVE", 2); + await vi.advanceTimersByTimeAsync(5); + expect(f.delivery.registrationCount).toBe(1); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(1); + expect(f.outputs.size).toBe(1); + expect(f.exits.size).toBe(1); + expect( + f.frames + .filter((row) => row.message?.type === "terminal_stream_exit") + .map((row) => row.message), + ).toEqual([ + { + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + subscriptionId: failed.id, + error: "Terminal worker request timed out: getTerminalState", + }, + }, + ]); + expect( + f.frames.some( + (row) => + row.binary?.slot === healthy.slot && + row.binary?.opcode === TerminalStreamOpcode.Output && + Buffer.from(row.binary.payload).toString() === "SIBLING-ALIVE", + ), + ).toBe(true); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { + terminalId: "exit-terminal", + subscriptionId: healthy.id, + }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, + ); + + test("release during pending rejection suppresses the failure and completes cleanup", async () => { + const snapshot = deferred(); + const f = exitFixture(() => snapshot.promise); + const source = {}; + try { + const owner = await f.subscribe(source, true, "pending"); + f.output("BUFFERED", 1); + const request = { + type: "subscription.release.request" as const, + requestId: "release", + subscriptionId: owner.id!, + }; + const release = f.delivery.request(source, request, () => f.delivery.release(owner.id!)); + snapshot.reject(new Error("Terminal worker request timed out: getTerminalState")); + await release; + expect(f.frames.map((row) => row.message?.type)).toEqual(["subscribe_terminal_response"]); + expect(f.delivery.registrationCount).toBe(0); + expect(f.controller.getMetrics().streamSubscriptionCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + snapshot.resolve(null); + await f.delivery.close(); + } + }); +}); + +test.each([false, true])( + "legacy snapshot failure retries without reporting a live PTY exited (backpressure=%s)", + async (backpressure) => { + vi.useFakeTimers(); + let reads = 0; + const f = exitFixture(async () => { + if (++reads === (backpressure ? 2 : 1)) throw new Error("Snapshot unavailable"); + return { state: terminalState("recovered"), revision: 0 }; + }, backpressure); + try { + await f.subscribe({}, false, "legacy-recovery"); + await vi.advanceTimersByTimeAsync(0); + if (backpressure) { + f.output("x".repeat(300 * 1024), 1); + await vi.advanceTimersByTimeAsync(5); + } + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toEqual([]); + f.output("RECOVERED-OUTPUT", 2); + for (const listener of f.outputs) listener({ type: "snapshotReady", revision: 2 }); + await vi.advanceTimersByTimeAsync(5); + expect(reads).toBe(backpressure ? 3 : 2); + expect(f.frames.some((row) => row.binary?.opcode === TerminalStreamOpcode.Snapshot)).toBe( + true, + ); + f.exit(); + await vi.advanceTimersByTimeAsync(0); + expect(f.frames.filter((row) => row.message?.type === "terminal_stream_exit")).toHaveLength( + 1, + ); + expect(f.frames.at(-1)?.message).toEqual({ + type: "terminal_stream_exit", + payload: { terminalId: "exit-terminal" }, + }); + expect(f.delivery.registrationCount).toBe(0); + expect(f.outputs.size + f.exits.size).toBe(0); + } finally { + await f.delivery.close(); + vi.useRealTimers(); + } + }, +); diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 50c4109d06..4f46dbc88c 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -1,4 +1,8 @@ import type pino from "pino"; +import type { + OwnedSubscription, + SessionDelivery, +} from "../server/session/owned-subscriptions/index.js"; import type { CaptureTerminalRequest, CreateTerminalRequest, @@ -39,6 +43,14 @@ import { terminalSubscriptionKey } from "@getpaseo/protocol/terminal-subscriptio const MAX_TERMINAL_STREAM_SLOTS = 256; +interface TerminalDirectorySubscription { + cwd: string; + workspaceId: string | undefined; + owner: OwnedSubscription; + refresh: Promise | null; + pending: boolean; +} + interface BufferedTerminalOutput { data: string; revision?: number; @@ -47,9 +59,14 @@ interface BufferedTerminalOutput { interface ActiveTerminalStream { terminalId: string; slot: number; + owner: OwnedSubscription; + snapshotTask?: Promise; + snapshotOutput?: Buffer; + exiting: boolean; unsubscribe: () => void; needsSnapshot: boolean; snapshotInFlight: boolean; + retrySnapshotErrors: boolean; readyRevision?: number; restore?: TerminalRestoreOptions; bufferedOutputs: BufferedTerminalOutput[]; @@ -65,7 +82,6 @@ interface SnapshotSendResult { export interface TerminalSessionControllerOptions { terminalManager: TerminalManager | null; emit: (msg: SessionOutboundMessage) => void; - emitBinary: (frame: Uint8Array) => void; hasBinaryChannel: () => boolean; isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; sessionLogger: pino.Logger; @@ -74,7 +90,7 @@ export interface TerminalSessionControllerOptions { // Whether the connected client can reflow restored snapshots. When true the // daemon attaches per-row soft-wrap flags to snapshots; otherwise it omits them // so old (strict-schema) clients still parse the snapshot. - clientSupportsWrapReflow?: () => boolean; + clientSupportsWrapReflow?: (source: object) => boolean; // Current max bytes queued on the client's transport(s) but not yet sent. // Drives the snapshot catch-up fallback: a keeping-up client reports ~0 and // keeps streaming; a backed-up client trips the snapshot path. Defaults to a @@ -82,7 +98,7 @@ export interface TerminalSessionControllerOptions { // stream. // Bytes queued on the client transport but not yet sent, or null when the // transport exposes no backpressure signal (e.g. the multiplexed relay socket). - getClientBufferedAmount?: () => number | null; + getClientBufferedAmount?: (source: object) => number | null; } interface TerminalWorkspaceRef { @@ -123,34 +139,22 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = export class TerminalSessionController { private readonly terminalManager: TerminalManager | null; private readonly emit: (msg: SessionOutboundMessage) => void; - private readonly emitBinary: (frame: Uint8Array) => void; private readonly hasBinaryChannel: () => boolean; private readonly isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean; private readonly sessionLogger: pino.Logger; private readonly listTerminalWorkspaceRefs: () => Promise; private readonly listTerminalWorkspaceRoots: () => Promise; - private readonly clientSupportsWrapReflow: () => boolean; - private readonly getClientBufferedAmount: () => number | null; - private readonly terminalSizeOwner = {}; + private readonly clientSupportsWrapReflow: (source: object) => boolean; + private readonly getClientBufferedAmount: (source: object) => number | null; - // A subscription is scoped to a (cwd, workspaceId) pair, keyed by - // terminalSubscriptionKey: two workspaces sharing a cwd subscribe and unsub - // independently, and each only receives its own workspace's terminals. The - // workspaceId is absent for old clients, which key to the cwd alone. - private readonly subscribedDirectories = new Map< - string, - { cwd: string; workspaceId: string | undefined } - >(); + private readonly subscribedDirectories = new Map(); private unsubscribeTerminalsChanged: (() => void) | null = null; - private readonly exitSubscriptions = new Map void>(); private readonly activeStreams = new Map(); - private readonly idToSlot = new Map(); private nextSlot = 0; constructor(options: TerminalSessionControllerOptions) { this.terminalManager = options.terminalManager; this.emit = options.emit; - this.emitBinary = options.emitBinary; this.hasBinaryChannel = options.hasBinaryChannel; this.isPathWithinRoot = options.isPathWithinRoot; this.sessionLogger = options.sessionLogger; @@ -163,9 +167,7 @@ export class TerminalSessionController { } start(): void { - if (!this.terminalManager) { - return; - } + if (!this.terminalManager || this.unsubscribeTerminalsChanged) return; this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => { void this.handleTerminalsChanged(event); }); @@ -178,9 +180,16 @@ export class TerminalSessionController { }; } - async hasDirectorySubscription(input: { cwd: string; workspaceId?: string }): Promise { + async hasDirectorySubscription( + input: { cwd: string; workspaceId?: string }, + source?: object, + ): Promise { + const subscriptions = [...this.subscribedDirectories.values()].filter( + (subscription) => !source || subscription.owner.source === source, + ); + if (subscriptions.length === 0) return false; const workspaceRoots = await this.listTerminalWorkspaceRoots(); - return Array.from(this.subscribedDirectories.values()).some((subscription) => { + return subscriptions.some((subscription) => { if ( subscription.workspaceId !== undefined && subscription.workspaceId !== input.workspaceId @@ -191,28 +200,28 @@ export class TerminalSessionController { }); } - dispatch(msg: SessionInboundMessage): Promise | undefined { + dispatch(msg: SessionInboundMessage, ownership: SessionDelivery): Promise | undefined { if (!isTerminalMessage(msg)) { return undefined; } switch (msg.type) { case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - return undefined; + return this.handleSubscribeTerminalsRequest(msg, ownership); case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - return undefined; + return ownership.releaseLegacySlot( + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); case "list_terminals_request": return this.handleListTerminalsRequest(msg); case "create_terminal_request": return this.handleCreateTerminalRequest(msg); case "subscribe_terminal_request": - return this.handleSubscribeTerminalRequest(msg); + return this.handleSubscribeTerminalRequest(msg, ownership); case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - return undefined; + return ownership.releaseLegacySlot(`terminal-output:${msg.terminalId}`); case "terminal_input": - this.handleTerminalInput(msg); + if (!ownership.currentSource) throw new Error("Terminal input requires a source"); + this.handleTerminalInput(msg, ownership.currentSource); return undefined; case "kill_terminal_request": return this.handleKillTerminalRequest(msg); @@ -225,9 +234,9 @@ export class TerminalSessionController { } } - handleBinaryFrame(frame: TerminalStreamFrame): void { + handleBinaryFrame(frame: TerminalStreamFrame, source: object): void { const activeStream = this.activeStreams.get(frame.slot); - if (!activeStream || !this.terminalManager) { + if (!activeStream || activeStream.owner.source !== source || !this.terminalManager) { return; } const terminal = this.terminalManager.getTerminal(activeStream.terminalId); @@ -254,7 +263,7 @@ export class TerminalSessionController { if (!resize) { return; } - applyTerminalSize(terminal, this.terminalSizeOwner, resize); + applyTerminalSize(terminal, source, resize); return; } @@ -289,52 +298,13 @@ export class TerminalSessionController { } this.subscribedDirectories.clear(); - for (const unsubscribeExit of this.exitSubscriptions.values()) { - unsubscribeExit(); + for (const stream of this.activeStreams.values()) { + void stream.owner + .release() + .catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - this.exitSubscriptions.clear(); - - for (const terminalId of Array.from(this.idToSlot.keys())) { - this.detachStream(terminalId, { emitExit: false }); - } - } - - private ensureExitSubscription(terminal: TerminalSession): void { - if (this.exitSubscriptions.has(terminal.id)) { - return; - } - const unsubscribeExit = terminal.onExit(() => { - this.handleTerminalExited(terminal.id); - }); - this.exitSubscriptions.set(terminal.id, unsubscribeExit); - } - - private handleTerminalExited(terminalId: string): void { - const unsubscribeExit = this.exitSubscriptions.get(terminalId); - if (unsubscribeExit) { - unsubscribeExit(); - this.exitSubscriptions.delete(terminalId); - } - this.detachStream(terminalId, { emitExit: true }); - } - - private emitTerminalsChangedSnapshot(input: { - cwd: string; - terminals: Array<{ - id: string; - name: string; - workspaceId: string; - title?: string; - activity: TerminalActivity | null; - }>; - }): void { - this.emit({ - type: "terminals_changed", - payload: { - cwd: input.cwd, - terminals: input.terminals, - }, - }); } private toTerminalInfo( @@ -371,47 +341,78 @@ export class TerminalSessionController { } } - private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void { - const subscription = { cwd: msg.cwd, workspaceId: msg.workspaceId }; - this.subscribedDirectories.set(terminalSubscriptionKey(msg.cwd, msg.workspaceId), subscription); - void this.emitTerminalsSnapshotForSubscription(subscription); - } - - private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void { - this.subscribedDirectories.delete(terminalSubscriptionKey(msg.cwd, msg.workspaceId)); - } - - private async emitTerminalsSnapshotForSubscription(subscription: { - cwd: string; - workspaceId: string | undefined; - }): Promise { - const key = terminalSubscriptionKey(subscription.cwd, subscription.workspaceId); - if (!this.terminalManager || !this.subscribedDirectories.has(key)) { - return; - } + private async handleSubscribeTerminalsRequest( + msg: SubscribeTerminalsRequest, + ownership: SessionDelivery, + ): Promise { + if (!this.terminalManager) throw new Error("Terminal manager not available"); + let subscription: TerminalDirectorySubscription | undefined; + const owner = ownership.begin( + "terminal-directories", + undefined, + async (id) => { + this.subscribedDirectories.delete(id); + if (this.subscribedDirectories.size === 0) { + this.unsubscribeTerminalsChanged?.(); + this.unsubscribeTerminalsChanged = null; + } + await subscription?.refresh?.catch(() => undefined); + }, + `terminal-directory:${terminalSubscriptionKey(msg.cwd, msg.workspaceId)}`, + ); + subscription = { + cwd: msg.cwd, + workspaceId: msg.workspaceId, + owner, + refresh: null, + pending: false, + }; + this.subscribedDirectories.set(owner.id, subscription); + this.start(); try { - const terminals = await this.getTerminalsForWorkspaceRoot( - subscription.cwd, - subscription.workspaceId, - ); - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); - } - if (!this.subscribedDirectories.has(key)) { - return; - } - this.emitTerminalsChangedSnapshot({ - cwd: subscription.cwd, - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), - }); + await this.emitTerminalsSnapshotForSubscription(subscription, msg.requestId); } catch (error) { - this.sessionLogger.warn( - { err: error, cwd: subscription.cwd }, - "Failed to emit initial terminal snapshot", - ); + await owner.release(); + throw error; } } + private emitTerminalsSnapshotForSubscription( + subscription: TerminalDirectorySubscription, + requestId?: string, + ): Promise { + if (subscription.refresh) { + subscription.pending = true; + return subscription.refresh; + } + const refresh = (async () => { + do { + subscription.pending = false; + const terminals = await this.getTerminalsForWorkspaceRoot( + subscription.cwd, + subscription.workspaceId, + ); + if (subscription.owner.signal.aborted) return; + subscription.owner.emit({ + type: "terminals_changed", + payload: { + cwd: subscription.cwd, + workspaceId: subscription.workspaceId, + terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + ...(requestId ? { requestId } : {}), + }, + }); + requestId = undefined; + } while (subscription.pending); + })(); + subscription.refresh = refresh; + const settled = () => { + subscription.refresh = null; + }; + void refresh.then(settled, settled); + return refresh; + } + private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise { if (!this.terminalManager) { this.emit({ @@ -426,18 +427,23 @@ export class TerminalSessionController { } try { - const terminals = - typeof msg.cwd === "string" - ? await this.getTerminalsForWorkspaceRoot(msg.cwd, msg.workspaceId) - : await this.getAllTerminalSessions(); - for (const terminal of terminals) { - this.ensureExitSubscription(terminal); + let terminals: TerminalSession[]; + if (msg.workspaceId !== undefined) { + terminals = (await this.getAllTerminalSessions()).filter( + (terminal) => terminal.workspaceId === msg.workspaceId, + ); + } else if (typeof msg.cwd === "string") { + terminals = await this.getTerminalsForWorkspaceRoot(msg.cwd); + } else { + terminals = await this.getAllTerminalSessions(); } this.emit({ type: "list_terminals_response", payload: { ...(msg.cwd ? { cwd: msg.cwd } : {}), - terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)), + terminals: terminals.map((terminal) => + Object.assign(this.toTerminalInfo(terminal), { cwd: terminal.cwd }), + ), requestId: msg.requestId, }, }); @@ -463,7 +469,9 @@ export class TerminalSessionController { const terminalsByDirectory = await Promise.all( directories.map((cwd) => manager.getTerminals(cwd)), ); - return terminalsByDirectory.flat(); + return [ + ...new Map(terminalsByDirectory.flat().map((terminal) => [terminal.id, terminal])).values(), + ]; } private async getTerminalsForWorkspaceRoot( @@ -558,6 +566,11 @@ export class TerminalSessionController { return; } + const workspaces = await this.listTerminalWorkspaceRefs(); + if (!workspaces.some((workspace) => workspace.workspaceId === workspaceId)) { + throw new Error(`Workspace ${workspaceId} is not active or does not exist`); + } + const session = await this.terminalManager.createTerminal({ cwd: msg.cwd, workspaceId, @@ -567,7 +580,6 @@ export class TerminalSessionController { rows: msg.size?.rows, cols: msg.size?.cols, }); - this.ensureExitSubscription(session); this.emit({ type: "create_terminal_response", payload: { @@ -647,7 +659,10 @@ export class TerminalSessionController { respond(renamed, renamed ? null : "Terminal not found"); } - private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { + private async handleSubscribeTerminalRequest( + msg: SubscribeTerminalRequest, + ownership: SessionDelivery, + ): Promise { if (!this.terminalManager) { this.emit({ type: "subscribe_terminal_response", @@ -672,17 +687,32 @@ export class TerminalSessionController { }); return; } - this.ensureExitSubscription(session); - if (msg.restore?.size) { - applyTerminalSize(session, this.terminalSizeOwner, { + let stream: ActiveTerminalStream | undefined; + const owner = ownership.begin( + "terminal-output", + undefined, + async () => { + if (!stream) return; + this.detachRegistration(stream); + await stream.snapshotTask; + }, + `terminal-output:${msg.terminalId}`, + ); + // COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09 once client floor >= v0.8.0. + if (!ownership.isModern(owner.source) && msg.restore?.size) { + applyTerminalSize(session, owner.source, { ...msg.restore.size, intent: "claim", }); } - const slot = this.bindActiveStream(session, { restore: msg.restore }); + const slot = this.bindActiveStream(session, owner, { + restore: msg.restore, + retrySnapshotErrors: !ownership.isModern(owner.source), + }); if (slot === null) { + await owner.release(); this.sessionLogger.warn( { terminalId: msg.terminalId, @@ -706,22 +736,17 @@ export class TerminalSessionController { payload: { terminalId: msg.terminalId, slot, + subscriptionId: owner.responseId, error: null, requestId: msg.requestId, }, }); - const activeStream = this.activeStreams.get(slot); - if (activeStream) { - void this.trySendSnapshot(activeStream); - } + stream = this.activeStreams.get(slot); + if (stream) void this.trySendSnapshot(stream); } - private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void { - this.detachStream(msg.terminalId, { emitExit: false }); - } - - private handleTerminalInput(msg: TerminalInput): void { + private handleTerminalInput(msg: TerminalInput, source: object): void { if (!this.terminalManager) { return; } @@ -730,10 +755,9 @@ export class TerminalSessionController { this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input"); return; } - this.ensureExitSubscription(session); if (msg.message.type === "resize") { - applyTerminalSize(session, this.terminalSizeOwner, msg.message); + applyTerminalSize(session, source, msg.message); return; } @@ -746,12 +770,24 @@ export class TerminalSessionController { } private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise { - const result = this.killTerminalForClose(msg.terminalId); + let success = false; + if (this.terminalManager) { + try { + this.detachStream(msg.terminalId, { emitExit: true }); + await this.terminalManager.killTerminalAndWait(msg.terminalId); + success = true; + } catch (error) { + this.sessionLogger.error( + { err: error, terminalId: msg.terminalId }, + "Failed to kill terminal", + ); + } + } this.emit({ type: "kill_terminal_response", payload: { - terminalId: result.terminalId, - success: result.success, + terminalId: msg.terminalId, + success, requestId: msg.requestId, }, }); @@ -785,8 +821,6 @@ export class TerminalSessionController { return; } - this.ensureExitSubscription(session); - try { const capture = await this.terminalManager.captureTerminal(msg.terminalId, { start: msg.start, @@ -821,23 +855,13 @@ export class TerminalSessionController { private bindActiveStream( terminal: TerminalSession, - options?: { restore?: TerminalRestoreOptions }, + owner: OwnedSubscription, + options: { restore?: TerminalRestoreOptions; retrySnapshotErrors: boolean }, ): number | null { if (!this.hasBinaryChannel()) { return null; } - const existingSlot = this.idToSlot.get(terminal.id); - if (typeof existingSlot === "number") { - const existingStream = this.activeStreams.get(existingSlot); - if (existingStream) { - existingStream.needsSnapshot = true; - existingStream.restore = options?.restore; - return existingSlot; - } - this.idToSlot.delete(terminal.id); - } - const slot = this.allocateSlot(); if (slot === null) { return null; @@ -846,9 +870,12 @@ export class TerminalSessionController { const activeStream: ActiveTerminalStream = { terminalId: terminal.id, slot, + owner, unsubscribe: () => {}, needsSnapshot: true, snapshotInFlight: false, + retrySnapshotErrors: options.retrySnapshotErrors, + exiting: false, readyRevision: undefined, restore: options?.restore, bufferedOutputs: [], @@ -870,17 +897,21 @@ export class TerminalSessionController { // (e.g. the multiplexed relay socket); there we can't tell a slow client // from a fast one, so fall back unconditionally at the byte threshold to // keep a slow relay client from falling unboundedly behind. - const clientBufferedAmount = this.getClientBufferedAmount(); + const clientBufferedAmount = this.getClientBufferedAmount(owner.source); if ( + !activeStream.exiting && activeStream.outputBytesSinceSnapshot > MAX_TERMINAL_OUTPUT_FRAME_BYTES && (clientBufferedAmount === null || clientBufferedAmount > MAX_CLIENT_BUFFERED_BYTES) ) { + // The snapshot replaces this batch only after it succeeds. If the + // terminal disappears during the read, completion still owns these bytes. + activeStream.snapshotOutput = payload; activeStream.restore = resolveRestoreAfterOutputOverflow(activeStream.restore); activeStream.needsSnapshot = true; void this.trySendSnapshot(activeStream); return; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, slot, @@ -892,9 +923,8 @@ export class TerminalSessionController { }; this.activeStreams.set(slot, activeStream); - this.idToSlot.set(terminal.id, slot); - activeStream.unsubscribe = terminal.subscribe( + const unsubscribeOutput = terminal.subscribe( (message) => { if (this.activeStreams.get(slot) !== activeStream) { return; @@ -923,10 +953,24 @@ export class TerminalSessionController { }, { initialSnapshot: resolveTerminalSubscriptionSnapshotMode(options?.restore) }, ); + const unsubscribeExit = terminal.onExit(() => + this.detachStream(terminal.id, { emitExit: true }), + ); + activeStream.unsubscribe = () => { + unsubscribeOutput(); + unsubscribeExit(); + }; return slot; } - private async trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + private trySendSnapshot(activeStream: ActiveTerminalStream): Promise { + if (activeStream.snapshotInFlight) return activeStream.snapshotTask ?? Promise.resolve(); + const task = this.sendSnapshot(activeStream); + activeStream.snapshotTask = task; + return task; + } + + private async sendSnapshot(activeStream: ActiveTerminalStream): Promise { if ( this.activeStreams.get(activeStream.slot) !== activeStream || !activeStream.needsSnapshot || @@ -959,15 +1003,40 @@ export class TerminalSessionController { if (!snapshotResult.shouldContinue) { return; } + activeStream.snapshotOutput = undefined; this.replayTerminalOutputAfterSnapshot(activeStream, terminal, snapshotResult.replayRevision); activeStream.needsSnapshot = false; activeStream.outputBytesSinceSnapshot = 0; } catch (error) { + if (this.activeStreams.get(activeStream.slot) !== activeStream) return; this.sessionLogger.warn( { err: error, terminalId: activeStream.terminalId }, "Failed to pull terminal snapshot", ); - activeStream.needsSnapshot = true; + // Natural completion owns the buffered final bytes and waits for this read. + if (activeStream.exiting) return; + // COMPAT(terminalSnapshotErrors): restored in v0.8.0; remove after 2027-03-11 once client floor >= v0.8.0. + // Old clients interpret every stream exit as PTY exit. Preserve their + // attached stream after failure. Another snapshot notification can retry; + // this does not schedule recovery. Source teardown still releases the stream. + if (activeStream.retrySnapshotErrors) { + activeStream.needsSnapshot = true; + return; + } + activeStream.owner.emit({ + type: "terminal_stream_exit", + payload: { + terminalId: activeStream.terminalId, + error: error instanceof Error ? error.message : "Unable to read terminal snapshot", + }, + }); + // Cleanup removes this slot/listeners synchronously, then awaits this task. + // Awaiting release here would make the snapshot task wait for itself. + void activeStream.owner + .release() + .catch((releaseError) => + this.sessionLogger.warn({ err: releaseError }, "Failed to release terminal stream"), + ); } finally { activeStream.snapshotInFlight = false; } @@ -978,7 +1047,7 @@ export class TerminalSessionController { terminalManager: TerminalManager, ): Promise { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -988,7 +1057,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeLegacyTerminalSnapshotFrame({ slot: activeStream.slot, snapshot, @@ -1012,7 +1081,7 @@ export class TerminalSessionController { const snapshot = await terminalManager.getTerminalState(activeStream.terminalId, { ...snapshotOptions, - includeWrapFlags: this.clientSupportsWrapReflow(), + includeWrapFlags: this.clientSupportsWrapReflow(activeStream.owner.source), }); if (this.activeStreams.get(activeStream.slot) !== activeStream) { return { shouldContinue: false }; @@ -1022,7 +1091,7 @@ export class TerminalSessionController { return { shouldContinue: false }; } - this.emitBinary( + activeStream.owner.emitBinary( encodeTerminalRestoreFrame({ slot: activeStream.slot, snapshot, @@ -1073,33 +1142,44 @@ export class TerminalSessionController { } private detachStream(terminalId: string, options?: { emitExit: boolean }): boolean { - const slot = this.idToSlot.get(terminalId); - if (typeof slot !== "number") { - return false; + let detached = false; + for (const stream of this.activeStreams.values()) { + if (stream.terminalId !== terminalId) continue; + detached = true; + if (stream.exiting) continue; + stream.exiting = true; + const completion = options?.emitExit ? this.completeStream(stream) : stream.owner.release(); + void completion.catch((error) => + this.sessionLogger.warn({ err: error }, "Failed to release terminal stream"), + ); } - const activeStream = this.activeStreams.get(slot); - if (!activeStream) { - this.idToSlot.delete(terminalId); - return false; + return detached; + } + + private async completeStream(stream: ActiveTerminalStream): Promise { + // Preserve bootstrap/snapshot ordering. Explicit release can remove this + // registration while the read is pending, in which case it discards delivery. + await stream.snapshotTask; + if (this.activeStreams.get(stream.slot) !== stream) return; + if (stream.snapshotOutput) { + stream.outputCoalescer.handle(stream.snapshotOutput.toString("utf8")); + stream.snapshotOutput = undefined; } - activeStream.outputCoalescer.flush(); - activeStream.bufferedOutputs.length = 0; - this.activeStreams.delete(slot); - this.idToSlot.delete(terminalId); - try { - activeStream.unsubscribe(); - } catch (error) { - this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream"); + for (const output of stream.bufferedOutputs.splice(0)) { + stream.outputCoalescer.handle(output.data); } - if (options?.emitExit) { - this.emit({ - type: "terminal_stream_exit", - payload: { - terminalId: activeStream.terminalId, - }, - }); - } - return true; + // Completion must not turn this final flush into another backpressure read. + stream.outputCoalescer.flush(); + stream.owner.emit({ type: "terminal_stream_exit", payload: { terminalId: stream.terminalId } }); + await stream.owner.release(); + } + + private detachRegistration(stream: ActiveTerminalStream): void { + this.activeStreams.delete(stream.slot); + stream.outputCoalescer.dispose(); + stream.snapshotOutput = undefined; + stream.bufferedOutputs.length = 0; + stream.unsubscribe(); } } diff --git a/packages/server/src/terminal/terminal-size-ownership.ts b/packages/server/src/terminal/terminal-size-ownership.ts index 626ae8a634..788a771a0c 100644 --- a/packages/server/src/terminal/terminal-size-ownership.ts +++ b/packages/server/src/terminal/terminal-size-ownership.ts @@ -6,7 +6,7 @@ interface TerminalSizeRequest { intent?: "claim" | "update"; } -const terminalSizeOwners = new WeakMap(); +const terminalSizeOwners = new WeakMap>(); export function applyTerminalSize( terminal: TerminalSession, @@ -14,12 +14,12 @@ export function applyTerminalSize( request: TerminalSizeRequest, ): boolean { const intent = resolveTerminalSizeIntent(request.intent); - if (intent === "update" && terminalSizeOwners.get(terminal) !== owner) { + if (intent === "update" && terminalSizeOwners.get(terminal)?.deref() !== owner) { return false; } if (intent === "claim") { - terminalSizeOwners.set(terminal, owner); + terminalSizeOwners.set(terminal, new WeakRef(owner)); } const currentSize = terminal.getSize(); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 9881f4bdfd..f83b6b9ba3 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -24,6 +24,8 @@ import { } from "node:fs"; import { spawnSync } from "node:child_process"; import * as pty from "node-pty"; +import xterm from "@xterm/headless"; +import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; @@ -50,6 +52,10 @@ function getLines(state: ReturnType): string[] { return state.grid.map(rowToText); } +function rowHasChar(row: TerminalRow, char: string): boolean { + return row.some((cell) => cell.char === char); +} + // Wait for terminal state to match expected lines async function waitForLines( session: TerminalSession, @@ -523,6 +529,44 @@ describe("createTerminal", () => { expect(withoutFlags.scrollbackWrapped).toBeUndefined(); }); + it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => { + // Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair + // whose second cell has no chars. Capturing that continuation as " " made + // every restored syllable three columns wide (paseo#3434). + const text = "통합 구현은 전체 테스트를 통과했습니다"; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 80, + rows: 10, + command: process.execPath, + args: [ + "-e", + `process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`, + ], + }), + ); + + const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다")); + + // Replay the snapshot the way the client does on tab switch and read the + // row back the way a selection copy would. + const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true }); + try { + await new Promise((resolve) => + restored.write(renderTerminalSnapshotToAnsi(state), resolve), + ); + expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text); + } finally { + restored.dispose(); + } + + // The continuation cell must stay empty so the restore path can skip it. + expect(state.grid[0][0].char).toBe("통"); + expect(state.grid[0][1].char).toBe(""); + }); + it("captures exit diagnostics from the terminal buffer", async () => { const session = trackSession( await createTerminal({ diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index b9d1b74017..1e708062b8 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -580,7 +580,9 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal const bg = bgMode !== 0 ? cell.getBgColor() : undefined; return { - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, @@ -639,7 +641,9 @@ function extractScrollback( const fg = fgMode !== 0 ? cell.getFgColor() : undefined; const bg = bgMode !== 0 ? cell.getBgColor() : undefined; rowCells.push({ - char: cell.getChars() || " ", + // Wide chars (CJK) span two cells; xterm returns "" for the zero-width + // continuation. Collapsing it to " " injects a real space on restore. + char: cell.getWidth() === 0 ? "" : cell.getChars() || " ", fg, bg, fgMode: fgMode !== 0 ? fgMode : undefined, diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2dc999c0c3..efccd3dfb6 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as session.send({ type: "input", data: `${command}\r` }); - await waitForCondition(() => existsSync(markerPath), 10000); - - expect(readFileSync(markerPath, "utf8")).toBe("shell-ok"); + await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok"); }); it("lists subdirectory terminals when querying the workspace root", async () => { diff --git a/packages/server/src/utils/checkout-git-batching.test.ts b/packages/server/src/utils/checkout-git-batching.test.ts index 85bea20c49..18b84bd0c3 100644 --- a/packages/server/src/utils/checkout-git-batching.test.ts +++ b/packages/server/src/utils/checkout-git-batching.test.ts @@ -2,39 +2,8 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; - -const spawnCounters = vi.hoisted(() => ({ - trackedTextDiffCalls: 0, -})); - -vi.mock("child_process", async () => { - const actual = await vi.importActual("child_process"); - return { - ...actual, - spawn: (...args: Parameters) => { - const [command, commandArgs] = args; - if (command === "git" && Array.isArray(commandArgs)) { - const normalizedArgs = commandArgs.map((arg) => String(arg)); - // `runGitCommand` always prepends its two config overrides; skip them - // to find the actual git subcommand. - const subcommandIndex = - normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 4 : 0; - const isTrackedTextDiff = - normalizedArgs[subcommandIndex] === "diff" && - normalizedArgs.includes("HEAD") && - !normalizedArgs.includes("--numstat") && - !normalizedArgs.includes("--no-index") && - !normalizedArgs.includes("--shortstat") && - !normalizedArgs.includes("--name-status"); - if (isTrackedTextDiff) { - spawnCounters.trackedTextDiffCalls += 1; - } - } - return actual.spawn(...args); - }, - }; -}); +import { beforeEach, afterEach, describe, expect, it } from "vitest"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "@server/utils/run-git-command.js"; import { getCheckoutDiff } from "./checkout-git.js"; @@ -70,14 +39,14 @@ describe("checkout git diff batching", () => { const setup = initRepoWithTrackedChanges(20); tempDir = setup.tempDir; repoDir = setup.repoDir; - spawnCounters.trackedTextDiffCalls = 0; }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - it("uses per-file tracked git diff commands for tracked file diffs", async () => { + it("collects ordinary tracked patches with one bounded Git command", async () => { + startGitCommandMetrics(); const result = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: false, @@ -85,6 +54,44 @@ describe("checkout git diff batching", () => { expect(result.diff).toContain("file-0.txt"); expect(result.diff).toContain("file-19.txt"); - expect(spawnCounters.trackedTextDiffCalls).toBe(20); + const metrics = stopGitCommandMetrics(); + expect( + metrics.commands.filter(({ args }) => args[0] === "diff" && args.includes("--")), + ).toHaveLength(1); + }); + + it("batches committed file contents and preserves the highlighted diff", async () => { + execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoDir }); + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "change"], { cwd: repoDir }); + startGitCommandMetrics(); + const result = await getCheckoutDiff(repoDir, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + const metrics = stopGitCommandMetrics(); + expect(result.structured).toHaveLength(20); + expect(result.structured?.every((file) => file.additions === 1 && file.deletions === 1)).toBe( + true, + ); + expect(metrics.commands.filter(({ args }) => args[0] === "show")).toHaveLength(0); + expect(metrics.commands.filter(({ args }) => args[0] === "cat-file")).toHaveLength(1); + }); + + it("keeps small neighbors when a patch exceeds the entire batch budget", async () => { + writeFileSync(join(repoDir, "file-0.txt"), "x".repeat(9 * 1024 * 1024) + "\n"); + const result = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(result.structured).toHaveLength(20); + expect(result.structured?.find((file) => file.path === "file-0.txt")).toMatchObject({ + status: "too_large", + hunks: [], + }); + expect(result.structured?.filter((file) => file.status === "ok")).toHaveLength(19); + expect(result.diff).toContain("+after-19"); + expect(result.diff).not.toContain("x".repeat(1_000)); }); }); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 5a006ae0eb..d614e32f9b 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -25,7 +25,10 @@ function git(args: string[], cwd?: string): void { } function commit(repoDir: string, message: string): void { - git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); + git( + ["-c", "commit.gpgsign=false", "commit", "--author=Test User ", "-m", message], + repoDir, + ); } function commitFile(repoDir: string, name: string, content: string, message: string): void { @@ -92,6 +95,30 @@ function addBareRemote(repoDir: string, tempDir: string): string { } describe("listCheckoutCommits", () => { + it("keeps fixture authors independent of command-scoped Git configuration", async () => { + const overrides = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "Other User", + }; + const previous = Object.fromEntries( + Object.keys(overrides).map((key) => [key, process.env[key]]), + ); + let repoDir: string; + try { + Object.assign(process.env, overrides); + ({ repoDir } = initRepoOnMain()); + } finally { + for (const [key, value] of Object.entries(previous)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(commits[0]?.authorName).toBe("Test User"); + }); + it("lists recent commits newest-first with on-remote flags and file stats", async () => { const { repoDir, tempDir } = initRepoOnMain(); git(["checkout", "-b", "feature"], repoDir); diff --git a/packages/server/src/utils/checkout-git.patch-format.test.ts b/packages/server/src/utils/checkout-git.patch-format.test.ts new file mode 100644 index 0000000000..d30ae1d7ce --- /dev/null +++ b/packages/server/src/utils/checkout-git.patch-format.test.ts @@ -0,0 +1,213 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js"; + +const repos: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])( + "preserves batched paths, spaces and literal prefix directories with %s", + async (config) => { + const cwd = createChangedRepo(config); + mkdirSync(join(cwd, "a")); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "before\n"); + } + git(cwd, "add", "a/literal.txt", "with spaces.txt"); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add files"); + for (const name of ["a/literal.txt", "with spaces.txt"]) { + writeFileSync(join(cwd, name), "after\n"); + } + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured?.map((file) => file.path)).toEqual([ + "a/literal.txt", + "file.txt", + "with spaces.txt", + ]); + for (const file of result.structured ?? []) { + expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + } + expect(git(cwd, "config", "--get", config.split("=")[0]).trim()).toBe(config.split("=")[1]); + }, +); + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function createChangedRepo(config: string): string { + const cwd = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-patch-format-"))); + repos.push(cwd); + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "file.txt"), "before\n"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git(cwd, "config", ...config.split("=")); + writeFileSync(join(cwd, "file.txt"), "after\n"); + return cwd; +} + +function createExternalDiffCommand() { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-external-diff-"))); + repos.push(directory); + const script = join(directory, "external-diff.cjs"); + const marker = join(directory, "invoked"); + writeFileSync( + script, + 'require("node:fs").writeFileSync(process.argv[2], "invoked");\n' + + 'process.stdout.write("CUSTOM EXTERNAL DIFF\\n");\n', + ); + const command = [process.execPath, script, marker] + .map((argument) => `'${argument.replaceAll("'", "'\\''")}'`) + .join(" "); + return { command, marker }; +} + +function createExternalDiffRepo() { + vi.stubEnv("GIT_EXTERNAL_DIFF", undefined); + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + git(cwd, "config", "diff.external", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + return { cwd, command, marker }; +} + +function expectStandardPatch(result: Awaited>) { + expect(result.structured).toMatchObject([ + { + path: "file.txt", + additions: 1, + deletions: 1, + status: "ok", + hunks: [ + { + lines: [ + { type: "header", content: "@@ -1 +1 @@" }, + { type: "remove", content: "before" }, + { type: "add", content: "after" }, + ], + }, + ], + }, + ]); +} + +it("returns Git patch hunks when a repository configures an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks against the base branch with an external diff command", async () => { + const { cwd, command, marker } = createExternalDiffRepo(); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command); +}); + +it("returns Git patch hunks when GIT_EXTERNAL_DIFF configures the external command", async () => { + const cwd = createChangedRepo("color.diff=always"); + const { command, marker } = createExternalDiffCommand(); + vi.stubEnv("GIT_EXTERNAL_DIFF", command); + expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n"); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expectStandardPatch(result); + expect(existsSync(marker)).toBe(false); + expect(process.env.GIT_EXTERNAL_DIFF).toBe(command); +}); + +describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])( + "patch format with %s", + (config) => { + it("returns displayable uncommitted diff lines with their file counts", async () => { + const cwd = createChangedRepo(config); + const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true }); + + expect(result.structured).toHaveLength(1); + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + }); + + it("returns displayable committed changes against the base branch", async () => { + const cwd = createChangedRepo(config); + git(cwd, "checkout", "-b", "feature"); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const result = await getCheckoutDiff(cwd, { + mode: "base", + baseRef: "main", + includeStructured: true, + }); + + expect(result.structured?.[0]).toMatchObject({ + path: "file.txt", + additions: 1, + deletions: 1, + }); + expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + + it("returns displayable lines when opening an individual commit file", async () => { + const cwd = createChangedRepo(config); + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + const file = await getCommitFileDiff({ + cwd, + sha: git(cwd, "rev-parse", "HEAD").trim(), + path: "file.txt", + }); + + expect(file).toMatchObject({ path: "file.txt", additions: 1, deletions: 1 }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]), + ); + }); + }, +); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 45c0aff7c1..21791fa49e 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -523,6 +523,7 @@ describe("checkout git utilities", () => { it("honors commit signing configuration", async () => { execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); writeFileSync(join(repoDir, "file.txt"), "signed\n"); @@ -538,10 +539,129 @@ describe("checkout git utilities", () => { }); expect(diff.structured).toContainEqual( - expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }), + expect.objectContaining({ + path: "renamed.txt", + oldPath: "file.txt", + additions: 0, + deletions: 0, + hunks: [], + }), ); }); + it.each([ + { mode: "uncommitted", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "base", oldPath: "to-rename.txt", path: "renamed.txt" }, + { mode: "uncommitted", oldPath: "old [file].txt", path: "new [file].txt" }, + ] as const)( + "shows only the edited line of a rename ($mode, $path)", + async ({ mode, oldPath, path }) => { + const original = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`); + commitFile(repoDir, oldPath, original.join("\n") + "\n", "file to rename"); + if (mode === "base") { + execFileSync("git", ["checkout", "-b", "feature/rename"], { cwd: repoDir }); + } + execFileSync("git", ["mv", oldPath, path], { cwd: repoDir }); + const edited = [...original]; + edited[5] = "edited line 6"; + writeFileSync(join(repoDir, path), edited.join("\n") + "\n"); + execFileSync("git", ["add", path], { cwd: repoDir }); + + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toMatch(/R\d+\t/); + if (mode === "base") { + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], { + cwd: repoDir, + }); + } + + const diff = await getCheckoutDiff(repoDir, { + mode, + ...(mode === "base" ? { baseRef: "main" } : {}), + includeStructured: true, + }); + + expect(diff.structured).toEqual([ + expect.objectContaining({ + path, + oldPath, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + status: "ok", + }), + ]); + expect(diff.diff).toContain(`rename from ${oldPath}`); + expect(diff.diff).toContain(`rename to ${path}`); + expect(diff.diff).toContain("-line 6\n+edited line 6"); + expect(diff.diff).not.toContain("new file mode"); + }, + ); + + it("includes a changed copy source only once in the diff", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original.replace("line 6", "edited line 6")); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + }), + expect.objectContaining({ path: "source.txt", additions: 1, deletions: 1 }), + ]); + expect(diff.diff.match(/^diff --git a\/source.txt b\/source.txt$/gm)).toHaveLength(1); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + }); + + it("keeps a small copy patch when its edited source exceeds the patch limit", async () => { + const original = + Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n"; + commitFile(repoDir, "source.txt", original, "copy source"); + execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir }); + writeFileSync(join(repoDir, "copy.txt"), original); + writeFileSync(join(repoDir, "source.txt"), original + "added source line\n".repeat(80_000)); + execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir }); + expect( + execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(), + ).toContain("C100\tsource.txt\tcopy.txt"); + + const diff = await getCheckoutDiff(repoDir, { + mode: "uncommitted", + includeStructured: true, + }); + expect(diff.structured).toEqual([ + expect.objectContaining({ + path: "copy.txt", + oldPath: "source.txt", + additions: 0, + deletions: 0, + hunks: [], + status: "ok", + }), + expect.objectContaining({ path: "source.txt", status: "too_large", hunks: [] }), + ]); + expect(diff.diff).toContain("copy from source.txt"); + expect(diff.diff).toContain("copy to copy.txt"); + expect(diff.diff).not.toContain("+added source line"); + }); + it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => { setupRemoteTrackingMain(repoDir, tempDir); @@ -991,9 +1111,9 @@ const x = 1; }); it("does not report local-only no-track worktree commits as ahead of origin", async () => { - setupRemoteTrackingMain(repoDir, tempDir); - commitFile(repoDir, "second.txt", "second\n", "second commit"); - execFileSync("git", ["push"], { cwd: repoDir }); + // This case needs a remote base ref, not a second clone or extra base history. + execFileSync("git", ["remote", "add", "origin", repoDir], { cwd: repoDir }); + execFileSync("git", ["fetch", "origin", "main:refs/remotes/origin/main"], { cwd: repoDir }); const worktree = await createLegacyWorktreeForTest({ branchName: "fresh-feature", @@ -1007,6 +1127,7 @@ const x = 1; const status = await getCheckoutStatus(worktree.worktreePath, { paseoHome }); expect(status).toMatchObject({ isGit: true, + hasRemote: true, isPaseoOwnedWorktree: true, baseRef: "main", aheadBehind: { ahead: 1, behind: 0 }, @@ -1506,9 +1627,12 @@ const x = 1; expect(diff.diff).toContain("# generated.js: diff too large omitted"); expect(diff.diff).toContain(`-export const value = "old";`); expect(diff.diff).toContain(`+export const value = "new";`); - expect(commands).toContain("diff --numstat HEAD"); - expect(commands).toContain("diff HEAD -- generated.js"); - expect(commands).toContain("diff HEAD -- small.ts"); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD", + ); + expect(commands).toContain( + "diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts", + ); expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); @@ -2992,6 +3116,36 @@ const x = 1; }); }); + it.each([ + "git@github.com:contributor/paseo.git", + "https://github.com/contributor/paseo.git", + "ssh://git@github.com/contributor/paseo.git", + ])("preserves fork PR identity with branch.remote=%s", async (branchRemote) => { + execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.topic.remote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.pushRemote", branchRemote], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { + cwd: repoDir, + }); + const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: repoDir, + encoding: "utf8", + }).trim(); + + expect(await readPullRequestLookupTargetFromFacts(repoDir, paseoHome)).toEqual({ + headRef: "topic", + headRepositoryOwner: "contributor", + headSha, + }); + }); + it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { @@ -3546,6 +3700,7 @@ const x = 1; cwd: repoDir, }); execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir }); + execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir }); execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir }); await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow( diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d20a41616e..ee3571504d 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1,6 +1,7 @@ import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; +import { setImmediate } from "node:timers/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; @@ -29,6 +30,7 @@ import { } from "../services/forge-cli-command.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand, type RunGitCommand } from "./run-git-command.js"; +import { readGitFileContents } from "./git-file-contents.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; import { branchNameFromRef, @@ -634,12 +636,20 @@ function normalizeNumstatPath(pathField: string): string { return pathField; } +// Parsed patches need a stable wire format regardless of terminal presentation settings. +const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"]; + function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] { - return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra]; + return [ + "diff", + ...GIT_PATCH_FORMAT_ARGS, + ...(args.ignoreWhitespace ? ["-w"] : []), + ...args.extra, + ]; } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB -const TRACKED_DIFF_BATCH_SIZE = 8; +const TRACKED_DIFF_BATCH_SIZE = 64; const EMPTY_TREE_OBJECT_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; function isUnbornHeadDiffError(error: unknown): boolean { @@ -711,12 +721,19 @@ async function getTrackedDiffTextForPath(input: { cwd: string; refsForDiff: CheckoutDiffRefs; path: string; + oldPath?: string; ignoreWhitespace: boolean; }): Promise<{ path: string; text: string; truncated: boolean }> { + const paths = input.oldPath ? [input.oldPath, input.path] : [input.path]; const result = await runGitCommand( buildGitDiffArgs({ ignoreWhitespace: input.ignoreWhitespace, - extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", input.path], + extra: [ + ...(input.oldPath ? ["--diff-filter=RC"] : []), + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...paths.map((path) => `:(literal)${path}`), + ], }), { cwd: input.cwd, @@ -725,13 +742,76 @@ async function getTrackedDiffTextForPath(input: { }, ); + // A source path can participate in another rename/copy in the same request. + // Return only the requested destination; the filter keeps source edits out of its budget. + let text = result.stdout; + if (input.oldPath && !result.truncated) { + text = result.stdout + .split(/(?=^diff --git )/m) + .filter((section) => { + const hunkStart = section.indexOf("\n@@ "); + const header = hunkStart < 0 ? section : section.slice(0, hunkStart); + return parseDiff(header)[0]?.path === input.path; + }) + .join(""); + } return { path: input.path, - text: result.stdout, + text, truncated: result.truncated, }; } +async function getTrackedDiffTexts(input: { + cwd: string; + refsForDiff: CheckoutDiffRefs; + paths: string[]; + ignoreWhitespace: boolean; +}): Promise> { + if (input.paths.length === 0) return []; + if (input.paths.length === 1) + return [await getTrackedDiffTextForPath({ ...input, path: input.paths[0] })]; + const result = await runGitCommand( + buildGitDiffArgs({ + ignoreWhitespace: input.ignoreWhitespace, + extra: [ + ...getCheckoutDiffRefArgs(input.refsForDiff), + "--", + ...input.paths.map((path) => `:(literal)${path}`), + ], + }), + { cwd: input.cwd, envOverlay: READ_ONLY_GIT_ENV, maxOutputBytes: 8 * PER_FILE_DIFF_MAX_BYTES }, + ); + const files = new Map(); + if (!result.truncated) { + for (const text of result.stdout.split(/(?=^diff --git )/m).filter(Boolean)) { + // Only identify the file here. Oversized bodies must not reach the parser. + const hunkStart = text.indexOf("\n@@ "); + const file = parseDiff(hunkStart < 0 ? text : text.slice(0, hunkStart))[0]; + if (!file || !input.paths.includes(file.path)) break; + files.set(file.path, { + path: file.path, + text, + truncated: Buffer.byteLength(text) > PER_FILE_DIFF_MAX_BYTES, + }); + } + // Empty patches are valid when whitespace is ignored. Every emitted section + // must be accounted for before accepting a combined command. + if ( + [...files.values()].reduce((length, file) => length + file.text.length, 0) === + result.stdout.length + ) { + return input.paths.map((path) => files.get(path) ?? { path, text: "", truncated: false }); + } + } + // Bound oversized output without letting one large file hide its neighbors. + const middle = Math.ceil(input.paths.length / 2); + return [ + ...(await getTrackedDiffTexts({ ...input, paths: input.paths.slice(0, middle) })), + ...(await getTrackedDiffTexts({ ...input, paths: input.paths.slice(middle) })), + ]; +} + export class NotGitRepoError extends Error { readonly cwd: string; readonly code = "NOT_GIT_REPO"; @@ -1107,13 +1187,16 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] { async function getWorktreePathForBranch(cwd: string, branchName: string): Promise { try { - const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { - cwd, - envOverlay: READ_ONLY_GIT_ENV, - }); - const entries = parseWorktreeList(stdout); const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`; - return entries.find((entry) => entry.branchRef === ref)?.path ?? null; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + for (const record of stdout.split("\0\n")) { + const [reference, worktreePath] = record.split("\0"); + if (reference === ref) return worktreePath || null; + } + return null; } catch { return null; } @@ -1317,6 +1400,23 @@ async function getGitConfigValue( } } +async function resolveGitRemoteUrl({ + cwd, + remote, + context, +}: { + cwd: string; + remote: string; + context?: CheckoutContext; +}): Promise { + const configuredUrl = await getGitConfigValue(cwd, `remote.${remote}.url`, context); + if (configuredUrl) { + return configuredUrl; + } + // Branch remote settings accept repository URLs as well as configured remote names. + return parseGitRemoteLocation(remote) ? remote : null; +} + async function getGitRemotePushUrl( cwd: string, remoteName: string, @@ -1381,7 +1481,7 @@ async function resolvePullRequestStatusLookupTarget( } const [branchRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ - branchRemoteName ? getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context) : null, + branchRemoteName ? resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }) : null, getGitConfigValue(cwd, "remote.origin.url", context), getResolvedBaseRefForCwd(cwd, context), ]); @@ -1491,11 +1591,15 @@ export async function resolveRepositoryDefaultBranch( ); return localName; } catch { + await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], { + cwd: repoRoot, + envOverlay: READ_ONLY_GIT_ENV, + }); return remoteShort; } } } catch { - // ignore + // A stale origin/HEAD must fall through to the available local branches. } const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], { @@ -1898,7 +2002,7 @@ async function resolvePullRequestLookupTargetFromPushConfig( const [pushRefspec, pushRemoteUrl, originRemoteUrl, resolvedBaseRef] = await Promise.all([ getGitConfigValue(cwd, `remote.${pushRemoteName}.push`, context), - getGitConfigValue(cwd, `remote.${pushRemoteName}.url`, context), + resolveGitRemoteUrl({ cwd, remote: pushRemoteName, context }), knownOriginRemoteUrl === null ? getGitConfigValue(cwd, "remote.origin.url", context) : null, knownResolvedBaseRef === null ? getResolvedBaseRefForCwd(cwd, context) : null, ]); @@ -2006,7 +2110,7 @@ export async function getCheckoutSnapshotFacts( getGitConfigValue(cwd, `branch.${inspected.currentBranch}.merge`, context), branchRemoteName === "origin" ? inspected.remoteUrl - : getGitConfigValue(cwd, `remote.${branchRemoteName}.url`, context), + : resolveGitRemoteUrl({ cwd, remote: branchRemoteName, context }), ]); } } @@ -2562,7 +2666,7 @@ export async function getCommitFileDiff({ path: string; }): Promise { const { stdout } = await runGitCommand( - ["show", sha, "--format=", "--diff-merges=first-parent", "--", path], + ["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path], { cwd, envOverlay: READ_ONLY_GIT_ENV, @@ -2978,13 +3082,23 @@ async function buildHighlightedTrackedDiffFile(input: { change: CheckoutFileChange; parsedFile: ParsedDiffFile; refsForDiff: CheckoutDiffRefs; + contents?: Map; }): Promise { const { cwd, change, parsedFile, refsForDiff } = input; const refPath = change.oldPath ?? change.path; - const [oldFileContent, newFileContent] = await Promise.all([ - change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), - refsForDiff.targetRef ? readGitFileContentAtRef(cwd, refsForDiff.targetRef, change.path) : null, - ]); + const [oldFileContent, newFileContent] = input.contents + ? [ + change.isNew ? null : input.contents.get(`${refsForDiff.baseRef}:${refPath}`), + refsForDiff.targetRef + ? input.contents.get(`${refsForDiff.targetRef}:${change.path}`) + : null, + ] + : await Promise.all([ + change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), + refsForDiff.targetRef + ? readGitFileContentAtRef(cwd, refsForDiff.targetRef, change.path) + : null, + ]); const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { oldFileContent, newFileContent, @@ -3012,6 +3126,19 @@ function isWhitespaceOnlyTrackedChange(input: { ); } +function readTrackedDiffContents( + cwd: string, + changes: CheckoutFileChange[], + refs: CheckoutDiffRefs, +): Promise | null> { + const specs: string[] = []; + for (const change of changes) { + if (!change.isNew) specs.push(`${refs.baseRef}:${change.oldPath ?? change.path}`); + if (refs.targetRef) specs.push(`${refs.targetRef}:${change.path}`); + } + return readGitFileContents(cwd, specs); +} + async function appendStructuredTrackedDiffs( input: AppendStructuredTrackedDiffsInput, ): Promise { @@ -3030,54 +3157,68 @@ async function appendStructuredTrackedDiffs( const parsedTrackedFiles = trackedDiffText.length > 0 ? parseDiff(trackedDiffText) : []; const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); - for (const change of trackedChanges) { - const placeholder = trackedPlaceholderByPath.get(change.path); - if (placeholder) { - const file = buildPlaceholderParsedDiffFile(change, { - status: placeholder.status, - stat: placeholder.stat, - }); + for (let start = 0; start < trackedChanges.length; start += TRACKED_DIFF_BATCH_SIZE) { + const changes = trackedChanges.slice(start, start + TRACKED_DIFF_BATCH_SIZE); + const contents = await readTrackedDiffContents( + cwd, + changes.filter( + (change) => + !trackedPlaceholderByPath.has(change.path) && parsedTrackedByPath.has(change.path), + ), + refsForDiff, + ); + for (const change of changes) { + const placeholder = trackedPlaceholderByPath.get(change.path); + if (placeholder) { + const file = buildPlaceholderParsedDiffFile(change, { + status: placeholder.status, + stat: placeholder.stat, + }); + if (!appendStructuredFile(structured, file)) { + return false; + } + appendTrackedPlaceholderComment(change, placeholder.status); + continue; + } + + const stat = trackedNumstatByPath.get(change.path) ?? null; + const parsedFile = parsedTrackedByPath.get(change.path); + if (parsedFile) { + const file = await buildHighlightedTrackedDiffFile({ + cwd, + change, + parsedFile, + refsForDiff, + contents: contents ?? undefined, + }); + if (!appendStructuredFile(structured, file)) { + return false; + } + // Batched blobs remove the per-file I/O pause; keep serving other daemon work. + await setImmediate(); + continue; + } + + // `git diff -w --name-status` can still report a modified path even when the + // whitespace-filtered patch and numstat are both empty. Skip emitting a + // structured placeholder in that case so whitespace-only edits truly disappear. + if (isWhitespaceOnlyTrackedChange({ change, stat, ignoreWhitespace })) { + continue; + } + + const file = { + path: change.path, + ...(change.oldPath ? { oldPath: change.oldPath } : {}), + isNew: change.isNew, + isDeleted: change.isDeleted, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + hunks: [], + status: "ok", + } satisfies ParsedDiffFile; if (!appendStructuredFile(structured, file)) { return false; } - appendTrackedPlaceholderComment(change, placeholder.status); - continue; - } - - const stat = trackedNumstatByPath.get(change.path) ?? null; - const parsedFile = parsedTrackedByPath.get(change.path); - if (parsedFile) { - const file = await buildHighlightedTrackedDiffFile({ - cwd, - change, - parsedFile, - refsForDiff, - }); - if (!appendStructuredFile(structured, file)) { - return false; - } - continue; - } - - // `git diff -w --name-status` can still report a modified path even when the - // whitespace-filtered patch and numstat are both empty. Skip emitting a - // structured placeholder in that case so whitespace-only edits truly disappear. - if (isWhitespaceOnlyTrackedChange({ change, stat, ignoreWhitespace })) { - continue; - } - - const file = { - path: change.path, - ...(change.oldPath ? { oldPath: change.oldPath } : {}), - isNew: change.isNew, - isDeleted: change.isDeleted, - additions: stat?.additions ?? 0, - deletions: stat?.deletions ?? 0, - hunks: [], - status: "ok", - } satisfies ParsedDiffFile; - if (!appendStructuredFile(structured, file)) { - return false; } } @@ -3196,18 +3337,35 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; + const oldPathByPath = new Map( + trackedChanges + .filter((change) => change.oldPath) + .map((change) => [change.path, change.oldPath]), + ); for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) { const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE); - const trackedDiffs = await Promise.all( - paths.map((path) => - getTrackedDiffTextForPath({ - cwd, - refsForDiff, - path, - ignoreWhitespace, - }), - ), + // Rename/copy detection needs both paths. + const ordinary = await getTrackedDiffTexts({ + cwd, + refsForDiff, + paths: paths.filter((path) => !oldPathByPath.has(path)), + ignoreWhitespace, + }); + const renamed = await Promise.all( + paths + .filter((path) => oldPathByPath.has(path)) + .map((path) => + getTrackedDiffTextForPath({ + cwd, + refsForDiff, + path, + oldPath: oldPathByPath.get(path), + ignoreWhitespace, + }), + ), ); + const byPath = new Map([...ordinary, ...renamed].map((file) => [file.path, file])); + const trackedDiffs = paths.map((path) => byPath.get(path)!); for (const fileDiff of trackedDiffs) { if (fileDiff.truncated) { diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 2b24ca2174..711a721e48 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,3 +1,4 @@ +import { createHook } from "node:async_hooks"; import { execFileSync } from "node:child_process"; import { mkdtempSync, @@ -12,6 +13,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; +import { startPathContainmentMetrics, stopPathContainmentMetrics } from "./path.js"; import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; import { searchDirectoryEntries, @@ -169,6 +171,26 @@ describe("searchDirectoryEntries", () => { ); }); + it.skipIf(isWindows)( + "prunes a symlink whose target is inside a Git-ignored directory", + async () => { + initGitRepo(searchRoot, "generated/\n"); + mkdirSync(path.join(searchRoot, "generated", "output"), { recursive: true }); + symlinkSync(path.join(searchRoot, "generated", "output"), path.join(searchRoot, "linked")); + + await expect( + searchDirectoryEntries({ + root: searchRoot, + query: "linked", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + respectGitIgnore: true, + }), + ).resolves.toEqual([]); + }, + ); + it("configures raw blank queries independently from explicit root aliases", async () => { const rootEntries = [ { path: "projects", kind: "directory" as const }, @@ -675,6 +697,105 @@ describe("absolute directory-path configuration", () => { }); }); +describe("home search with excluded discovery paths", () => { + let homeDir: string; + let libraryDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-excluded-"))); + libraryDir = path.join(homeDir, "Library"); + mkdirSync(path.join(libraryDir, "sub", "match-me"), { recursive: true }); + mkdirSync(path.join(homeDir, "projects", "other"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + function searchHome(query: string) { + return searchDirectoryEntries({ + root: homeDir, + query, + pathFormat: "absolute", + includeDirectories: true, + includeFiles: false, + pathQueryPolicy: "rooted", + rootAliases: ["~"], + blankQueryBehavior: "none", + confidentResultScanThreshold: 5_000, + excludedDiscoveryPaths: [libraryDir], + }).then((entries) => entries.map((entry) => entry.path)); + } + + it("does not discover entries inside an excluded path", async () => { + await expect(searchHome("match")).resolves.toEqual([]); + await expect(searchHome("~")).resolves.toEqual([path.join(homeDir, "projects")]); + }); + + it("excludes by path, so a nested directory with the same name is still discovered", async () => { + const nested = path.join(homeDir, "projects", "game", "Library", "match-me"); + mkdirSync(nested, { recursive: true }); + + await expect(searchHome("match")).resolves.toEqual([nested]); + }); + + // POSIX-only: creates and follows a symlink fixture. + it.skipIf(isWindows)("does not discover an excluded path through a symlink", async () => { + symlinkSync(path.join(libraryDir, "sub"), path.join(homeDir, "link"), "dir"); + + await expect(searchHome("match")).resolves.toEqual([]); + }); + + it("resolves an exact typed path inside an excluded path", async () => { + await expect(searchHome("~/Library/sub/match-me")).resolves.toEqual([ + path.join(libraryDir, "sub", "match-me"), + ]); + }); + + it("lists one level when browsing inside an excluded path", async () => { + await expect(searchHome("~/Library/")).resolves.toEqual([ + libraryDir, + path.join(libraryDir, "sub"), + ]); + await expect(searchHome("~/Library/su")).resolves.toEqual([path.join(libraryDir, "sub")]); + }); +}); + +describe("rooted queries with a typed parent", () => { + let homeDir: string; + + beforeEach(() => { + homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-typed-parent-"))); + mkdirSync(path.join(homeDir, "Developer", "pasta"), { recursive: true }); + mkdirSync(path.join(homeDir, "Archive", "Developer", "pasta"), { recursive: true }); + }); + + afterEach(() => { + rmSync(homeDir, { recursive: true, force: true }); + }); + + it("walks only under the typed parent when it exists", async () => { + await expect( + searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pas" }), + ).resolves.toEqual([path.join(homeDir, "Developer", "pasta")]); + }); + + it("matches the whole tree when the typed parent does not exist", async () => { + await expect(searchAbsoluteDirectoryPaths({ homeDir, query: "~/Devel/pas" })).resolves.toEqual([ + path.join(homeDir, "Developer", "pasta"), + path.join(homeDir, "Archive", "Developer", "pasta"), + ]); + }); + + it("returns the typed directory first", async () => { + mkdirSync(path.join(homeDir, "Developer", "pasta-extra")); + + const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pasta" }); + + expect(results[0]).toBe(path.join(homeDir, "Developer", "pasta")); + }); +}); + describe("relative typed-entry configuration", () => { let tempRoot: string; let workspaceDir: string; @@ -756,22 +877,13 @@ describe("relative typed-entry configuration", () => { }); it("suffix mode resolves exact workspace file paths before broad traversal", async () => { - const targetPath = path.join( - workspaceDir, - "packages", - "server", - "src", - "services", - "quota-fetcher", - "providers", - "local.ts", - ); + const targetPath = path.join(workspaceDir, "plugins", "usage-sources", "providers", "local.ts"); mkdirSync(path.dirname(targetPath), { recursive: true }); writeFileSync(targetPath, ""); const results = await searchRelativeDirectoryEntries({ cwd: workspaceDir, - query: "packages/server/src/services/quota-fetcher/providers/local.ts", + query: "plugins/usage-sources/providers/local.ts", limit: 20, includeFiles: true, includeDirectories: false, @@ -781,7 +893,7 @@ describe("relative typed-entry configuration", () => { expect(results).toEqual([ { - path: "packages/server/src/services/quota-fetcher/providers/local.ts", + path: "plugins/usage-sources/providers/local.ts", kind: "file", }, ]); @@ -953,3 +1065,124 @@ describe("relative typed-entry configuration", () => { expect(results).toEqual([{ path: "blankpage/editor", kind: "directory" }]); }); }); + +// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether +// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the +// read and is what made large home directories exceed the client request timeout. +describe("home-tree scan cost", () => { + const DEPTH = 8; + const CHAINS = 40; + let scanRoot: string; + + beforeEach(() => { + scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-"))); + for (let chain = 0; chain < CHAINS; chain += 1) { + const segments: string[] = []; + for (let level = 0; level < DEPTH; level += 1) segments.push(`c${chain}l${level}`); + mkdirSync(path.join(scanRoot, ...segments), { recursive: true }); + } + }); + + afterEach(() => { + rmSync(scanRoot, { recursive: true, force: true }); + }); + + // Nothing matches, so the scan walks the whole tree instead of stopping at a confident result. + function scanWholeTree() { + return searchAbsoluteDirectoryPaths({ + homeDir: scanRoot, + query: "nomatch", + maxDepth: DEPTH + 4, + }); + } + + async function countContainmentChecks(run: () => Promise): Promise { + startPathContainmentMetrics(); + await run(); + return stopPathContainmentMetrics(); + } + + it("derives no containment for a tree that cannot leave the root", async () => { + const checks = await countContainmentChecks(scanWholeTree); + + expect({ checks, scanned: CHAINS * DEPTH }).toEqual({ checks: 0, scanned: 320 }); + }); + + it.skipIf(isWindows)( + "derives containment for symlinked entries, which can leave the root", + async () => { + const outside = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-away-"))); + symlinkSync(outside, path.join(scanRoot, "escape"), "dir"); + + const checks = await countContainmentChecks(scanWholeTree); + + expect({ checked: checks > 0, escaped: (await scanWholeTree()).includes(outside) }).toEqual({ + checked: true, + escaped: false, + }); + rmSync(outside, { recursive: true, force: true }); + }, + ); +}); + +// Every pending fs call holds one of libuv's shared threadpool threads (4 by default). A read that +// never returns, such as one on a hung network mount, keeps its thread, so searches that pile up +// while the user types must not be able to take every thread from the rest of the daemon. +describe("filesystem threads held by concurrent searches", () => { + const SEARCHES = 8; + let scanRoot: string; + + beforeEach(() => { + scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-threads-"))); + for (let branch = 0; branch < 20; branch += 1) { + mkdirSync(path.join(scanRoot, `b${branch}`, "inner", "leaf"), { recursive: true }); + } + }); + + afterEach(() => { + rmSync(scanRoot, { recursive: true, force: true }); + }); + + async function peakPendingFilesystemRequests(run: () => Promise): Promise { + const pending = new Set(); + let peak = 0; + const hook = createHook({ + init(asyncId, type) { + if (type !== "FSREQPROMISE" && type !== "FSREQCALLBACK") return; + pending.add(asyncId); + peak = Math.max(peak, pending.size); + }, + before(asyncId) { + pending.delete(asyncId); + }, + }); + hook.enable(); + try { + await run(); + } finally { + hook.disable(); + } + return peak; + } + + function searchWhileTyping() { + const queries = Array.from({ length: SEARCHES }, (_, index) => `nomatch${index}`); + return Promise.all( + queries.map((query) => searchAbsoluteDirectoryPaths({ homeDir: scanRoot, query })), + ); + } + + it("leaves threadpool threads free for the rest of the daemon", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBeLessThanOrEqual(2); + }); + + // Searches over one tree walk it in the same order, so searches stuck on one unreadable folder + // must hold one thread between them and leave the other for unrelated searches. + it("shares one filesystem request among searches waiting on the same path", async () => { + const peak = await peakPendingFilesystemRequests(searchWhileTyping); + + expect(peak).toBe(1); + }); +}); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index aa24c4a6b9..e0443475bc 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -1,6 +1,7 @@ import type { Dirent, Stats } from "node:fs"; -import { readdir, realpath, stat } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import path from "node:path"; +import pLimit from "p-limit"; import { scorePathMatch, type MatchScore } from "@getpaseo/protocol/search/text-match"; import { isPathInsideRoot } from "./path.js"; import { runGitCommand } from "./run-git-command.js"; @@ -32,6 +33,9 @@ export interface SearchDirectoryEntriesOptions { maxEntriesScanned?: number; confidentResultScanThreshold?: number; respectGitIgnore?: boolean; + // Absolute paths that discovery never walks into. A path the caller types explicitly still + // resolves, and browsing inside one lists a single level at a time. + excludedDiscoveryPaths?: readonly string[]; } interface QueryPlan { @@ -46,6 +50,7 @@ interface ChildEntry { name: string; resolvedPath: string; kind: DirectorySuggestionKind; + viaSymlink: boolean; } interface RawChildEntry { @@ -53,6 +58,11 @@ interface RawChildEntry { kind: DirectorySuggestionKind | "symlink"; } +interface WalkStart { + visiblePath: string; + resolvedPath: string; +} + interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; @@ -101,10 +111,42 @@ const NO_SEGMENT_INDEX = Number.MAX_SAFE_INTEGER; const NO_MATCH_OFFSET = Number.MAX_SAFE_INTEGER; const NO_FUZZY_SCORE = Number.MAX_SAFE_INTEGER; const NO_MATCH_TIER = 5; + +// Each pending fs call holds one of libuv's shared threadpool threads (4 by default), and a read on +// a hung mount or a protected folder never returns. Searches pile up while the user types, so they +// share two threads and leave the rest of the pool to the daemon. Searches over one tree walk it +// in the same order and stop at the same stuck folder, so they share its pending call and hold one +// thread between them instead of both. +const filesystemCalls = pLimit(2); +const pendingFilesystemCalls = new Map>(); + +function sharedFilesystemCall(key: string, call: () => Promise): Promise { + const pending = pendingFilesystemCalls.get(key); + if (pending) return pending as Promise; + const started = filesystemCalls(call).finally(() => pendingFilesystemCalls.delete(key)); + pendingFilesystemCalls.set(key, started); + return started; +} + +function readdir(directory: string): Promise { + return sharedFilesystemCall(`readdir:${directory}`, () => + fs.readdir(directory, { withFileTypes: true }), + ); +} + +function realpath(target: string): Promise { + return sharedFilesystemCall(`realpath:${target}`, () => fs.realpath(target)); +} + +function stat(target: string): Promise { + return sharedFilesystemCall(`stat:${target}`, () => fs.stat(target)); +} + export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [ ".agents", ".claude", ".codex", + ".cursor", ".github", ".opencode", ".paseo", @@ -141,29 +183,64 @@ export async function searchDirectoryEntries( const gitIgnoredPaths = options.respectGitIgnore ? await loadGitIgnoredPaths(root) : new Set(); - const input = buildSearchInput(options, root, gitIgnoredPaths); + const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []); + const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths); if (!input) return []; const exact = - input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery) + input.plan.browseExactPath || + (input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input))) ? await findExactEntry(input) : null; if (exact && input.limit === 1) return [exact]; - const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; - const browsesAbsoluteParent = input.plan.browseExactPath === true; - const ranked = - browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input); + const ranked = await searchCandidates(input); const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit); return exact ? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit) : results; } +async function searchCandidates(input: SearchInput): Promise { + const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery; + const browsesAbsoluteParent = input.plan.browseExactPath === true; + if (browsesRoot || browsesAbsoluteParent) return searchChildren(input); + if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input); + + // A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that + // does not exist falls back to matching the typed path against the whole tree. + const typedParent = await resolveTypedParent(input); + if (!typedParent) return searchTree(input); + if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input); + return searchTree(input, typedParent); +} + +// Only a rooted query under the rooted policy names a location. Under the slashes policy a query +// with a slash is still a fragment that may match anywhere in the tree. +function resolvesTypedPath(input: SearchInput): boolean { + return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery; +} + +async function resolveTypedParent(input: SearchInput): Promise { + const visiblePath = path.resolve(input.root, input.plan.parentPart); + const resolvedPath = await realpath(visiblePath).catch(() => null); + if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; + const info = await stat(resolvedPath).catch(() => null); + return info?.isDirectory() ? { visiblePath, resolvedPath } : null; +} + +async function resolveExistingPaths(paths: readonly string[]): Promise> { + const resolved = await Promise.all( + paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)), + ); + return new Set(resolved.filter((candidate): candidate is string => candidate !== null)); +} + function buildSearchInput( options: SearchDirectoryEntriesOptions, root: string, gitIgnoredPaths: Set, + excludedDiscoveryPaths: Set, ): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; @@ -185,6 +262,7 @@ function buildSearchInput( includeDirectories, includeFiles, matchMode: options.matchMode ?? "fuzzy", + pathQueryPolicy: options.pathQueryPolicy ?? "slashes", pathFormat: options.pathFormat, hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []), limit: normalizeLimit(options.limit), @@ -192,6 +270,7 @@ function buildSearchInput( maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, gitIgnoredPaths, + excludedDiscoveryPaths, }; } @@ -219,6 +298,7 @@ interface SearchInput { includeDirectories: boolean; includeFiles: boolean; matchMode: DirectorySuggestionMatchMode; + pathQueryPolicy: PathQueryPolicy; pathFormat: DirectorySuggestionPathFormat; hiddenDirectoryNames: Set; limit: number; @@ -226,6 +306,7 @@ interface SearchInput { maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; gitIgnoredPaths: Set; + excludedDiscoveryPaths: Set; } async function searchChildren(input: SearchInput): Promise { @@ -233,9 +314,13 @@ async function searchChildren(input: SearchInput): Promise { const parent = await realpath(visibleParent).catch(() => null); if (!parent || !isPathInsideRoot(input.root, parent)) return []; if (isGitIgnoredPath(parent, input)) return []; + // Browsing inside an excluded path is explicit: the caller typed it, so list what is there. + const discoveryInput = isInsideExcludedPath(parent, input) + ? { ...input, excludedDiscoveryPaths: new Set() } + : input; const entries = await readChildren(parent); return entries.flatMap((entry) => { - if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) + if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput)) return []; const candidate: TraversedEntry = { ...entry, @@ -246,17 +331,20 @@ async function searchChildren(input: SearchInput): Promise { }); } -async function searchTree(input: SearchInput): Promise { +async function searchTree( + input: SearchInput, + start: WalkStart = { visiblePath: input.root, resolvedPath: input.root }, +): Promise { if (!(input.maxEntriesScanned > 0)) return []; - const roots = (await readChildren(input.root)).filter((entry) => - isPathInsideRoot(input.root, entry.resolvedPath), + const roots = (await readChildren(start.resolvedPath)).filter((entry) => + staysInsideRoot(entry, input.root), ); - const visited = new Set([input.root]); + const visited = new Set([start.resolvedPath]); const branches = roots.flatMap((entry) => shouldDiscover(entry, input) ? [ walkBranch( - { ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 }, + { ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 }, input, visited, ), @@ -294,7 +382,7 @@ async function* walkBranch( return; visited.add(entry.resolvedPath); const children = (await readChildren(entry.resolvedPath)).filter((child) => - isPathInsideRoot(input.root, child.resolvedPath), + staysInsideRoot(child, input.root), ); const branches = children.flatMap((child) => shouldDiscover(child, input) @@ -329,8 +417,15 @@ async function* roundRobin(branches: Array>): AsyncGenerato } } +// Only a symlink can resolve outside the tree being walked. Every other child is its parent's +// path plus a name, and the parent was already proved inside the root. +function staysInsideRoot(entry: ChildEntry, root: string): boolean { + return !entry.viaSymlink || isPathInsideRoot(root, entry.resolvedPath); +} + function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { - if (isGitIgnoredPath(entry.resolvedPath, input)) return false; + if (isNewlyGitIgnored(entry, input)) return false; + if (isNewlyExcluded(entry, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } @@ -339,6 +434,29 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { return input.hiddenDirectoryNames.has(entry.name); } +// Traversal only descends through entries that already passed this check, so every ancestor of +// a named child is known discoverable and only the entry itself can be newly ignored. A symlink +// resolves to a path with different ancestors, so it still needs the full walk. +function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean { + if (entry.viaSymlink) return isGitIgnoredPath(entry.resolvedPath, input); + return input.gitIgnoredPaths.has(entry.resolvedPath); +} + +// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without +// first passing through it by name. +function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean { + if (input.excludedDiscoveryPaths.size === 0) return false; + if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input); + return input.excludedDiscoveryPaths.has(entry.resolvedPath); +} + +function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean { + for (const excludedPath of input.excludedDiscoveryPaths) { + if (isPathInsideRoot(excludedPath, absolutePath)) return true; + } + return false; +} + function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { let candidate = absolutePath; while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) { @@ -640,7 +758,7 @@ async function readChildren(directory: string): Promise { ) { rawEntries = cached.entries; } else { - const dirents = await readdir(directory, { withFileTypes: true }).catch(() => [] as Dirent[]); + const dirents = await readdir(directory).catch(() => [] as Dirent[]); rawEntries = dirents .map(toRawChildEntry) .filter((entry): entry is RawChildEntry => entry !== null) @@ -712,14 +830,14 @@ function toRawChildEntry(dirent: Dirent): RawChildEntry | null { async function resolveChild(directory: string, entry: RawChildEntry): Promise { const visiblePath = path.join(directory, entry.name); if (entry.kind !== "symlink") { - return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind }; + return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind, viaSymlink: false }; } const resolvedPath = await realpath(visiblePath).catch(() => null); if (!resolvedPath) return null; const info = await stat(resolvedPath).catch(() => null); const kind = getEntryKind(info); - return kind ? { name: entry.name, resolvedPath, kind } : null; + return kind ? { name: entry.name, resolvedPath, kind, viaSymlink: true } : null; } function getEntryKind(info: Stats | null): DirectorySuggestionKind | null { @@ -728,10 +846,9 @@ function getEntryKind(info: Stats | null): DirectorySuggestionKind | null { return null; } +// Reads already reject stale entries by expiry and by directory metadata, so this only has to +// bound the map. Sweeping it for expired keys would cost a full pass on every cache miss. function pruneCache(): void { - if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) return; - for (const [key, entry] of directoryListCache) - if (entry.expiresAt <= Date.now()) directoryListCache.delete(key); while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) { const key = directoryListCache.keys().next().value; if (!key) return; diff --git a/packages/server/src/utils/git-file-contents.test.ts b/packages/server/src/utils/git-file-contents.test.ts new file mode 100644 index 0000000000..7b25686e65 --- /dev/null +++ b/packages/server/src/utils/git-file-contents.test.ts @@ -0,0 +1,53 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { readGitFileContents } from "@server/utils/git-file-contents.js"; +import { runGitCommand, runGitCommandBytes } from "@server/utils/run-git-command.js"; + +let cwd: string; +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "git-contents-")); + execFileSync("git", ["init", "-q"], { cwd }); +}); +afterEach(() => rmSync(cwd, { recursive: true, force: true })); + +test("batch byte framing survives Unicode, invalid UTF-8, and missing objects", async () => { + const text = Buffer.concat([Buffer.from("中文👩‍💻\n"), Buffer.from([0xff]), Buffer.from("\n")]); + const { stdout: oid } = await runGitCommand(["hash-object", "-w", "--stdin"], { + cwd, + input: text, + }); + const ref = oid.trim(); + const empty = execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd, input: "" }) + .toString() + .trim(); + const contents = await readGitFileContents(cwd, [ref, "HEAD:missing", empty, ref]); + expect(contents).toEqual( + new Map([ + [ref, text.toString("utf8")], + ["HEAD:missing", null], + [empty, ""], + ]), + ); + const raw = await runGitCommandBytes(["cat-file", "blob", ref], { cwd }); + expect(raw.stdout.equals(text)).toBe(true); +}); + +test("oversized output stays bounded and requests the existing per-file path", async () => { + writeFileSync(join(cwd, "big"), Buffer.alloc(20 * 1024 * 1024, 97)); + const oid = execFileSync("git", ["hash-object", "-w", "big"], { cwd }).toString().trim(); + expect(await readGitFileContents(cwd, [oid])).toBeNull(); + const limited = await runGitCommandBytes(["cat-file", "--batch"], { + cwd, + input: oid + "\n", + maxOutputBytes: 100, + }); + expect(limited.truncated).toBe(true); + expect(limited.stdout.length).toBe(100); +}); + +test("newline paths do not become extra batch requests", async () => { + expect(await readGitFileContents(cwd, ["HEAD:with\nnewline"])).toBeNull(); +}); diff --git a/packages/server/src/utils/git-file-contents.ts b/packages/server/src/utils/git-file-contents.ts new file mode 100644 index 0000000000..e42d3c6c83 --- /dev/null +++ b/packages/server/src/utils/git-file-contents.ts @@ -0,0 +1,48 @@ +import { runGitCommandBytes } from "./run-git-command.js"; + +// Match the existing git-show ceiling. Oversized batches use bounded per-file reads. +const MAX_CONTENT_BYTES = 20 * 1024 * 1024; + +/** Null means the batch cannot preserve per-file read semantics within its budget. */ +export async function readGitFileContents( + cwd: string, + specs: string[], +): Promise | null> { + const unique = [...new Set(specs)]; + if (unique.length === 0) return new Map(); + // The line-based batch protocol cannot represent embedded newlines. + if (unique.some((spec) => spec.includes("\n"))) return null; + const result = await runGitCommandBytes(["cat-file", "--batch"], { + cwd, + input: unique.join("\n") + "\n", + envOverlay: { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }, + maxOutputBytes: MAX_CONTENT_BYTES, + }); + if (result.truncated) return null; + const contents = new Map(); + let offset = 0; + for (const spec of unique) { + const end = result.stdout.indexOf(10, offset); + if (end < 0) throw new Error("Incomplete git object header"); + const header = result.stdout.subarray(offset, end).toString("utf8"); + offset = end + 1; + if (header === `${spec} missing`) { + contents.set(spec, null); + continue; + } + const match = /^[a-f0-9]+ blob (\d+)$/.exec(header); + // Submodules and other non-blob objects retain git-show behavior. + if (!match) return null; + const size = Number(match[1]); + if ( + !Number.isSafeInteger(size) || + offset + size >= result.stdout.length || + result.stdout[offset + size] !== 10 + ) + throw new Error("Invalid git object length"); + contents.set(spec, result.stdout.subarray(offset, offset + size).toString("utf8")); + offset += size + 1; + } + if (offset !== result.stdout.length) throw new Error("Unexpected git object output"); + return contents; +} diff --git a/packages/server/src/utils/git-rev-parse-path.test.ts b/packages/server/src/utils/git-rev-parse-path.test.ts new file mode 100644 index 0000000000..7614b7dec8 --- /dev/null +++ b/packages/server/src/utils/git-rev-parse-path.test.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; + +describe("Git path output", () => { + it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => { + expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo "); + expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path "); + }); + + it.each([ + "", + "\n", + "\r\n", + "--path-format=absolute\n", + "--path-format=absolute\n/repo\n", + "/repo\n/another\n", + "/repo\n\n", + ])("rejects invalid output %j", (output) => { + expect(parseGitRevParsePath(output)).toBeNull(); + }); + + it("resolves a relative path without stripping its trailing space", () => { + expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo ")); + }); +}); diff --git a/packages/server/src/utils/git-rev-parse-path.ts b/packages/server/src/utils/git-rev-parse-path.ts index b799815056..2f027d5020 100644 --- a/packages/server/src/utils/git-rev-parse-path.ts +++ b/packages/server/src/utils/git-rev-parse-path.ts @@ -1,18 +1,9 @@ import { resolve } from "node:path"; export function parseGitRevParsePath(stdout: string): string | null { - const trimmed = stdout.trim(); - if (!trimmed) { - return null; - } - - const lines = trimmed.split(/\r?\n/); - if (lines.length !== 1) { - return null; - } - - const path = lines[0]?.trim() ?? ""; - if (!path || path.startsWith("--")) { + // Remove Git's record delimiter, not whitespace belonging to the path. + const path = stdout.replace(/\r?\n$/, ""); + if (!path || /[\r\n]/.test(path) || path.startsWith("--")) { return null; } diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index f72f5d82b2..bb279d6c2f 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -42,6 +42,35 @@ describe("path equivalence", () => { ); }); + test.each([ + { root: "/repo", candidate: "/repo/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo", inside: true }, + { root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false }, + { root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false }, + { root: "/repo", candidate: "/repo-other/notes.md", inside: false }, + { root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false }, + { + root: String.raw`\\server\share\repo`, + candidate: String.raw`\\server\share\repo\..drafts\notes.md`, + inside: true, + }, + { + root: String.raw`\\?\C:\Repo`, + candidate: "C:\\Repo\\..notes.txt", + inside: true, + }, + ])("checks named path containment for $candidate", ({ root, candidate, inside }) => { + expect(isPathInsideRoot(root, candidate)).toBe(inside); + }); + test("preserves the casing of Windows relative suffixes", () => { expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD"); expect( diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index d5f0d30c8c..a0ec908fbb 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -58,7 +58,22 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri }; } +let pathContainmentChecks: number | null = null; + +// Containment is re-derived from scratch on every call. A tree walk that asks it per entry, or +// per ancestor of every entry, costs more than reading the tree does, so tests count the calls. +export function startPathContainmentMetrics(): void { + pathContainmentChecks = 0; +} + +export function stopPathContainmentMetrics(): number { + const checks = pathContainmentChecks ?? 0; + pathContainmentChecks = null; + return checks; +} + export function isPathInsideRoot(root: string, candidate: string): boolean { + if (pathContainmentChecks !== null) pathContainmentChecks += 1; return getRelativePathInsideRoot(root, candidate) !== null; } @@ -99,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate); - if ( - comparableRelative !== "" && - (comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative)) - ) { + const isOutsideRoot = + comparableRelative === ".." || + comparableRelative.startsWith(`..${platformPath.sep}`) || + platformPath.isAbsolute(comparableRelative); + if (isOutsideRoot) { return null; } @@ -138,7 +154,15 @@ function shouldCompareAsWindows(left: string, right: string): boolean { return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right); } -function looksLikeDefiniteWindowsPath(value: string): boolean { +/** + * True when `value`'s shape identifies it as a Windows path (drive letter, + * `\\?\` device namespace, or UNC), independent of the host platform. Callers + * that fold case for Windows-looking paths (this module's own comparisons, + * and `pruneKnownDirectories` in `workspace-git-service.ts`, which needs the + * same decision without paying for a realpath syscall) must all agree on this + * one rule — do not reimplement the pattern. + */ +export function looksLikeDefiniteWindowsPath(value: string): boolean { return ( /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}\?[/\\]/u.test(value) || diff --git a/packages/server/src/utils/run-git-command.ts b/packages/server/src/utils/run-git-command.ts index aca69555ee..bdf7bc1f32 100644 --- a/packages/server/src/utils/run-git-command.ts +++ b/packages/server/src/utils/run-git-command.ts @@ -46,6 +46,7 @@ export function configureGitProcessPolicy(policy: GitProcessPolicy): void { export interface GitCommandOptions { cwd: string; + input?: string | Buffer; env?: ProcessEnvRecord; envOverlay?: ProcessEnvRecord; logger?: Pick; @@ -54,8 +55,8 @@ export interface GitCommandOptions { acceptExitCodes?: number[]; } -export interface GitCommandResult { - stdout: string; +export interface GitCommandResult { + stdout: Output; stderr: string; truncated: boolean; exitCode: number | null; @@ -263,6 +264,23 @@ function runGitCommandWithProvenance( options: GitCommandOptions, provenance?: string, ): Promise { + return executeGitCommand(args, options, (output) => output.toString("utf8"), provenance); +} + +/** Binary stdout preserves cat-file byte framing before text decoding. */ +export function runGitCommandBytes( + args: string[], + options: GitCommandOptions, +): Promise> { + return executeGitCommand(args, options, (output) => output); +} + +function executeGitCommand( + args: string[], + options: GitCommandOptions, + decode: (output: Buffer) => Output, + provenance?: string, +): Promise> { const metricsState = submitGitCommandMetric(args, options.cwd); const commandTrace = submitGitCommandTrace(args, options.cwd, { active: gitProcessScheduler.activeCount, @@ -274,7 +292,7 @@ function runGitCommandWithProvenance( const exited = new Promise((resolve) => { releaseProcessSlot = resolve; }); - const resultPromise = new Promise((resolve, reject) => { + const resultPromise = new Promise>((resolve, reject) => { startGitCommandTrace(commandTrace, { active: gitProcessScheduler.activeCount, pending: gitProcessScheduler.pendingCount, @@ -385,7 +403,7 @@ function runGitCommandWithProvenance( cwd: options.cwd, envOverlay, shell: false, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], }, ); spawnGitCommandTrace(commandTrace, child.pid); @@ -465,12 +483,23 @@ function runGitCommandWithProvenance( } }); + if (options.input !== undefined) { + child.stdin!.on("error", (error: NodeJS.ErrnoException) => { + // A command may close stdin when it exits or reaches the output limit. + if (error.code !== "EPIPE") { + processError = error; + child.kill("SIGKILL"); + } + }); + child.stdin!.end(options.input); + } + child.on("exit", markProcessExited); child.on("close", (exitCode, signal) => { markProcessExited(exitCode, signal); - const result: GitCommandResult = { - stdout: Buffer.concat(stdoutChunks).toString("utf8"), + const result: GitCommandResult = { + stdout: decode(Buffer.concat(stdoutChunks)), stderr: Buffer.concat(stderrChunks).toString("utf8"), truncated, exitCode, diff --git a/packages/server/src/utils/spawn.launch-regression.test.ts b/packages/server/src/utils/spawn.launch-regression.test.ts index 847214c624..4943e68990 100644 --- a/packages/server/src/utils/spawn.launch-regression.test.ts +++ b/packages/server/src/utils/spawn.launch-regression.test.ts @@ -126,10 +126,15 @@ async function runFixture(params: { command: string; args: string[]; shell?: boolean; + env?: NodeJS.ProcessEnv; }): Promise { const child = spawnProcess(params.command, params.args, { env: { ...process.env, + ...params.env, + // Terminal color settings must not add Node warnings to the argv fixture's stderr. + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]), }, stdio: ["ignore", "pipe", "pipe"], @@ -216,6 +221,8 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{ const child = spawnProcess(found, fixture.args, { env: { ...process.env, + NO_COLOR: undefined, + FORCE_COLOR: undefined, PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson, PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom), }, @@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => { }); describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => { - test("direct launch with a space-containing executable works on this platform", async () => { - const fixture = makeFixture(); + test.each([ + { colors: "inherited", env: {} }, + { colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } }, + ])( + "direct launch with a space-containing executable works with $colors colors", + async ({ env }) => { + const fixture = makeFixture(); - const result = await runFixture({ - command: fixture.fakeDaemonNode, - args: [fixture.assertScript, ...fixture.expectedArgs], - shell: false, - }); + const result = await runFixture({ + command: fixture.fakeDaemonNode, + args: [fixture.assertScript, ...fixture.expectedArgs], + shell: false, + env, + }); - expect(result.error).toBeNull(); - expect(result.code).toBe(0); - expect(result.stderr).toBe(""); - expect(result.stdout.trim()).toBe("ARGV_OK"); - }); + expect(result.error).toBeNull(); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toBe("ARGV_OK"); + }, + ); }); diff --git a/packages/server/src/utils/spawn.ts b/packages/server/src/utils/spawn.ts index 8fac115f00..b26104c63e 100644 --- a/packages/server/src/utils/spawn.ts +++ b/packages/server/src/utils/spawn.ts @@ -1,15 +1,6 @@ -import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process"; -import { extname } from "node:path"; -import { promisify } from "node:util"; - +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawnProcess as spawnCli, execCommand as execCli } from "@getpaseo/plugin/server"; import { createExternalCommandProcessEnv, type ProcessEnvRecord } from "../server/paseo-env.js"; -import { - isWindowsCommandScript, - quoteWindowsArgument, - quoteWindowsCommand, -} from "./windows-command.js"; - -const execFileAsync = promisify(execFile); interface ExternalEnvOptions { baseEnv?: ProcessEnvRecord; @@ -35,23 +26,6 @@ interface ExecCommandResult { stderr: string; } -function hasPathSeparator(value: string): boolean { - return value.includes("/") || value.includes("\\"); -} - -function shouldUseWindowsShell( - command: string, - requestedShell?: boolean | string, -): boolean | string { - if (isWindowsCommandScript(command)) { - return true; - } - if (requestedShell !== undefined) { - return requestedShell; - } - return process.platform === "win32" && !hasPathSeparator(command) && !extname(command); -} - export function spawnProcess( command: string, args: string[], @@ -59,12 +33,6 @@ export function spawnProcess( ): ChildProcess { const { baseEnv, env, envOverlay, ...spawnOptions } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, spawnOptions.shell); - - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -74,12 +42,10 @@ export function spawnProcess( ...(envOverlay ? [envOverlay] : []), ); - return spawn(resolvedCommand, resolvedArgs, { + return spawnCli(command, args, { ...spawnOptions, env: childEnv, - shell, signal: options?.signal, - windowsHide: true, }); } @@ -90,11 +56,6 @@ export async function execCommand( ): Promise { const { baseEnv, env, envOverlay } = options ?? {}; const resolvedBaseEnv = env ?? baseEnv ?? process.env; - const isWindows = process.platform === "win32"; - const shell = shouldUseWindowsShell(command, options?.shell); - const shouldQuoteForShell = isWindows && shell !== false; - const resolvedCommand = shouldQuoteForShell ? quoteWindowsCommand(command) : command; - const resolvedArgs = shouldQuoteForShell ? args.map(quoteWindowsArgument) : args; const childEnv = options?.envMode === "internal" ? ({ ...resolvedBaseEnv, ...envOverlay } as NodeJS.ProcessEnv) @@ -104,14 +65,14 @@ export async function execCommand( ...(envOverlay ? [envOverlay] : []), ); - return execFileAsync(resolvedCommand, resolvedArgs, { + return execCli(command, args, { cwd: options?.cwd, env: childEnv, + shell: options?.shell, encoding: options?.encoding ?? "utf8", + signal: options?.signal, killSignal: options?.killSignal, timeout: options?.timeout, maxBuffer: options?.maxBuffer, - shell, - windowsHide: true, }) as Promise; } diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index e07691ba1c..b977f7952b 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -162,6 +162,10 @@ export function getPaseoWorktreeMetadataPath(worktreeRoot: string): string { return join(gitDir, "paseo", "worktree.json"); } +export function isQualifiedRef(ref: string): boolean { + return ref.trim().startsWith("refs/"); +} + const REMOTE_TRACKING_PREFIX = "refs/remotes/"; /** diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 5673c1412a..2b9181bb77 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -265,6 +265,38 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + it.each([false, true])( + "shows committed changes for an existing branch (already checked out: %s)", + async (alreadyCheckedOut) => { + execFileSync("git", ["checkout", "-b", "feature/existing"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + if (!alreadyCheckedOut) { + execFileSync("git", ["checkout", "main"], { cwd: repoDir }); + } + + const result = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "existing-feature", + source: { kind: "checkout-branch", branchName: "feature/existing" }, + runSetup: false, + paseoHome, + }); + const status = await getCheckoutStatus(result.worktreePath, { paseoHome }); + const diff = await getCheckoutDiff(result.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(status).toMatchObject({ + isGit: true, + baseRef: "main", + aheadBehind: { ahead: 1, behind: 0 }, + }); + expect(result.comparisonBaseRef).toBe("main"); + }, + ); + it("checks out an existing local branch that is not checked out elsewhere", async () => { execFileSync("git", ["branch", "dev"], { cwd: repoDir }); @@ -284,15 +316,65 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { .trim(); expect(currentBranch).toBe("dev"); + // baseRefName must be the repo's default branch, not the checked-out branch + // itself — otherwise the diff panel compares the branch against itself and + // always shows no changes. const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); expect(metadata).toMatchObject({ version: 1, - baseRefName: "dev", + baseRefName: "main", changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" }, }); }); + it("shows committed changes when origin HEAD points to a deleted default branch", async () => { + execFileSync("git", ["branch", "-m", "main", "master"], { cwd: repoDir }); + execFileSync( + "git", + ["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"], + { cwd: repoDir }, + ); + execFileSync("git", ["checkout", "-b", "feature/stale-default"], { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "feature change\n"); + execFileSync("git", ["add", "file.txt"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "master"], { cwd: repoDir }); + const created = await createWorktreePrimitive({ + cwd: repoDir, + worktreeSlug: "stale-default", + source: { kind: "checkout-branch", branchName: "feature/stale-default" }, + runSetup: false, + paseoHome, + }); + const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome }); + expect(diff.diff).toContain("+feature change"); + expect(created.comparisonBaseRef).toBe("master"); + }); + + it("falls back to the currently checked out branch when no default branch can be resolved", async () => { + // No origin remote and no local main/master — resolveRepositoryDefaultBranch + // can't find a default branch, so the fix must fall back to whatever is + // currently checked out in the main repo (here: "develop"). + execFileSync("git", ["branch", "-m", "main", "develop"], { cwd: repoDir }); + execFileSync("git", ["branch", "feature-x"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "feature-x-worktree", + source: { kind: "checkout-branch", branchName: "feature-x" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath); + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" }); + }); + it("checks out an existing local branch whose name contains uppercase letters and dots", async () => { execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir }); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4769bed7d0..479d4cfeab 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -20,6 +20,7 @@ import { writeFileSync, } from "fs"; import { join } from "path"; +import { createServer, type AddressInfo, type Socket } from "net"; import { tmpdir } from "os"; import { createRealpathAwarePathMatcher } from "./path"; @@ -52,6 +53,21 @@ function createLegacyWorktreeForTest( }); } +// A remote that accepts connections and never answers, like a VPN-only host while off the VPN. +async function startSilentRemote(): Promise<{ url: string; close: () => Promise }> { + const connections = new Set(); + const server = createServer((socket) => connections.add(socket)); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}/repo.git`, + close: () => { + for (const socket of connections) socket.destroy(); + return new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + describe("paseo worktree manager", () => { let tempDir: string; let repoDir: string; @@ -263,6 +279,103 @@ describe("paseo worktree manager", () => { expect(existsSync(created.worktreePath)).toBe(false); }); + + describe("branch-off from a remote-tracking base", () => { + function git(args: string[], cwd: string): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + }).trim(); + } + + // The repo caches /main at "initial" while the remote itself moves one commit ahead, + // as when nothing has fetched since someone else pushed. + function pushPastCachedRemoteRef(remoteName: string): string { + const remoteDir = join(tempDir, "remote.git"); + git(["clone", "--bare", "-q", repoDir, remoteDir], tempDir); + git(["remote", "add", remoteName, remoteDir], repoDir); + git(["fetch", "-q", remoteName], repoDir); + + const pusherDir = join(tempDir, "pusher"); + git(["clone", "-q", remoteDir, pusherDir], tempDir); + git(["config", "user.email", "test@test.com"], pusherDir); + git(["config", "user.name", "Test"], pusherDir); + writeFileSync(join(pusherDir, "file.txt"), "pushed after the last fetch\n"); + git(["commit", "-q", "-am", "remote ahead"], pusherDir); + git(["push", "-q", "origin", "HEAD:main"], pusherDir); + return git(["rev-parse", "HEAD"], pusherDir); + } + + it("starts the new branch at the remote tip, not the cached origin/main", async () => { + const remoteTip = pushPastCachedRemoteRef("origin"); + const fetchRefspecs = git(["config", "--get-all", "remote.origin.fetch"], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-remote-tip", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-remote-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + expect(git(["config", "--get-all", "remote.origin.fetch"], repoDir)).toBe(fetchRefspecs); + }); + + it("refreshes a base on a remote other than origin", async () => { + const remoteTip = pushPastCachedRemoteRef("team/upstream"); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-upstream-tip", + cwd: repoDir, + baseBranch: "refs/remotes/team/upstream/main", + worktreeSlug: "from-upstream-tip", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(remoteTip); + }); + + it("branches from the cached ref when the remote is unreachable", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + git(["remote", "set-url", "origin", join(tempDir, "missing.git")], repoDir); + + const created = await createLegacyWorktreeForTest({ + branchName: "from-cached-ref", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-cached-ref", + paseoHome, + }); + + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + }); + + it("branches from the cached ref when the remote accepts but never answers", async () => { + pushPastCachedRemoteRef("origin"); + const cachedTip = git(["rev-parse", "refs/remotes/origin/main"], repoDir); + const silentRemote = await startSilentRemote(); + git(["remote", "set-url", "origin", silentRemote.url], repoDir); + + try { + const startedAt = Date.now(); + const created = await createLegacyWorktreeForTest({ + branchName: "from-silent-remote", + cwd: repoDir, + baseBranch: "origin/main", + worktreeSlug: "from-silent-remote", + paseoHome, + }); + + // Clients give up on a create request after 60s, so the fallback has to land well before. + expect(Date.now() - startedAt).toBeLessThan(30_000); + expect(git(["rev-parse", "HEAD"], created.worktreePath)).toBe(cachedTip); + } finally { + await silentRemote.close(); + } + }, 45_000); + }); }); describe("slugify", () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 987867c0d3..f454a7225a 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -31,6 +31,7 @@ import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-co import { createPaseoWorktreeChangeRequestHint, normalizeBaseRefName, + isQualifiedRef, type PaseoWorktreeChangeRequestHint, readPaseoWorktreeMetadata, readPaseoWorktreeRuntimePort, @@ -38,6 +39,7 @@ import { writePaseoWorktreeRuntimeMetadata, } from "./worktree-metadata.js"; import { runGitCommand } from "./run-git-command.js"; +import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js"; import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; @@ -57,6 +59,10 @@ export interface WorktreeConfig { worktreePath: string; } +export interface CreatedWorktree extends WorktreeConfig { + comparisonBaseRef: string | null; +} + export interface WorktreeRuntimeEnv { [key: string]: string; PASEO_SOURCE_CHECKOUT_PATH: string; @@ -180,6 +186,8 @@ export interface WorktreeCheckoutRef { export type WorktreeSource = | { kind: "branch-off"; baseBranch: string; branchName: string } | { kind: "checkout-branch"; branchName: string } + | { kind: "restore"; branchName: string; baseRef: string | null } + | { kind: "restore-from-base"; baseRef: string; branchName: string } | { kind: "checkout-change-request"; forge: string; @@ -1216,7 +1224,7 @@ export const createWorktree = async ({ runSetup, paseoHome, worktreesRoot, -}: CreateWorktreeOptions): Promise => { +}: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); mkdirSync(dirname(worktreePath), { recursive: true }); @@ -1272,6 +1280,7 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, worktreePath, + comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName, }; }; @@ -1302,50 +1311,98 @@ interface WorktreeSourcePlan { }; } +async function resolveRestoredWorktreeSourcePlan( + cwd: string, + source: Extract, +): Promise { + await validateGitBranchName(cwd, source.branchName); + await ensureLocalBranch(cwd, source.branchName); + if (await isBranchCheckedOut(cwd, source.branchName)) { + throw new BranchAlreadyCheckedOutError(source.branchName); + } + let exactBase: string | undefined; + if (source.baseRef && isQualifiedRef(source.baseRef)) { + try { + exactBase = await resolveBaseBranchForWorktree(cwd, source.baseRef); + } catch { + // Restore the saved branch even when its comparison ref no longer exists. + } + } + return { + branchName: source.branchName, + metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef ?? source.branchName), + ...(exactBase ? { metadataBaseRef: exactBase } : {}), + changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ + headRef: source.branchName, + localBranchName: source.branchName, + }), + addArguments: [source.branchName], + }; +} + +async function resolveRestoredWorktreeFromBasePlan( + cwd: string, + source: Extract, +): Promise { + await validateGitBranchName(cwd, source.branchName); + const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef); + const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); + return { + branchName, + metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef), + metadataBaseRef: baseRef, + addArguments: ["-b", branchName, "--no-track", baseRef], + }; +} + +async function resolveBranchOffWorktreeSourcePlan( + cwd: string, + source: Extract, + desiredSlug: string, +): Promise { + const branchName = source.branchName; + await validateGitBranchName(cwd, branchName); + const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); + const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); + const branchExists = await localBranchExists(cwd, branchName); + const base = branchExists ? branchName : resolvedBaseBranch; + await refreshRemoteTrackingBaseRef(cwd, base); + const candidateBranch = branchExists ? desiredSlug : branchName; + const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch); + + return { + branchName: newBranchName, + metadataBaseRefName: normalizedBaseBranch, + metadataBaseRef: resolvedBaseBranch, + changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ + headRef: newBranchName, + localBranchName: newBranchName, + }), + addArguments: ["-b", newBranchName, "--no-track", base], + }; +} + async function resolveWorktreeSourcePlan({ cwd, source, desiredSlug, }: ResolveWorktreeSourcePlanOptions): Promise { switch (source.kind) { - case "branch-off": { - const branchName = source.branchName; - await validateGitBranchName(cwd, branchName); - const normalizedBaseBranch = normalizeRequiredBaseBranch(source.baseBranch); - const resolvedBaseBranch = await resolveBaseBranchForWorktree(cwd, source.baseBranch); - const branchExists = await localBranchExists(cwd, branchName); - const base = branchExists ? branchName : resolvedBaseBranch; - const candidateBranch = branchExists ? desiredSlug : branchName; - const newBranchName = await resolveUniqueLocalBranchName(cwd, candidateBranch); - - return { - branchName: newBranchName, - metadataBaseRefName: normalizedBaseBranch, - metadataBaseRef: resolvedBaseBranch, - changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ - headRef: newBranchName, - localBranchName: newBranchName, - }), - addArguments: ["-b", newBranchName, "--no-track", base], - }; - } + case "branch-off": + return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug); + case "restore": + return resolveRestoredWorktreeSourcePlan(cwd, source); + case "restore-from-base": + return resolveRestoredWorktreeFromBasePlan(cwd, source); case "checkout-branch": { await validateGitBranchName(cwd, source.branchName); - if (!(await localBranchExists(cwd, source.branchName))) { - try { - await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { - cwd, - timeout: 120_000, - }); - } catch { - throw new UnknownBranchError({ branchName: source.branchName, cwd }); - } - } + await ensureLocalBranch(cwd, source.branchName); + const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName); if (await isBranchCheckedOut(cwd, source.branchName)) { const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); return { branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: branchName, localBranchName: branchName, @@ -1356,7 +1413,7 @@ async function resolveWorktreeSourcePlan({ return { branchName: source.branchName, - metadataBaseRefName: source.branchName, + metadataBaseRefName: baseRef, changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: source.branchName, localBranchName: source.branchName, @@ -1611,6 +1668,21 @@ function normalizeRequiredBaseBranch(baseBranch: string): string { return normalizedBaseBranch; } +// The checked-out branch itself isn't a base to diff against — resolve the +// repository's default branch so the diff panel has a real comparison target. +// If the default branch can't be determined (no origin/HEAD, no local +// main/master — e.g. a local-only repo based on "develop"), fall back to +// whichever branch is currently checked out in the main repo, since that's +// almost always the branch this one was intended to be compared against. +async function resolveCheckoutBranchBaseRef(cwd: string, branchName: string): Promise { + const defaultBranch = await resolveRepositoryDefaultBranch(cwd); + if (defaultBranch) { + return defaultBranch; + } + const currentBranch = await getCurrentBranch(cwd); + return currentBranch && currentBranch !== branchName ? currentBranch : branchName; +} + async function resolveBaseBranchForWorktree( cwd: string, requestedBaseBranch: string, @@ -1618,7 +1690,7 @@ async function resolveBaseBranchForWorktree( const requested = requestedBaseBranch.trim(); const normalized = normalizeRequiredBaseBranch(requested); let exactRef: string | null = null; - if (requested.startsWith("refs/")) { + if (isQualifiedRef(requested)) { exactRef = requested; } else if (requested.startsWith("origin/")) { exactRef = `refs/remotes/${requested}`; @@ -1629,7 +1701,7 @@ async function resolveBaseBranchForWorktree( await runGitCommand(["rev-parse", "--verify", exactRef], { cwd }); return exactRef; } catch { - throw new Error(`Base branch not found: ${normalized}`); + throw new Error(`Base branch not found: ${exactRef}`); } } @@ -1645,6 +1717,55 @@ async function resolveBaseBranchForWorktree( throw new Error(`Base branch not found: ${normalized}`); } +// Clients give up on a create request after 60s. A remote that accepts the connection and never +// answers (a VPN-only host while off the VPN) must fall back to the cached ref well before that. +const REMOTE_BASE_REFRESH_TIMEOUT_MS = 15_000; + +// A remote-tracking base is only as fresh as the last fetch, and the background fetch only runs +// while a workspace of the repository is observed. Refresh the one base branch so the new branch +// starts at the remote tip; when the fetch fails (offline, auth) the cached ref is still usable. +async function refreshRemoteTrackingBaseRef(cwd: string, baseRef: string): Promise { + if (!baseRef.startsWith("refs/remotes/")) { + return; + } + try { + // Remote names may contain slashes, so the owning remote is looked up rather than parsed. + const { stdout } = await runGitCommand(["remote"], { cwd }); + const remoteName = stdout + .split("\n") + .map((name) => name.trim()) + .find((name) => name.length > 0 && baseRef.startsWith(`refs/remotes/${name}/`)); + if (!remoteName) { + return; + } + const remoteBranch = baseRef.slice(`refs/remotes/${remoteName}/`.length); + // No refspec is added to remote..fetch: one left behind breaks later fetches once the + // branch is deleted on the remote. + await runGitCommand(["fetch", remoteName, `+refs/heads/${remoteBranch}:${baseRef}`], { + cwd, + envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + timeout: REMOTE_BASE_REFRESH_TIMEOUT_MS, + acceptExitCodes: [0, 1, 128], + }); + } catch { + // The fetch timed out; branch from the cached ref. + } +} + +async function ensureLocalBranch(cwd: string, branchName: string): Promise { + if (await localBranchExists(cwd, branchName)) { + return; + } + try { + await runGitCommand(["fetch", "origin", `${branchName}:${branchName}`], { + cwd, + timeout: 120_000, + }); + } catch { + throw new UnknownBranchError({ branchName, cwd }); + } +} + async function localBranchExists(cwd: string, branchName: string): Promise { try { await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { diff --git a/packages/website/README.md b/packages/website/README.md new file mode 100644 index 0000000000..5a938ca2f4 --- /dev/null +++ b/packages/website/README.md @@ -0,0 +1,22 @@ +# Paseo website + +## Plugin install counts + +Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or +`?intent=install`, using `/api/plugins/resolve//` or +`/plugins//.json`. Ordinary resolves do not count. These numbers +measure install intent, not successful artifact installation or npm downloads. + +The worker requires Cloudflare's `CF-Connecting-IP` header to count a report. +It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key +with a one hour TTL, never the raw IP. An existing key skips the count; repeats +do not extend the hour. Missing or empty IP headers do not count. + +Accepted reports increment the existing all-time total and UTC daily buckets in +`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day +and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped +to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts) +reads that endpoint into the published plugin details and index; the website +reads windowed counts from KV through its five-minute cache. Existing counts +remain intact. The KV guard is advisory: concurrent requests and KV propagation +are not serialized. diff --git a/packages/website/e2e/download-thanks.spec.ts b/packages/website/e2e/download-thanks.spec.ts new file mode 100644 index 0000000000..bc33f8f087 --- /dev/null +++ b/packages/website/e2e/download-thanks.spec.ts @@ -0,0 +1,47 @@ +import { expect, test, type Page } from "playwright/test"; + +const APPLE_SILICON_DMG = /\/getpaseo\/paseo\/releases\/download\/v[^/]+\/Paseo-[^/]+-arm64\.dmg$/; + +async function openDownloadPage(page: Page) { + // Serve a stand-in file so the test never downloads a real release. + await page.route(APPLE_SILICON_DMG, (route) => + route.fulfill({ + status: 200, + headers: { + "content-type": "application/octet-stream", + "content-disposition": "attachment; filename=Paseo.dmg", + }, + body: "dmg", + }), + ); + await page.goto("/download"); +} + +async function clickAppleSilicon(page: Page) { + await page.getByRole("link", { name: "Apple Silicon", exact: true }).click(); +} + +test("a download button opens the thanks page and requests the file", async ({ page }) => { + await openDownloadPage(page); + + const fileRequest = page.waitForRequest(APPLE_SILICON_DMG); + await clickAppleSilicon(page); + + await fileRequest; + await expect(page).toHaveURL(/\/download\/thanks\?file=/); + await expect(page.getByRole("heading", { name: "Thanks for downloading Paseo" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Try again" })).toHaveAttribute( + "href", + APPLE_SILICON_DMG, + ); +}); + +test("the thanks page starts a browser download", async ({ page, browserName }) => { + test.skip(browserName === "webkit", "WebKit reports no download event for a stubbed response"); + await openDownloadPage(page); + + const download = page.waitForEvent("download"); + await clickAppleSilicon(page); + + expect((await download).url()).toMatch(APPLE_SILICON_DMG); +}); diff --git a/packages/website/e2e/mockups.spec.ts b/packages/website/e2e/mockups.spec.ts new file mode 100644 index 0000000000..8b0ba65857 --- /dev/null +++ b/packages/website/e2e/mockups.spec.ts @@ -0,0 +1,61 @@ +import { expect, test, type Locator, type Page } from "playwright/test"; + +const desktopName = + "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; + +async function openHomepage(page: Page) { + await page.setViewportSize({ width: 1512, height: 930 }); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible(); +} + +async function viewPhones(page: Page) { + await page.getByRole("img", { name: "Paseo agent chat", exact: true }).scrollIntoViewIfNeeded(); +} + +async function expectScreenContentInsidePhone(page: Page, name: string, time: string) { + const phone = page.getByRole("img", { name, exact: true }); + const clock = phone.getByText(time, { exact: true }); + await expect(clock).toBeVisible(); + await expect(async () => { + const frame = await phone.boundingBox(); + const content = await clock.boundingBox(); + expect(frame).not.toBeNull(); + expect(content).not.toBeNull(); + if (!frame || !content) throw new Error("Phone screen is not rendered"); + expect(content.x).toBeGreaterThanOrEqual(frame.x); + expect(content.y).toBeGreaterThanOrEqual(frame.y); + expect(content.x + content.width).toBeLessThanOrEqual(frame.x + frame.width); + expect(content.y + content.height).toBeLessThanOrEqual(frame.y + frame.height); + }).toPass({ timeout: 5000 }); +} + +async function cornerProportion(mockup: Locator) { + return mockup.evaluate((element) => { + const radius = getComputedStyle(element).borderTopLeftRadius.split(" ")[0]; + if (radius.endsWith("%")) return Number.parseFloat(radius) / 100; + return Number.parseFloat(radius) / element.getBoundingClientRect().width; + }); +} + +async function resizeToPhone(page: Page) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("img", { name: desktopName, exact: true }).scrollIntoViewIfNeeded(); +} + +test("the three phone mockups show their screen content", async ({ page }) => { + await openHomepage(page); + await viewPhones(page); + await expectScreenContentInsidePhone(page, "Paseo workspace drawer", "18:54"); + await expectScreenContentInsidePhone(page, "Paseo agent chat", "18:53"); + await expectScreenContentInsidePhone(page, "Paseo diff view", "18:55"); +}); + +test("desktop mockup corners shrink in proportion on a phone", async ({ page }) => { + await openHomepage(page); + const mockup = page.getByRole("img", { name: desktopName, exact: true }); + const desktopCorner = await cornerProportion(mockup); + expect(desktopCorner).toBeGreaterThan(0); + await resizeToPhone(page); + await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3); +}); diff --git a/packages/website/e2e/overview.fixture.json b/packages/website/e2e/overview.fixture.json new file mode 100644 index 0000000000..2553fe83c6 --- /dev/null +++ b/packages/website/e2e/overview.fixture.json @@ -0,0 +1,32 @@ +{ + "id": "security/overview", + "name": "", + "description": "", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "javascript:alert(1)" + }, + "categories": ["themes"], + "author": { + "github": "security", + "name": "" + }, + "media": [ + "data:image/svg+xml,", + "http://example.com/image.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-10-03T00:00:00.000Z", + "publishedAt": "2026-09-24T17:12:05.467Z", + "updatedAt": "2026-10-03T00:00:00.000Z", + "installs": 0, + "readme": "## Overview security\n\n", + "icon": "data:image/png;base64,AAAA" +} diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts new file mode 100644 index 0000000000..6eb9f7d816 --- /dev/null +++ b/packages/website/e2e/plugins.spec.ts @@ -0,0 +1,782 @@ +import securityPlugin from "./overview.fixture.json" with { type: "json" }; +import { expect, test, type Page, type Locator } from "playwright/test"; +import registry from "./registry.fixture.json" with { type: "json" }; +import { CATEGORIES } from "../src/plugins/categories"; + +async function openPlugins(page: Page) { + await page.goto("/plugins"); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +} + +function pluginCards(page: Page, section: string): Locator { + return page + .getByRole("region", { name: section }) + .getByRole("link") + .filter({ hasNotText: /^See all$/ }); +} + +test("scrolls mobile plugin carousels without widening the page", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openPlugins(page); + await expectPageWithinViewport(page); + await scrollPluginCarousel(page, "Featured"); + await scrollPluginCarousel(page, "What’s new"); + await expectPageWithinViewport(page); +}); + +// Graphite's fixture description ends in raw README HTML with an unbreakable srcset URL. +test("keeps browse grids within a phone's width when a description has an unbreakable word", async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/plugins/all"); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expectPageWithinViewport(page); +}); + +async function expectPageWithinViewport(page: Page) { + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( + await page.evaluate(() => document.documentElement.clientWidth), + ); + await page.evaluate(() => window.scrollTo({ left: 1000, behavior: "instant" })); + expect(await page.evaluate(() => window.scrollX)).toBe(0); +} + +async function scrollPluginCarousel(page: Page, section: string) { + const cards = pluginCards(page, section); + // The cards share a non-semantic scrolling container. + const strip = cards.first().locator(".."); + await strip.evaluate((element) => + element.scrollIntoView({ block: "center", behavior: "instant" }), + ); + expect(await strip.evaluate((element) => element.scrollWidth)).toBeGreaterThan( + await strip.evaluate((element) => element.clientWidth), + ); + await expect(cards.last()).not.toBeInViewport(); + await strip.evaluate((element) => + element.scrollTo({ left: element.scrollWidth, behavior: "instant" }), + ); + await expect(cards.last()).toBeInViewport({ ratio: 1 }); + await expect(cards.first()).not.toBeInViewport(); +} + +test("browses from the directory into a category, a plugin, and its author", async ({ + page, + context, +}) => { + // WebKit has no clipboard permission to grant; record what the page writes instead. + await context.addInitScript(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + (window as unknown as { __copied?: string }).__copied = text; + return Promise.resolve(); + }, + }, + }); + }); + await openPlugins(page); + + await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await page.getByRole("link", { name: /Fresh Worktrees/ }).click(); + await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/); + await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1); + await expect( + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), + ).toBeVisible(); + await page.getByRole("button", { name: "Copy to clipboard" }).click(); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied)) + .toBe("paseo plugin add omercnet/fresh-worktrees"); + await expect(page.getByRole("link", { name: "Git", exact: true })).toHaveAttribute( + "href", + "/plugins/category/git", + ); + // The source is a folder in a monorepo; issues are filed on the repository. + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveAttribute( + "href", + "https://github.com/omercnet/paseo-plugins/issues/new", + ); + + await page.getByRole("link", { name: "Omer Cohen" }).click(); + await expect(page).toHaveURL(/\/plugins\/omercnet$/); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Agent Monitor/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0); +}); + +test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({ + page, +}) => { + await page.goto("/plugins/all"); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute( + "aria-current", + "page", + ); + await page.getByRole("link", { name: "This month" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?window=month$/); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); + + await page.getByRole("link", { name: "Newest" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=new$/); + await expect(page.getByRole("navigation", { name: "Time window" })).toHaveCount(0); + await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText( + "ago", + ); + + await page.getByRole("link", { name: "Recently updated" }).click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/); + await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([ + /Defer/, + /OMP/, + ]); +}); + +test("shows each plugin's author with their avatar on browse cards", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Defer/ }); + await expect(card).toContainText("Tom Gringauz"); + await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1); +}); + +test("filters by search and clears to all plugins", async ({ page }) => { + await page.goto("/"); + await openPlugins(page); + + await submitSearch(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await searchPlugins(page, "zzzz-nothing"); + await expect(page.getByText("No plugins match.")).toBeVisible(); + await page.getByRole("link", { name: "Clear filters" }).click(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); +}); + +test("keeps the directory's ranking window when searching", async ({ page }) => { + await page.goto("/plugins?window=month"); + await submitSearch(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); + await expect(page.getByRole("link", { name: "This month" })).toHaveAttribute( + "aria-current", + "true", + ); +}); + +test("searches for a term typed before the page finished loading", async ({ page }) => { + const loadScripts = await holdScripts(page); + await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" }); + await searchPlugins(page, "graphite"); + await loadScripts(); + await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/); +}); + +test("filters browse results for a term typed before the page finished loading", async ({ + page, +}) => { + await page.goto("/"); + const loadScripts = await holdScripts(page); + await page.goto("/plugins/all", { waitUntil: "domcontentloaded" }); + const entries = await historyLength(page); + await searchPlugins(page, "gra"); + await loadScripts(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + +test("clears the search with the clear button", async ({ page }) => { + await page.goto("/plugins/all"); + const searchbox = page.getByRole("searchbox", { name: "Search plugins" }); + const clear = page.getByRole("button", { name: "Clear search" }); + await expect(clear).toHaveCount(0); + + await searchPlugins(page, "graphite"); + await expect(page).toHaveURL(/\/plugins\/all\?q=graphite$/); + await clear.click(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(searchbox).toHaveValue(""); + await expect(searchbox).toBeFocused(); + await expect(clear).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); +}); + +test("searches from the directory on submit, and Back returns to the directory", async ({ + page, +}) => { + await page.goto("/"); + await openPlugins(page); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins$/); + expect(await historyLength(page)).toBe(entries); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins$/); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test("filters browse results as you type in one history entry", async ({ page }) => { + await page.goto("/"); + await page.goto("/plugins/all"); + const entries = await historyLength(page); + + await typeSearch(page, "gra"); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + expect(await historyLength(page)).toBe(entries + 1); + + await page.goBack(); + await expect(page).toHaveURL(/\/plugins\/all$/); + await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue(""); + + await page.goForward(); + await expect(page).toHaveURL(/\/plugins\/all\?q=gra$/); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); + + await page.reload(); + await expect(page.getByRole("heading", { level: 1, name: /^Results for “gra”/ })).toBeVisible(); + await expect(page.getByRole("searchbox", { name: "Search plugins" })).toHaveValue("gra"); +}); + +test("keeps old category links working", async ({ page }) => { + await page.goto("/plugins?category=git&sort=new"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?sort=new$/); + await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible(); +}); + +test("explains a plugin that is not listed", async ({ page }) => { + await page.goto("/plugins/acme/does-not-exist"); + await expect(page.getByRole("heading", { level: 1, name: "Plugin not found" })).toBeVisible(); + await page.getByRole("link", { name: "Browse all plugins" }).click(); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +test.describe("search engine visits without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("reads directory, category, author, and complete plugin HTML with page metadata", async ({ + page, + }) => { + await openPlugins(page); + await expect(page.getByRole("link", { name: /Base2Tone/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Plugins – Extend Paseo with community plugins", "/plugins"); + + await page.goto("/plugins/category/git"); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await expectPageMetadata(page, "Git – Paseo plugins", "/plugins/category/git"); + + await page.goto("/plugins/omercnet"); + await expect(page.getByRole("heading", { level: 1, name: "Omer Cohen" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ }).first()).toBeVisible(); + await expectPageMetadata(page, "Omer Cohen – Paseo plugins", "/plugins/omercnet"); + + const response = await page.goto("/plugins/omercnet/fresh-worktrees"); + expect(response?.status()).toBe(200); + expect(response?.headers()["cache-control"]).toBe("private, no-store"); + expect(response?.headers()["x-robots-tag"]).toBeUndefined(); + await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Behavior", exact: true }), + ).toBeVisible(); + await expectPageMetadata( + page, + "Fresh Worktrees – Paseo plugin", + "/plugins/omercnet/fresh-worktrees", + ); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + ); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + ); + await expect(page.locator('meta[name="robots"]')).toHaveCount(0); + + await page.goto("/plugins/tomgrin10/graphite"); + await expectPageMetadata(page, "Graphite – Paseo plugin", "/plugins/tomgrin10/graphite"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://paseo.sh/og-image.png", + ); + }); + + test("renders search results and submits the search form", async ({ page }) => { + await page.goto("/plugins/all?q=graphite"); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “graphite”/ }), + ).toBeVisible(); + await expect(page.getByRole("main").getByRole("link", { name: /Graphite/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0); + + await page.goto("/plugins/category/git"); + await searchPlugins(page, "fresh"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/plugins\/category\/git\?q=fresh$/); + await expect( + page.getByRole("heading", { level: 1, name: /^Results for “fresh” in Git/ }), + ).toBeVisible(); + await expect(page.getByRole("link", { name: /Fresh Worktrees/ })).toBeVisible(); + await page.getByRole("link", { name: "Clear", exact: true }).click(); + await expect(page).toHaveURL(/\/plugins\/category\/git$/); + }); + + test("returns real 404 pages for unknown plugins, authors, and categories", async ({ page }) => { + const plugin = await page.goto("/plugins/acme/does-not-exist"); + expect(plugin?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Plugin not found" })).toBeVisible(); + await expect(page).toHaveTitle("Plugin not found – Paseo"); + const author = await page.goto("/plugins/does-not-exist"); + expect(author?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Author not found" })).toBeVisible(); + await expect(page).toHaveTitle("Author not found – Paseo"); + const category = await page.goto("/plugins/category/does-not-exist"); + expect(category?.status()).toBe(404); + await expect(page.getByRole("heading", { name: "Category not found" })).toBeVisible(); + await expect(page).toHaveTitle("Category not found – Paseo"); + }); + + test("redirects old category links permanently", async ({ request }) => { + const response = await request.get("/plugins?category=git", { maxRedirects: 0 }); + expect(response.status()).toBe(301); + expect(response.headers().location).toMatch(/\/plugins\/category\/git$/); + const search = await request.get("/plugins?q=graphite", { maxRedirects: 0 }); + expect(search.status()).toBe(301); + expect(search.headers().location).toMatch(/\/plugins\/all\?q=graphite$/); + const windowed = await request.get("/plugins?q=graphite&window=month", { maxRedirects: 0 }); + expect(windowed.headers().location).toMatch(/\/plugins\/all\?q=graphite&window=month$/); + }); + + test("discovers plugin, category, and author URLs through robots and the sitemap index", async ({ + request, + }) => { + const robots = await request.get("/robots.txt"); + expect(await robots.text()).toContain("Sitemap: https://paseo.sh/sitemap-index.xml"); + const index = await request.get("/sitemap-index.xml"); + expect(await index.text()).toContain("https://paseo.sh/sitemap-plugins.xml"); + const plugins = await request.get("/sitemap-plugins.xml"); + expect(plugins.status()).toBe(200); + expect(plugins.headers()["content-type"]).toContain("application/xml"); + const sitemap = await plugins.text(); + expect(sitemap).toContain("https://paseo.sh/plugins/all"); + expect(sitemap).toContain("https://paseo.sh/plugins/category/git"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet"); + expect(sitemap).toContain("https://paseo.sh/plugins/omercnet/fresh-worktrees"); + }); +}); + +/** Holds the page's scripts so typing lands before hydration; the returned function loads them. */ +async function holdScripts(page: Page): Promise<() => Promise> { + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + await page.route(/\.js($|\?)/, async (route) => { + await held; + await route.continue(); + }); + return async () => { + release(); + await page.waitForLoadState("load"); + }; +} + +async function searchPlugins(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).fill(term); +} + +async function submitSearch(page: Page, term: string) { + await searchPlugins(page, term); + await page.keyboard.press("Enter"); +} + +async function typeSearch(page: Page, term: string) { + await page.getByRole("searchbox", { name: "Search plugins" }).pressSequentially(term); +} + +async function historyLength(page: Page): Promise { + return page.evaluate(() => window.history.length); +} + +async function expectPageMetadata(page: Page, title: string, path: string) { + await expect(page).toHaveTitle(title); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + "href", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", title); + await expect(page.locator('meta[property="og:url"]')).toHaveAttribute( + "content", + `https://paseo.sh${path}`, + ); + await expect(page.locator('meta[property="og:type"]')).toHaveAttribute("content", "website"); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute( + "content", + "summary_large_image", + ); + const description = await page.locator('meta[name="description"]').getAttribute("content"); + expect(description).toBeTruthy(); + await expect(page.locator('meta[property="og:description"]')).toHaveAttribute( + "content", + description!, + ); +} + +test("links the directory from the site navigation", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("navigation").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); + await expect( + page.getByRole("contentinfo").getByRole("link", { name: "Plugins", exact: true }), + ).toHaveAttribute("href", "/plugins"); + await expect(page.getByRole("link", { name: "Browse plugins" })).toHaveAttribute( + "href", + "/plugins", + ); + const response = await page.goto("/plugins"); + expect(response?.status()).toBe(200); + await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); +}); + +// External deployments own their registry contents; these assertions use the local fixture. +test.describe("registry fixture layout", () => { + test.skip(Boolean(process.env.WEBSITE_TEST_URL), "Requires the local registry fixture"); + + test("uses density-aware card thumbnails and keeps detail screenshots original", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "alhassanaraouf/base2tone")!; + const source = plugin.media[0]; + await openPlugins(page); + await expectThumbnailCard( + page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Base2Tone/ }), + source, + plugin.id, + ); + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /Base2Tone/ }); + await expectThumbnailCard(card, source, plugin.id); + await card.click(); + await expect( + page.getByRole("img", { name: "Base2Tone screenshot 1", exact: true }), + ).toHaveAttribute("src", source); + }); + + test("shows images and videos as gallery tiles in registry order and opens each in the viewer", async ({ + page, + }) => { + const plugin = registry.plugins.find((entry) => entry.id === "omercnet/dracula")!; + const [firstImage, video] = plugin.media; + await page.goto(`/plugins/${plugin.id}`); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + await expect(tiles).toHaveCount(3); + const layout = await tiles.evaluateAll(galleryTiles); + expect(layout.map((tile) => tile.href)).toEqual(plugin.media); + expect(new Set(layout.map((tile) => tile.size)).size).toBe(1); + const preview = tiles.nth(1).locator("video"); + await expect(preview).toHaveAttribute("preload", "metadata"); + await expect(preview).not.toHaveAttribute("controls"); + await expect(preview).not.toHaveAttribute("autoplay"); + expect(await preview.evaluate((element: HTMLVideoElement) => element.muted)).toBe(true); + + const viewer = page.getByRole("dialog"); + await tiles.nth(1).click(); + const playing = viewer.getByLabel("Dracula video 2"); + await expect(playing).toHaveAttribute("src", video); + await expect(playing).toHaveAttribute("controls", ""); + await expect.poll(() => playing.evaluate(isPaused)).toBe(false); + // The fixture video is 320x240, so it has to scale up to fill the viewer. + await expect.poll(() => playing.evaluate(viewerFill)).toBeCloseTo(1, 2); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + await expect(page.locator("dialog video")).toHaveCount(0); + + await tiles.first().click(); + await expect(viewer.getByRole("img", { name: "Dracula screenshot 1" })).toHaveAttribute( + "src", + firstImage, + ); + await viewer.getByRole("button", { name: "Close" }).click(); + await expect(viewer).toBeHidden(); + expect(page.context().pages()).toHaveLength(1); + + const newTab = page.context().waitForEvent("page"); + await tiles.first().click({ modifiers: ["ControlOrMeta"] }); + await expect(await newTab).toHaveURL(firstImage); + await expect(viewer).toBeHidden(); + }); + + test("peeks the next gallery tile at the strip's edge, and fills the row with two", async ({ + page, + }) => { + // Wide screens show two tiles and part of the third; phones show one and part of the second. + for (const [width, expected] of [ + [1280, [1, 1, "peeks"]], + [375, [1, "peeks", 0]], + ] as const) { + await page.setViewportSize({ width, height: 720 }); + await page.goto("/plugins/omercnet/dracula"); + const three = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const shares = await three.evaluateAll(visibleShares); + expect(shares.map((share) => (share > 0.1 && share < 0.5 ? "peeks" : share))).toEqual( + expected, + ); + } + + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/plugins/gpambrozio/herald"); + const two = page.getByRole("link", { name: /^Herald screenshot \d$/ }); + expect(await two.evaluateAll(visibleShares)).toEqual([1, 1]); + }); + + test("steps through the viewer with arrow keys and buttons, wrapping at the ends", async ({ + page, + }) => { + await page.goto("/plugins/omercnet/dracula"); + const tiles = page.getByRole("link", { name: /^Dracula (screenshot|video) \d$/ }); + const viewer = page.getByRole("dialog"); + const shows = (name: string) => + expect( + viewer.getByRole("img", { name, exact: true }).or(viewer.getByLabel(name, { exact: true })), + ).toBeVisible(); + + await tiles.nth(1).click(); + await expect.poll(() => viewer.getByLabel("Dracula video 2").evaluate(isPaused)).toBe(false); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 3"); + await expect(page.locator("dialog video")).toHaveCount(0); + await page.keyboard.press("ArrowRight"); + await shows("Dracula screenshot 1"); + await viewer.getByRole("img").click(); + await page.keyboard.press("ArrowLeft"); + await shows("Dracula screenshot 3"); + + await viewer.getByRole("button", { name: "Previous" }).click(); + await shows("Dracula video 2"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 3"); + await viewer.getByRole("button", { name: "Next" }).click(); + await shows("Dracula screenshot 1"); + await page.keyboard.press("Escape"); + await expect(viewer).toBeHidden(); + }); + + test("keeps a video that fails to load visible in the viewer", async ({ page }) => { + const plugin = registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!; + await page.route(plugin.media[0], (route) => route.abort()); + await page.goto(`/plugins/${plugin.id}`); + await page.getByRole("link", { name: "launchd Jobs video 1" }).click(); + const video = page.getByRole("dialog").getByLabel("launchd Jobs video 1"); + await expect(video).toBeVisible(); + await expect(video).toHaveCSS("opacity", "1"); + await expect(page.getByRole("button", { name: "Next" })).toHaveCount(0); + }); + + test("shows the plugin tile on cards when its media has no image", async ({ page }) => { + await page.goto("/plugins/all"); + const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ }); + // The only image is the author's avatar. + await expect(card.locator("img, video")).toHaveCount(1); + await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1); + await card.click(); + await expect( + page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"), + ).toHaveAttribute( + "src", + registry.plugins.find((entry) => entry.id === "gpambrozio/launchd-jobs")!.media[0], + ); + }); + + test("features listed plugins above What's new in the registry's order", async ({ page }) => { + await openPlugins(page); + await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); + const featured = page.getByRole("region", { name: "Featured" }); + await expect(featured).toContainText("A selection of hand picked plugins"); + await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]); + }); + + test("lists the nine categories in order with counts, and the newest plugins first", async ({ + page, + }) => { + await openPlugins(page); + const categories = page.getByRole("region", { name: "Categories" }); + await expect(categories.getByRole("link")).toHaveText( + CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), + ); + await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); + await expect(pluginCards(page, "What’s new")).toHaveText([ + /Base2Tone/, + /Sayr/, + /PromptKit/, + /Defer/, + ]); + }); + + test("lists only plugins updated since they were listed under Recently updated", async ({ + page, + }) => { + await openPlugins(page); + await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]); + await page + .getByRole("region", { name: "Recently updated" }) + .getByRole("link", { name: "See all" }) + .click(); + await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/); + }); +}); + +function galleryTiles(links: Element[]) { + return links.map((link) => ({ + href: link.getAttribute("href"), + size: `${link.clientWidth}x${link.clientHeight}`, + })); +} + +/** How much of each tile's width the gallery strip shows, rounded to hundredths. */ +function visibleShares(links: Element[]) { + const strip = links[0].parentElement!.getBoundingClientRect(); + return links.map((link) => { + const tile = link.getBoundingClientRect(); + const shown = Math.min(tile.right, strip.right) - Math.max(tile.left, strip.left); + return Math.round((Math.max(shown, 0) / tile.width) * 100) / 100; + }); +} + +/** The share of the viewer's room the media fills along its limiting axis; 1 fills it. */ +function viewerFill(media: HTMLElement) { + const box = media.getBoundingClientRect(); + return Math.max(box.width / (window.innerWidth * 0.9), box.height / (window.innerHeight * 0.85)); +} + +function isPaused(video: HTMLVideoElement) { + return video.paused; +} + +async function expectThumbnailCard(card: Locator, source: string, id: string) { + // Card screenshots are decorative and hidden from the accessibility tree. + const image = card.locator("img").first(); + const path = (width: number) => + `/plugins/thumb/${width}/${encodeURIComponent(source)}?plugin=${encodeURIComponent(id)}`; + await expect(image).toHaveAttribute("src", path(592)); + await expect(image).toHaveAttribute("srcset", `${path(592)} 1x, ${path(1184)} 2x`); + await expect(image).toHaveAttribute("loading", "lazy"); + await expect(image).toHaveAttribute("decoding", "async"); + await image.scrollIntoViewIfNeeded(); + await expect + .poll( + () => + image.evaluate((element: HTMLImageElement) => element.complete && element.naturalWidth > 0), + { timeout: 15_000 }, + ) + .toBe(true); + const box = await image.evaluate((element) => { + const rect = element.parentElement!.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(box.width).toBeGreaterThan(0); + expect(box.width / box.height).toBeCloseTo(1.6, 2); +} + +test("keeps author content inert through SSR and hydration", async ({ + page, + request, +}, testInfo) => { + await verifyPluginOverviewResponse(request); + await openUntrustedPlugin(page); + await expectPluginOverviewSafe(page); + await page.reload(); + await expectPluginOverviewSafe(page); + await testInfo.attach("plugin-overview", { + body: await page.screenshot(), + contentType: "image/png", + }); +}); + +async function verifyPluginOverviewResponse(request: import("playwright/test").APIRequestContext) { + const response = await request.get("/plugins/security/overview"); + expect(response.status()).toBe(200); + const html = await response.text(); + expect(html).not.toContain(""); + expect(html).toContain("\\x3C/script>"); +} + +async function openUntrustedPlugin(page: Page) { + await page.goto("/plugins/security/overview"); + await expect(page.getByRole("heading", { name: "Overview security", exact: true })).toBeVisible(); +} + +async function expectPluginOverviewSafe(page: Page) { + expect( + await page.evaluate(() => (globalThis as { __overviewExecuted?: number }).__overviewExecuted), + ).toBeUndefined(); + // Security assertions inspect every emitted attribute, including inaccessible injected elements. + const violations = await page.locator(".docs-prose").evaluate((root) => { + const bad: string[] = []; + for (const element of root.querySelectorAll("*")) { + if ( + ["SCRIPT", "IFRAME", "SVG", "FORM", "INPUT", "META", "BASE", "STYLE"].includes( + element.tagName, + ) + ) + bad.push(element.tagName); + for (const attr of element.attributes) { + if (/^on|^style$|^srcdoc$/i.test(attr.name)) bad.push(attr.name); + if (["href", "src"].includes(attr.name) && !attr.value.startsWith("https://")) + bad.push(attr.value); + } + if ( + element.tagName === "A" && + (element.getAttribute("rel") !== "noopener noreferrer nofollow" || + element.getAttribute("target") !== "_blank") + ) + bad.push("unsafe anchor"); + } + return bad; + }); + expect(violations).toEqual([]); + await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "Report a bug" })).toHaveCount(0); + await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0); + await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name); + await expect( + page.getByRole("link", { name: securityPlugin.author.name, exact: true }), + ).toHaveAttribute("href", "/plugins/security"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute( + "content", + securityPlugin.description, + ); +} diff --git a/packages/website/e2e/registry-server.mjs b/packages/website/e2e/registry-server.mjs new file mode 100644 index 0000000000..228f3dd859 --- /dev/null +++ b/packages/website/e2e/registry-server.mjs @@ -0,0 +1,25 @@ +import { createServer } from "node:http"; +import registry from "./registry.fixture.json" with { type: "json" }; + +import securityPlugin from "./overview.fixture.json" with { type: "json" }; + +import overviewCorpus from "../../protocol/tests/fixtures/plugin-overview.json" with { type: "json" }; +securityPlugin.readme += + "\n\n" + + overviewCorpus + .map(({ markdown }) => markdown + "\n\n```html\n" + markdown + "\n```") + .join("\n\n"); + +// Serve the published registry contract so SSR and browser navigation use the same data. +createServer((request, response) => { + response.setHeader("Content-Type", "application/json"); + if (request.url === "/index.json") { + response.end(JSON.stringify(registry)); + return; + } + const plugin = [securityPlugin, ...registry.plugins].find( + (entry) => request.url === `/plugins/${entry.id}.json`, + ); + response.statusCode = plugin ? 200 : 404; + response.end(JSON.stringify(plugin ?? { error: "Plugin not found" })); +}).listen(8188, "127.0.0.1"); diff --git a/packages/website/e2e/registry.fixture.json b/packages/website/e2e/registry.fixture.json new file mode 100644 index 0000000000..4ad4eabf1b --- /dev/null +++ b/packages/website/e2e/registry.fixture.json @@ -0,0 +1,638 @@ +{ + "schemaVersion": 1, + "registry": { + "name": "Paseo plugins", + "url": "https://plugins.paseo.sh" + }, + "generatedAt": "2026-10-04T15:55:47.882Z", + "featured": [ + "omercnet/dracula", + "gpambrozio/herald", + "acme/not-published", + "gpambrozio/launchd-jobs" + ], + "categories": [ + { + "slug": "daemon-management", + "label": "Daemon management", + "description": "Manage hosts, daemon jobs, resources, approvals, and MCP servers." + }, + { + "slug": "themes", + "label": "Themes", + "description": "Change the app color scheme." + }, + { + "slug": "providers", + "label": "Providers", + "description": "Connect coding agents, ACP servers, and model routers, with account and quota fallback." + }, + { + "slug": "orchestration", + "label": "Orchestration", + "description": "Coordinate agents, delegate work, and send messages across hosts." + }, + { + "slug": "git", + "label": "Git", + "description": "Work with branches, worktrees, pull requests, stacks, diffs, and reviews." + }, + { + "slug": "workspaces", + "label": "Workspaces", + "description": "Add workspace panels and tabs for boards, browsers, documents, and terminals." + }, + { + "slug": "sidebar", + "label": "Sidebar", + "description": "Add sidebar items and panels for usage, notifications, pricing, and links." + }, + { + "slug": "extras", + "label": "Extras", + "description": "Add presence, pets, puzzles, wellbeing reminders, and demos." + }, + { + "slug": "utils", + "label": "Utils", + "description": "Extend the composer and timeline with commands, prompts, skills, and small helpers." + } + ], + "plugins": [ + { + "id": "alhassanaraouf/base2tone", + "name": "Base2Tone", + "description": "Base2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md.", + "artifact": { + "kind": "npm", + "package": "@alhassanaraouf/paseo-base2tone-theme", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@alhassanaraouf/paseo-base2tone-theme/-/paseo-base2tone-theme-0.1.2.tgz", + "integrity": "sha512-baP+qh3MlPgwACvt72XjrULjbDHARofzHfxyGGVVl22iw6qVnMzyfBJXi4exgtplvD/yPqf/XlsqD8wTqHB+lg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/alhassanaraouf/paseo-themes/tree/HEAD/packages/paseo-base2tone-theme" + }, + "categories": ["themes"], + "author": { + "npm": "alhassanaraouf", + "name": "alhassanaraouf", + "github": "alhassanaraouf" + }, + "media": [ + "https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png" + ], + "submittedAt": "2026-09-24T00:00:00.000Z", + "reviewedAt": "2026-09-24T00:00:00.000Z", + "publishedAt": "2026-09-24T00:00:00.000Z", + "updatedAt": "2026-09-24T00:00:00.000Z", + "installs": 0, + "readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md." + }, + { + "id": "dorasto/sayr", + "name": "Sayr", + "description": "A paseo plugin for viewing and managing your Sayr tasks", + "artifact": { + "kind": "npm", + "package": "@sayrio/paseo-plugin", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sayrio/paseo-plugin/-/paseo-plugin-0.2.0.tgz", + "integrity": "sha512-LPmVkEPWl8Km6US4zQAzhqSbGm7lBDwCjgNoysOhCsDiRy0jbtDKYRg1XVgwwUkoEwhF1EdZHCPwIuT8IizyWQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/dorasto/sayr/tree/HEAD/packages/paseo-plugin", + "commit": "e70919b89df3cf9e95c18242939193a79c7593b1" + }, + "categories": ["workspaces"], + "author": { + "npm": "trent-001", + "name": "Tom Lundy", + "github": "dorasto" + }, + "media": [ + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/sayr-plguin-1.png", + "https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", + "installs": 0, + "readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks" + }, + { + "id": "gpambrozio/github-board", + "name": "GitHub Board", + "description": "Paseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-github-board", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-github-board/-/paseo-github-board-0.10.0.tgz", + "integrity": "sha512-G6LxSsbt4Kt36yWEB2rXUxOwNLFsA4DbF20ViroVqaicMPjaw5H4JUWXjzEZAxWJT+0DlH12/TtqN4I0sNoQtg==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/github-board", + "commit": "530a66c592e20915169b5e67a35de8793f71ec60" + }, + "categories": ["git"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/screenshot.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", + "installs": 0, + "readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions" + }, + { + "id": "gpambrozio/herald", + "name": "Herald", + "description": "Paseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-herald", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-herald/-/paseo-herald-0.5.1.tgz", + "integrity": "sha512-rXdlaxgRlYP3nmZrvbG29sQeluz5JC7TPuJDgKzyh9itDH1AvZYGjLgGp9XZGKxdBBhaGxgGAFhDh+VTL8h9dw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/herald", + "commit": "b658d0ab58735959e46d12660ae179c002cdadda" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/panel.png", + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", + "installs": 0, + "readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel" + }, + { + "id": "gpambrozio/launchd-jobs", + "name": "launchd Jobs", + "description": "Paseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-launchd-jobs", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-launchd-jobs/-/paseo-launchd-jobs-0.5.0.tgz", + "integrity": "sha512-H6CQnalMdyQjW9ta9ZT40qEPJsREgNFSEAH8EC4sJXdz2Hz9tIsInSUmm2bDcgfY3D3HFffUrwf1mmeevi/wUA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/launchd-jobs", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["daemon-management"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", + "installs": 0, + "readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel" + }, + { + "id": "gpambrozio/model-pricing", + "name": "Model Pricing", + "description": "Paseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-model-pricing", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-model-pricing/-/paseo-model-pricing-0.3.0.tgz", + "integrity": "sha512-jLfC6iCLOwNACiili8nw3/qmBymoj6YHitMeGCa2RggiMdUhT6n+INRsSjoTs2kNzeYMftMfQ3MZJXeLLF1opA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/model-pricing", + "commit": "08e75003b287208ef804f3d31b2fda2597f69dce" + }, + "categories": ["sidebar"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [ + "https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png" + ], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", + "installs": 0, + "readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter" + }, + { + "id": "gpambrozio/skills", + "name": "Skills", + "description": "Paseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it", + "artifact": { + "kind": "npm", + "package": "@gpambrozio/paseo-skills", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@gpambrozio/paseo-skills/-/paseo-skills-0.4.0.tgz", + "integrity": "sha512-zMgP2U/vrRsLkKhoQICkN52+YoDy2WkkYymEDmruduKxf4Dq99Ct8Pk3xqJyAzijq2oprMFYLbj/wT6XepBLeA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/gpambrozio/paseo-plugins/tree/HEAD/skills", + "commit": "d04187ee60ca3702ca5843803567600427102d99" + }, + "categories": ["utils"], + "author": { + "npm": "gpambrozio", + "name": "gpambrozio", + "github": "gpambrozio" + }, + "media": [], + "submittedAt": "2026-09-19T00:00:00.000Z", + "reviewedAt": "2026-09-19T00:00:00.000Z", + "publishedAt": "2026-09-19T00:00:00.000Z", + "updatedAt": "2026-09-19T00:00:00.000Z", + "installs": 0, + "readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it" + }, + { + "id": "hungcuong9125/prompt-kit", + "name": "PromptKit", + "description": "PromptKit - rewrite and transform prompts directly from the Paseo composer.", + "artifact": { + "kind": "npm", + "package": "paseo-prompt-kit", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/paseo-prompt-kit/-/paseo-prompt-kit-0.6.0.tgz", + "integrity": "sha512-eiagOY1OokDx2iihfe3Vjh2dgKKP6IsBKbJYbfhM+TXw3aU7duV/rs/TUnBSeeWAk0cEsVg2Jd+EkeaPM8IJdA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/hungcuong9125/paseo-prompt-kit" + }, + "categories": ["utils"], + "author": { + "npm": "hungcuong9125", + "name": "hungcuong9125", + "github": "hungcuong9125" + }, + "media": [ + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-provider-cli.png", + "https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-09-22T00:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-09-22T00:00:00.000Z", + "installs": 0, + "readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer." + }, + { + "id": "omercnet/agent-monitor", + "name": "Agent Monitor", + "description": "A Paseo plugin that provides a host-wide agent triage roster.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-agent-monitor", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-agent-monitor/-/paseo-agent-monitor-1.2.0.tgz", + "integrity": "sha512-dY7/3f49sPMVwb+Wr7iUsw4jCoQWRX2UF9Li+uU8yJdN0cj4BvTsz4GDiR3OsKk8zJ1/o01DSRi1e4uDRS3TmA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/agent-monitor", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-roster.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", + "installs": 0, + "readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster." + }, + { + "id": "omercnet/beads", + "name": "Beads", + "description": "A dependency-aware Beads work queue for each Paseo workspace.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-beads", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-beads/-/paseo-beads-1.2.0.tgz", + "integrity": "sha512-UkI1SNP73RUrW0ap4qOx2Jv1GCxkrw4HIMrWOFCS3FDoPg2jw06DqHC8Jf34psaFglxXKWLAOkfL2wEZJhlUFw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-beads", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", + "installs": 0, + "readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace." + }, + { + "id": "omercnet/dracula", + "name": "Dracula", + "description": "Dracula Classic and Alucard Classic themes for Paseo.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-dracula", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-dracula/-/paseo-dracula-1.2.0.tgz", + "integrity": "sha512-I3yUiFIvaIe3jdvuQ1tZyngsfgukaUJwEQza2XYkEaB8yIA0pplZehqJLvqqmA403cjNXdVtsdW7umPtmnycqw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-dracula", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["themes"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot.png", + "https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.webm", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", + "installs": 0, + "readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo." + }, + { + "id": "omercnet/fresh-worktrees", + "name": "Fresh Worktrees", + "description": "Fast-forwards clean local base branches before Paseo creates branch-off worktrees", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-fresh-worktrees", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-fresh-worktrees/-/paseo-fresh-worktrees-2.2.0.tgz", + "integrity": "sha512-WRarzdfTbrZ36JfzmvX9HfnIYh6Pp+GV9TkoeVwM7I6f1MAEZ4gzPEmT51lWuUyMGvwTs2HFB7LthCa+VYs7IA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/fresh-worktrees", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-behind.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", + "installs": 0, + "readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees" + }, + { + "id": "omercnet/omp", + "name": "OMP", + "description": "Paseo integration for OMP, including its direct provider and workspace tooling.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-omp", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-omp/-/paseo-omp-1.2.0.tgz", + "integrity": "sha512-ueZede5D34hoNrdF4y6DBaomErA+hHcv+bcz0Sd6eArQKqF8L6ecKXdBe2tEZd3Ev8uQwxMHuiDQhMBx3q6gOw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-omp", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["providers"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/plugin-manager.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png" + ], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-10-03T09:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-10-03T09:00:00.000Z", + "installs": 0, + "readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling." + }, + { + "id": "omercnet/pr-radar", + "name": "PR Radar", + "description": "A viewer-aware delivery queue for pull requests linked to Paseo workspaces.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-pr-radar", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-pr-radar/-/paseo-pr-radar-1.2.0.tgz", + "integrity": "sha512-sk+zuzTGZ5Vmri2toic3z3uChm4d6kEShSr/UjkqfOTeILrsZVDHUc+XKX/CtZIQazT/vWvbgZ115+KUiAonCw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/pr-radar", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["git"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", + "installs": 0, + "readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces." + }, + { + "id": "omercnet/shared-browser", + "name": "Shared Browser", + "description": "One real Chromium session per Paseo workspace, shared live across every connected client.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-shared-browser", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-shared-browser/-/paseo-shared-browser-1.2.0.tgz", + "integrity": "sha512-ddvtkBSzHLFzyTRZFixO1y+QXOKW0of8Uo3PSkETRhW5xhd9ep0jqsJJF9JSfWLt1ouvVa5K8zFXic/EBzQ8lw==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/paseo-shared-browser", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["workspaces"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [ + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-wide.png", + "https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png" + ], + "submittedAt": "2026-09-18T00:00:00.000Z", + "reviewedAt": "2026-09-18T00:00:00.000Z", + "publishedAt": "2026-09-18T00:00:00.000Z", + "updatedAt": "2026-09-18T00:00:00.000Z", + "installs": 0, + "readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client." + }, + { + "id": "omercnet/tell-agent", + "name": "Tell Agent", + "description": "Send messages to agents in other workspaces on the same Paseo host.", + "artifact": { + "kind": "npm", + "package": "@omercnet/paseo-tell-agent", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@omercnet/paseo-tell-agent/-/paseo-tell-agent-1.2.0.tgz", + "integrity": "sha512-4XqL1RK1x3TQLoRRKTkZfDxytJbUCEnu2em5pbPDpsGQ5Gv6VN1h07mBci9iW8s2FbNINohNcIBdUaAMTXdOHA==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/omercnet/paseo-plugins/tree/HEAD/tell-agent", + "commit": "d7b3e654f364b5be72edf6fd1d914a3750b53082" + }, + "categories": ["orchestration"], + "author": { + "npm": "omercnet", + "name": "Omer Cohen", + "github": "omercnet" + }, + "media": [], + "submittedAt": "2026-09-17T00:00:00.000Z", + "reviewedAt": "2026-09-17T00:00:00.000Z", + "publishedAt": "2026-09-17T00:00:00.000Z", + "updatedAt": "2026-09-17T00:00:00.000Z", + "installs": 0, + "readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host." + }, + { + "id": "tomgrin10/defer", + "name": "Defer", + "description": "Defer messages to Paseo coding agents until a chosen time or usage-window reset.", + "artifact": { + "kind": "npm", + "package": "paseo-defer", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/paseo-defer/-/paseo-defer-2.1.9.tgz", + "integrity": "sha512-xBBf4TUC8zkTh9WMzQRRDt0alRAYPRCmUdBnBI1ZEkatUFC4i9MxFHgaTY939xij99dASM/261IWqctcKsQppQ==" + }, + "license": "MIT", + "repository": { + "url": "https://github.com/tomgrin10/paseo-defer" + }, + "categories": ["utils"], + "author": { + "npm": "tomgrin10", + "name": "Tom Gringauz", + "github": "tomgrin10" + }, + "media": [ + "https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png" + ], + "submittedAt": "2026-09-22T00:00:00.000Z", + "reviewedAt": "2026-10-03T15:00:00.000Z", + "publishedAt": "2026-09-22T00:00:00.000Z", + "updatedAt": "2026-10-03T15:00:00.000Z", + "installs": 0, + "readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset." + }, + { + "id": "tomgrin10/graphite", + "name": "Graphite", + "description": "Workspace-native Graphite stack status and PR action radar for Paseo.

+ + https://paseo.sh/sitemap.xml + https://paseo.sh/sitemap-plugins.xml + diff --git a/packages/website/src/assets/agent-icons/agoragentic.svg b/packages/website/src/assets/agent-icons/agoragentic.svg new file mode 100644 index 0000000000..b1372e6835 --- /dev/null +++ b/packages/website/src/assets/agent-icons/agoragentic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/amp.svg b/packages/website/src/assets/agent-icons/amp.svg new file mode 100644 index 0000000000..314881aff8 --- /dev/null +++ b/packages/website/src/assets/agent-icons/amp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/auggie.svg b/packages/website/src/assets/agent-icons/auggie.svg new file mode 100644 index 0000000000..215107744a --- /dev/null +++ b/packages/website/src/assets/agent-icons/auggie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/website/src/assets/agent-icons/autohand.svg b/packages/website/src/assets/agent-icons/autohand.svg new file mode 100644 index 0000000000..f3bc983c4d --- /dev/null +++ b/packages/website/src/assets/agent-icons/autohand.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/cline.svg b/packages/website/src/assets/agent-icons/cline.svg new file mode 100644 index 0000000000..aeeafbc61e --- /dev/null +++ b/packages/website/src/assets/agent-icons/cline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/codebuddy.svg b/packages/website/src/assets/agent-icons/codebuddy.svg new file mode 100644 index 0000000000..d1f4b1d74f --- /dev/null +++ b/packages/website/src/assets/agent-icons/codebuddy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/copilot.svg b/packages/website/src/assets/agent-icons/copilot.svg new file mode 100644 index 0000000000..626d33badc --- /dev/null +++ b/packages/website/src/assets/agent-icons/copilot.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/cortex-code.svg b/packages/website/src/assets/agent-icons/cortex-code.svg new file mode 100644 index 0000000000..28f87a258e --- /dev/null +++ b/packages/website/src/assets/agent-icons/cortex-code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/corust.svg b/packages/website/src/assets/agent-icons/corust.svg new file mode 100644 index 0000000000..9f30636cb0 --- /dev/null +++ b/packages/website/src/assets/agent-icons/corust.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/crow.svg b/packages/website/src/assets/agent-icons/crow.svg new file mode 100644 index 0000000000..1169116cd9 --- /dev/null +++ b/packages/website/src/assets/agent-icons/crow.svg @@ -0,0 +1,9 @@ + + + diff --git a/packages/website/src/assets/agent-icons/deepagents.svg b/packages/website/src/assets/agent-icons/deepagents.svg new file mode 100644 index 0000000000..abd818ec47 --- /dev/null +++ b/packages/website/src/assets/agent-icons/deepagents.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/website/src/assets/agent-icons/dimcode.svg b/packages/website/src/assets/agent-icons/dimcode.svg new file mode 100644 index 0000000000..1fa31ce884 --- /dev/null +++ b/packages/website/src/assets/agent-icons/dimcode.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/dirac.svg b/packages/website/src/assets/agent-icons/dirac.svg new file mode 100644 index 0000000000..4fbb06ceea --- /dev/null +++ b/packages/website/src/assets/agent-icons/dirac.svg @@ -0,0 +1,6 @@ + + δ + + + + diff --git a/packages/website/src/assets/agent-icons/factory-droid.svg b/packages/website/src/assets/agent-icons/factory-droid.svg new file mode 100644 index 0000000000..5c6fb8d1ff --- /dev/null +++ b/packages/website/src/assets/agent-icons/factory-droid.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/assets/agent-icons/fast-agent.svg b/packages/website/src/assets/agent-icons/fast-agent.svg new file mode 100644 index 0000000000..a07fab2886 --- /dev/null +++ b/packages/website/src/assets/agent-icons/fast-agent.svg @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/gemini.svg b/packages/website/src/assets/agent-icons/gemini.svg new file mode 100644 index 0000000000..588d89c52a --- /dev/null +++ b/packages/website/src/assets/agent-icons/gemini.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/glm.svg b/packages/website/src/assets/agent-icons/glm.svg new file mode 100644 index 0000000000..d552d2a3d0 --- /dev/null +++ b/packages/website/src/assets/agent-icons/glm.svg @@ -0,0 +1 @@ +Z.ai diff --git a/packages/website/src/assets/agent-icons/goose.svg b/packages/website/src/assets/agent-icons/goose.svg new file mode 100644 index 0000000000..c492885426 --- /dev/null +++ b/packages/website/src/assets/agent-icons/goose.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/grok.svg b/packages/website/src/assets/agent-icons/grok.svg new file mode 100644 index 0000000000..35db840319 --- /dev/null +++ b/packages/website/src/assets/agent-icons/grok.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/website/src/assets/agent-icons/junie.svg b/packages/website/src/assets/agent-icons/junie.svg new file mode 100644 index 0000000000..54ce5ff9a9 --- /dev/null +++ b/packages/website/src/assets/agent-icons/junie.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/website/src/assets/agent-icons/kilo.svg b/packages/website/src/assets/agent-icons/kilo.svg new file mode 100644 index 0000000000..8af6e96f34 --- /dev/null +++ b/packages/website/src/assets/agent-icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/kimi.svg b/packages/website/src/assets/agent-icons/kimi.svg new file mode 100644 index 0000000000..4f7547cf79 --- /dev/null +++ b/packages/website/src/assets/agent-icons/kimi.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/minion-code.svg b/packages/website/src/assets/agent-icons/minion-code.svg new file mode 100644 index 0000000000..eb3d8eb31d --- /dev/null +++ b/packages/website/src/assets/agent-icons/minion-code.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/mistral-vibe.svg b/packages/website/src/assets/agent-icons/mistral-vibe.svg new file mode 100644 index 0000000000..b13631b96d --- /dev/null +++ b/packages/website/src/assets/agent-icons/mistral-vibe.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/nova.svg b/packages/website/src/assets/agent-icons/nova.svg new file mode 100644 index 0000000000..5e19f58879 --- /dev/null +++ b/packages/website/src/assets/agent-icons/nova.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/poolside.svg b/packages/website/src/assets/agent-icons/poolside.svg new file mode 100644 index 0000000000..91de4c46d4 --- /dev/null +++ b/packages/website/src/assets/agent-icons/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/qoder.svg b/packages/website/src/assets/agent-icons/qoder.svg new file mode 100644 index 0000000000..417d83693d --- /dev/null +++ b/packages/website/src/assets/agent-icons/qoder.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/qwen-code.svg b/packages/website/src/assets/agent-icons/qwen-code.svg new file mode 100644 index 0000000000..6a33da557a --- /dev/null +++ b/packages/website/src/assets/agent-icons/qwen-code.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/assets/agent-icons/sigit.svg b/packages/website/src/assets/agent-icons/sigit.svg new file mode 100644 index 0000000000..334fc95cba --- /dev/null +++ b/packages/website/src/assets/agent-icons/sigit.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + diff --git a/packages/website/src/assets/agent-icons/stakpak.svg b/packages/website/src/assets/agent-icons/stakpak.svg new file mode 100644 index 0000000000..64425076ed --- /dev/null +++ b/packages/website/src/assets/agent-icons/stakpak.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/assets/agent-icons/vtcode.svg b/packages/website/src/assets/agent-icons/vtcode.svg new file mode 100644 index 0000000000..5ae616428c --- /dev/null +++ b/packages/website/src/assets/agent-icons/vtcode.svg @@ -0,0 +1,4 @@ + + + VT + diff --git a/packages/website/src/components/agent-icons.tsx b/packages/website/src/components/agent-icons.tsx index 7b6b66146d..fda21cf176 100644 --- a/packages/website/src/components/agent-icons.tsx +++ b/packages/website/src/components/agent-icons.tsx @@ -1,4 +1,4 @@ -// Agent provider brand marks, shared by the landing page and the Hub page. +// Agent provider brand marks, shared by the landing page, the Hub page, and /agents. import type * as React from "react"; @@ -82,3 +82,51 @@ export function PiIcon(props: React.SVGProps) { ); } + +export function OmpIcon(props: React.SVGProps) { + return ( + + ); +} + +export function MuseCodeIcon(props: React.SVGProps) { + return ( + + ); +} + +export function AntigravityIcon(props: React.SVGProps) { + return ( + + ); +} diff --git a/packages/website/src/components/agent-page-icon.tsx b/packages/website/src/components/agent-page-icon.tsx new file mode 100644 index 0000000000..e0f7a2601e --- /dev/null +++ b/packages/website/src/components/agent-page-icon.tsx @@ -0,0 +1,57 @@ +// The mark for any agent page. Only /agents needs every agent's mark, so the +// vendored SVG set lives here instead of in agent-icons, keeping it out of the +// homepage and agent landing page bundles. + +import { Bot } from "lucide-react"; +import type * as React from "react"; +import { + AntigravityIcon, + ClaudeCodeIcon, + CodexIcon, + CursorIcon, + MuseCodeIcon, + OmpIcon, + OpenCodeIcon, + PiIcon, +} from "~/components/agent-icons"; +const AGENT_PAGE_ICONS: Record> = { + "claude-code": ClaudeCodeIcon, + codex: CodexIcon, + opencode: OpenCodeIcon, + pi: PiIcon, + omp: OmpIcon, + cursor: CursorIcon, + "muse-code": MuseCodeIcon, + antigravity: AntigravityIcon, +}; + +// Marks vendored from the app's ACP provider catalog, named by agent page slug. +const VENDORED_AGENT_PAGE_SVGS = import.meta.glob("../assets/agent-icons/*.svg", { + eager: true, + query: "?raw", + import: "default", +}) as Record; + +const VENDORED_AGENT_PAGE_MARKUP = new Map( + Object.entries(VENDORED_AGENT_PAGE_SVGS).map(([path, svg]) => [ + path.slice(path.lastIndexOf("/") + 1, -".svg".length), + { __html: svg }, + ]), +); + +/** The mark for an agent page, falling back to a generic bot for agents without one. */ +export function AgentPageIcon({ slug, className }: { slug: string; className?: string }) { + const Icon = AGENT_PAGE_ICONS[slug]; + if (Icon) return ; + const markup = VENDORED_AGENT_PAGE_MARKUP.get(slug); + if (markup) { + return ( +

+ {page.content} +
); } diff --git a/packages/website/src/components/brand-icons.tsx b/packages/website/src/components/brand-icons.tsx index 0b48a9d5fe..4e964be8e5 100644 --- a/packages/website/src/components/brand-icons.tsx +++ b/packages/website/src/components/brand-icons.tsx @@ -44,3 +44,39 @@ export function SlackIcon(props: React.SVGProps) { ); } + +export function OpenCollectiveIcon(props: React.SVGProps) { + return ( + + + + + ); +} + +export function BuyMeACoffeeIcon(props: React.SVGProps) { + return ( + + + + ); +} + +export function RedditIcon(props: React.SVGProps) { + return ( + + ); +} diff --git a/packages/website/src/components/breadcrumbs.tsx b/packages/website/src/components/breadcrumbs.tsx new file mode 100644 index 0000000000..240f34047b --- /dev/null +++ b/packages/website/src/components/breadcrumbs.tsx @@ -0,0 +1,32 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronRight } from "lucide-react"; + +export interface BreadcrumbItem { + label: string; + /** Internal path. The last item is the current page and has no href. */ + href?: string; +} + +export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) { + return ( + + ); +} diff --git a/packages/website/src/components/docs-breadcrumbs.tsx b/packages/website/src/components/docs-breadcrumbs.tsx index 7b82e7b4fb..0da4f4472a 100644 --- a/packages/website/src/components/docs-breadcrumbs.tsx +++ b/packages/website/src/components/docs-breadcrumbs.tsx @@ -1,5 +1,5 @@ -import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; +import { useMemo } from "react"; +import { type BreadcrumbItem, Breadcrumbs } from "~/components/breadcrumbs"; import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs"; interface DocsBreadcrumbsProps { @@ -8,27 +8,13 @@ interface DocsBreadcrumbsProps { } export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) { - const groups = getDocBreadcrumbGroups(doc, tree); - - return ( - + const items = useMemo( + () => [ + { label: "Docs", href: "/docs" }, + ...getDocBreadcrumbGroups(doc, tree).map((group) => ({ label: group.label })), + { label: doc.frontmatter.nav }, + ], + [doc, tree], ); + return ; } diff --git a/packages/website/src/components/faq-item.tsx b/packages/website/src/components/faq-item.tsx index 217e4d1281..7813f9ddbf 100644 --- a/packages/website/src/components/faq-item.tsx +++ b/packages/website/src/components/faq-item.tsx @@ -3,12 +3,14 @@ import type { ReactNode } from "react"; export function FAQItem({ question, children }: { question: string; children: ReactNode }) { return (
- - + - − + + + + + − + {question} -
{children}
+
{children}
); } diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx index 4204a8d036..eeffc41d79 100644 --- a/packages/website/src/components/hero-mockup.tsx +++ b/packages/website/src/components/hero-mockup.tsx @@ -7,22 +7,12 @@ import { MOCKUP_STATES, type MockupStateId, MockupWindow, + ScaledMockup, } from "~/components/mockup"; const ALT = "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; -const ASPECT_STYLE = { aspectRatio: `${DESIGN_WIDTH} / ${DESIGN_HEIGHT}` }; - -// `tan(atan2(a, b))` is the CSS way to divide one length by another, so the scale -// factor tracks the container with no JS, no layout thrash, and a correct -// server-rendered first paint. -const SCALE_STYLE = { - width: DESIGN_WIDTH, - height: DESIGN_HEIGHT, - transform: `scale(tan(atan2(100cqw, ${DESIGN_WIDTH}px)))`, -}; - const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const }; export function HeroMockup() { @@ -42,21 +32,15 @@ export function HeroMockup() { ))} -
- {/* The window is authored at DESIGN_WIDTH and scaled to fit the hero column. */} -
-
-
- -
-
-
-
+ + + ); } diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx index 638609fcf1..a5b573a1d6 100644 --- a/packages/website/src/components/landing-page.tsx +++ b/packages/website/src/components/landing-page.tsx @@ -1,17 +1,24 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import * as React from "react"; import { ArrowRight, + Blocks, Bot, - BookOpen, Braces, + Compass, ExternalLink, + Gem, GitFork, + LayoutPanelLeft, Laptop, + Lock, + Merge, Monitor, Puzzle, Smartphone, Terminal, Users, + Workflow, type LucideIcon, } from "lucide-react"; import { @@ -67,16 +74,20 @@ import { isMobilePlatform } from "~/platform"; import { useRelease, useVisitorPlatform } from "~/routes/__root"; import { HeroMockup } from "~/components/hero-mockup"; import { + AntigravityIcon, ClaudeCodeIcon, CodexIcon, CursorIcon, + MuseCodeIcon, + OmpIcon, OpenCodeIcon, PiIcon, } from "~/components/agent-icons"; import { DiscordIcon, GitHubIcon, SlackIcon } from "~/components/brand-icons"; -import { ClaudeIcon, MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup"; +import { MobileChat, MobileDiff, MobileSidebar, PhoneFrame } from "~/components/mockup"; import { FAQItem } from "~/components/faq-item"; import { SiteFooter } from "~/components/site-footer"; +import { SponsorSection, SponsorsSection } from "~/components/sponsorship"; import { SiteHeader } from "~/components/site-header"; import "~/styles.css"; @@ -122,7 +133,9 @@ export function LandingPage({ title, subtitle }: LandingPageProps) { - + + + @@ -150,14 +163,18 @@ function Hero({ title, subtitle }: { title: React.ReactNode; subtitle: React.Rea ); } -const CLAUDE_CODE_BADGE_ICON = ; -const CODEX_BADGE_ICON = ; -const OPENCODE_BADGE_ICON = ; -const PI_BADGE_ICON = ; -const CURSOR_BADGE_ICON = ; +const FEATURED_AGENTS = [ + { name: "Claude Code", Icon: ClaudeCodeIcon }, + { name: "Codex", Icon: CodexIcon }, + { name: "OpenCode", Icon: OpenCodeIcon }, + { name: "Pi", Icon: PiIcon }, + { name: "OMP", Icon: OmpIcon }, + { name: "Cursor", Icon: CursorIcon }, + { name: "Muse Code", Icon: MuseCodeIcon }, + { name: "Antigravity", Icon: AntigravityIcon }, +] as const; -const FEATURED_AGENT_COUNT = 5; -const ADDITIONAL_AGENT_COUNT = AGENT_PAGES.length - FEATURED_AGENT_COUNT; +const ADDITIONAL_AGENT_COUNT = AGENT_PAGES.length - FEATURED_AGENTS.length; const SOCIAL_PROOF_TWEETS = [ { @@ -241,7 +258,7 @@ const SOCIAL_PROOF_ROWS = [ type SocialProofTweet = (typeof SOCIAL_PROOF_TWEETS)[number]; -function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) { +function AgentBadge({ name, Icon }: (typeof FEATURED_AGENTS)[number]) { const [hovered, setHovered] = React.useState(false); const handleMouseEnter = React.useCallback(() => setHovered(true), []); const handleMouseLeave = React.useCallback(() => setHovered(false), []); @@ -252,7 +269,7 @@ function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) { onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {icon} + {hovered && ( }, - { name: "Codex", icon: }, - { name: "OpenCode", icon: }, - { name: "Pi", icon: }, - { name: "Cursor", icon: }, - ]; - return (
- {providers.map((p) => ( + {FEATURED_AGENTS.map((agent) => (
- {p.icon} - {p.name} + + + + {agent.name}
))}

Desktop app

- The one click experience, download the app and it just works + The one click experience, download the app and it just works, no server required

@@ -478,8 +487,8 @@ function TurnkeySection() {
@@ -784,67 +793,166 @@ function SdkAutomationExample() { function ExtensibleSection() { return ( - -
- - + +
+ + +
+
+ {PLUGIN_CAPABILITIES.map((capability) => ( + + ))} +
+
+ +
+

Paseo is Apache 2.0.

+ + + Fork the repo + + +
); } -function ExtensibleCard({ +const PLUGINS_BROWSE = PLUGINS_LINKED + ? { href: "/plugins", external: false } + : { href: "https://paseo.cafe", external: true }; + +const PLUGIN_CAPABILITIES: ReadonlyArray<{ + icon: LucideIcon; + title: string; + description: string; +}> = [ + { + icon: LayoutPanelLeft, + title: "UI", + description: "Screens, workspace panels, sidebar items, slash commands, and themes", + }, + { + icon: Workflow, + title: "Agent lifecycle", + description: + "Change configuration, environment, and MCP servers. Answer permissions. Follow up when a turn ends", + }, + { + icon: Bot, + title: "Providers", + description: "Add a coding agent as a provider, used the same way as the built-in ones", + }, +]; + +function PluginCapabilityCard({ icon: Icon, title, description, - href, - linkLabel, - linkIcon, - external = false, }: { icon: LucideIcon; title: string; description: string; - href: string; - linkLabel: string; - linkIcon?: "book" | "github"; - external?: boolean; }) { return ( -
-
- + ); } +const PRINCIPLES: ReadonlyArray<{ icon: LucideIcon; title: string; description: string }> = [ + { + icon: Lock, + title: "Private", + description: "No telemetry, tracking, or forced login. Code stays on your machine.", + }, + { + icon: Compass, + title: "Independent", + description: "Paseo doesn't answer to investors. Its users guide what gets built.", + }, + { + icon: Gem, + title: "Polished", + description: "Install the app and start working. No need to know what a daemon is.", + }, + { + icon: Merge, + title: "Unified", + description: "Every coding agent, used the same way. Switch providers any time.", + }, + { + icon: Blocks, + title: "Composable", + description: "Run agents on your laptop or a server, and connect from any device.", + }, + { + icon: Puzzle, + title: "Extensible", + description: "If Paseo doesn't fit how you work, change it with a plugin or fork.", + }, +]; + +function PhilosophySection() { + return ( + +
+ {PRINCIPLES.map((principle) => ( +
+
+ +

{principle.title}

+
+

{principle.description}

+
+ ))} +
+
+ ); +} + function GetStarted() { const platform = useVisitorPlatform(); return ( @@ -866,11 +974,9 @@ function GetStarted() {
Supports
- - - - - + {FEATURED_AGENTS.map((agent) => ( + + ))}
+ Download for {download.label} @@ -1114,10 +1220,15 @@ function FAQ() { Paseo supports many providers. It has custom implementations for Claude, Codex, OpenCode, - Pi, and OMP, and supports many more via ACP. See the full list here:{" "} + Pi, OMP, Antigravity, and Muse Code, and supports many more via ACP. See the full list + here:{" "} all supported providers + . See{" "} + + Muse Code setup and limitations + . @@ -1168,45 +1279,3 @@ function FAQ() { ); } - -function SponsorCTA() { - return ( - -
-

Paseo is an independent open source project for running coding agents.

-

Its guiding principle is optionality and freedom of choice.

-

- I wanted to use any provider without being locked into any ecosystem, run it on my own - infrastructure, access it from anywhere, and have it be fully automatable. -

-

I am hoping that you will enjoy Paseo as much as I do.

-

If you like Paseo, sponsorship is the best way to support continued development.

-

- Mo

-
- -
- ); -} diff --git a/packages/website/src/components/mockup/index.ts b/packages/website/src/components/mockup/index.ts index cc4a842aa5..71ffed8b8d 100644 --- a/packages/website/src/components/mockup/index.ts +++ b/packages/website/src/components/mockup/index.ts @@ -6,3 +6,4 @@ export { MobileDiff } from "./mobile/diff"; export { MobileSidebar } from "./mobile/sidebar"; export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states"; export { MockupWindow } from "./window"; +export { ScaledMockup } from "./scaled-mockup"; diff --git a/packages/website/src/components/mockup/mobile/frame.tsx b/packages/website/src/components/mockup/mobile/frame.tsx index a4ccd4f07d..8449321037 100644 --- a/packages/website/src/components/mockup/mobile/frame.tsx +++ b/packages/website/src/components/mockup/mobile/frame.tsx @@ -1,20 +1,10 @@ -// The iPhone frame every mobile mockup sits inside. Authored at a fixed -// "design" size — iPhone 17 Pro points, 402 x 874 — and scaled to whatever -// width its column gives it. The scale is pure CSS (`tan(atan2(100cqw, …))`, -// the same trick the desktop hero uses), so the frame stays crisp at any width -// with no JS and a correct server-rendered first paint. - import type * as React from "react"; +import { ScaledMockup } from "../scaled-mockup"; export const PHONE_W = 402; export const PHONE_H = 874; const FRAME_ASPECT = { aspectRatio: `${PHONE_W} / ${PHONE_H}` }; -const SCREEN_SCALE = { - width: PHONE_W, - height: PHONE_H, - transform: `scale(tan(atan2(100cqw, ${PHONE_W}px)))`, -}; type PhoneDepth = "front" | "left" | "right"; const SLAB_STYLE: Record, React.CSSProperties> = { @@ -122,8 +112,8 @@ export function PhoneFrame({ )}
-
-
+
+
@@ -131,7 +121,7 @@ export function PhoneFrame({
-
+
diff --git a/packages/website/src/components/mockup/scaled-mockup.tsx b/packages/website/src/components/mockup/scaled-mockup.tsx new file mode 100644 index 0000000000..4a30de8917 --- /dev/null +++ b/packages/website/src/components/mockup/scaled-mockup.tsx @@ -0,0 +1,61 @@ +import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +interface ScaledMockupProps { + width: number; + height: number; + children: ReactNode; + label?: string; + borderRadius?: number; + className?: string; +} + +export function ScaledMockup({ + width, + height, + children, + label, + borderRadius = 0, + className = "", +}: ScaledMockupProps) { + const containerRef = useRef(null); + const [scale, setScale] = useState(0); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Measure layout width, not the transformed bounds of the tilted phones. + setScale(container.clientWidth / width); + const observer = new ResizeObserver(([entry]) => { + setScale(entry.contentRect.width / width); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [width]); + + const frameStyle = useMemo( + () => ({ + aspectRatio: `${width} / ${height}`, + borderRadius: `${(borderRadius / width) * 100}% / ${(borderRadius / height) * 100}%`, + }), + [width, height, borderRadius], + ); + const surfaceStyle = useMemo( + () => ({ width, height, transform: `scale(${scale})` }), + [width, height, scale], + ); + + return ( +
+
+ {children} +
+
+ ); +} diff --git a/packages/website/src/components/mockup/titlebar.tsx b/packages/website/src/components/mockup/titlebar.tsx index 6f3b91032f..de99f4db76 100644 --- a/packages/website/src/components/mockup/titlebar.tsx +++ b/packages/website/src/components/mockup/titlebar.tsx @@ -64,7 +64,7 @@ export function TitleBar({ state }: { state: MockupStateId }) { {shipping ? ( <> - Merge PR (squash) + Merge PR ) : ( <> diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index f73192c98a..198a60c9ce 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -1,19 +1,25 @@ +import { PLUGINS_LINKED } from "~/plugins/launch"; import { getAlternativePages } from "~/data/alternative-pages"; import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads"; interface SiteFooterProps { - width?: "default" | "prose"; + width?: "default" | "prose" | "wide"; } +const WIDTH_CLASSES: Record, string> = { + prose: "max-w-prose p-6 md:p-12 md:pt-0", + default: "max-w-5xl p-6 md:p-20 md:pt-0", + wide: "max-w-7xl p-6 md:p-20 md:pt-0", +}; + export function SiteFooter({ width = "default" }: SiteFooterProps) { - const widthClasses = - width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0"; + const widthClasses = WIDTH_CLASSES[width]; const alternatives = getAlternativePages(); return ( -